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 };
+};