Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 26 additions & 24 deletions src/app/components/message/Time.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 <Text as="time"> 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<typeof Text>>(
({ 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 (
<Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
<Text
as="time"
style={{ flexShrink: 0 }}
size="T200"
priority="300"
title={absoluteTime}
{...props}
ref={ref}
>
{time}
</Text>
);
Expand Down
4 changes: 4 additions & 0 deletions src/app/features/feed/Feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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}
/>
))}
Expand Down
12 changes: 11 additions & 1 deletion src/app/features/feed/FeedPostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
MImage,
MText,
RenderBody,
Time,
Username,
UsernameBold,
} from '../../components/message';
Expand Down Expand Up @@ -123,6 +124,8 @@ type FeedPostCardProps = {
events: MatrixEvent[];
mediaAutoLoad?: boolean;
urlPreview?: boolean;
hour24Clock: boolean;
dateFormatString: string;
onOpenComments?: (room: Room, event: MatrixEvent) => void;
};

Expand All @@ -131,6 +134,8 @@ export function FeedPostCard({
events,
mediaAutoLoad,
urlPreview,
hour24Clock,
dateFormatString,
onOpenComments,
}: FeedPostCardProps) {
const mx = useMatrixClient();
Expand Down Expand Up @@ -209,11 +214,16 @@ export function FeedPostCard({
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
<Username>
<Username style={{ flexGrow: 1 }}>
<Text as="span" truncate>
<UsernameBold>{displayName}</UsernameBold>
</Text>
</Username>
<Time
ts={primaryEvent.getTs()}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</Box>
{events.map((event) => (
<FeedPostEventBody
Expand Down
34 changes: 34 additions & 0 deletions src/app/utils/time.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
39 changes: 39 additions & 0 deletions src/app/utils/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,42 @@ export const getYesterday = () => {
const date = dayjs(nowTs);
return dateFor(date.year(), date.month() + 1, date.date());
};

const relativeTimeFormatters = new Map<Intl.RelativeTimeFormatStyle, Intl.RelativeTimeFormat>();

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