From 8f3bab01a2826819630b4be5598459c5cda109f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 19:37:02 +0000 Subject: [PATCH 1/2] Show relative timestamps on messages using Intl.RelativeTimeFormat Adds a relativeTime() helper (src/app/utils/time.ts) built on Intl.RelativeTimeFormat and uses it in the shared Time component so messages/events show "2 days ago", "10 minutes ago", etc. instead of a fixed clock time. The absolute date/time is kept as a hover tooltip, and the label refreshes every minute so it stays current. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UQphQjunoV9ToZDZRgePG2 --- src/app/components/message/Time.tsx | 50 +++++++++++++++-------------- src/app/utils/time.test.ts | 34 ++++++++++++++++++++ src/app/utils/time.ts | 39 ++++++++++++++++++++++ 3 files changed, 99 insertions(+), 24 deletions(-) create mode 100644 src/app/utils/time.test.ts diff --git a/src/app/components/message/Time.tsx b/src/app/components/message/Time.tsx index 3eab5cc2..0353ec03 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 React, { ComponentProps, useEffect, useState } from 'react'; import { Text, as } from 'folds'; -import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time'; +import { relativeTime, timeDayMonYear, timeHourMinute } from '../../utils/time'; export type TimeProps = { compact?: boolean; @@ -9,35 +9,37 @@ export type TimeProps = { dateFormatString: string; }; +const REFRESH_INTERVAL_MS = 60 * 1000; + /** - * 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. - * - * @param {number} ts - The timestamp to display. - * @param {boolean} [compact=false] - If true, always show only the time. - * @param {boolean} hour24Clock - Whether to use 24-hour time format. - * @param {string} dateFormatString - Format string for the date part. - * @returns {React.ReactElement} A element with the formatted date/time. + * Renders a relative timestamp (e.g. "2 minutes ago", "3 days ago"), refreshing periodically. + * The absolute date/time is available as a hover tooltip. */ export const Time = as<'span', TimeProps & ComponentProps>( ({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => { - const formattedTime = timeHourMinute(ts, hour24Clock); + const [, setTick] = useState(0); + + useEffect(() => { + const intervalId = setInterval(() => setTick((tick) => tick + 1), REFRESH_INTERVAL_MS); + return () => clearInterval(intervalId); + }, []); - 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 = relativeTime(ts, Date.now(), compact ? 'narrow' : 'long'); + const absoluteTime = `${timeDayMonYear(ts, dateFormatString)} ${timeHourMinute( + ts, + hour24Clock + )}`; return ( - + {time} ); diff --git a/src/app/utils/time.test.ts b/src/app/utils/time.test.ts new file mode 100644 index 00000000..768d42f4 --- /dev/null +++ b/src/app/utils/time.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'vitest'; +import { relativeTime } from './time'; + +describe('relativeTime', () => { + const now = new Date('2024-01-10T12:00:00.000Z').getTime(); + + test('formats seconds in the past', () => { + expect(relativeTime(now - 30 * 1000, now)).toBe('30 seconds ago'); + }); + + test('formats "now" for sub-second differences', () => { + expect(relativeTime(now - 500, now)).toBe('now'); + }); + + test('formats minutes in the past', () => { + expect(relativeTime(now - 10 * 60 * 1000, now)).toBe('10 minutes ago'); + }); + + test('formats hours in the past', () => { + expect(relativeTime(now - 3 * 60 * 60 * 1000, now)).toBe('3 hours ago'); + }); + + test('formats days in the past', () => { + expect(relativeTime(now - 2 * 24 * 60 * 60 * 1000, now)).toBe('2 days ago'); + }); + + test('formats a future timestamp', () => { + expect(relativeTime(now + 5 * 60 * 1000, now)).toBe('in 5 minutes'); + }); + + test('supports a narrow style', () => { + expect(relativeTime(now - 2 * 24 * 60 * 60 * 1000, now, 'narrow')).toBe('2d ago'); + }); +}); diff --git a/src/app/utils/time.ts b/src/app/utils/time.ts index 8f1e30e9..e4933c9b 100644 --- a/src/app/utils/time.ts +++ b/src/app/utils/time.ts @@ -84,3 +84,42 @@ export const getYesterday = () => { const date = dayjs(nowTs); return dateFor(date.year(), date.month() + 1, date.date()); }; + +const relativeTimeFormatters = new Map(); + +const getRelativeTimeFormatter = (style: Intl.RelativeTimeFormatStyle): Intl.RelativeTimeFormat => { + let formatter = relativeTimeFormatters.get(style); + if (!formatter) { + formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style }); + relativeTimeFormatters.set(style, formatter); + } + return formatter; +}; + +const RELATIVE_TIME_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [ + ['year', daysToMs(365)], + ['month', daysToMs(30)], + ['week', daysToMs(7)], + ['day', daysToMs(1)], + ['hour', hoursToMs(1)], + ['minute', minutesToMs(1)], + ['second', secondsToMs(1)], +]; + +/** + * Formats `ts` relative to `now` using Intl.RelativeTimeFormat, e.g. "2 days ago", "10 minutes ago". + */ +export const relativeTime = ( + ts: number, + now: number = Date.now(), + style: Intl.RelativeTimeFormatStyle = 'long' +): string => { + const diffMs = ts - now; + const absDiffMs = Math.abs(diffMs); + + const [unit, unitMs] = + RELATIVE_TIME_UNITS.find(([, ms]) => absDiffMs >= ms) ?? + RELATIVE_TIME_UNITS[RELATIVE_TIME_UNITS.length - 1]; + + return getRelativeTimeFormatter(style).format(Math.round(diffMs / unitMs), unit); +}; From 560124a794a9f7cb2ec80898b742717598251c67 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 20:04:40 +0000 Subject: [PATCH 2/2] Show relative timestamps on feed posts Feed post cards had no timestamp at all. Add the shared Time component next to the sender's name, using the post group's most recent event timestamp and the same relativeTime() formatting used elsewhere. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UQphQjunoV9ToZDZRgePG2 --- src/app/features/feed/Feed.tsx | 4 ++++ src/app/features/feed/FeedPostCard.tsx | 12 +++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/app/features/feed/Feed.tsx b/src/app/features/feed/Feed.tsx index 03ca7d49..f7efd431 100644 --- a/src/app/features/feed/Feed.tsx +++ b/src/app/features/feed/Feed.tsx @@ -18,6 +18,8 @@ type FeedProps = { export function Feed({ rooms }: FeedProps) { const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); const [commentsTarget, setCommentsTarget] = useState<{ room: Room; event: MatrixEvent }>(); const posts = useFeedPosts(rooms); @@ -49,6 +51,8 @@ export function Feed({ rooms }: FeedProps) { events={group.posts.map((post) => post.event)} mediaAutoLoad={mediaAutoLoad} urlPreview={urlPreview} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} onOpenComments={handleOpenComments} /> ))} diff --git a/src/app/features/feed/FeedPostCard.tsx b/src/app/features/feed/FeedPostCard.tsx index 81d5d0a2..587f88d3 100644 --- a/src/app/features/feed/FeedPostCard.tsx +++ b/src/app/features/feed/FeedPostCard.tsx @@ -30,6 +30,7 @@ import { MImage, MText, RenderBody, + Time, Username, UsernameBold, } from '../../components/message'; @@ -123,6 +124,8 @@ type FeedPostCardProps = { events: MatrixEvent[]; mediaAutoLoad?: boolean; urlPreview?: boolean; + hour24Clock: boolean; + dateFormatString: string; onOpenComments?: (room: Room, event: MatrixEvent) => void; }; @@ -131,6 +134,8 @@ export function FeedPostCard({ events, mediaAutoLoad, urlPreview, + hour24Clock, + dateFormatString, onOpenComments, }: FeedPostCardProps) { const mx = useMatrixClient(); @@ -209,11 +214,16 @@ export function FeedPostCard({ renderFallback={() => } /> - + {displayName} +