Compare commits

...
2 Commits
Author SHA1 Message Date
jaredandClaude Opus 5 bd8c79e0e6 feat(media): consecutive photos/videos render as one gallery grid (#137)
CI / Build & Quality Checks (push) Successful in 1m33s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 5m9s
Client-side only: every file is still its own standard m.image/m.video
event, so Element and friends keep seeing N plain images. In Lotus a run
of media from one sender — contiguous, ≤ 60 s apart, no reply/thread/edit
relation, up to 10 — renders once, at its last event, as a 2–4 column
grid of square thumbnails (blurhash placeholder, video play badge,
tap-to-load when media auto-load is off). A member with reactions or a
thread closes its group so those stay visible under the rendered event.

Tapping a tile opens the lightbox on just that group in send order
(←/→, zoom, download, jump). "Show separately" splits a group back into
individual messages for the session; "Show as gallery" undoes it.

Planning is lazy per render pass (utils/mediaGroups.ts, unit-tested):
the first media event met plans its whole run in both directions, so a
virtual window that starts mid-run agrees with one that starts before it.

Verified: 5 files dropped at once in an encrypted room — both sender and
recipient see one 5-tile grid with decrypted thumbnails; desktop + phone;
a reaction on photo 3 yields [1–3]+👍 and [4–5].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 00:48:13 -04:00
jaredandClaude Opus 5 4d4a76214a 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
2026-09-20 00:28:47 -04:00
34 changed files with 1071 additions and 346 deletions
@@ -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<HTMLButtonElement>;
};
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)}
</Text>
)}
</Box>
@@ -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(
@@ -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 (
+4 -15
View File
@@ -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<typeof Text>>(
({ 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 (
<Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
+3 -5
View File
@@ -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 (
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
@@ -135,7 +133,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
<Text size="T200" priority="300">
{'Created by '}
<b>@{creatorName}</b>
{` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`}
{` on ${format(ts, 'dateTime')}`}
</Text>
)}
</Box>
@@ -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 (
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
@@ -28,11 +23,7 @@ export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
<Box direction="Column" gap="200">
<Box gap="200" justifyContent="SpaceBetween">
<Text size="L400">Kicked User</Text>
{time && date && (
<Text size="T200">
{date} {time}
</Text>
)}
{when && <Text size="T200">{when}</Text>}
</Box>
<Box direction="Column">
{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<undefined, Error, []>(
useCallback(async () => {
@@ -86,11 +74,7 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan
<Box direction="Column" gap="200">
<Box gap="200" justifyContent="SpaceBetween">
<Text size="L400">Banned User</Text>
{time && date && (
<Text size="T200">
{date} {time}
</Text>
)}
{when && <Text size="T200">{when}</Text>}
</Box>
<Box direction="Column">
{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<undefined, Error, []>(
useCallback(async () => {
@@ -161,11 +142,7 @@ export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: User
<Box direction="Column" gap="200">
<Box gap="200" justifyContent="SpaceBetween">
<Text size="L400">Invited User</Text>
{time && date && (
<Text size="T200">
{date} {time}
</Text>
)}
{when && <Text size="T200">{when}</Text>}
</Box>
<Box direction="Column">
{invitedBy && (
+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">
+10 -21
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';
@@ -56,7 +58,7 @@ const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' };
function useDecryptedMediaUrl(
export function useDecryptedMediaUrl(
mx: MatrixClient,
mxcUrl: string | undefined,
encInfo: IEncryptedFile | undefined,
@@ -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`;
@@ -164,7 +154,7 @@ function getSenderName(room: Room, userId: string): string {
// the grid and the lightbox must use this so their positional indices stay in
// lockstep — otherwise a tile skipped for lack of a thumb would shift the
// lightbox and open the wrong media.
function getThumbMxc(mEvent: MatrixEvent): string | undefined {
export function getThumbMxc(mEvent: MatrixEvent): string | undefined {
const c = mEvent.getContent();
const isEnc = !!c.file;
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
@@ -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');
+198 -58
View File
@@ -19,6 +19,7 @@ import {
IContent,
MatrixClient,
MatrixEvent,
MsgType,
RelationType,
Room,
RoomEvent,
@@ -90,6 +91,9 @@ import {
reactionOrEditEvent,
} from '../../utils/room';
import { getLastEditDiff } from '../../utils/editDiff';
import { GroupCandidate, GroupPlan, planMediaGroups } from '../../utils/mediaGroups';
import { MediaGroupGrid, RegroupChip } from './message/MediaGroupGrid';
import { Lightbox, getThumbMxc, toLightboxItems } from './MediaGallery';
import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../state/settings';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
@@ -105,7 +109,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';
@@ -180,6 +185,9 @@ export const getFirstLinkedTimeline = (
return getFirstLinkedTimeline(linkedTm, direction);
};
/** [Gitea #137] Galleries the user asked to see as separate messages (keyed by the group's last event id). */
const separatedGalleries = new Set<string>();
export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
const timelines: EventTimeline[] = [];
@@ -469,6 +477,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// Read positions are computed once in Room.tsx and provided via ReadPositionsContext
// so both RoomTimeline and ThreadTimeline consume the same value (Gitea #38).
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
// [Gitea #137] Re-render after "Show separately" (the Set itself is module-level).
const [, setSeparatedTick] = useState(0);
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
@@ -528,6 +538,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
// [Gitea #219] Timeline images open the shared media lightbox at that event.
const [lightboxEventId, setLightboxEventId] = useState<string | undefined>();
// [Gitea #137] Opened from a gallery grid: the viewer walks that group in send order.
const [lightboxGroup, setLightboxGroup] = useState<MatrixEvent[] | undefined>();
const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
@@ -1166,10 +1178,26 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const { t } = useTranslation();
const renderMatrixEvent = useMatrixEventRenderer<
[string, MatrixEvent, number, EventTimelineSet, boolean]
[
string,
MatrixEvent,
number,
EventTimelineSet,
boolean,
MatrixEvent[] | undefined,
string | undefined,
]
>(
{
[MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
[MessageEvent.RoomMessage]: (
mEventId,
mEvent,
item,
timelineSet,
collapse,
mediaGroup,
regroupId,
) => {
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
@@ -1262,24 +1290,47 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
getContent={getContent}
) : mediaGroup ? (
<MediaGroupGrid
events={mediaGroup}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId}
onOpenImageViewer={() => setLightboxEventId(mEventId)}
mEvent={mEvent}
onOpen={(id) => {
setLightboxGroup(mediaGroup);
setLightboxEventId(id);
}}
onShowSeparately={() => {
separatedGalleries.add(mEventId);
setSeparatedTick((n) => n + 1);
}}
/>
) : (
<>
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId}
onOpenImageViewer={() => setLightboxEventId(mEventId)}
mEvent={mEvent}
/>
{regroupId && (
<RegroupChip
onClick={() => {
separatedGalleries.delete(regroupId);
setSeparatedTick((n) => n + 1);
}}
/>
)}
</>
)}
</Message>
);
@@ -2184,30 +2235,100 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
let isPrevRendered = false;
let newDivider = false;
let dayDivider = false;
const eventRenderer = (item: number) => {
// Perf-5: O(T) → O(log T) via precomputed segments
let eventTimeline: EventTimeline | undefined;
let baseIndex = 0;
{
let lo = 0;
let hi = timelineSegments.length - 1;
while (lo <= hi) {
// eslint-disable-next-line no-bitwise
const mid = (lo + hi) >>> 1;
const [base, len] = timelineSegments[mid];
if (item < base) {
hi = mid - 1;
} else if (item >= base + len) {
lo = mid + 1;
} else {
eventTimeline = timelineSegments[mid][2];
baseIndex = base;
break;
}
// Perf-5: O(T) → O(log T) via precomputed segments
const resolveItem = (
item: number,
): { eventTimeline: EventTimeline; baseIndex: number } | undefined => {
let lo = 0;
let hi = timelineSegments.length - 1;
while (lo <= hi) {
// eslint-disable-next-line no-bitwise
const mid = (lo + hi) >>> 1;
const [base, len] = timelineSegments[mid];
if (item < base) {
hi = mid - 1;
} else if (item >= base + len) {
lo = mid + 1;
} else {
return { eventTimeline: timelineSegments[mid][2], baseIndex: base };
}
}
if (!eventTimeline) return null;
const timelineSet = eventTimeline?.getTimelineSet();
return undefined;
};
const eventAt = (
item: number,
): { mEvent: MatrixEvent; timelineSet: EventTimelineSet } | undefined => {
const seg = resolveItem(item);
if (!seg) return undefined;
const mEvent = getTimelineEvent(
seg.eventTimeline,
getTimelineRelativeIndex(item, seg.baseIndex),
);
return mEvent ? { mEvent, timelineSet: seg.eventTimeline.getTimelineSet() } : undefined;
};
// [Gitea #137] Gallery grouping is planned lazily per render pass: the first
// media event we meet plans its whole run (looking both ways, so a virtual
// window that starts mid-run still agrees), and the plan is reused for the
// run's other members.
const groupPlans = new Map<number, GroupPlan | null>();
const candidateAt = (index: number): GroupCandidate | undefined => {
const found = eventAt(index);
if (!found) return undefined;
const { mEvent: ev, timelineSet } = found;
const sender = ev.getSender() ?? '';
const base = { sender, ts: ev.getTs(), hasRelation: false, redacted: false, mustEnd: false };
if (
reactionOrEditEvent(ev) ||
ev.getType() === 'm.room.redaction' ||
ignoredUsersSet.has(sender)
)
return { ...base, kind: 'skip' };
if (ev.getType() === StateEvent.RoomMember && hideMembershipEvents)
return { ...base, kind: 'skip' };
const msgtype = ev.getContent().msgtype;
const isMedia =
ev.getType() === MessageEvent.RoomMessage &&
(msgtype === MsgType.Image || msgtype === MsgType.Video) &&
!!getThumbMxc(ev);
if (!isMedia) return { ...base, kind: 'other' };
const id = ev.getId() ?? '';
const reactions = getEventReactions(timelineSet, id)?.getSortedAnnotationsByKey();
const hasThread =
ev.getThread() !== undefined ||
ev.getServerAggregatedRelation(RelationType.Thread) !== undefined;
return {
...base,
kind: 'media',
hasRelation: !!ev.getContent()['m.relates_to'],
redacted: ev.isRedacted(),
mustEnd: (reactions?.length ?? 0) > 0 || hasThread,
};
};
const mediaGroupFor = (
item: number,
): { hidden: boolean; events?: MatrixEvent[]; regroup?: string } | undefined => {
if (!groupPlans.has(item)) {
if (candidateAt(item)?.kind !== 'media') return undefined;
const plans = planMediaGroups(candidateAt, item);
plans.forEach((plan, index) => groupPlans.set(index, plan));
if (!plans.has(item)) groupPlans.set(item, null);
}
const plan = groupPlans.get(item);
if (!plan) return undefined;
const lastId = eventAt(plan.members[plan.members.length - 1])?.mEvent.getId() ?? '';
if (separatedGalleries.has(lastId))
return plan.renders ? { hidden: false, regroup: lastId } : undefined;
if (!plan.renders) return { hidden: true };
const events = plan.members
.map((index) => eventAt(index)?.mEvent)
.filter((ev): ev is MatrixEvent => !!ev);
return { hidden: false, events };
};
const eventRenderer = (item: number) => {
const resolved = resolveItem(item);
if (!resolved) return null;
const { eventTimeline, baseIndex } = resolved;
const timelineSet = eventTimeline.getTimelineSet();
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
const mEventId = mEvent?.getId();
@@ -2251,17 +2372,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
prevEvent.getType() === mEvent.getType() &&
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
const eventJSX = reactionOrEditEvent(mEvent)
? null
: renderMatrixEvent(
mEvent.getType(),
typeof mEvent.getStateKey() === 'string',
mEventId,
mEvent,
item,
timelineSet,
collapsed,
);
const mediaGroup = mediaGroupFor(item);
const eventJSX =
reactionOrEditEvent(mEvent) || mediaGroup?.hidden
? null
: renderMatrixEvent(
mEvent.getType(),
typeof mEvent.getStateKey() === 'string',
mEventId,
mEvent,
item,
timelineSet,
collapsed,
mediaGroup?.events,
mediaGroup?.regroup,
);
prevEvent = mEvent;
isPrevRendered = !!eventJSX;
@@ -2282,11 +2407,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>
@@ -2461,7 +2582,26 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
onClose={() => setEditHistoryEvent(undefined)}
/>
)}
{lightboxEventId && (
{lightboxEventId && lightboxGroup && (
<Lightbox
items={toLightboxItems(room, lightboxGroup)}
initialIndex={Math.max(
0,
lightboxGroup.findIndex((ev) => ev.getId() === lightboxEventId),
)}
useAuthentication={useAuthentication}
onClose={() => {
setLightboxEventId(undefined);
setLightboxGroup(undefined);
}}
onJump={(id) => {
setLightboxEventId(undefined);
setLightboxGroup(undefined);
navigateRoom(room.roomId, id);
}}
/>
)}
{lightboxEventId && !lightboxGroup && (
<RoomMediaLightbox
room={room}
eventId={lightboxEventId}
@@ -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);
@@ -0,0 +1,118 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const Wrap = style([
DefaultReset,
{
display: 'block',
width: toRem(480),
maxWidth: '100%',
},
]);
export const Grid = style([
DefaultReset,
{
display: 'grid',
gap: toRem(3),
width: '100%',
borderRadius: config.radii.R400,
overflow: 'hidden',
},
]);
export const Cell = style([
DefaultReset,
{
position: 'relative',
aspectRatio: '1 / 1',
minWidth: 0,
padding: 0,
border: 'none',
cursor: 'pointer',
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
selectors: {
'&:focus-visible': {
outline: `${config.borderWidth.B600} solid ${color.Primary.Main}`,
outlineOffset: `calc(-1 * ${config.borderWidth.B600})`,
},
},
},
]);
export const CellImg = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
transition: 'transform 150ms',
selectors: {
[`${Cell}:hover &`]: {
transform: 'scale(1.03)',
},
},
},
]);
export const CellBlur = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
},
]);
export const PlayBadge = style([
DefaultReset,
{
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(36),
height: toRem(36),
borderRadius: config.radii.Pill,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
color: 'white',
pointerEvents: 'none',
},
]);
export const Footer = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
marginTop: config.space.S100,
},
]);
export const FooterButton = style([
DefaultReset,
{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'inherit',
textDecoration: 'underline',
textDecorationColor: 'transparent',
selectors: {
'&:hover, &:focus-visible': {
textDecorationColor: 'currentColor',
},
},
},
]);
@@ -0,0 +1,148 @@
import React, { useState } from 'react';
import { Icon, Icons, Spinner, Text } from 'folds';
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
import { BlurhashCanvas } from 'react-blurhash';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { getThumbMxc, useDecryptedMediaUrl } from '../MediaGallery';
import { validBlurHash } from '../../../utils/blurHash';
import { MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
import * as css from './MediaGroupGrid.css';
/** Column count for `n` tiles: 2 → 2, 3 → 3, 4 → 2×2, 56 → 3, 7+ → 4. */
export const gridColumns = (n: number): number => {
if (n <= 2) return 2;
if (n === 3) return 3;
if (n === 4) return 2;
if (n <= 6) return 3;
return 4;
};
/** "5 photos" / "2 videos" / "6 items". */
export const describeGroup = (events: MatrixEvent[]): string => {
const videos = events.filter((e) => e.getContent().msgtype === MsgType.Video).length;
const n = events.length;
if (videos === 0) return `${n} photos`;
if (videos === n) return `${n} videos`;
return `${n} items`;
};
function Cell({
mEvent,
load,
onOpen,
}: {
mEvent: MatrixEvent;
load: boolean;
onOpen: (eventId: string) => void;
}) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const content = mEvent.getContent();
const isVideo = content.msgtype === MsgType.Video;
const thumbMxc = getThumbMxc(mEvent);
const info = content.info as Record<string, unknown> | undefined;
const encInfo = content.file
? ((info?.thumbnail_file as typeof content.file | undefined) ?? content.file)
: undefined;
const mimeType =
(info?.thumbnail_info as { mimetype?: string } | undefined)?.mimetype ??
(info?.mimetype as string | undefined);
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME] as string | undefined);
const media = useDecryptedMediaUrl(mx, thumbMxc, encInfo, useAuthentication, mimeType, load);
const body = typeof content.body === 'string' ? content.body : '';
return (
<button
type="button"
className={css.Cell}
aria-label={body || (isVideo ? 'Video' : 'Image')}
onClick={() => onOpen(mEvent.getId() ?? '')}
>
{blurHash && media.status !== 'ok' && (
<BlurhashCanvas className={css.CellBlur} hash={blurHash} width={32} height={32} punch={1} />
)}
{load && media.status === 'loading' && <Spinner size="200" />}
{media.status === 'error' && <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />}
{!load && !blurHash && <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />}
{media.status === 'ok' && <img src={media.url} alt="" className={css.CellImg} />}
{isVideo && (
<span className={css.PlayBadge}>
<Icon src={Icons.Play} size="200" filled />
</span>
)}
</button>
);
}
type MediaGroupGridProps = {
events: MatrixEvent[];
mediaAutoLoad: boolean;
onOpen: (eventId: string) => void;
onShowSeparately: () => void;
};
/**
* [Gitea #137] Several consecutive image/video events from one sender shown
* as one grid. Each tile opens the room's shared lightbox at that event, so
* / walk through the group (and beyond). Purely a render-time grouping.
*/
export function MediaGroupGrid({
events,
mediaAutoLoad,
onOpen,
onShowSeparately,
}: MediaGroupGridProps) {
const [load, setLoad] = useState(mediaAutoLoad);
const columns = gridColumns(events.length);
return (
<div className={css.Wrap}>
<div
className={css.Grid}
role="group"
aria-label={describeGroup(events)}
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
onClick={load ? undefined : () => setLoad(true)}
>
{events.map((ev) => (
<Cell
key={ev.getId()}
mEvent={ev}
load={load}
onOpen={load ? onOpen : () => setLoad(true)}
/>
))}
</div>
<div className={css.Footer}>
<Text size="T200" priority="300">
{describeGroup(events)}
{!load && ' · tap to load'}
</Text>
<Text size="T200" priority="300">
·
</Text>
<Text
as="button"
size="T200"
priority="300"
className={css.FooterButton}
onClick={onShowSeparately}
>
Show separately
</Text>
</div>
</div>
);
}
/** Shown under the last of a gallery the user split up, to put it back together. */
export function RegroupChip({ onClick }: { onClick: () => void }) {
return (
<div className={css.Footer}>
<Text as="button" size="T200" priority="300" className={css.FooterButton} onClick={onClick}>
Show as gallery
</Text>
</div>
);
}
+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
+28
View File
@@ -0,0 +1,28 @@
import { useCallback, useMemo } from 'react';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import {
TimestampStyle,
formatShortAge,
formatTimestamp,
TimestampPrefs,
} from '../utils/formatTimestamp';
/**
* [Gitea #139] Timestamp formatting bound to the user's clock/date settings.
* `format(ts)` is the everyday "auto" style; pass a style for the others.
*/
export function useTimestampFormatter() {
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const prefs = useMemo<TimestampPrefs>(
() => ({ 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 };
}
+9 -7
View File
@@ -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('<'));
});
+5 -5
View File
@@ -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')}`;
+13 -7
View File
@@ -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 ',
),
);
});
+12 -15
View File
@@ -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 <input>s — matches the app's surface tokens and
+96
View File
@@ -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');
});
});
+117
View File
@@ -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<TimestampPrefs, 'hour24Clock'>): 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);
}
+119
View File
@@ -0,0 +1,119 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { GroupCandidate, findMediaRun, planMediaGroups, splitRun } from './mediaGroups';
const media = (
sender: string,
ts: number,
extra: Partial<GroupCandidate> = {},
): GroupCandidate => ({
sender,
ts,
kind: 'media',
hasRelation: false,
redacted: false,
mustEnd: false,
...extra,
});
const text = (sender: string, ts: number): GroupCandidate => ({
...media(sender, ts),
kind: 'other',
});
const skip = (ts: number): GroupCandidate => ({ ...media('x', ts), kind: 'skip' });
const seq = (items: GroupCandidate[]) => (i: number) => items[i];
describe('findMediaRun', () => {
it('collects contiguous same-sender media within the gap, from any member', () => {
const at = seq([
text('a', 0),
media('a', 1000),
media('a', 2000),
media('a', 3000),
text('a', 4000),
]);
assert.deepEqual(findMediaRun(at, 1), [1, 2, 3]);
assert.deepEqual(findMediaRun(at, 2), [1, 2, 3]);
assert.deepEqual(findMediaRun(at, 3), [1, 2, 3]);
});
it('breaks on a different sender, text in between, or a long gap', () => {
const at = seq([
media('a', 0),
media('b', 1000),
media('a', 2000),
text('a', 2500),
media('a', 3000),
media('a', 70_000),
]);
assert.deepEqual(findMediaRun(at, 0), [0]);
assert.deepEqual(findMediaRun(at, 2), [2]);
assert.deepEqual(findMediaRun(at, 4), [4]);
assert.deepEqual(findMediaRun(at, 5), [5]);
});
it('skips invisible filler such as reactions and edits', () => {
const at = seq([media('a', 0), skip(100), skip(200), media('a', 1000)]);
assert.deepEqual(findMediaRun(at, 0), [0, 3]);
});
it('never groups replies, thread messages, edits or redacted events', () => {
const at = seq([
media('a', 0),
media('a', 500, { hasRelation: true }),
media('a', 1000),
media('a', 1500, { redacted: true }),
]);
assert.deepEqual(findMediaRun(at, 0), [0]);
assert.deepEqual(findMediaRun(at, 1), []);
assert.deepEqual(findMediaRun(at, 2), [2]);
});
it('measures the gap between consecutive members, not from the first', () => {
const at = seq([media('a', 0), media('a', 50_000), media('a', 100_000)]);
assert.deepEqual(findMediaRun(at, 0), [0, 1, 2]);
});
});
describe('splitRun', () => {
it('caps group size and drops singles', () => {
const items = Array.from({ length: 12 }, (_, i) => media('a', i * 1000));
const groups = splitRun(
Array.from({ length: 12 }, (_, i) => i),
seq(items),
10,
);
assert.deepEqual(groups, [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[10, 11],
]);
assert.deepEqual(splitRun([0, 1, 2, 3], seq(items), 3), [[0, 1, 2]]);
});
it('closes a group at a member with reactions so they stay visible', () => {
const items = [
media('a', 0),
media('a', 1000, { mustEnd: true }),
media('a', 2000),
media('a', 3000),
];
assert.deepEqual(splitRun([0, 1, 2, 3], seq(items)), [
[0, 1],
[2, 3],
]);
});
});
describe('planMediaGroups', () => {
it('marks only the last member as the renderer', () => {
const at = seq([media('a', 0), media('a', 1000), media('a', 2000)]);
const plan = planMediaGroups(at, 1);
assert.equal(plan.get(0)?.renders, false);
assert.equal(plan.get(1)?.renders, false);
assert.equal(plan.get(2)?.renders, true);
assert.deepEqual(plan.get(2)?.members, [0, 1, 2]);
});
it('returns an empty plan for a lone image', () => {
assert.equal(planMediaGroups(seq([text('a', 0), media('a', 1000)]), 1).size, 0);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* [Gitea #137] Client-side "gallery" grouping. Consecutive image/video events
* from one sender, close together in time and with nothing else in between,
* render as one grid. Nothing changes on the wire: every file is still its
* own standard event, so other clients see N ordinary images.
*/
export type GroupCandidate = {
sender: string;
ts: number;
/** `media` can join; `skip` is invisible filler (reactions, edits…); anything else breaks. */
kind: 'media' | 'skip' | 'other';
/** Reply / thread / edit relation on the event itself — never grouped. */
hasRelation: boolean;
redacted: boolean;
/** Reactions or a thread hang off this event: it may only be a group's last member. */
mustEnd: boolean;
};
export const MEDIA_GROUP_MAX_GAP_MS = 60_000;
export const MEDIA_GROUP_CAP = 10;
const joinable = (c: GroupCandidate | undefined): c is GroupCandidate =>
!!c && c.kind === 'media' && !c.hasRelation && !c.redacted;
/**
* The maximal run of groupable media around index `i` (inclusive), as ordered
* indices. `at` returns the candidate at an absolute index or undefined past
* either end. Returns just `[i]` (or `[]` if `i` itself can't group) when
* there is nothing to group with.
*/
export function findMediaRun(
at: (index: number) => GroupCandidate | undefined,
i: number,
maxGapMs: number = MEDIA_GROUP_MAX_GAP_MS,
): number[] {
const me = at(i);
if (!joinable(me)) return [];
const extend = (dir: 1 | -1): number[] => {
const out: number[] = [];
let last = me;
let j = i + dir;
for (;;) {
const c = at(j);
if (!c) break;
if (c.kind === 'skip') {
j += dir;
continue;
}
if (!joinable(c) || c.sender !== me.sender) break;
if (Math.abs(c.ts - last.ts) > maxGapMs) break;
out.push(j);
last = c;
j += dir;
}
return out;
};
return [...extend(-1).reverse(), i, ...extend(1)];
}
/**
* Cut a run into groups: at most `cap` members each, and a member that has
* reactions/threads (`mustEnd`) closes its group so those stay visible under
* the rendered (last) event. Runs of one are dropped they render normally.
*/
export function splitRun(
run: number[],
at: (index: number) => GroupCandidate | undefined,
cap: number = MEDIA_GROUP_CAP,
): number[][] {
const groups: number[][] = [];
let current: number[] = [];
run.forEach((index) => {
current.push(index);
if (at(index)?.mustEnd || current.length >= cap) {
groups.push(current);
current = [];
}
});
if (current.length) groups.push(current);
return groups.filter((g) => g.length >= 2);
}
export type GroupPlan = {
/** Ordered member indices; the last one renders the grid. */
members: number[];
/** Whether `index` is the member that renders. */
renders: boolean;
};
/** Plan every group in the run containing `i`, keyed by member index. */
export function planMediaGroups(
at: (index: number) => GroupCandidate | undefined,
i: number,
opts: { maxGapMs?: number; cap?: number } = {},
): Map<number, GroupPlan> {
const plans = new Map<number, GroupPlan>();
const run = findMediaRun(at, i, opts.maxGapMs);
splitRun(run, at, opts.cap).forEach((members) => {
const last = members[members.length - 1];
members.forEach((index) => plans.set(index, { members, renders: index === last }));
});
return plans;
}