feat(messages): word diff of the last edit on "(edited)" hover (#144)
CI / Build & Quality Checks (push) Successful in 1m40s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Canceled after 1m37s
CI / Build & Quality Checks (push) Successful in 1m40s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Canceled after 1m37s
Hovering or focusing "(edited)" shows a tooltip with only the most recent edit as a word diff — removed words struck, added words bold — plus a +N/−N summary. Clicking still opens the full history viewer. On touch, a long-press on the label shows the same diff as a popout (a plain tap opens the viewer; the message's own long-press action sheet is not triggered). utils/wordDiff.ts is a unit-tested LCS over words that ignores whitespace- only changes and gives up past 400 words. Only plain-text bodies are diffed; formatted edits fall back to the viewer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { EventTimelineSet, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { getEventEdits, trimReplyFromBody } from './room';
|
||||
import { DiffToken, wordDiff } from './wordDiff';
|
||||
|
||||
const isPlainText = (content: Record<string, unknown> | undefined): content is { body: string } =>
|
||||
!!content &&
|
||||
typeof content.body === 'string' &&
|
||||
content.format === undefined &&
|
||||
content.formatted_body === undefined;
|
||||
|
||||
/**
|
||||
* [Gitea #144] Word diff of the *last* edit to `mEvent`: the newest edit's body
|
||||
* against the one it replaced (an earlier edit, or the original). Only for
|
||||
* plain-text bodies — formatted edits return `undefined` and the caller falls
|
||||
* back to the history viewer.
|
||||
*/
|
||||
export function getLastEditDiff(
|
||||
mEvent: MatrixEvent,
|
||||
timelineSet: EventTimelineSet,
|
||||
): DiffToken[] | undefined {
|
||||
const eventId = mEvent.getId();
|
||||
if (!eventId) return undefined;
|
||||
const relations = getEventEdits(timelineSet, eventId, mEvent.getType());
|
||||
if (!relations) return undefined;
|
||||
const edits = relations
|
||||
.getRelations()
|
||||
.filter((e) => e.getSender() === mEvent.getSender())
|
||||
.sort((m1, m2) => m2.getTs() - m1.getTs());
|
||||
const latest = edits[0];
|
||||
if (!latest) return undefined;
|
||||
const after = latest.getContent()['m.new_content'] as Record<string, unknown> | undefined;
|
||||
const before = (edits[1]?.getContent()['m.new_content'] ?? mEvent.getContent()) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!isPlainText(after) || !isPlainText(before)) return undefined;
|
||||
return wordDiff(trimReplyFromBody(before.body), trimReplyFromBody(after.body));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { countChanges, wordDiff, WORD_DIFF_LIMIT } from './wordDiff';
|
||||
|
||||
const render = (tokens: ReturnType<typeof wordDiff>) =>
|
||||
tokens
|
||||
?.map((t) =>
|
||||
t.op === 'same'
|
||||
? t.text
|
||||
: `${t.op === 'add' ? '+' : '-'}[${t.text.trim()}]${t.text.endsWith(' ') ? ' ' : ''}`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
describe('wordDiff', () => {
|
||||
it('returns undefined for identical text', () => {
|
||||
assert.equal(wordDiff('hello world', 'hello world'), undefined);
|
||||
});
|
||||
|
||||
it('marks a replaced word', () => {
|
||||
assert.equal(render(wordDiff('see you at 5', 'see you at 6')), 'see you at -[5]+[6]');
|
||||
});
|
||||
|
||||
it('marks insertions and deletions in place', () => {
|
||||
assert.equal(
|
||||
render(wordDiff('the quick fox', 'the quick brown fox jumps')),
|
||||
'the quick +[brown] fox +[jumps]',
|
||||
);
|
||||
assert.equal(render(wordDiff('a b c d', 'a d')), 'a -[b c] d');
|
||||
});
|
||||
|
||||
it('ignores whitespace-only changes', () => {
|
||||
assert.equal(wordDiff('a b', 'a b'), undefined);
|
||||
assert.equal(wordDiff('a b\n', 'a b'), undefined);
|
||||
});
|
||||
|
||||
it('handles fully different text', () => {
|
||||
assert.deepEqual(wordDiff('one', 'two'), [
|
||||
{ op: 'del', text: 'one' },
|
||||
{ op: 'add', text: 'two' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives up past the word limit', () => {
|
||||
const long = Array.from({ length: WORD_DIFF_LIMIT }, (_, i) => `w${i}`).join(' ');
|
||||
assert.equal(wordDiff(long, `${long} extra`), undefined);
|
||||
});
|
||||
|
||||
it('counts changed words', () => {
|
||||
const d = wordDiff('a b c', 'a x y z c');
|
||||
assert.ok(d);
|
||||
assert.deepEqual(countChanges(d), { added: 3, removed: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* [Gitea #144] Word-level diff for the "(edited)" hover. Plain text only; the
|
||||
* caller decides what to do with formatted bodies. LCS over word tokens, with
|
||||
* whitespace preserved on the token that precedes it so the rendered result
|
||||
* reads like the original message.
|
||||
*/
|
||||
|
||||
export type DiffOp = 'same' | 'add' | 'del';
|
||||
export type DiffToken = { op: DiffOp; text: string };
|
||||
|
||||
/** Above this many words (before + after) we give up: O(n·m) memory and an unreadable tooltip. */
|
||||
export const WORD_DIFF_LIMIT = 400;
|
||||
|
||||
const tokenize = (text: string): string[] => text.match(/\S+\s*|\s+/g) ?? [];
|
||||
const word = (token: string) => token.trim();
|
||||
|
||||
/**
|
||||
* Diff `before` → `after` into runs. Returns `undefined` when the texts are
|
||||
* identical or too long to diff meaningfully (caller should fall back to the
|
||||
* full history viewer).
|
||||
*/
|
||||
export function wordDiff(before: string, after: string): DiffToken[] | undefined {
|
||||
if (before === after) return undefined;
|
||||
const a = tokenize(before);
|
||||
const b = tokenize(after);
|
||||
if (a.length + b.length > WORD_DIFF_LIMIT) return undefined;
|
||||
|
||||
// LCS table on trimmed words so whitespace-only changes don't count as edits.
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const lcs: Uint16Array[] = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
|
||||
for (let i = n - 1; i >= 0; i -= 1) {
|
||||
for (let j = m - 1; j >= 0; j -= 1) {
|
||||
lcs[i][j] =
|
||||
word(a[i]) === word(b[j]) ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const out: DiffToken[] = [];
|
||||
const push = (op: DiffOp, text: string) => {
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.op === op) last.text += text;
|
||||
else out.push({ op, text });
|
||||
};
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (word(a[i]) === word(b[j])) {
|
||||
push('same', b[j]);
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
push('del', a[i]);
|
||||
i += 1;
|
||||
} else {
|
||||
push('add', b[j]);
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
while (i < n) push('del', a[i++]);
|
||||
while (j < m) push('add', b[j++]);
|
||||
|
||||
// Only whitespace moved around: nothing worth showing.
|
||||
if (out.every((t) => t.op === 'same' || t.text.trim() === '')) return undefined;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Number of changed words in a diff (for a compact summary). */
|
||||
export const countChanges = (tokens: DiffToken[]): { added: number; removed: number } => {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
tokens.forEach((t) => {
|
||||
const words = t.text.trim() === '' ? 0 : t.text.trim().split(/\s+/).length;
|
||||
if (t.op === 'add') added += words;
|
||||
if (t.op === 'del') removed += words;
|
||||
});
|
||||
return { added, removed };
|
||||
};
|
||||
Reference in New Issue
Block a user