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

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:
2026-09-19 23:34:38 -04:00
co-authored by Claude Opus 5
parent 4fe9c87010
commit 2bdb2eb4cb
7 changed files with 353 additions and 22 deletions
+37
View File
@@ -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));
}