Files
cinny/src/app/features/room/message/EditHistoryModal.tsx
T
jaredandClaude Opus 5 4d4a76214a
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
refactor(time): one timestamp formatter honouring the clock/date settings (#139)
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

377 lines
14 KiB
TypeScript

import React, { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import parse from 'html-react-parser';
import Linkify from 'linkify-react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Header,
Icon,
IconButton,
Icons,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Spinner,
Switch,
Text,
color,
config,
} from 'folds';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { stopPropagation } from '../../../utils/keyboard';
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 { formatTimestamp } from '../../../utils/formatTimestamp';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { diffWords } from '../../../utils/textDiff';
type RawEditEvent = {
type: string;
content: Record<string, unknown>;
origin_server_ts: number;
event_id: string;
};
type EditHistoryResponse = {
chunk: Array<Record<string, unknown>>;
next_batch?: string;
};
type EditHistoryModalProps = {
room: Room;
mEvent: MatrixEvent;
onClose: () => void;
};
function isRawEditEvent(raw: unknown): raw is RawEditEvent {
if (typeof raw !== 'object' || raw === null) return false;
const r = raw as Record<string, unknown>;
return typeof r.event_id === 'string' && typeof r.origin_server_ts === 'number';
}
function renderContent(source: Record<string, unknown>): ReactNode {
const format = source.format;
const formattedBody = source.formatted_body;
if (
format === 'org.matrix.custom.html' &&
typeof formattedBody === 'string' &&
formattedBody.trim()
) {
return parse(sanitizeCustomHtml(formattedBody));
}
const body = source.body;
const text = typeof body === 'string' ? body : '(no text)';
return <Linkify options={LINKIFY_OPTS}>{text}</Linkify>;
}
function getOriginalContent(evt: MatrixEvent): ReactNode {
// For E2EE events, evt.event.content is the ciphertext (no body field) — "(no text)" bug.
// getClearContent() returns the decrypted original content, bypassing _replacingEvent,
// so it gives us the pre-edit body even when the SDK has an edit applied.
// For unencrypted events, getClearContent() returns null, so we fall back to event.content.
const raw =
(evt.getClearContent() as Record<string, unknown> | null) ??
(evt.event as { content?: Record<string, unknown> }).content ??
{};
return renderContent(raw);
}
function getVersionContent(evt: MatrixEvent): ReactNode {
// Edit events carry the new text in m.new_content per Matrix spec.
const content = evt.getContent();
const newContent = content['m.new_content'] as Record<string, unknown> | undefined;
return renderContent(newContent ?? content);
}
const asText = (source: Record<string, unknown> | null | undefined): string => {
const body = source?.body;
return typeof body === 'string' ? body : '';
};
// Plain-text (body) of the pre-edit message — mirrors getOriginalContent, but
// returns the raw string for diffing rather than a rendered node.
function getOriginalText(evt: MatrixEvent): string {
const raw =
(evt.getClearContent() as Record<string, unknown> | null) ??
(evt.event as { content?: Record<string, unknown> }).content ??
{};
return asText(raw);
}
// Plain-text (body) of an edit's new content.
function getVersionText(evt: MatrixEvent): string {
const content = evt.getContent();
const newContent = content['m.new_content'] as Record<string, unknown> | undefined;
return asText(newContent ?? content);
}
// Renders a word-level diff of prev -> next: added words highlighted, removed
// words struck-through, using semantic <ins>/<del> elements. Plain-text only
// (formatting isn't diffed — see the "Highlight changes" toggle).
function DiffText({ prev, next }: { prev: string; next: string }) {
const segments = useMemo(() => diffWords(prev, next), [prev, next]);
if (segments.length === 0) return <>(no text)</>;
return (
<>
{segments.map((seg, i) => {
const key = `${i}-${seg.type}`;
if (seg.type === 'added') {
return (
<ins
key={key}
style={{
background: color.Success.Container,
color: color.Success.OnContainer,
border: `${config.borderWidth.B300} solid ${color.Success.ContainerLine}`,
borderRadius: config.radii.R300,
padding: `0 ${config.space.S100}`,
textDecoration: 'none',
boxDecorationBreak: 'clone',
WebkitBoxDecorationBreak: 'clone',
}}
>
{seg.text}
</ins>
);
}
if (seg.type === 'removed') {
return (
<del key={key} style={{ color: color.Critical.Main }}>
{seg.text}
</del>
);
}
return <span key={key}>{seg.text}</span>;
})}
</>
);
}
export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProps) {
const mx = useMatrixClient();
const modalStyle = useModalStyle(560);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const [showDiff, setShowDiff] = useState(true);
const eventId = mEvent.getId();
const roomId = room.roomId;
// Accumulated, de-duplicated edits across paginated fetches.
const [edits, setEdits] = useState<MatrixEvent[]>([]);
const [nextBatch, setNextBatch] = useState<string | undefined>(undefined);
const parseRawEvents = useCallback(
(rawEvents: Array<Record<string, unknown>>): Promise<MatrixEvent[]> =>
Promise.all(
rawEvents.filter(isRawEditEvent).map(async (raw) => {
const existing = room.findEventById(raw.event_id);
if (existing) return existing;
const evt = new MatrixEvent({
type: raw.type,
content: raw.content,
origin_server_ts: raw.origin_server_ts,
event_id: raw.event_id,
room_id: roomId,
sender: mEvent.getSender() ?? '',
});
if (evt.isEncrypted()) {
await mx.decryptEventIfNeeded(evt);
}
return evt;
}),
),
[room, roomId, mEvent, mx],
);
const [historyState, fetchHistory] = useAsyncCallback<void, unknown, [string | undefined]>(
useCallback(
async (from?: string) => {
if (!eventId) return;
// Relations API lives at /_matrix/client/v1/ (not v3); use raw fetch to avoid SDK prefix
const token = mx.getAccessToken();
const baseUrl = mx.getHomeserverUrl();
const fromParam = from ? `&from=${encodeURIComponent(from)}` : '';
const url = `${baseUrl}/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(eventId)}/m.replace?limit=50${fromParam}`;
const fetchRes = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!fetchRes.ok) throw new Error(`HTTP ${fetchRes.status}`);
const res = (await fetchRes.json()) as EditHistoryResponse;
const newEvents = await parseRawEvents(res.chunk ?? []);
// Merge with prior pages, de-dupe by event id, sort chronologically so
// page ordering across batches is always correct.
setEdits((prev) => {
const byId = new Map<string, MatrixEvent>();
[...prev, ...newEvents].forEach((evt) => {
const id = evt.getId();
if (id) byId.set(id, evt);
});
return Array.from(byId.values()).sort((a, b) => a.getTs() - b.getTs());
});
setNextBatch(res.next_batch);
},
[mx, roomId, eventId, parseRawEvents],
),
);
useEffect(() => {
fetchHistory(undefined).catch(() => undefined);
}, [fetchHistory]);
const initialLoading = historyState.status === AsyncStatus.Loading && edits.length === 0;
const loadingMore = historyState.status === AsyncStatus.Loading && edits.length > 0;
const formatTs = (ts: number): string => formatTimestamp(ts, { hour24Clock, dateFormatString });
const originalContent = getOriginalContent(mEvent);
const originalTs = mEvent.getTs();
// Ordered plain-text of every version: [original, ...each edit]. Edit i diffs
// against versionTexts[i] (the version immediately before it).
const versionTexts = [getOriginalText(mEvent), ...edits.map(getVersionText)];
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: onClose,
escapeDeactivates: stopPropagation,
}}
>
<Modal
variant="Surface"
size="500"
role="dialog"
aria-modal="true"
aria-labelledby="edit-history-title"
style={modalStyle}
>
<Header
variant="Surface"
size="500"
style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
>
<Box grow="Yes">
<Text as="h2" id="edit-history-title" size="H4" truncate>
Edit History
</Text>
</Box>
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
<Text size="T200" priority="300">
Highlight changes
</Text>
<Switch variant="Primary" value={showDiff} onChange={setShowDiff} />
</Box>
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Scroll size="300" hideTrack style={{ maxHeight: '60vh' }}>
<Box
direction="Column"
gap="200"
style={{
padding: config.space.S400,
paddingBottom: config.space.S700,
}}
>
{initialLoading && (
<Box
justifyContent="Center"
alignItems="Center"
style={{ padding: config.space.S400 }}
>
<Spinner size="200" />
</Box>
)}
{historyState.status === AsyncStatus.Error && edits.length === 0 && (
<Text size="T300" priority="300">
Failed to load edit history.
</Text>
)}
{!initialLoading && historyState.status !== AsyncStatus.Error && (
<Box direction="Column" gap="300">
<Box direction="Column" gap="100">
<Box gap="200" alignItems="Center">
<Text size="L400">Original</Text>
<Text size="T200" priority="300">
{formatTs(originalTs)}
</Text>
</Box>
<Text size="T300" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{originalContent}
</Text>
</Box>
{edits.map((editEvt, index) => (
<Box key={editEvt.getId() ?? index} direction="Column" gap="100">
<Box gap="200" alignItems="Center">
<Text size="L400">
{index === edits.length - 1
? `Edit ${index + 1} (current)`
: `Edit ${index + 1}`}
</Text>
<Text size="T200" priority="300">
{formatTs(editEvt.getTs())}
</Text>
</Box>
<Text
size="T300"
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
>
{showDiff ? (
<DiffText prev={versionTexts[index]} next={versionTexts[index + 1]} />
) : (
getVersionContent(editEvt)
)}
</Text>
</Box>
))}
{edits.length === 0 && (
<Text size="T300" priority="300">
No edit history found.
</Text>
)}
{nextBatch && (
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
disabled={loadingMore}
before={
loadingMore ? <Spinner size="100" variant="Secondary" /> : undefined
}
onClick={() => fetchHistory(nextBatch).catch(() => undefined)}
>
<Text size="B300">Load more</Text>
</Button>
</Box>
)}
</Box>
)}
</Box>
</Scroll>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}