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