refactor(time): one timestamp formatter honouring the clock/date settings (#139)
CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-20 00:28:47 -04:00
co-authored by Claude Opus 5
parent 8d11a62e14
commit 4d4a76214a
30 changed files with 383 additions and 292 deletions
+4 -14
View File
@@ -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 (
<Box
@@ -52,6 +52,7 @@ import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput';
import { SearchFilters } from './SearchFilters';
import { VirtualTile } from '../../components/virtualizer';
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams =>
useMemo(
@@ -74,6 +75,7 @@ type EncryptedRoomCachePanelProps = {
};
function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelProps) {
const mx = useMatrixClient();
const { format } = useTimestampFormatter();
const [loadingRooms, setLoadingRooms] = useState<Set<string>>(new Set());
const encryptedRooms = useMemo(
@@ -140,7 +142,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP
</Text>
<Text size="T200" priority="300">
{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'}
</Text>
</Box>
+4 -24
View File
@@ -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<HTMLElement> = (evt) => {
@@ -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<typeof useMatrixClient>, userId: string): string {
@@ -296,6 +284,7 @@ type LogEntryProps = {
};
function LogEntry({ ev, desc }: LogEntryProps) {
const { prefs } = useTimestampFormatter();
return (
<Box
alignItems="Center"
@@ -326,7 +315,7 @@ function LogEntry({ ev, desc }: LogEntryProps) {
{desc.text}
</Text>
<Text size="T200" priority="300">
{formatRelativeTs(ev.getTs())}
{formatRelativeAge(ev.getTs(), prefs)}
</Text>
</Box>
</Box>
@@ -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) {
</Text>
{stats.oldestTs !== null && stats.newestTs !== null && (
<Text size="T200" priority="300">
from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)}
from {format(stats.oldestTs, 'date')} to {format(stats.newestTs, 'date')}
</Text>
)}
<Text size="T200" priority="300">
Last updated {formatUpdatedAt(lastUpdated)}
Last updated {format(lastUpdated, 'time')}
</Text>
</Box>
<Box shrink="No">
+8 -19
View File
@@ -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 (
<Overlay open backdrop={<OverlayBackdrop />}>
@@ -614,7 +601,8 @@ function GalleryTile({
mimeType,
nearViewport,
);
const relDate = formatRelativeDate(ts);
const { prefs } = useTimestampFormatter();
const relDate = formatRelativeAge(ts, prefs);
return (
<div className={css.GalleryTileWrap}>
@@ -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');
+3 -6
View File
@@ -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
<TimelineDivider variant="Surface">
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
<Text size="L400">
{(() => {
if (today(mEvent.getTs())) return 'Today';
if (yesterday(mEvent.getTs())) return 'Yesterday';
return timeDayMonthYear(mEvent.getTs());
})()}
{formatDayDivider(mEvent.getTs(), { hour24Clock, dateFormatString })}
</Text>
</Badge>
</TimelineDivider>
@@ -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<string>(() => toLocalDate(def));
const [timeValue, setTimeValue] = useState<string>(() => 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();
@@ -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<Set<string>>(new Set());
@@ -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<RectCords>();
const [datePickerCords, setDatePickerCords] = useState<RectCords>();
@@ -131,7 +133,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
after={<Icon size="50" src={Icons.ChevronBottom} />}
onClick={handleTimePicker}
>
<Text size="B300">{timeHourMinute(ts, hour24Clock)}</Text>
<Text size="B300">{formatTime(ts, { hour24Clock })}</Text>
</Chip>
<PopOut
anchor={timePickerCords}
@@ -172,7 +174,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
after={<Icon size="50" src={Icons.ChevronBottom} />}
onClick={handleDatePicker}
>
<Text size="B300">{timeDayMonthYear(ts)}</Text>
<Text size="B300">{formatDate(ts, { hour24Clock, dateFormatString })}</Text>
</Chip>
<PopOut
anchor={datePickerCords}
@@ -27,7 +27,7 @@ import { useModalStyle } from '../../../hooks/useModalStyle';
import { sanitizeCustomHtml } from '../../../utils/sanitize';
import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { timeDayMonYear, timeHourMinute } from '../../../utils/time';
import { formatTimestamp } from '../../../utils/formatTimestamp';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { diffWords } from '../../../utils/textDiff';
@@ -229,11 +229,7 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
const initialLoading = historyState.status === AsyncStatus.Loading && edits.length === 0;
const loadingMore = historyState.status === AsyncStatus.Loading && edits.length > 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);
+6 -1
View File
@@ -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}
@@ -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<string | null>(null);
const [customOpen, setCustomOpen] = useState(false);
@@ -185,7 +188,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
<Box key={`${r.timestamp}-${idx}`} alignItems="Center" gap="200">
<Icon src={Icons.Clock} size="100" style={{ flexShrink: 0 }} />
<Text size="T200" style={{ flexGrow: 1, minWidth: 0 }} truncate>
{formatFriendlyDateTime(r.timestamp)}
{formatFriendlyDateTime(r.timestamp, prefs)}
</Text>
<IconButton
size="300"
@@ -193,7 +196,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
variant="SurfaceVariant"
fill="None"
onClick={() => handleCancelExisting(r.timestamp)}
aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp)}`}
aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp, prefs)}`}
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
+3 -10
View File
@@ -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 (
<Box style={{ marginTop: config.space.S200 }}>
@@ -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) {
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
<Text size="L400">
{(() => {
if (today(mEvent.getTs())) return 'Today';
if (yesterday(mEvent.getTs())) return 'Yesterday';
return timeDayMonthYear(mEvent.getTs());
})()}
{formatDayDivider(mEvent.getTs(), { hour24Clock, dateFormatString })}
</Text>
</Badge>
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
@@ -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
<Icon size="50" src={Icons.Thread} />
<Text size="T200" priority="300" truncate style={{ flexGrow: 1 }}>
{count} {count === 1 ? 'reply' : 'replies'}
{typeof lastTs === 'number' ? ` · ${formatTimeAgo(lastTs)}` : ''}
{typeof lastTs === 'number' ? ` · ${formatRelativeAge(lastTs, prefs)}` : ''}
</Text>
<Box shrink="No" alignItems="Center">
{participants.slice(0, MAX_PARTICIPANTS).map((userId) => (
@@ -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 (
<Text className={BreakWord} size="T200">
<Text size="Inherit" as="span" priority="300">
{'Last activity: '}
</Text>
<>
{today(ts) && 'Today'}
{yesterday(ts) && 'Yesterday'}
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts, dateFormatString)}{' '}
{timeHourMinute(ts, hour24Clock)}
</>
{format(ts)}
</Text>
);
}
@@ -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 (
<SettingTile