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:
@@ -660,6 +660,7 @@ The indicator is hidden once the server confirms the event (when the internal st
|
|||||||
|
|
||||||
- API: `GET /_matrix/client/v1/rooms/{roomId}/relations/{eventId}/m.replace`
|
- API: `GET /_matrix/client/v1/rooms/{roomId}/relations/{eventId}/m.replace`
|
||||||
- E2EE fix: the "Original" entry uses `getClearContent()` to retrieve the decrypted content rather than the encrypted payload
|
- E2EE fix: the "Original" entry uses `getClearContent()` to retrieve the decrypted content rather than the encrypted payload
|
||||||
|
- **Word-level diff**: a "Highlight changes" toggle (on by default) renders each edit as a word diff against the previous version — added words highlighted (green), removed words struck-through (red) — using semantic `<ins>`/`<del>`. Toggle off to see full text (formatted messages render rich there; the diff is plain-text only). Diff logic is the pure, unit-tested `diffWords` (LCS) in `src/app/utils/textDiff.ts`.
|
||||||
- Accessible from the message context menu
|
- Accessible from the message context menu
|
||||||
|
|
||||||
### Inline GIF Preview
|
### Inline GIF Preview
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
OverlayCenter,
|
OverlayCenter,
|
||||||
Scroll,
|
Scroll,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
|
color,
|
||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||||
@@ -28,6 +30,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|||||||
import { timeDayMonYear, timeHourMinute } from '../../../utils/time';
|
import { timeDayMonYear, timeHourMinute } from '../../../utils/time';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
import { diffWords } from '../../../utils/textDiff';
|
||||||
|
|
||||||
type RawEditEvent = {
|
type RawEditEvent = {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -87,11 +90,71 @@ function getVersionContent(evt: MatrixEvent): ReactNode {
|
|||||||
return renderContent(newContent ?? content);
|
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) {
|
export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const modalStyle = useModalStyle(560);
|
const modalStyle = useModalStyle(560);
|
||||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
const [showDiff, setShowDiff] = useState(true);
|
||||||
|
|
||||||
const eventId = mEvent.getId();
|
const eventId = mEvent.getId();
|
||||||
const roomId = room.roomId;
|
const roomId = room.roomId;
|
||||||
@@ -171,6 +234,10 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
|||||||
|
|
||||||
const originalTs = mEvent.getTs();
|
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 (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
@@ -200,6 +267,12 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
|||||||
Edit History
|
Edit History
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</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">
|
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -258,7 +331,11 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
|||||||
size="T300"
|
size="T300"
|
||||||
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||||
>
|
>
|
||||||
{getVersionContent(editEvt)}
|
{showDiff ? (
|
||||||
|
<DiffText prev={versionTexts[index]} next={versionTexts[index + 1]} />
|
||||||
|
) : (
|
||||||
|
getVersionContent(editEvt)
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { diffWords, DiffSegment } from './textDiff';
|
||||||
|
|
||||||
|
// Helper: reconstruct the old / new text from segments to prove correctness.
|
||||||
|
const oldText = (segs: DiffSegment[]) =>
|
||||||
|
segs
|
||||||
|
.filter((s) => s.type !== 'added')
|
||||||
|
.map((s) => s.text)
|
||||||
|
.join('');
|
||||||
|
const newText = (segs: DiffSegment[]) =>
|
||||||
|
segs
|
||||||
|
.filter((s) => s.type !== 'removed')
|
||||||
|
.map((s) => s.text)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
test('identical text yields a single equal segment', () => {
|
||||||
|
const out = diffWords('hello world', 'hello world');
|
||||||
|
assert.deepEqual(out, [{ type: 'equal', text: 'hello world' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pure insertion marks only the new words as added', () => {
|
||||||
|
const out = diffWords('hello world', 'hello there world');
|
||||||
|
assert.equal(oldText(out), 'hello world');
|
||||||
|
assert.equal(newText(out), 'hello there world');
|
||||||
|
assert.deepEqual(
|
||||||
|
out.filter((s) => s.type === 'added').map((s) => s.text.trim()),
|
||||||
|
['there'],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
out.some((s) => s.type === 'removed'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pure deletion marks only the dropped words as removed', () => {
|
||||||
|
const out = diffWords('hello there world', 'hello world');
|
||||||
|
assert.equal(
|
||||||
|
out.some((s) => s.type === 'added'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
out.filter((s) => s.type === 'removed').map((s) => s.text.trim()),
|
||||||
|
['there'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a word replacement is a removed run followed by an added run', () => {
|
||||||
|
const out = diffWords('hello world', 'hi world');
|
||||||
|
// Reconstructs both sides.
|
||||||
|
assert.equal(oldText(out), 'hello world');
|
||||||
|
assert.equal(newText(out), 'hi world');
|
||||||
|
const removed = out.filter((s) => s.type === 'removed').map((s) => s.text.trim());
|
||||||
|
const added = out.filter((s) => s.type === 'added').map((s) => s.text.trim());
|
||||||
|
assert.deepEqual(removed, ['hello']);
|
||||||
|
assert.deepEqual(added, ['hi']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whitespace and newlines are preserved in reconstruction', () => {
|
||||||
|
const a = ' line one\nline two ';
|
||||||
|
const b = ' line one\nline three ';
|
||||||
|
const out = diffWords(a, b);
|
||||||
|
assert.equal(oldText(out), a);
|
||||||
|
assert.equal(newText(out), b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty <-> non-empty', () => {
|
||||||
|
assert.deepEqual(diffWords('', 'new text'), [{ type: 'added', text: 'new text' }]);
|
||||||
|
assert.deepEqual(diffWords('old text', ''), [{ type: 'removed', text: 'old text' }]);
|
||||||
|
assert.deepEqual(diffWords('', ''), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diffWords does not mutate its inputs', () => {
|
||||||
|
const a = 'alpha beta';
|
||||||
|
const b = 'alpha gamma';
|
||||||
|
diffWords(a, b);
|
||||||
|
assert.equal(a, 'alpha beta');
|
||||||
|
assert.equal(b, 'alpha gamma');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('word-level granularity (not character-level)', () => {
|
||||||
|
const out = diffWords('cat', 'cats');
|
||||||
|
// "cat" and "cats" are different tokens → full removed + added, no partial.
|
||||||
|
assert.deepEqual(
|
||||||
|
out.map((s) => s.type),
|
||||||
|
['removed', 'added'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Word-level text diff for the Edit History viewer. Pure and dependency-free.
|
||||||
|
|
||||||
|
export type DiffSegment = {
|
||||||
|
type: 'equal' | 'added' | 'removed';
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Split into a sequence of tokens where each token is either a run of
|
||||||
|
// whitespace or a run of non-whitespace, so word boundaries and the original
|
||||||
|
// spacing/newlines are both preserved when segments are re-joined.
|
||||||
|
const tokenize = (text: string): string[] => text.match(/\s+|\S+/g) ?? [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Word-level diff of `oldText` → `newText` via a classic LCS. Returns segments
|
||||||
|
* in reading order: `equal` runs, `removed` runs (present only in old), and
|
||||||
|
* `added` runs (present only in new). Consecutive same-type tokens are merged.
|
||||||
|
* Pure — never mutates its inputs. Whitespace is preserved.
|
||||||
|
*/
|
||||||
|
export function diffWords(oldText: string, newText: string): DiffSegment[] {
|
||||||
|
const a = tokenize(oldText);
|
||||||
|
const b = tokenize(newText);
|
||||||
|
const n = a.length;
|
||||||
|
const m = b.length;
|
||||||
|
|
||||||
|
// lcs[i][j] = length of the longest common subsequence of a[i..] and b[j..].
|
||||||
|
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
||||||
|
for (let i = n - 1; i >= 0; i -= 1) {
|
||||||
|
for (let j = m - 1; j >= 0; j -= 1) {
|
||||||
|
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw: { type: DiffSegment['type']; text: string }[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < n && j < m) {
|
||||||
|
if (a[i] === b[j]) {
|
||||||
|
raw.push({ type: 'equal', text: a[i] });
|
||||||
|
i += 1;
|
||||||
|
j += 1;
|
||||||
|
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||||
|
raw.push({ type: 'removed', text: a[i] });
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
raw.push({ type: 'added', text: b[j] });
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (i < n) {
|
||||||
|
raw.push({ type: 'removed', text: a[i] });
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
while (j < m) {
|
||||||
|
raw.push({ type: 'added', text: b[j] });
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge consecutive same-type tokens into segments.
|
||||||
|
const segments: DiffSegment[] = [];
|
||||||
|
raw.forEach((tok) => {
|
||||||
|
const last = segments[segments.length - 1];
|
||||||
|
if (last && last.type === tok.type) last.text += tok.text;
|
||||||
|
else segments.push({ type: tok.type, text: tok.text });
|
||||||
|
});
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user