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
+13 -2
View File
@@ -26,6 +26,8 @@ import {
VerificationRequestContent, VerificationRequestContent,
VideoContent, VideoContent,
} from './message'; } from './message';
import { DiffToken } from '../utils/wordDiff';
import { EditDiffContext } from './message/content/EditDiffContext';
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview'; import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
import { Image, MediaControl, Video } from './media'; import { Image, MediaControl, Video } from './media';
import { ImageViewer } from './image-viewer'; import { ImageViewer } from './image-viewer';
@@ -74,8 +76,17 @@ type RenderMessageContentProps = {
onOpenImageViewer?: () => void; onOpenImageViewer?: () => void;
/** [Gitea #159] The event, for the undecryptable placeholder's reason + retry. */ /** [Gitea #159] The event, for the undecryptable placeholder's reason + retry. */
mEvent?: MatrixEvent; mEvent?: MatrixEvent;
/** [Gitea #144] Word diff of the last edit, shown on "(edited)" hover. */
editDiff?: DiffToken[];
}; };
export function RenderMessageContent({ export function RenderMessageContent({ editDiff, ...props }: RenderMessageContentProps) {
return (
<EditDiffContext.Provider value={editDiff}>
<RenderMessageContentBody {...props} />
</EditDiffContext.Provider>
);
}
function RenderMessageContentBody({
displayName, displayName,
msgType, msgType,
ts, ts,
@@ -91,7 +102,7 @@ export function RenderMessageContent({
eventId, eventId,
onOpenImageViewer, onOpenImageViewer,
mEvent, mEvent,
}: RenderMessageContentProps) { }: Omit<RenderMessageContentProps, 'editDiff'>) {
const renderUrlsPreview = (urls: string[]) => { const renderUrlsPreview = (urls: string[]) => {
// Cap previews per message so a link-dump doesn't spawn dozens of preview // Cap previews per message so a link-dump doesn't spawn dozens of preview
// fetches + iframes at once. De-dupe first: a message linking the same URL // fetches + iframes at once. De-dupe first: a message linking the same URL
@@ -0,0 +1,6 @@
import { createContext, useContext } from 'react';
import { DiffToken } from '../../../utils/wordDiff';
/** [Gitea #144] Word diff of the message's last edit, for the "(edited)" hover. */
export const EditDiffContext = createContext<DiffToken[] | undefined>(undefined);
export const useEditDiff = () => useContext(EditDiffContext);
@@ -1,5 +1,20 @@
import { Box, Icon, Icons, Text, as, color, config } from 'folds'; import {
import React from 'react'; Box,
Icon,
Icons,
PopOut,
RectCords,
Text,
Tooltip,
TooltipProvider,
as,
color,
config,
} from 'folds';
import React, { useEffect, useState } from 'react';
import { useLongPress } from '../../../hooks/useLongPress';
import { DiffToken, countChanges } from '../../../utils/wordDiff';
import { useEditDiff } from './EditDiffContext';
const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 }; const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 };
const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 }; const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 };
@@ -66,29 +81,157 @@ export const MessageVerificationRequestContent = as<'div', { children?: never }>
), ),
); );
const editDiffStyle: React.CSSProperties = {
maxWidth: '40ch',
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
maxHeight: '12lh',
overflow: 'hidden',
};
/** The last edit as a word diff: removed words struck, added words bold. */
export function EditDiffBody({ tokens, hint }: { tokens: DiffToken[]; hint: string }) {
const { added, removed } = countChanges(tokens);
return (
<Box direction="Column" gap="100" style={editDiffStyle}>
<Text size="T200" priority="300">
Last edit
{added > 0 && ` · +${added}`}
{removed > 0 && ` · ${removed}`}
</Text>
<Text as="span" size="T300" dir="auto">
{tokens.map((t, i) => {
if (t.op === 'add')
return (
<ins key={i} style={{ textDecoration: 'none', fontWeight: 'bold' }}>
{t.text}
</ins>
);
if (t.op === 'del')
return (
<del key={i} style={{ opacity: config.opacity.P300 }}>
{t.text}
</del>
);
return <React.Fragment key={i}>{t.text}</React.Fragment>;
})}
</Text>
<Text size="T200" priority="300">
{hint}
</Text>
</Box>
);
}
const editedButtonStyle: React.CSSProperties = {
cursor: 'pointer',
background: 'none',
border: 'none',
padding: 0,
};
/**
* "(edited)" label. With an edit-history handler it is a button that opens
* the viewer; when the surrounding message also provides a last-edit diff
* (see `EditDiffContext`) hover/focus shows it as a tooltip and a touch
* long-press shows it as a popout. [Gitea #144]
*/
export const MessageEditedContent = as< export const MessageEditedContent = as<
'span', 'span',
{ children?: never; onEditHistoryClick?: () => void } { children?: never; onEditHistoryClick?: () => void }
>(({ onEditHistoryClick, ...props }, ref) => >(({ onEditHistoryClick, ...props }, ref) => {
onEditHistoryClick ? ( const diff = useEditDiff();
const [pressAnchor, setPressAnchor] = useState<RectCords | undefined>();
const { coarse, handlers, suppressContextMenu } = useLongPress((x, y) => {
if (diff) setPressAnchor({ x, y, width: 1, height: 1 });
});
useEffect(() => {
if (!pressAnchor) return undefined;
const close = () => setPressAnchor(undefined);
const t = window.setTimeout(close, 6000);
window.addEventListener('touchstart', close, { passive: true });
window.addEventListener('scroll', close, { passive: true, capture: true });
return () => {
window.clearTimeout(t);
window.removeEventListener('touchstart', close);
window.removeEventListener('scroll', close, { capture: true });
};
}, [pressAnchor]);
if (!onEditHistoryClick) {
return (
<Text as="span" size="T200" priority="300" {...props} ref={ref}>
{' (edited)'}
</Text>
);
}
const button = (tipRef?: React.RefCallback<HTMLElement>) => (
<button
ref={tipRef}
type="button"
onClick={() => {
if (suppressContextMenu.current) return;
onEditHistoryClick();
}}
onContextMenu={(evt) => {
if (suppressContextMenu.current) evt.preventDefault();
}}
{...handlers}
// Keep the message's own long-press (action sheet) from firing too.
onTouchStart={(evt) => {
evt.stopPropagation();
handlers.onTouchStart?.(evt);
}}
aria-label={diff ? 'View edit history — hover for the last change' : 'View edit history'}
style={editedButtonStyle}
>
<Text as="span" size="T200" priority="300">
{' (edited)'}
</Text>
</button>
);
return (
<span ref={ref} {...(props as React.HTMLAttributes<HTMLSpanElement>)}> <span ref={ref} {...(props as React.HTMLAttributes<HTMLSpanElement>)}>
<button {diff ? (
type="button" <TooltipProvider
onClick={onEditHistoryClick} position="Top"
aria-label="View edit history" align="Start"
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }} delay={300}
> tooltip={
<Text as="span" size="T200" priority="300"> <Tooltip>
{' (edited)'} <EditDiffBody
</Text> tokens={diff}
</button> hint={coarse ? 'Tap for full history' : 'Click for full history'}
/>
</Tooltip>
}
>
{(tipRef) => button(tipRef)}
</TooltipProvider>
) : (
button()
)}
{diff && pressAnchor && (
<PopOut
anchor={pressAnchor}
position="Top"
align="Start"
content={
<Tooltip>
<EditDiffBody
tokens={diff}
hint={coarse ? 'Tap for full history' : 'Click for full history'}
/>
</Tooltip>
}
/>
)}
</span> </span>
) : ( );
<Text as="span" size="T200" priority="300" {...props} ref={ref}> });
{' (edited)'}
</Text>
),
);
type TranslatedStatus = 'downloading' | 'translating' | 'done' | 'error'; type TranslatedStatus = 'downloading' | 'translating' | 'done' | 'error';
+3
View File
@@ -89,6 +89,7 @@ import {
isMembershipChanged, isMembershipChanged,
reactionOrEditEvent, reactionOrEditEvent,
} from '../../utils/room'; } from '../../utils/room';
import { getLastEditDiff } from '../../utils/editDiff';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../state/settings'; import { MessageLayout, settingsAtom } from '../../state/settings';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer'; import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
@@ -1267,6 +1268,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
msgType={mEvent.getContent().msgtype ?? ''} msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()} ts={mEvent.getTs()}
edited={!!editedEvent} edited={!!editedEvent}
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined} onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
getContent={getContent} getContent={getContent}
mediaAutoLoad={mediaAutoLoad} mediaAutoLoad={mediaAutoLoad}
@@ -1396,6 +1398,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
msgType={mEvent.getContent().msgtype ?? ''} msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()} ts={mEvent.getTs()}
edited={!!editedEvent} edited={!!editedEvent}
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
onEditHistoryClick={ onEditHistoryClick={
editedEvent ? () => setEditHistoryEvent(mEvent) : undefined editedEvent ? () => setEditHistoryEvent(mEvent) : undefined
} }
+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));
}
+53
View File
@@ -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 });
});
});
+78
View File
@@ -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 };
};