From 0d08ba0a550055213ba00baf07cf56f4f09d56ba Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:14:09 -0400 Subject: [PATCH] fix(search): an edited message shows once, with its latest text (#288) Server search also returns the m.replace events, so one message edited twice came back as three rows. Fold edits into their message: one row at the best-ranked position, showing the latest m.new_content and pointing at the original event so Open jumps to the real message. Fixes #288 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../message-search/useMessageSearch.test.ts | 45 +++++++++++++++++ .../message-search/useMessageSearch.ts | 50 ++++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/app/features/message-search/useMessageSearch.test.ts b/src/app/features/message-search/useMessageSearch.test.ts index c57b6a484..2b02b7245 100644 --- a/src/app/features/message-search/useMessageSearch.test.ts +++ b/src/app/features/message-search/useMessageSearch.test.ts @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + foldEdits, filterGroupsByDateRange, filterGroupsByMsgType, filterGroupsByPinned, @@ -109,3 +110,47 @@ test('filterGroupsByPinned: keeps pinned items and drops empty groups', () => { ['$2'], ); }); + +const sr = (eventId: string, ts: number, content: Record) => + ({ + rank: 1, + result: { + event_id: eventId, + room_id: '!r:x', + origin_server_ts: ts, + type: 'm.room.message', + content, + }, + }) as unknown as Parameters[0][number]; + +test('foldEdits: one row per message, showing the latest edit (#288)', () => { + const edit = (id: string, ts: number, body: string) => + sr(id, ts, { + body: `* ${body}`, + 'm.new_content': { body }, + 'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' }, + }); + const out = foldEdits([ + edit('$e2', 30, 'Tuesday 11am room B'), + sr('$orig', 10, { body: 'Monday 10am room A' }), + edit('$e1', 20, 'Tuesday 10am room A'), + sr('$other', 5, { body: 'meeting notes' }), + ]); + assert.deepEqual( + out.map((r) => r.result.event_id), + ['$orig', '$other'], + ); + assert.deepEqual(out[0].result.content['m.new_content'], { body: 'Tuesday 11am room B' }); + assert.equal(out[0].result.content.body, 'Monday 10am room A'); +}); + +test('foldEdits: an edit whose original did not match stands in for it', () => { + const out = foldEdits([ + sr('$e1', 20, { + 'm.new_content': { body: 'x' }, + 'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' }, + }), + ]); + assert.equal(out.length, 1); + assert.equal(out[0].result.event_id, '$orig'); +}); diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index dbf5cc545..2ce6cfb5b 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -96,10 +96,58 @@ export const filterGroupsByDateRange = ( .filter((group) => group.items.length > 0); }; +/** + * [Gitea #288] Server search also returns `m.replace` events, so an edited + * message showed once per version. Fold each edit into its message: one row, + * at the first position any version ranked, showing the latest text and + * pointing at the original event (so "jump" lands on the real message). + */ +export const foldEdits = (results: ISearchResult[]): ISearchResult[] => { + const targetOf = (r: ISearchResult): string => { + const rel = r.result.content?.['m.relates_to']; + return rel?.rel_type === 'm.replace' && typeof rel.event_id === 'string' + ? rel.event_id + : r.result.event_id; + }; + const isEdit = (r: ISearchResult) => targetOf(r) !== r.result.event_id; + const order: string[] = []; + const originals = new Map(); + const latestEdit = new Map(); + results.forEach((r) => { + const target = targetOf(r); + if (!originals.has(target) && !latestEdit.has(target)) order.push(target); + if (!isEdit(r)) { + originals.set(target, r); + return; + } + const prev = latestEdit.get(target); + if (!prev || r.result.origin_server_ts > prev.result.origin_server_ts) + latestEdit.set(target, r); + }); + return order.map((target) => { + const original = originals.get(target); + const edit = latestEdit.get(target); + const newContent = edit?.result.content?.['m.new_content']; + if (original && !newContent) return original; + if (original) { + return { + ...original, + result: { + ...original.result, + content: { ...original.result.content, 'm.new_content': newContent }, + }, + }; + } + // Only an edit matched: show it as its message. + const only = edit as ISearchResult; + return { ...only, result: { ...only.result, event_id: target } }; + }); +}; + const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => { const groups: ResultGroup[] = []; - results.forEach((item) => { + foldEdits(results).forEach((item) => { const roomId = item.result.room_id; const resultItem: ResultItem = { rank: item.rank,