diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 6e14d921c..d16b720ab 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -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` - 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 ``/``. 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 ### Inline GIF Preview diff --git a/src/app/features/room/message/EditHistoryModal.tsx b/src/app/features/room/message/EditHistoryModal.tsx index 1f0e449d9..f1461c7c6 100644 --- a/src/app/features/room/message/EditHistoryModal.tsx +++ b/src/app/features/room/message/EditHistoryModal.tsx @@ -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 | 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 | null) ?? + (evt.event as { content?: Record }).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 | undefined; + return asText(newContent ?? content); +} + +// Renders a word-level diff of prev -> next: added words highlighted, removed +// words struck-through. Uses semantic / 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 ( + + {seg.text} + + ); + } + if (seg.type === 'removed') { + return ( + + {seg.text} + + ); + } + return {seg.text}; + })} + + ); +} + 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 ( }> @@ -200,6 +267,12 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp Edit History + + + Highlight changes + + + @@ -258,7 +331,11 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp size="T300" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }} > - {getVersionContent(editEvt)} + {showDiff ? ( + + ) : ( + getVersionContent(editEvt) + )} ))} diff --git a/src/app/utils/textDiff.test.ts b/src/app/utils/textDiff.test.ts new file mode 100644 index 000000000..91df2fd20 --- /dev/null +++ b/src/app/utils/textDiff.test.ts @@ -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'], + ); +}); diff --git a/src/app/utils/textDiff.ts b/src/app/utils/textDiff.ts new file mode 100644 index 000000000..4479c9c3a --- /dev/null +++ b/src/app/utils/textDiff.ts @@ -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(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; +}