Private
Public Access
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>
This commit is contained in:
1 parent
eb30dc1753
commit
62b5eec2fb
9 files changed
+323
-19
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user