Author SHA1 Message Date
ksmithandClaude Sonnet 5.5 7ff7f2ec0e Bump to v2026.10.9
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 15:25:31 -06:00
ksmithandClaude Sonnet 5.5 62b5eec2fb Show the date on message timestamps that aren't from today
A bare "4:04 PM" gave no way to tell today's message from one sent last
week. Today stays time-only; older messages gain just enough date to
place them: "Yesterday 4:04 PM", a weekday for the past week ("Mon
4:04 PM"), then "Oct 2, 4:04 PM" (with the year if it isn't this one).
Hovering a time shows the full date. All in the viewer's local time.

A new calendar day now always starts a new message group. Previously a
same-sender run that crossed midnight within the 5-minute window (11:58
PM then 12:01 AM) stayed under one header, which would have put the
later message under the wrong day's timestamp. The grouping rule moves
out of MessageList into messageGrouping.ts so it's unit-tested.

useLocalDay refreshes "today" at local midnight and when a hidden tab
becomes visible again, so a tab left open overnight doesn't keep showing
yesterday's messages as a bare time. .message-time no longer wraps, and
.message-author can shrink, so a long display name can't push the longer
label out of the row on a phone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 15:25:22 -06:00
11 changed files with 325 additions and 21 deletions

No files matched your search

+5
View File
@@ -68,6 +68,11 @@ new message, or if you message them again yourself.
Type in the message box at the bottom of a room and press **Enter** to
send. Use **Shift+Enter** to add a line break without sending.
Each message shows when it was sent, in your local time. Messages from today
show just the time; older ones add the day — "Yesterday 4:04 PM", a weekday
like "Mon 4:04 PM" for the past week, then the date ("Oct 2, 4:04 PM", with
the year if it's not this one). Hover a time to see the full date.
## Formatting
Messages support Markdown. Prefer buttons to typing the syntax? Click the
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "ds-chat"
version = "2026.10.2"
version = "2026.10.9"
description = "DS Chat backend service"
license = { text = "AGPL-3.0-or-later" }
requires-python = ">=3.11"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "2026.10.2",
"version": "2026.10.9",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
+5
View File
@@ -40,12 +40,17 @@
}
.message-author {
/* Lets a very long display name shrink/wrap instead of pushing the
(now longer, e.g. "Yesterday 4:04 PM") timestamp out of the row. */
min-width: 0;
font-size: 0.86rem;
font-weight: 700;
color: var(--ds-text);
}
.message-time {
flex: none;
white-space: nowrap;
font-size: 0.68rem;
color: var(--ds-muted);
font-family: var(--mono);
+11 -19
View File
@@ -2,8 +2,10 @@ import type { CSSProperties } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext'
import { useLocalDay } from '../hooks/useLocalDay'
import { formatFileSize } from '../lib/fileSize'
import { avatarUrlFor, displayNameFor, senderColorIndex, statusFor } from '../lib/messageGrouping'
import { formatMessageTime, formatMessageTimeTitle } from '../lib/formatMessageTime'
import { avatarUrlFor, displayNameFor, isGroupStart, senderColorIndex, statusFor } from '../lib/messageGrouping'
import type { ChatMessageEnvelope, Message, MessageFileInfo, RoomMember } from '../types'
import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
@@ -69,13 +71,6 @@ function FileAttachmentCard({ file, roomId, onPreview }: FileAttachmentCardProps
// trigger a download the moment the browser tries to fetch it).
const PLAYABLE_VIDEO_CONTENT_TYPES = new Set(['video/mp4', 'video/webm', 'video/ogg'])
// #73: Slack's own threshold for the same "still grouped, but it's been a
// while" call -- past this gap a same-sender message starts a new group
// (its own avatar/name/timestamp) even with nobody else posting in
// between, so a message sent minutes later doesn't hide under a stale
// timestamp from the start of the run.
const GROUP_BREAK_MS = 5 * 60 * 1000
interface VideoAttachmentProps {
file: MessageFileInfo
roomId: string
@@ -134,6 +129,7 @@ export function MessageList({
onDelete,
}: MessageListProps) {
const { user } = useAuth()
const now = useLocalDay()
// #71: same viewer preference MessageContent applies to in-text emoji,
// looked up separately here since a reaction pill isn't a descendant of
// that component's wrapper div (see EmojiGlyph's own comment).
@@ -218,19 +214,15 @@ export function MessageList({
// consecutive messages from the same sender only repeat the
// avatar/name/timestamp header on the first one in the run --
// applies uniformly, including to your own messages. Also breaks on
// a long gap (see GROUP_BREAK_MS) so a message sent well after the
// rest of the run still gets its own visible timestamp.
const isGroupStart =
!prev ||
prev.user_id !== msg.user_id ||
new Date(msg.created_at).getTime() - new Date(prev.created_at).getTime() > GROUP_BREAK_MS
// a long gap or a new calendar day -- see isGroupStart.
const groupStart = isGroupStart(prev, msg)
const editing = editingId === msg.id
const deleted = !!msg.deleted_at
return (
<div key={msg.id} className={`message-row${isGroupStart ? ' message-row-start' : ''}`}>
<div key={msg.id} className={`message-row${groupStart ? ' message-row-start' : ''}`}>
<div className="message-avatar-slot">
{isGroupStart && (
{groupStart && (
<UserAvatar
username={msg.username}
colorIndex={senderColorIndex(msg.username, members)}
@@ -240,11 +232,11 @@ export function MessageList({
)}
</div>
<div className="message-content">
{isGroupStart && (
{groupStart && (
<div className="message-header">
<span className="message-author">{displayNameFor(msg.username, members)}</span>
<span className="message-time">
{new Date(msg.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
<span className="message-time" title={formatMessageTimeTitle(msg.created_at)}>
{formatMessageTime(msg.created_at, now)}
</span>
</div>
)}
+67
View File
@@ -0,0 +1,67 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useLocalDay } from './useLocalDay'
function setVisibility(state: 'visible' | 'hidden') {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
}
describe('useLocalDay', () => {
beforeEach(() => {
vi.useFakeTimers()
setVisibility('visible')
})
afterEach(() => {
vi.useRealTimers()
// Remove the own-property override so jsdom's real getter shows through again.
delete (document as unknown as Record<string, unknown>).visibilityState
})
it('rolls over to the next day when local midnight passes', () => {
vi.setSystemTime(new Date(2026, 9, 9, 23, 59, 30))
const { result } = renderHook(() => useLocalDay())
expect(result.current.getDate()).toBe(9)
act(() => {
vi.advanceTimersByTime(60_000)
})
expect(result.current.getDate()).toBe(10)
})
it('refreshes when the tab becomes visible after the clock moved a day (e.g. a sleeping laptop)', () => {
vi.setSystemTime(new Date(2026, 9, 9, 12, 0))
const { result } = renderHook(() => useLocalDay())
// The clock jumps without any timer firing in between.
vi.setSystemTime(new Date(2026, 9, 10, 12, 0))
act(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
expect(result.current.getDate()).toBe(10)
})
it('keeps the same value (no rerender) when visibility changes on the same day', () => {
vi.setSystemTime(new Date(2026, 9, 9, 12, 0))
const { result } = renderHook(() => useLocalDay())
const first = result.current
vi.setSystemTime(new Date(2026, 9, 9, 18, 0))
act(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
expect(result.current).toBe(first)
})
it('ignores visibility changes while the tab is hidden', () => {
vi.setSystemTime(new Date(2026, 9, 9, 12, 0))
const { result } = renderHook(() => useLocalDay())
setVisibility('hidden')
vi.setSystemTime(new Date(2026, 9, 10, 12, 0))
act(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
expect(result.current.getDate()).toBe(9)
})
})
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react'
import { isSameLocalDay } from '../lib/formatMessageTime'
function msUntilNextLocalMidnight(now: Date): number {
// One second past, so the timer never lands a hair before midnight.
const next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 1)
return next.getTime() - now.getTime()
}
// A `now` that only changes when the local calendar day does -- at midnight,
// and when a tab becomes visible again (a laptop that slept through
// midnight). Without it, a tab left open overnight keeps showing yesterday's
// messages as a bare "4:04 PM" until something unrelated rerenders the list.
export function useLocalDay(): Date {
const [now, setNow] = useState(() => new Date())
useEffect(() => {
let timer: ReturnType<typeof setTimeout>
function refresh() {
const current = new Date()
// Same state object when the day hasn't changed, so no rerender.
setNow((prev) => (isSameLocalDay(prev, current) ? prev : current))
}
function schedule() {
timer = setTimeout(() => {
refresh()
schedule()
}, msUntilNextLocalMidnight(new Date()))
}
function handleVisibilityChange() {
if (document.visibilityState === 'visible') refresh()
}
schedule()
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
clearTimeout(timer)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [])
return now
}
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { formatMessageTime, formatMessageTimeTitle, isSameLocalDay } from './formatMessageTime'
// Local-time constructors throughout, and expected text built with the same
// locale calls the code uses -- so these hold in any timezone and any ICU
// locale instead of hardcoding "3:00 PM".
const NOW = new Date(2026, 9, 9, 15, 0) // Fri Oct 9 2026, 3:00 PM local
const iso = (d: Date) => d.toISOString()
const timeText = (d: Date) => d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
const weekday = (d: Date) => d.toLocaleDateString([], { weekday: 'short' })
describe('formatMessageTime', () => {
it('shows just the time for a message sent today', () => {
const morning = new Date(2026, 9, 9, 8, 5)
expect(formatMessageTime(iso(morning), NOW)).toBe(timeText(morning))
})
it('prefixes "Yesterday" for yesterday', () => {
const d = new Date(2026, 9, 8, 23, 59)
expect(formatMessageTime(iso(d), NOW)).toBe(`Yesterday ${timeText(d)}`)
})
it('treats 11:59 PM vs 12:03 AM as different days despite being minutes apart', () => {
const justBeforeMidnight = new Date(2026, 9, 8, 23, 59)
const justAfterMidnight = new Date(2026, 9, 9, 0, 3)
expect(formatMessageTime(iso(justBeforeMidnight), justAfterMidnight)).toBe(
`Yesterday ${timeText(justBeforeMidnight)}`,
)
expect(formatMessageTime(iso(justAfterMidnight), justAfterMidnight)).toBe(timeText(justAfterMidnight))
})
it('uses the short weekday for 2 to 6 days ago', () => {
const twoDays = new Date(2026, 9, 7, 12, 0)
const sixDays = new Date(2026, 9, 3, 12, 0)
expect(formatMessageTime(iso(twoDays), NOW)).toBe(`${weekday(twoDays)} ${timeText(twoDays)}`)
expect(formatMessageTime(iso(sixDays), NOW)).toBe(`${weekday(sixDays)} ${timeText(sixDays)}`)
})
it('switches to a month and day at 7 days, without the year when it is this year', () => {
const d = new Date(2026, 9, 2, 12, 0)
const label = formatMessageTime(iso(d), NOW)
expect(label).toBe(`${d.toLocaleDateString([], { month: 'short', day: 'numeric' })}, ${timeText(d)}`)
expect(label).not.toContain('2026')
})
it('adds the year for a message from a different year', () => {
const d = new Date(2025, 11, 25, 9, 30)
const label = formatMessageTime(iso(d), NOW)
expect(label).toBe(
`${d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })}, ${timeText(d)}`,
)
expect(label).toContain('2025')
})
it('counts Dec 31 -> Jan 1 as yesterday', () => {
const d = new Date(2026, 11, 31, 22, 0)
expect(formatMessageTime(iso(d), new Date(2027, 0, 1, 10, 0))).toBe(`Yesterday ${timeText(d)}`)
})
it('treats a slightly-future timestamp (clock skew) as today', () => {
const tomorrow = new Date(2026, 9, 10, 9, 0)
expect(formatMessageTime(iso(tomorrow), NOW)).toBe(timeText(tomorrow))
})
})
describe('formatMessageTimeTitle', () => {
it('includes the full date, with the year', () => {
expect(formatMessageTimeTitle(iso(new Date(2026, 9, 2, 16, 4)))).toContain('2026')
})
})
describe('isSameLocalDay', () => {
it('is false across midnight and true within a day', () => {
expect(isSameLocalDay(new Date(2026, 9, 8, 23, 59), new Date(2026, 9, 9, 0, 1))).toBe(false)
expect(isSameLocalDay(new Date(2026, 9, 9, 0, 1), new Date(2026, 9, 9, 23, 59))).toBe(true)
})
it('accepts ISO strings as well as Dates', () => {
expect(isSameLocalDay(iso(new Date(2026, 9, 9, 8, 0)), new Date(2026, 9, 9, 20, 0))).toBe(true)
})
})
+44
View File
@@ -0,0 +1,44 @@
// "Today" is the viewer's local calendar day, so everything here goes through
// local-time getters, never UTC.
type DateLike = Date | string
function toDate(d: DateLike): Date {
return d instanceof Date ? d : new Date(d)
}
// A calendar-day number, not elapsed milliseconds: built from the local
// y/m/d via Date.UTC so a 23- or 25-hour DST day still counts as one day,
// and 11:58 PM vs 12:01 AM (3 minutes apart) correctly counts as 1 day apart.
function dayNumber(d: Date): number {
return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86_400_000)
}
export function isSameLocalDay(a: DateLike, b: DateLike): boolean {
return dayNumber(toDate(a)) === dayNumber(toDate(b))
}
// A bare "4:04 PM" gives no way to tell today's message from one sent last
// week. Today stays time-only; anything older gains just enough date to
// place it.
export function formatMessageTime(iso: string, now: Date = new Date()): string {
const sent = new Date(iso)
const time = sent.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
// <= 0 also covers a timestamp slightly in the future (clock skew between
// server and this device) -- better shown as today than as a negative age.
const daysAgo = dayNumber(now) - dayNumber(sent)
if (daysAgo <= 0) return time
if (daysAgo === 1) return `Yesterday ${time}`
// Under 7 so the weekday is never the same as today's (7 days ago would be).
if (daysAgo < 7) return `${sent.toLocaleDateString([], { weekday: 'short' })} ${time}`
const date = sent.toLocaleDateString([], {
month: 'short',
day: 'numeric',
year: sent.getFullYear() === now.getFullYear() ? undefined : 'numeric',
})
return `${date}, ${time}`
}
// Hover text: the one place the full, unambiguous date is always available.
export function formatMessageTimeTitle(iso: string): string {
return new Date(iso).toLocaleString([], { dateStyle: 'full', timeStyle: 'short' })
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { GROUP_BREAK_MS, isGroupStart } from './messageGrouping'
const msg = (user: string, d: Date) => ({ user_id: user, created_at: d.toISOString() })
describe('isGroupStart', () => {
const t = new Date(2026, 9, 9, 10, 0, 0)
const later = (ms: number) => new Date(t.getTime() + ms)
it('starts a group for the first message', () => {
expect(isGroupStart(undefined, msg('a', t))).toBe(true)
})
it('starts a group when the sender changes', () => {
expect(isGroupStart(msg('a', t), msg('b', later(1000)))).toBe(true)
})
it('keeps the same sender grouped within the break window', () => {
expect(isGroupStart(msg('a', t), msg('a', later(60_000)))).toBe(false)
})
it('groups at exactly the break threshold and splits just past it', () => {
expect(isGroupStart(msg('a', t), msg('a', later(GROUP_BREAK_MS)))).toBe(false)
expect(isGroupStart(msg('a', t), msg('a', later(GROUP_BREAK_MS + 1000)))).toBe(true)
})
it('measures the gap from the previous message, not the first in the run', () => {
const fourMin = 4 * 60_000
expect(isGroupStart(msg('a', later(fourMin)), msg('a', later(2 * fourMin)))).toBe(false)
expect(isGroupStart(msg('a', later(2 * fourMin)), msg('a', later(3 * fourMin)))).toBe(false)
})
it('starts a new group when the same sender crosses midnight, even a few minutes apart', () => {
const before = new Date(2026, 9, 8, 23, 58)
const after = new Date(2026, 9, 9, 0, 1)
expect(after.getTime() - before.getTime()).toBeLessThan(GROUP_BREAK_MS)
expect(isGroupStart(msg('a', before), msg('a', after))).toBe(true)
})
})
+24
View File
@@ -1,7 +1,31 @@
import { getUserAvatarUrl } from '../api/users'
import { hashIndex } from './avatar'
import { isSameLocalDay } from './formatMessageTime'
import type { RoomMember } from '../types'
// #73: Slack's own threshold for the same "still grouped, but it's been a
// while" call -- past this gap a same-sender message starts a new group
// (its own avatar/name/timestamp) even with nobody else posting in
// between, so a message sent minutes later doesn't hide under a stale
// timestamp from the start of the run.
export const GROUP_BREAK_MS = 5 * 60 * 1000
interface Groupable {
user_id: string
created_at: string
}
// Whether `msg` gets its own avatar/name/timestamp header, vs. continuing
// `prev`'s group. A new calendar day always breaks the group: now that
// timestamps can carry a date, a same-sender run that crosses midnight
// (11:58 PM, then 12:01 AM) would otherwise leave the later message under a
// header showing the wrong day.
export function isGroupStart(prev: Groupable | undefined, msg: Groupable): boolean {
if (!prev || prev.user_id !== msg.user_id) return true
if (new Date(msg.created_at).getTime() - new Date(prev.created_at).getTime() > GROUP_BREAK_MS) return true
return !isSameLocalDay(prev.created_at, msg.created_at)
}
export function senderColorIndex(username: string, members: RoomMember[]): number {
const idx = members.findIndex((m) => m.username === username)
if (idx >= 0) return idx