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); +}