diff --git a/USER_GUIDE.md b/USER_GUIDE.md
index ee4bf8a..99a08bc 100644
--- a/USER_GUIDE.md
+++ b/USER_GUIDE.md
@@ -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
diff --git a/frontend/src/components/MessageList.css b/frontend/src/components/MessageList.css
index 6bbf712..0322350 100644
--- a/frontend/src/components/MessageList.css
+++ b/frontend/src/components/MessageList.css
@@ -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);
diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx
index 149091e..3d04998 100644
--- a/frontend/src/components/MessageList.tsx
+++ b/frontend/src/components/MessageList.tsx
@@ -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 (
-
+
- {isGroupStart && (
+ {groupStart && (
- {isGroupStart && (
+ {groupStart && (
{displayNameFor(msg.username, members)}
-
- {new Date(msg.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
+
+ {formatMessageTime(msg.created_at, now)}
)}
diff --git a/frontend/src/hooks/useLocalDay.test.ts b/frontend/src/hooks/useLocalDay.test.ts
new file mode 100644
index 0000000..9d52891
--- /dev/null
+++ b/frontend/src/hooks/useLocalDay.test.ts
@@ -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
).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)
+ })
+})
diff --git a/frontend/src/hooks/useLocalDay.ts b/frontend/src/hooks/useLocalDay.ts
new file mode 100644
index 0000000..647f445
--- /dev/null
+++ b/frontend/src/hooks/useLocalDay.ts
@@ -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
+
+ 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
+}
diff --git a/frontend/src/lib/formatMessageTime.test.ts b/frontend/src/lib/formatMessageTime.test.ts
new file mode 100644
index 0000000..351657f
--- /dev/null
+++ b/frontend/src/lib/formatMessageTime.test.ts
@@ -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)
+ })
+})
diff --git a/frontend/src/lib/formatMessageTime.ts b/frontend/src/lib/formatMessageTime.ts
new file mode 100644
index 0000000..658071d
--- /dev/null
+++ b/frontend/src/lib/formatMessageTime.ts
@@ -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' })
+}
diff --git a/frontend/src/lib/messageGrouping.test.ts b/frontend/src/lib/messageGrouping.test.ts
new file mode 100644
index 0000000..0f93545
--- /dev/null
+++ b/frontend/src/lib/messageGrouping.test.ts
@@ -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)
+ })
+})
diff --git a/frontend/src/lib/messageGrouping.ts b/frontend/src/lib/messageGrouping.ts
index 528c30b..926b660 100644
--- a/frontend/src/lib/messageGrouping.ts
+++ b/frontend/src/lib/messageGrouping.ts
@@ -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