From 2bdb2eb4cb9a2af10a36306290f532620fa0214d Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 19 Sep 2026 23:34:38 -0400 Subject: [PATCH] feat(messages): word diff of the last edit on "(edited)" hover (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/components/RenderMessageContent.tsx | 15 +- .../message/content/EditDiffContext.ts | 6 + .../message/content/FallbackContent.tsx | 183 ++++++++++++++++-- src/app/features/room/RoomTimeline.tsx | 3 + src/app/utils/editDiff.ts | 37 ++++ src/app/utils/wordDiff.test.ts | 53 +++++ src/app/utils/wordDiff.ts | 78 ++++++++ 7 files changed, 353 insertions(+), 22 deletions(-) create mode 100644 src/app/components/message/content/EditDiffContext.ts create mode 100644 src/app/utils/editDiff.ts create mode 100644 src/app/utils/wordDiff.test.ts create mode 100644 src/app/utils/wordDiff.ts diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 906b79e36..56d016077 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -26,6 +26,8 @@ import { VerificationRequestContent, VideoContent, } from './message'; +import { DiffToken } from '../utils/wordDiff'; +import { EditDiffContext } from './message/content/EditDiffContext'; import { UrlPreviewCard, UrlPreviewHolder } from './url-preview'; import { Image, MediaControl, Video } from './media'; import { ImageViewer } from './image-viewer'; @@ -74,8 +76,17 @@ type RenderMessageContentProps = { onOpenImageViewer?: () => void; /** [Gitea #159] The event, for the undecryptable placeholder's reason + retry. */ 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 ( + + + + ); +} +function RenderMessageContentBody({ displayName, msgType, ts, @@ -91,7 +102,7 @@ export function RenderMessageContent({ eventId, onOpenImageViewer, mEvent, -}: RenderMessageContentProps) { +}: Omit) { const renderUrlsPreview = (urls: string[]) => { // 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 diff --git a/src/app/components/message/content/EditDiffContext.ts b/src/app/components/message/content/EditDiffContext.ts new file mode 100644 index 000000000..352e2e787 --- /dev/null +++ b/src/app/components/message/content/EditDiffContext.ts @@ -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(undefined); +export const useEditDiff = () => useContext(EditDiffContext); diff --git a/src/app/components/message/content/FallbackContent.tsx b/src/app/components/message/content/FallbackContent.tsx index 85fdc5cbb..16b72f6c9 100644 --- a/src/app/components/message/content/FallbackContent.tsx +++ b/src/app/components/message/content/FallbackContent.tsx @@ -1,5 +1,20 @@ -import { Box, Icon, Icons, Text, as, color, config } from 'folds'; -import React from 'react'; +import { + 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 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 ( + + + Last edit + {added > 0 && ` · +${added}`} + {removed > 0 && ` · −${removed}`} + + + {tokens.map((t, i) => { + if (t.op === 'add') + return ( + + {t.text} + + ); + if (t.op === 'del') + return ( + + {t.text} + + ); + return {t.text}; + })} + + + {hint} + + + ); +} + +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< 'span', { children?: never; onEditHistoryClick?: () => void } ->(({ onEditHistoryClick, ...props }, ref) => - onEditHistoryClick ? ( +>(({ onEditHistoryClick, ...props }, ref) => { + const diff = useEditDiff(); + const [pressAnchor, setPressAnchor] = useState(); + 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 ( + + {' (edited)'} + + ); + } + + const button = (tipRef?: React.RefCallback) => ( + + ); + + return ( )}> - + {diff ? ( + + + + } + > + {(tipRef) => button(tipRef)} + + ) : ( + button() + )} + {diff && pressAnchor && ( + + + + } + /> + )} - ) : ( - - {' (edited)'} - - ), -); + ); +}); type TranslatedStatus = 'downloading' | 'translating' | 'done' | 'error'; diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index b89ab3846..2c029d937 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -89,6 +89,7 @@ import { isMembershipChanged, reactionOrEditEvent, } from '../../utils/room'; +import { getLastEditDiff } from '../../utils/editDiff'; import { useSetting } from '../../state/hooks/settings'; import { MessageLayout, settingsAtom } from '../../state/settings'; import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer'; @@ -1267,6 +1268,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli msgType={mEvent.getContent().msgtype ?? ''} ts={mEvent.getTs()} edited={!!editedEvent} + editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined} onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined} getContent={getContent} mediaAutoLoad={mediaAutoLoad} @@ -1396,6 +1398,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli msgType={mEvent.getContent().msgtype ?? ''} ts={mEvent.getTs()} edited={!!editedEvent} + editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined} onEditHistoryClick={ editedEvent ? () => setEditHistoryEvent(mEvent) : undefined } diff --git a/src/app/utils/editDiff.ts b/src/app/utils/editDiff.ts new file mode 100644 index 000000000..b7de9d677 --- /dev/null +++ b/src/app/utils/editDiff.ts @@ -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 | 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 | undefined; + const before = (edits[1]?.getContent()['m.new_content'] ?? mEvent.getContent()) as + | Record + | undefined; + if (!isPlainText(after) || !isPlainText(before)) return undefined; + return wordDiff(trimReplyFromBody(before.body), trimReplyFromBody(after.body)); +} diff --git a/src/app/utils/wordDiff.test.ts b/src/app/utils/wordDiff.test.ts new file mode 100644 index 000000000..39401e000 --- /dev/null +++ b/src/app/utils/wordDiff.test.ts @@ -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) => + 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 }); + }); +}); diff --git a/src/app/utils/wordDiff.ts b/src/app/utils/wordDiff.ts new file mode 100644 index 000000000..a2d2c436b --- /dev/null +++ b/src/app/utils/wordDiff.ts @@ -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 }; +};