feat(edit-history): word-level diff view

The Edit History modal listed each version's full text with no indication
of what changed. Add a word-level diff: each edit highlights the words
added (green) and removed (struck-through red) relative to the previous
version, so a one-word fix is obvious at a glance.

- New pure, dependency-free diffWords (LCS over word/whitespace tokens) in
  utils/textDiff.ts, with 8 unit tests (insert/delete/replace, whitespace
  preserved, empty, no-mutation, word-not-char granularity).
- EditHistoryModal renders each edit via a DiffText component using
  semantic <ins>/<del> (screen-reader-meaningful) styled with folds
  Success/Critical tokens. A "Highlight changes" header toggle (default
  on) switches back to full text, which keeps the rich formatted render;
  the Original row is always the plain baseline.
- Diff is plain-text (body) only by design; formatted markup isn't diffed
  (the toggle restores the rich view), and media/no-body edits diff as
  empty strings gracefully.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 11:02:59 -04:00
co-authored by Claude Opus 4.8
parent b6413d763d
commit 961789fd71
4 changed files with 233 additions and 1 deletions
@@ -15,7 +15,9 @@ import {
OverlayCenter,
Scroll,
Spinner,
Switch,
Text,
color,
config,
} from 'folds';
import { MatrixEvent, Room } from 'matrix-js-sdk';
@@ -28,6 +30,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { timeDayMonYear, timeHourMinute } from '../../../utils/time';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { diffWords } from '../../../utils/textDiff';
type RawEditEvent = {
type: string;
@@ -87,11 +90,71 @@ function getVersionContent(evt: MatrixEvent): ReactNode {
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. Uses semantic <ins>/<del> so screen readers announce
// the change. Plain-text only (formatting isn't diffed — see the toggle).
function DiffText({ prev, next }: { prev: string; next: string }) {
const segments = diffWords(prev, next);
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,
borderRadius: config.radii.R300,
textDecoration: 'none',
}}
>
{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;
@@ -171,6 +234,10 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
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>
@@ -200,6 +267,12 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
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>
@@ -258,7 +331,11 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
size="T300"
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
>
{getVersionContent(editEvt)}
{showDiff ? (
<DiffText prev={versionTexts[index]} next={versionTexts[index + 1]} />
) : (
getVersionContent(editEvt)
)}
</Text>
</Box>
))}