From 4d4a76214ad968aaa90d3865c46e24e3e65153ce Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 20 Sep 2026 00:28:47 -0400 Subject: [PATCH] refactor(time): one timestamp formatter honouring the clock/date settings (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of every rendered time found five families of ad-hoc formatting: the shared Time component + copies of its today/yesterday branch (forwarded header, thread summary, read receipts, device tile, moderation alerts, edit history), locale-default toLocale*String calls that ignored the user's 12/24 h and date-format settings (scheduled tray, reminders, schedule preview, notification snooze, bookmarks, threads list, search cache line, room insights, media gallery), a hard-coded en-US date in the activity log, and three relative-age variants. utils/formatTimestamp.ts now holds the rules — today → time; yesterday / tomorrow → day word + time; last 6 days → weekday + time; older → date + time in dateFormatString — plus autoDate / time / date / dateTime styles, formatDayDivider (full weekday), formatShortAge (room list) and formatRelativeAge (list rows). useTimestampFormatter binds them to the settings. 11 unit tests with an injected 'now'. Visible changes are limited to consistency: 12 h times keep the existing zero-padded hh:mm A; the a11y label and Created-by line use the user's date format instead of a fixed long month. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../components/event-readers/EventReaders.tsx | 29 ++--- .../components/message/ForwardedHeader.tsx | 8 +- src/app/components/message/Time.tsx | 19 +-- src/app/components/room-intro/RoomIntro.tsx | 8 +- .../user-profile/UserModeration.tsx | 43 ++----- src/app/features/bookmarks/BookmarksPanel.tsx | 18 +-- .../features/message-search/MessageSearch.tsx | 4 +- src/app/features/room-nav/RoomNavItem.tsx | 28 +---- .../room-settings/RoomActivityLog.tsx | 19 +-- .../features/room-settings/RoomInsights.tsx | 18 +-- src/app/features/room/MediaGallery.tsx | 27 ++-- src/app/features/room/RoomTimeline.tsx | 9 +- .../features/room/ScheduleMessageModal.tsx | 6 +- .../features/room/ScheduledMessagesTray.tsx | 23 +--- .../features/room/jump-to-time/JumpToTime.tsx | 8 +- .../room/message/EditHistoryModal.tsx | 8 +- src/app/features/room/message/Message.tsx | 7 +- .../features/room/message/RemindMeDialog.tsx | 13 +- .../features/room/thread/ThreadSummary.tsx | 13 +- .../features/room/thread/ThreadTimeline.tsx | 15 +-- .../features/room/thread/ThreadsListPanel.tsx | 20 +-- .../features/settings/devices/DeviceTile.tsx | 14 +-- .../notifications/SystemNotification.tsx | 4 +- src/app/hooks/useTimestampFormatter.ts | 28 +++++ src/app/utils/a11y.test.ts | 16 +-- src/app/utils/a11y.ts | 10 +- src/app/utils/datetimeInput.test.ts | 20 +-- src/app/utils/datetimeInput.ts | 27 ++-- src/app/utils/formatTimestamp.test.ts | 96 ++++++++++++++ src/app/utils/formatTimestamp.ts | 117 ++++++++++++++++++ 30 files changed, 383 insertions(+), 292 deletions(-) create mode 100644 src/app/hooks/useTimestampFormatter.ts create mode 100644 src/app/utils/formatTimestamp.test.ts create mode 100644 src/app/utils/formatTimestamp.ts diff --git a/src/app/components/event-readers/EventReaders.tsx b/src/app/components/event-readers/EventReaders.tsx index 796866057..f3476b3ed 100644 --- a/src/app/components/event-readers/EventReaders.tsx +++ b/src/app/components/event-readers/EventReaders.tsx @@ -24,32 +24,19 @@ import { useSpaceOptionally } from '../../hooks/useSpace'; import { getMouseEventCords } from '../../utils/dom'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; -import { today, yesterday, timeHourMinute, timeMon, timeDay, timeYear } from '../../utils/time'; +import { TimestampPrefs, formatTimestamp } from '../../utils/formatTimestamp'; +import { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; -function formatReadTs(ts: number, hour24Clock: boolean): string { - const timeStr = timeHourMinute(ts, hour24Clock); - if (today(ts)) return `Today at ${timeStr}`; - if (yesterday(ts)) return `Yesterday at ${timeStr}`; - const sameYear = timeYear(ts) === timeYear(Date.now()); - return sameYear - ? `${timeMon(ts)} ${timeDay(ts)} at ${timeStr}` - : `${timeMon(ts)} ${timeDay(ts)} ${timeYear(ts)} at ${timeStr}`; -} +const formatReadTs = (ts: number, prefs: TimestampPrefs): string => formatTimestamp(ts, prefs); type EventReaderItemProps = { room: Room; readerId: string; - hour24Clock: boolean; + prefs: TimestampPrefs; lotusTerminal: boolean; onSelect: React.MouseEventHandler; }; -function EventReaderItem({ - room, - readerId, - hour24Clock, - lotusTerminal, - onSelect, -}: EventReaderItemProps) { +function EventReaderItem({ room, readerId, prefs, lotusTerminal, onSelect }: EventReaderItemProps) { const { name, avatarUrl } = useMemberAvatar(room, readerId, 100, 100); const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts; @@ -86,7 +73,7 @@ function EventReaderItem({ : undefined } > - {formatReadTs(receiptTs, hour24Clock)} + {formatReadTs(receiptTs, prefs)} )} @@ -106,7 +93,7 @@ export const EventReaders = as<'div', EventReadersProps>( const latestEventReaders = useRoomEventReaders(room, eventId).filter((id) => id !== myUserId); const openProfile = useOpenUserRoomProfile(); const space = useSpaceOptionally(); - const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const { prefs } = useTimestampFormatter(); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); return ( @@ -157,7 +144,7 @@ export const EventReaders = as<'div', EventReadersProps>( key={readerId} room={room} readerId={readerId} - hour24Clock={hour24Clock} + prefs={prefs} lotusTerminal={lotusTerminal} onSelect={(event) => { openProfile( diff --git a/src/app/components/message/ForwardedHeader.tsx b/src/app/components/message/ForwardedHeader.tsx index 35a49d844..2112af94c 100644 --- a/src/app/components/message/ForwardedHeader.tsx +++ b/src/app/components/message/ForwardedHeader.tsx @@ -6,7 +6,7 @@ import * as css from './Reply.css'; import { ForwardedMeta } from '../../features/room/message/forwardContent'; import { getMemberDisplayName } from '../../utils/room'; import { getMxIdLocalPart } from '../../utils/matrix'; -import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time'; +import { formatTimestamp } from '../../utils/formatTimestamp'; type ForwardedHeaderProps = { mx: MatrixClient; @@ -32,11 +32,7 @@ export const ForwardedHeader = as<'div', ForwardedHeaderProps>( getMxIdLocalPart(meta.sender) ?? meta.sender; const ts = meta.origin_server_ts; - const when = today(ts) - ? timeHourMinute(ts, hour24Clock) - : yesterday(ts) - ? `Yesterday ${timeHourMinute(ts, hour24Clock)}` - : `${timeDayMonYear(ts, dateFormatString)} ${timeHourMinute(ts, hour24Clock)}`; + const when = formatTimestamp(ts, { hour24Clock, dateFormatString }); const canJump = !!sourceRoom && !!onJump; return ( diff --git a/src/app/components/message/Time.tsx b/src/app/components/message/Time.tsx index 29716c581..c55f7b9cf 100644 --- a/src/app/components/message/Time.tsx +++ b/src/app/components/message/Time.tsx @@ -1,6 +1,6 @@ import React, { ComponentProps } from 'react'; import { Text, as } from 'folds'; -import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time'; +import { formatTimestamp } from '../../utils/formatTimestamp'; export type TimeProps = { compact?: boolean; @@ -12,8 +12,8 @@ export type TimeProps = { /** * Renders a formatted timestamp, supporting compact and full display modes. * - * Displays the time in hour:minute format if the message is from today, yesterday, or if `compact` is true. - * For older messages, it shows the date and time. + * `compact` always shows the clock time; otherwise the shared `formatTimestamp` + * rules apply (today → time, yesterday/this week → day word + time, else date + time). * * @param {number} ts - The timestamp to display. * @param {boolean} [compact=false] - If true, always show only the time. @@ -23,18 +23,7 @@ export type TimeProps = { */ export const Time = as<'span', TimeProps & ComponentProps>( ({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => { - const formattedTime = timeHourMinute(ts, hour24Clock); - - let time = ''; - if (compact) { - time = formattedTime; - } else if (today(ts)) { - time = formattedTime; - } else if (yesterday(ts)) { - time = `Yesterday ${formattedTime}`; - } else { - time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`; - } + const time = formatTimestamp(ts, { hour24Clock, dateFormatString }, compact ? 'time' : 'auto'); return ( diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index a3a22f224..54ac1df28 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -19,15 +19,13 @@ import { getMemberDisplayName, getStateEvent } from '../../utils/room'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; -import { timeDayMonthYear, timeHourMinute } from '../../utils/time'; +import { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { RoomAvatar } from '../room-avatar'; import { nameInitials } from '../../utils/common'; import { useRoomAvatar, useLocalRoomName, useRoomTopic } from '../../hooks/useRoomMeta'; import { mDirectAtom } from '../../state/mDirectList'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { useSetting } from '../../state/hooks/settings'; -import { settingsAtom } from '../../state/settings'; import { InviteUserPrompt } from '../invite-user-prompt'; import { RoomTopicViewer } from '../room-topic-viewer'; import { stopPropagation } from '../../utils/keyboard'; @@ -68,7 +66,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]), ); - const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const { format } = useTimestampFormatter(); return ( @@ -135,7 +133,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {'Created by '} @{creatorName} - {` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`} + {` on ${format(ts, 'dateTime')}`} )} diff --git a/src/app/components/user-profile/UserModeration.tsx b/src/app/components/user-profile/UserModeration.tsx index db95f1cc4..4c4ebcf29 100644 --- a/src/app/components/user-profile/UserModeration.tsx +++ b/src/app/components/user-profile/UserModeration.tsx @@ -6,9 +6,7 @@ import { SettingTile } from '../setting-tile'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { BreakWord } from '../../styles/Text.css'; -import { useSetting } from '../../state/hooks/settings'; -import { settingsAtom } from '../../state/settings'; -import { timeDayMonYear, timeHourMinute } from '../../utils/time'; +import { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; type UserKickAlertProps = { reason?: string; @@ -16,11 +14,8 @@ type UserKickAlertProps = { ts?: number; }; export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) { - const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); - const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); - - const time = ts ? timeHourMinute(ts, hour24Clock) : undefined; - const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined; + const { format } = useTimestampFormatter(); + const when = ts ? format(ts) : undefined; return ( @@ -28,11 +23,7 @@ export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) { Kicked User - {time && date && ( - - {date} {time} - - )} + {when && {when}} {kickedBy && ( @@ -66,11 +57,8 @@ type UserBanAlertProps = { export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBanAlertProps) { const mx = useMatrixClient(); const room = useRoom(); - const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); - const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); - - const time = ts ? timeHourMinute(ts, hour24Clock) : undefined; - const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined; + const { format } = useTimestampFormatter(); + const when = ts ? format(ts) : undefined; const [unbanState, unban] = useAsyncCallback( useCallback(async () => { @@ -86,11 +74,7 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan Banned User - {time && date && ( - - {date} {time} - - )} + {when && {when}} {bannedBy && ( @@ -141,11 +125,8 @@ type UserInviteAlertProps = { export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) { const mx = useMatrixClient(); const room = useRoom(); - const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); - const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); - - const time = ts ? timeHourMinute(ts, hour24Clock) : undefined; - const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined; + const { format } = useTimestampFormatter(); + const when = ts ? format(ts) : undefined; const [kickState, kick] = useAsyncCallback( useCallback(async () => { @@ -161,11 +142,7 @@ export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: User Invited User - {time && date && ( - - {date} {time} - - )} + {when && {when}} {invitedBy && ( diff --git a/src/app/features/bookmarks/BookmarksPanel.tsx b/src/app/features/bookmarks/BookmarksPanel.tsx index 8b815b65e..7e82c220e 100644 --- a/src/app/features/bookmarks/BookmarksPanel.tsx +++ b/src/app/features/bookmarks/BookmarksPanel.tsx @@ -35,19 +35,8 @@ import { nameInitials } from '../../utils/common'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { stopPropagation } from '../../utils/keyboard'; import * as css from './BookmarksPanel.css'; - -function formatTimeAgo(ts: number): string { - const diff = Date.now() - ts; - const minutes = Math.floor(diff / 60_000); - if (minutes < 1) return 'just now'; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days === 1) return 'yesterday'; - if (days < 7) return `${days}d ago`; - return new Date(ts).toLocaleDateString(); -} +import { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; +import { formatRelativeAge } from '../../utils/formatTimestamp'; // Remember the last-chosen sort across panel opens (the panel unmounts on close). // getOnInit reads localStorage synchronously at init so the persisted sort is @@ -110,7 +99,8 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: Bookm : undefined; // Prefer a live-resolved author name, then the stored snapshot. const author = senderName ?? bookmark.senderName; - const timeAgo = formatTimeAgo(bookmark.savedAt); + const { prefs } = useTimestampFormatter(); + const timeAgo = formatRelativeAge(bookmark.savedAt, prefs); return ( useMemo( @@ -74,6 +75,7 @@ type EncryptedRoomCachePanelProps = { }; function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelProps) { const mx = useMatrixClient(); + const { format } = useTimestampFormatter(); const [loadingRooms, setLoadingRooms] = useState>(new Set()); const encryptedRooms = useMemo( @@ -140,7 +142,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP {msgEvents.length > 0 - ? `${msgEvents.length} messages cached · oldest: ${new Date(oldest!.getTs()).toLocaleDateString()}` + ? `${msgEvents.length} messages cached · oldest: ${format(oldest!.getTs(), 'date')}` : 'No messages cached yet'} diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 0334cf163..f7bacd69c 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -37,9 +37,6 @@ import { useFocusWithin, useHover } from 'react-aria'; import FocusTrap from 'focus-trap-react'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { selectAtom } from 'jotai/utils'; -import dayjs from 'dayjs'; -import isToday from 'dayjs/plugin/isToday'; -import isYesterday from 'dayjs/plugin/isYesterday'; import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; @@ -97,29 +94,11 @@ import { MessageEvent, StateEvent } from '../../../types/matrix/room'; import { webRTCSupported } from '../../utils/rtc'; import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent'; import { EmojiBoard } from '../../components/emoji-board'; - -dayjs.extend(isToday); -dayjs.extend(isYesterday); +import { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; +import { formatShortAge } from '../../utils/formatTimestamp'; const PREVIEW_MAX_CHARS = 48; -function formatDmTimestamp(ts: number): string { - const d = dayjs(ts); - const now = dayjs(); - const diffMinutes = now.diff(d, 'minute'); - if (diffMinutes < 60) { - return `${diffMinutes < 1 ? 0 : diffMinutes}m`; - } - const diffHours = now.diff(d, 'hour'); - if (diffHours < 24) { - return `${diffHours}h`; - } - if (d.isYesterday()) { - return 'Yesterday'; - } - return d.format('D MMM'); -} - type RenameRoomDialogProps = { room: Room; onClose: () => void; @@ -646,6 +625,7 @@ function RoomNavItem_({ const roomName = useLocalRoomName(room); const hasLocalName = useHasLocalRoomName(room.roomId); + const { prefs } = useTimestampFormatter(); // Whether this room has an unsent message draft. selectAtom maps to a boolean // so the row only re-renders when that flips (the draft atom itself is written @@ -677,7 +657,7 @@ function RoomNavItem_({ } if (!body) return null; const preview = body.length > PREVIEW_MAX_CHARS ? `${body.slice(0, PREVIEW_MAX_CHARS)}…` : body; - return { preview, time: formatDmTimestamp(ts) }; + return { preview, time: formatShortAge(ts, prefs) }; })(); const handleContextMenu: MouseEventHandler = (evt) => { diff --git a/src/app/features/room-settings/RoomActivityLog.tsx b/src/app/features/room-settings/RoomActivityLog.tsx index d2833b4d4..b640a8b0c 100644 --- a/src/app/features/room-settings/RoomActivityLog.tsx +++ b/src/app/features/room-settings/RoomActivityLog.tsx @@ -9,6 +9,8 @@ import { createDetachedTimelineSet, createTypesFilter, } from '../../utils/detachedTimeline'; +import { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; +import { formatRelativeAge } from '../../utils/formatTimestamp'; // ── Types ───────────────────────────────────────────────────────────────────── @@ -27,20 +29,6 @@ type StateEventType = (typeof STATE_EVENT_TYPES)[number]; // ── Timestamp formatting ────────────────────────────────────────────────────── -function formatRelativeTs(ts: number): string { - const diff = Date.now() - ts; - if (diff < 60000) return 'just now'; - if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; - if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; - const d = new Date(ts); - const sameYear = d.getFullYear() === new Date().getFullYear(); - return d.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - ...(sameYear ? {} : { year: 'numeric' }), - }); -} - // ── Event description ───────────────────────────────────────────────────────── function getDisplayName(mx: ReturnType, userId: string): string { @@ -296,6 +284,7 @@ type LogEntryProps = { }; function LogEntry({ ev, desc }: LogEntryProps) { + const { prefs } = useTimestampFormatter(); return ( - {formatRelativeTs(ev.getTs())} + {formatRelativeAge(ev.getTs(), prefs)} diff --git a/src/app/features/room-settings/RoomInsights.tsx b/src/app/features/room-settings/RoomInsights.tsx index a60667b04..d7dfddac4 100644 --- a/src/app/features/room-settings/RoomInsights.tsx +++ b/src/app/features/room-settings/RoomInsights.tsx @@ -9,21 +9,10 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { getMemberAvatarMxc, getMemberName } from '../../utils/room'; import { mxcUrlToHttp } from '../../utils/matrix'; import { UserAvatar } from '../../components/user-avatar'; +import { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; // ── Helpers ─────────────────────────────────────────────────────────────────── -function formatDate(ts: number): string { - return new Date(ts).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - }); -} - -function formatUpdatedAt(ts: number): string { - return new Date(ts).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); -} - // Throttle window for re-computing stats on new timeline events - avoids // re-running every heatmap/list computation on every single incoming message // during a burst. @@ -74,6 +63,7 @@ type RoomInsightsProps = { }; export function RoomInsights({ requestClose }: RoomInsightsProps) { + const { format } = useTimestampFormatter(); const mx = useMatrixClient(); const room = useRoom(); const useAuthentication = useMediaAuthentication(); @@ -234,11 +224,11 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) { {stats.oldestTs !== null && stats.newestTs !== null && ( - from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)} + from {format(stats.oldestTs, 'date')} to {format(stats.newestTs, 'date')} )} - Last updated {formatUpdatedAt(lastUpdated)} + Last updated {format(lastUpdated, 'time')} diff --git a/src/app/features/room/MediaGallery.tsx b/src/app/features/room/MediaGallery.tsx index 06fab5a0b..43d66041d 100644 --- a/src/app/features/room/MediaGallery.tsx +++ b/src/app/features/room/MediaGallery.tsx @@ -35,6 +35,8 @@ import { useRoomMediaTimeline } from '../../hooks/useRoomMediaTimeline'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { stopPropagation } from '../../utils/keyboard'; import * as css from './MediaGallery.css'; +import { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; +import { formatRelativeAge } from '../../utils/formatTimestamp'; type GalleryTab = 'image' | 'video' | 'file' | 'audio'; @@ -117,18 +119,6 @@ function useDecryptedMediaUrl( // ── Helpers ─────────────────────────────────────────────────────────────────── -function formatRelativeDate(ts: number): string { - const diff = Date.now() - ts; - const mins = Math.floor(diff / 60000); - if (mins < 2) return 'Just now'; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(diff / 3600000); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(diff / 86400000); - if (days < 7) return `${days}d ago`; - return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); -} - function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; @@ -323,6 +313,7 @@ export function Lightbox({ onJump: (eventId: string) => void; }) { const [index, setIndex] = useState(initialIndex); + const { format } = useTimestampFormatter(); const item = items[index]; const isImage = item?.msgtype === MsgType.Image; @@ -366,11 +357,7 @@ export function Lightbox({ if (!item) return null; - const dateStr = new Date(item.ts).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - }); + const dateStr = format(item.ts, 'date'); return ( }> @@ -614,7 +601,8 @@ function GalleryTile({ mimeType, nearViewport, ); - const relDate = formatRelativeDate(ts); + const { prefs } = useTimestampFormatter(); + const relDate = formatRelativeAge(ts, prefs); return (
@@ -724,6 +712,7 @@ type MediaGalleryProps = { }; export function MediaGallery({ room, onClose }: MediaGalleryProps) { + const { prefs } = useTimestampFormatter(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const { navigateRoom } = useRoomNavigate(); @@ -1040,7 +1029,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { if (!url) return null; const body: string = c.body || 'Voice message'; const sender = getSenderName(room, mEvent.getSender() ?? ''); - const relDate = formatRelativeDate(mEvent.getTs()); + const relDate = formatRelativeAge(mEvent.getTs(), prefs); // Sanitize the mimetype the way MAudio does (e.g. application/ogg → // audio/ogg) so the decrypted blob actually plays. const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? 'audio/ogg'); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 2c029d937..0b7f5bb5b 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -105,7 +105,8 @@ import { markAsRead } from '../../utils/notifications'; import { useDebounce } from '../../hooks/useDebounce'; import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver'; import * as css from './RoomTimeline.css'; -import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time'; +import { inSameDay, minuteDifference } from '../../utils/time'; +import { formatDayDivider } from '../../utils/formatTimestamp'; import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor'; import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread'; @@ -2282,11 +2283,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli - {(() => { - if (today(mEvent.getTs())) return 'Today'; - if (yesterday(mEvent.getTs())) return 'Yesterday'; - return timeDayMonthYear(mEvent.getTs()); - })()} + {formatDayDivider(mEvent.getTs(), { hour24Clock, dateFormatString })} diff --git a/src/app/features/room/ScheduleMessageModal.tsx b/src/app/features/room/ScheduleMessageModal.tsx index 9cc125110..144013e9a 100644 --- a/src/app/features/room/ScheduleMessageModal.tsx +++ b/src/app/features/room/ScheduleMessageModal.tsx @@ -17,6 +17,7 @@ import { config, } from 'folds'; import { IContent } from 'matrix-js-sdk'; +import { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { stopPropagation } from '../../utils/keyboard'; import { scheduleMessage } from '../../utils/scheduledMessages'; @@ -101,6 +102,7 @@ export function ScheduleMessageModal({ }; // When editing, seed the pickers from the existing send-time; else default to +1h. + const { prefs } = useTimestampFormatter(); const def = initialSendAt ? new Date(initialSendAt) : defaultDate(); const [dateValue, setDateValue] = useState(() => toLocalDate(def)); const [timeValue, setTimeValue] = useState(() => toLocalTime(def)); @@ -124,10 +126,10 @@ export function ScheduleMessageModal({ return; } setPreview({ - label: formatFriendlyDateTime(sendAt.getTime()), + label: formatFriendlyDateTime(sendAt.getTime(), prefs), relative: formatRelativeTime(diffMs), }); - }, [getSendAt]); + }, [getSendAt, prefs]); useEffect(() => { updatePreview(); diff --git a/src/app/features/room/ScheduledMessagesTray.tsx b/src/app/features/room/ScheduledMessagesTray.tsx index 658fb1352..46866bd22 100644 --- a/src/app/features/room/ScheduledMessagesTray.tsx +++ b/src/app/features/room/ScheduledMessagesTray.tsx @@ -6,32 +6,17 @@ import { useMatrixClient } from '../../hooks/useMatrixClient'; import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages'; import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages'; import { ScheduleMessageModal } from './ScheduleMessageModal'; +import { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; +import { formatFriendlyDateTime } from '../../utils/datetimeInput'; interface ScheduledMessagesTrayProps { roomId: string; } -function formatSendAt(sendAt: number): string { - const date = new Date(sendAt); - const now = new Date(); - const isToday = - date.getFullYear() === now.getFullYear() && - date.getMonth() === now.getMonth() && - date.getDate() === now.getDate(); - const tomorrow = new Date(now); - tomorrow.setDate(tomorrow.getDate() + 1); - const isTomorrow = - date.getFullYear() === tomorrow.getFullYear() && - date.getMonth() === tomorrow.getMonth() && - date.getDate() === tomorrow.getDate(); - const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); - if (isToday) return `Today ${timeStr}`; - if (isTomorrow) return `Tomorrow ${timeStr}`; - return `${date.toLocaleDateString()} ${timeStr}`; -} - export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) { const mx = useMatrixClient(); + const { prefs } = useTimestampFormatter(); + const formatSendAt = (sendAt: number) => formatFriendlyDateTime(sendAt, prefs); const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom); const [expanded, setExpanded] = useState(false); const [cancelling, setCancelling] = useState>(new Set()); diff --git a/src/app/features/room/jump-to-time/JumpToTime.tsx b/src/app/features/room/jump-to-time/JumpToTime.tsx index 894b6bc4d..63db3a2f8 100644 --- a/src/app/features/room/jump-to-time/JumpToTime.tsx +++ b/src/app/features/room/jump-to-time/JumpToTime.tsx @@ -27,7 +27,8 @@ import { useAlive } from '../../../hooks/useAlive'; import { useStateEvent } from '../../../hooks/useStateEvent'; import { useRoom } from '../../../hooks/useRoom'; import { StateEvent } from '../../../../types/matrix/room'; -import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../../utils/time'; +import { getToday, getYesterday } from '../../../utils/time'; +import { formatDate, formatTime } from '../../../utils/formatTimestamp'; import { DatePicker, TimePicker } from '../../../components/time-date'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; @@ -50,6 +51,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) { const [ts, setTs] = useState(() => Date.now()); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); const [timePickerCords, setTimePickerCords] = useState(); const [datePickerCords, setDatePickerCords] = useState(); @@ -131,7 +133,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) { after={} onClick={handleTimePicker} > - {timeHourMinute(ts, hour24Clock)} + {formatTime(ts, { hour24Clock })} } onClick={handleDatePicker} > - {timeDayMonthYear(ts)} + {formatDate(ts, { hour24Clock, dateFormatString })} 0; - const formatTs = (ts: number): string => { - const time = timeHourMinute(ts, hour24Clock); - const date = timeDayMonYear(ts, dateFormatString); - return `${date} at ${time}`; - }; + const formatTs = (ts: number): string => formatTimestamp(ts, { hour24Clock, dateFormatString }); const originalContent = getOriginalContent(mEvent); diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index b11ad4c02..99a0c9fd9 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -1334,7 +1334,12 @@ export const Message = React.memo( })} role="article" aria-label={ - collapse ? messageAriaLabel(senderDisplayName, mEvent.getTs(), hour24Clock) : undefined + collapse + ? messageAriaLabel(senderDisplayName, mEvent.getTs(), { + hour24Clock, + dateFormatString, + }) + : undefined } tabIndex={0} space={messageSpacing} diff --git a/src/app/features/room/message/RemindMeDialog.tsx b/src/app/features/room/message/RemindMeDialog.tsx index 0642af793..e61dafa26 100644 --- a/src/app/features/room/message/RemindMeDialog.tsx +++ b/src/app/features/room/message/RemindMeDialog.tsx @@ -16,6 +16,8 @@ import { OverlayCenter, Text, } from 'folds'; +import { TimestampPrefs, formatTime } from '../../../utils/formatTimestamp'; +import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter'; import { stopPropagation } from '../../../utils/keyboard'; import { useReminders } from '../../../hooks/useReminders'; import { useModalStyle } from '../../../hooks/useModalStyle'; @@ -34,11 +36,11 @@ type RemindMeDialogProps = { onClose: () => void; }; -function getPresets(): Array<{ label: string; ms: number }> { +function getPresets(prefs: TimestampPrefs): Array<{ label: string; ms: number }> { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(9, 0, 0, 0); - const timeLabel = tomorrow.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const timeLabel = formatTime(tomorrow.getTime(), prefs); return [ { label: 'In 20 minutes', ms: 20 * 60_000 }, { label: 'In 1 hour', ms: 60 * 60_000 }, @@ -58,7 +60,8 @@ function defaultCustomDate(): Date { export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) { const modalStyle = useModalStyle(320); const { addReminder, removeReminder, reminders } = useReminders(); - const presets = useMemo(() => getPresets(), []); + const { prefs } = useTimestampFormatter(); + const presets = useMemo(() => getPresets(prefs), [prefs]); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [customOpen, setCustomOpen] = useState(false); @@ -185,7 +188,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind - {formatFriendlyDateTime(r.timestamp)} + {formatFriendlyDateTime(r.timestamp, prefs)} handleCancelExisting(r.timestamp)} - aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp)}`} + aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp, prefs)}`} > diff --git a/src/app/features/room/thread/ThreadSummary.tsx b/src/app/features/room/thread/ThreadSummary.tsx index 875ae5d49..29a44e3ba 100644 --- a/src/app/features/room/thread/ThreadSummary.tsx +++ b/src/app/features/room/thread/ThreadSummary.tsx @@ -3,9 +3,7 @@ import { Badge, Box, Chip, Icon, Icons, Text, config } from 'folds'; import { MatrixEvent, Room } from 'matrix-js-sdk'; import { MobileTouchTarget } from '../../../styles/mobile.css'; import { useThreadSummary } from '../../../hooks/useThreadSummary'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { timeDayMonthYear, timeHourMinute, today } from '../../../utils/time'; +import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter'; import { ThreadNotificationMode } from '../../../utils/threadNotifications'; type ThreadSummaryProps = { @@ -15,17 +13,12 @@ type ThreadSummaryProps = { }; export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) { const { summary, unread, mode } = useThreadSummary(rootEvent, room); - const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const { format } = useTimestampFormatter(); if (!summary || summary.count === 0) return null; const { count, latestTs } = summary; - const latestStr = - latestTs !== undefined - ? today(latestTs) - ? timeHourMinute(latestTs, hour24Clock) - : timeDayMonthYear(latestTs) - : undefined; + const latestStr = latestTs !== undefined ? format(latestTs) : undefined; return ( diff --git a/src/app/features/room/thread/ThreadTimeline.tsx b/src/app/features/room/thread/ThreadTimeline.tsx index 090ba3aa3..06ea71f3f 100644 --- a/src/app/features/room/thread/ThreadTimeline.tsx +++ b/src/app/features/room/thread/ThreadTimeline.tsx @@ -76,13 +76,8 @@ import { RoomMediaLightbox } from '../RoomMediaLightbox'; import { Image } from '../../../components/media'; import { ImageViewer } from '../../../components/image-viewer'; import * as css from './ThreadTimeline.css'; -import { - inSameDay, - minuteDifference, - timeDayMonthYear, - today, - yesterday, -} from '../../../utils/time'; +import { inSameDay, minuteDifference } from '../../../utils/time'; +import { formatDayDivider } from '../../../utils/formatTimestamp'; import { createMentionElement, isEmptyEditor, moveCursor } from '../../../components/editor'; import { useKeyDown } from '../../../hooks/useKeyDown'; import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts'; @@ -910,11 +905,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) { - {(() => { - if (today(mEvent.getTs())) return 'Today'; - if (yesterday(mEvent.getTs())) return 'Yesterday'; - return timeDayMonthYear(mEvent.getTs()); - })()} + {formatDayDivider(mEvent.getTs(), { hour24Clock, dateFormatString })} diff --git a/src/app/features/room/thread/ThreadsListPanel.tsx b/src/app/features/room/thread/ThreadsListPanel.tsx index b889a32a7..5447b2237 100644 --- a/src/app/features/room/thread/ThreadsListPanel.tsx +++ b/src/app/features/room/thread/ThreadsListPanel.tsx @@ -27,6 +27,8 @@ import { isThreadSort, } from '../../../utils/threadList'; import { useRoomThreads } from '../../../hooks/useRoomThreads'; +import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter'; +import { formatRelativeAge } from '../../../utils/formatTimestamp'; // Persisted across panel opens (the panel unmounts on close). getOnInit reads // localStorage synchronously so the chosen filter/sort apply on first render. @@ -55,19 +57,6 @@ const SORT_OPTIONS: { value: ThreadSort; label: string }[] = [ const MAX_PARTICIPANTS = 5; -function formatTimeAgo(ts: number): string { - const diff = Date.now() - ts; - const minutes = Math.floor(diff / 60_000); - if (minutes < 1) return 'just now'; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days === 1) return 'yesterday'; - if (days < 7) return `${days}d ago`; - return new Date(ts).toLocaleDateString(); -} - // Segmented button, mirroring the Bookmarks panel sort control for consistency. function SegButton({ label, @@ -115,6 +104,7 @@ type ThreadRowProps = { onOpen: (threadId: string) => void; }; function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: ThreadRowProps) { + const { prefs } = useTimestampFormatter(); const rootEvent = thread.rootEvent; const rootSender = rootEvent?.getSender() ?? ''; const { name: rootName, avatarUrl } = useMemberAvatar(room, rootSender); @@ -126,7 +116,7 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th const extra = participants.length - MAX_PARTICIPANTS; const replyLabel = `${count} ${count === 1 ? 'reply' : 'replies'}`; const ariaLabel = `Open thread by ${rootName}${unread > 0 ? ', unread' : ''}, ${replyLabel}${ - typeof lastTs === 'number' ? `, last reply ${formatTimeAgo(lastTs)}` : '' + typeof lastTs === 'number' ? `, last reply ${formatRelativeAge(lastTs, prefs)}` : '' }`; return ( @@ -175,7 +165,7 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th {count} {count === 1 ? 'reply' : 'replies'} - {typeof lastTs === 'number' ? ` · ${formatTimeAgo(lastTs)}` : ''} + {typeof lastTs === 'number' ? ` · ${formatRelativeAge(lastTs, prefs)}` : ''} {participants.slice(0, MAX_PARTICIPANTS).map((userId) => ( diff --git a/src/app/features/settings/devices/DeviceTile.tsx b/src/app/features/settings/devices/DeviceTile.tsx index efc8de0f0..915808a47 100644 --- a/src/app/features/settings/devices/DeviceTile.tsx +++ b/src/app/features/settings/devices/DeviceTile.tsx @@ -20,15 +20,13 @@ import FocusTrap from 'focus-trap-react'; import { IMyDevice, MatrixError } from 'matrix-js-sdk'; import { SettingTile } from '../../../components/setting-tile'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../../utils/time'; +import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter'; import { BreakWord } from '../../../styles/Text.css'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { LogoutDialog } from '../../../components/LogoutDialog'; import { stopPropagation } from '../../../utils/keyboard'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; export function DeviceTilePlaceholder() { return ( @@ -43,20 +41,14 @@ export function DeviceTilePlaceholder() { } function DeviceActiveTime({ ts }: { ts: number }) { - const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); - const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + const { format } = useTimestampFormatter(); return ( {'Last activity: '} - <> - {today(ts) && 'Today'} - {yesterday(ts) && 'Yesterday'} - {!today(ts) && !yesterday(ts) && timeDayMonYear(ts, dateFormatString)}{' '} - {timeHourMinute(ts, hour24Clock)} - + {format(ts)} ); } diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 894047e46..fc46b4ce9 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Box, Text, Switch, Button, Chip, Icon, Icons, color, config, Spinner } from 'folds'; import { IPusherRequest } from 'matrix-js-sdk'; import { useAtomValue, useSetAtom } from 'jotai'; +import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter'; import { NOTIFICATION_SOUND_MAP } from '../../../utils/notificationSounds'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; @@ -139,6 +140,7 @@ const SNOOZE_PRESETS: Array<{ label: string; resolve: (now: number) => number }> // Cross-platform "pause notifications" — sets a snooze instant that the // notification gate (ClientNonUIFeatures) reads to suppress alerts + sounds. function PauseNotifications() { + const { prefs } = useTimestampFormatter(); const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom); const setSnoozeUntil = useSetAtom(notificationSnoozeUntilAtom); // While paused, tick so the status flips to "on" the moment the snooze lapses. @@ -155,7 +157,7 @@ function PauseNotifications() { ? 'Notifications are on.' : snoozeUntil >= SNOOZE_INDEFINITE ? 'Paused until you resume.' - : `Paused until ${formatFriendlyDateTime(snoozeUntil)}.`; + : `Paused until ${formatFriendlyDateTime(snoozeUntil, prefs)}.`; return ( ( + () => ({ hour24Clock, dateFormatString }), + [hour24Clock, dateFormatString], + ); + const format = useCallback( + (ts: number, style: TimestampStyle = 'auto') => formatTimestamp(ts, prefs, style), + [prefs], + ); + const shortAge = useCallback((ts: number) => formatShortAge(ts, prefs), [prefs]); + return { format, shortAge, prefs }; +} diff --git a/src/app/utils/a11y.test.ts b/src/app/utils/a11y.test.ts index f2313c886..5d044ac98 100644 --- a/src/app/utils/a11y.test.ts +++ b/src/app/utils/a11y.test.ts @@ -2,27 +2,29 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import dayjs from 'dayjs'; import { messageAriaLabel } from './a11y'; -import { timeDayMonthYear, timeHourMinute } from './time'; test('messageAriaLabel composes sender, date and time (24h)', () => { const ts = dayjs('2026-07-01T14:30:00').valueOf(); assert.equal( - messageAriaLabel('Alice', ts, true), - `Alice, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, true)}`, + messageAriaLabel('Alice', ts, { hour24Clock: true, dateFormatString: 'D MMM YYYY' }), + 'Alice, 1 Jul 2026 14:30', ); }); -test('messageAriaLabel honours the 12-hour clock preference', () => { +test('messageAriaLabel honours the 12-hour clock and date-format preferences', () => { const ts = dayjs('2026-07-01T14:30:00').valueOf(); assert.equal( - messageAriaLabel('Bob', ts, false), - `Bob, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, false)}`, + messageAriaLabel('Bob', ts, { hour24Clock: false, dateFormatString: 'MM/DD/YYYY' }), + 'Bob, 07/01/2026 02:30 PM', ); }); test('messageAriaLabel keeps the sender name verbatim as plain text', () => { const ts = dayjs('2026-07-01T09:05:00').valueOf(); - const label = messageAriaLabel('@user:example.org', ts, true); + const label = messageAriaLabel('@user:example.org', ts, { + hour24Clock: true, + dateFormatString: '', + }); assert.ok(label.startsWith('@user:example.org, ')); assert.ok(!label.includes('<')); }); diff --git a/src/app/utils/a11y.ts b/src/app/utils/a11y.ts index 2485e9d95..087a9c76d 100644 --- a/src/app/utils/a11y.ts +++ b/src/app/utils/a11y.ts @@ -1,4 +1,4 @@ -import { timeDayMonthYear, timeHourMinute } from './time'; +import { TimestampPrefs, formatTimestamp } from './formatTimestamp'; /** * Builds a plain-text accessible label for a message row, used when the @@ -7,8 +7,8 @@ import { timeDayMonthYear, timeHourMinute } from './time'; * * @param sender - Sender display name (already resolved to a human string). * @param ts - Message origin timestamp in milliseconds. - * @param hour24Clock - Whether to format the time using a 24-hour clock. - * @returns A label such as `Alice, 1 July 2026 14:30`. + * @param prefs - The user's clock/date preferences. + * @returns A label such as `Alice, 1 Jul 2026 14:30`. */ -export const messageAriaLabel = (sender: string, ts: number, hour24Clock: boolean): string => - `${sender}, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`; +export const messageAriaLabel = (sender: string, ts: number, prefs: TimestampPrefs): string => + `${sender}, ${formatTimestamp(ts, prefs, 'dateTime')}`; diff --git a/src/app/utils/datetimeInput.test.ts b/src/app/utils/datetimeInput.test.ts index 8ca1880da..664ad374b 100644 --- a/src/app/utils/datetimeInput.test.ts +++ b/src/app/utils/datetimeInput.test.ts @@ -55,16 +55,22 @@ test('formatFriendlyDateTime: uses Today/Tomorrow/date prefixes', () => { const tomorrow = new Date(2026, 0, 6, 9, 0).getTime(); const nextWeek = new Date(2026, 0, 12, 9, 0).getTime(); - assert.ok(formatFriendlyDateTime(laterToday, now).startsWith('Today at ')); - assert.ok(formatFriendlyDateTime(tomorrow, now).startsWith('Tomorrow at ')); - const other = formatFriendlyDateTime(nextWeek, now); - assert.ok(!other.startsWith('Today')); - assert.ok(!other.startsWith('Tomorrow')); - assert.ok(other.includes(' at ')); + const prefs = { hour24Clock: true, dateFormatString: 'D MMM YYYY' }; + assert.equal(formatFriendlyDateTime(laterToday, prefs, now), 'Today at 15:30'); + assert.equal(formatFriendlyDateTime(tomorrow, prefs, now), 'Tomorrow at 09:00'); + assert.equal(formatFriendlyDateTime(nextWeek, prefs, now), '12 Jan 2026 at 09:00'); + assert.equal( + formatFriendlyDateTime(nextWeek, { hour24Clock: false, dateFormatString: 'MM/DD/YYYY' }, now), + '01/12/2026 at 09:00 AM', + ); }); test('formatFriendlyDateTime: Tomorrow rolls over month/year boundaries', () => { const nye = new Date(2026, 11, 31, 23, 0).getTime(); const jan1 = new Date(2027, 0, 1, 9, 0).getTime(); - assert.ok(formatFriendlyDateTime(jan1, nye).startsWith('Tomorrow at ')); + assert.ok( + formatFriendlyDateTime(jan1, { hour24Clock: true, dateFormatString: '' }, nye).startsWith( + 'Tomorrow at ', + ), + ); }); diff --git a/src/app/utils/datetimeInput.ts b/src/app/utils/datetimeInput.ts index ed7c396c0..655d1b248 100644 --- a/src/app/utils/datetimeInput.ts +++ b/src/app/utils/datetimeInput.ts @@ -1,5 +1,6 @@ import { CSSProperties } from 'react'; import { color as foldsColor, config as foldsConfig } from 'folds'; +import { TimestampPrefs, dayWord, formatDate, formatTime } from './formatTimestamp'; const pad = (n: number): string => String(n).padStart(2, '0'); @@ -23,21 +24,17 @@ export function parseLocalDateTime(dateValue: string, timeValue: string): Date | return Number.isNaN(dt.getTime()) ? null : dt; } -// Human-friendly absolute time: "Today at 3:00 PM", "Tomorrow at 9:00 AM", or -// "1/5/2026 at 3:00 PM". `now` is injectable so the relative-day logic is testable. -export function formatFriendlyDateTime(ts: number, now: number = Date.now()): string { - const date = new Date(ts); - const nowDate = new Date(now); - const sameDay = (a: Date, b: Date): boolean => - a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate(); - const tomorrow = new Date(nowDate); - tomorrow.setDate(tomorrow.getDate() + 1); - const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); - if (sameDay(date, nowDate)) return `Today at ${timeStr}`; - if (sameDay(date, tomorrow)) return `Tomorrow at ${timeStr}`; - return `${date.toLocaleDateString()} at ${timeStr}`; +// Human-friendly absolute time for scheduled things: "Today at 03:00 PM", +// "Tomorrow at 09:00 AM", "Mon at 09:00", or "12 Jan 2026 at 09:00" — the +// shared day-word rules and the user's clock/date preferences (#139). `now` is +// injectable so the relative-day logic is testable. +export function formatFriendlyDateTime( + ts: number, + prefs: TimestampPrefs, + now: number = Date.now(), +): string { + const day = dayWord(ts, now) ?? formatDate(ts, prefs); + return `${day} at ${formatTime(ts, prefs)}`; } // Shared style for date/time s — matches the app's surface tokens and diff --git a/src/app/utils/formatTimestamp.test.ts b/src/app/utils/formatTimestamp.test.ts new file mode 100644 index 000000000..baf38762b --- /dev/null +++ b/src/app/utils/formatTimestamp.test.ts @@ -0,0 +1,96 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + formatDayDivider, + formatRelativeAge, + formatShortAge, + formatTimestamp, +} from './formatTimestamp'; + +// Fri 18 Sep 2026 14:05 local. +const now = new Date(2026, 8, 18, 14, 5).getTime(); +const at = (y: number, m: number, d: number, h = 21, min = 14) => + new Date(y, m - 1, d, h, min).getTime(); +const p12 = { hour24Clock: false, dateFormatString: 'D MMM YYYY' }; +const p24 = { hour24Clock: true, dateFormatString: 'YYYY-MM-DD' }; + +describe('formatTimestamp', () => { + it('today → time only', () => { + assert.equal(formatTimestamp(at(2026, 9, 18, 9, 3), p12, 'auto', now), '09:03 AM'); + assert.equal(formatTimestamp(at(2026, 9, 18, 9, 3), p24, 'auto', now), '09:03'); + }); + + it('yesterday / tomorrow → day word + time', () => { + assert.equal(formatTimestamp(at(2026, 9, 17), p24, 'auto', now), 'Yesterday 21:14'); + assert.equal(formatTimestamp(at(2026, 9, 19), p24, 'auto', now), 'Tomorrow 21:14'); + // Just before midnight yesterday is still yesterday, not "hours ago". + assert.equal(formatTimestamp(at(2026, 9, 17, 23, 59), p24, 'auto', now), 'Yesterday 23:59'); + }); + + it('within the last week → weekday + time', () => { + assert.equal(formatTimestamp(at(2026, 9, 14), p24, 'auto', now), 'Mon 21:14'); + assert.equal(formatTimestamp(at(2026, 9, 12), p24, 'auto', now), 'Sat 21:14'); + }); + + it('a week or more → date + time in the user format', () => { + assert.equal(formatTimestamp(at(2026, 9, 11), p24, 'auto', now), '2026-09-11 21:14'); + assert.equal(formatTimestamp(at(2025, 1, 2), p12, 'auto', now), '2 Jan 2025 09:14 PM'); + assert.equal(formatTimestamp(at(2026, 9, 25), p12, 'auto', now), '25 Sep 2026 09:14 PM'); + }); + + it('autoDate never shows the time', () => { + assert.equal(formatTimestamp(at(2026, 9, 18), p12, 'autoDate', now), 'Today'); + assert.equal(formatTimestamp(at(2026, 9, 17), p12, 'autoDate', now), 'Yesterday'); + assert.equal(formatTimestamp(at(2026, 9, 15), p12, 'autoDate', now), 'Tue'); + assert.equal(formatTimestamp(at(2026, 1, 15), p12, 'autoDate', now), '15 Jan 2026'); + }); + + it('fixed styles ignore the relative day', () => { + assert.equal(formatTimestamp(at(2026, 9, 18), p12, 'time', now), '09:14 PM'); + assert.equal(formatTimestamp(at(2026, 9, 18), p12, 'date', now), '18 Sep 2026'); + assert.equal(formatTimestamp(at(2026, 9, 18), p24, 'dateTime', now), '2026-09-18 21:14'); + }); + + it('falls back to a default date format when the preference is empty', () => { + assert.equal( + formatTimestamp(at(2026, 1, 15), { hour24Clock: true, dateFormatString: '' }, 'date', now), + '15 Jan 2026', + ); + }); +}); + +describe('formatShortAge', () => { + it('minutes and hours today', () => { + assert.equal(formatShortAge(now - 20_000, p12, now), 'now'); + assert.equal(formatShortAge(now - 5 * 60_000, p12, now), '5m'); + assert.equal(formatShortAge(now - 3 * 3_600_000, p12, now), '3h'); + }); + + it('day words, then a short date (year dropped when current)', () => { + assert.equal(formatShortAge(at(2026, 9, 17, 23, 0), p12, now), 'Yesterday'); + assert.equal(formatShortAge(at(2026, 9, 14), p12, now), 'Mon'); + assert.equal(formatShortAge(at(2026, 3, 2), p12, now), '2 Mar'); + assert.equal(formatShortAge(at(2026, 3, 2), p24, now), '03-02'); + assert.equal(formatShortAge(at(2025, 3, 2), p12, now), '2 Mar 2025'); + }); +}); + +describe('formatRelativeAge', () => { + it('steps through minutes, hours, days, then the date', () => { + assert.equal(formatRelativeAge(now - 10_000, p12, now), 'just now'); + assert.equal(formatRelativeAge(now - 7 * 60_000, p12, now), '7m ago'); + assert.equal(formatRelativeAge(now - 5 * 3_600_000, p12, now), '5h ago'); + assert.equal(formatRelativeAge(at(2026, 9, 17, 8), p12, now), 'yesterday'); + assert.equal(formatRelativeAge(at(2026, 9, 15, 8), p12, now), '3d ago'); + assert.equal(formatRelativeAge(at(2026, 9, 1, 8), p24, now), '2026-09-01'); + }); +}); + +describe('formatDayDivider', () => { + it('uses the full weekday inside the last week', () => { + assert.equal(formatDayDivider(at(2026, 9, 18), p12, now), 'Today'); + assert.equal(formatDayDivider(at(2026, 9, 17), p12, now), 'Yesterday'); + assert.equal(formatDayDivider(at(2026, 9, 14), p12, now), 'Monday'); + assert.equal(formatDayDivider(at(2026, 9, 11), p12, now), '11 Sep 2026'); + }); +}); diff --git a/src/app/utils/formatTimestamp.ts b/src/app/utils/formatTimestamp.ts new file mode 100644 index 000000000..a342effc1 --- /dev/null +++ b/src/app/utils/formatTimestamp.ts @@ -0,0 +1,117 @@ +import dayjs from 'dayjs'; + +/** + * [Gitea #139] One place that turns a timestamp into words, honouring the + * user's 12/24 h clock and date-format preferences everywhere. + * + * Rules for the `auto` styles, relative to `now`: + * today → "21:14" + * yesterday → "Yesterday 21:14" + * tomorrow → "Tomorrow 21:14" (scheduled sends, reminders) + * last 6 days → "Mon 21:14" + * older / further → "5 Sep 2026 21:14" (date per `dateFormatString`) + */ +export type TimestampPrefs = { + hour24Clock: boolean; + dateFormatString: string; +}; + +export type TimestampStyle = + /** Time for today, day word + time for nearby days, date + time otherwise. */ + | 'auto' + /** Like `auto` but never the time: "Today", "Yesterday", "Mon", "5 Sep 2026". */ + | 'autoDate' + /** Always the clock time. */ + | 'time' + /** Always the date per `dateFormatString`. */ + | 'date' + /** Always date + time. */ + | 'dateTime'; + +const DEFAULT_DATE_FORMAT = 'D MMM YYYY'; + +const dateFormat = (prefs: TimestampPrefs) => prefs.dateFormatString || DEFAULT_DATE_FORMAT; + +export const formatTime = (ts: number, prefs: Pick): string => + dayjs(ts).format(prefs.hour24Clock ? 'HH:mm' : 'hh:mm A'); + +export const formatDate = (ts: number, prefs: TimestampPrefs): string => + dayjs(ts).format(dateFormat(prefs)); + +/** Whole days between the start of `ts`'s day and the start of `now`'s day (negative = future). */ +export const dayDistance = (ts: number, now: number): number => + dayjs(now).startOf('day').diff(dayjs(ts).startOf('day'), 'day'); + +/** "Today" / "Yesterday" / "Tomorrow" / "Mon" for nearby days, else undefined. */ +export const dayWord = (ts: number, now: number): string | undefined => { + const d = dayDistance(ts, now); + if (d === 0) return 'Today'; + if (d === 1) return 'Yesterday'; + if (d === -1) return 'Tomorrow'; + if (d > 1 && d < 7) return dayjs(ts).format('ddd'); + return undefined; +}; + +export function formatTimestamp( + ts: number, + prefs: TimestampPrefs, + style: TimestampStyle = 'auto', + now: number = Date.now(), +): string { + const time = formatTime(ts, prefs); + if (style === 'time') return time; + const date = formatDate(ts, prefs); + if (style === 'date') return date; + if (style === 'dateTime') return `${date} ${time}`; + + const word = dayWord(ts, now); + if (style === 'autoDate') return word ?? date; + if (word === 'Today') return time; + return `${word ?? date} ${time}`; +} + +/** Short relative age for dense lists: "now", "5m", "3h", "Yesterday", "Mon", "5 Sep". */ +export function formatShortAge( + ts: number, + prefs: TimestampPrefs, + now: number = Date.now(), +): string { + const diffMin = Math.floor((now - ts) / 60_000); + if (diffMin < 60) return diffMin < 1 ? 'now' : `${diffMin}m`; + const diffHours = Math.floor(diffMin / 60); + if (diffHours < 24 && dayDistance(ts, now) === 0) return `${diffHours}h`; + const word = dayWord(ts, now); + if (word && word !== 'Today') return word; + // Same year: drop the year from the user's format for brevity. + const sameYear = dayjs(ts).year() === dayjs(now).year(); + const fmt = dateFormat(prefs); + return dayjs(ts).format(sameYear ? fmt.replace(/[-/.\s]*Y{2,4}[-/.\s]*/, ' ').trim() : fmt); +} + +/** Conversational age for list rows: "just now", "5m ago", "3h ago", "yesterday", "3d ago", then the date. */ +export function formatRelativeAge( + ts: number, + prefs: TimestampPrefs, + now: number = Date.now(), +): string { + const diffMin = Math.floor((now - ts) / 60_000); + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHours = Math.floor(diffMin / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const days = dayDistance(ts, now); + if (days === 1) return 'yesterday'; + if (days < 7) return `${days}d ago`; + return formatDate(ts, prefs); +} + +/** Timeline day divider: "Today", "Yesterday", "Monday" (last 6 days), else the date. */ +export function formatDayDivider( + ts: number, + prefs: TimestampPrefs, + now: number = Date.now(), +): string { + const d = dayDistance(ts, now); + if (d > 1 && d < 7) return dayjs(ts).format('dddd'); + return formatTimestamp(ts, prefs, 'autoDate', now); +}