From 1c3a53ec21f4e361a6421ffa73b3d30534334972 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 7 Jul 2026 14:28:18 -0600 Subject: [PATCH] fix(desktop): backfill edits for thread replies so edited replies survive refetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread-subtree fetch (get_thread_replies) returns content kinds only: the server resolves deletions in the query itself, but a reply's kind:40003 edit never rides along. Live, the streamed 40003 lands in the thread-replies cache and the reply renders edited — but the query has staleTime 0, so switching channels (or reopening the thread) refetches and replaces the cache with the aux-less response, reverting the reply to its original text. Fix: after the page loop, pull the structural aux closure (edits + deletions of those edits) by #e over the fetched reply ids, reusing the existing auxBackfill machinery, and append it to the cached events. formatTimelineMessages already overlays 40003s wherever they appear, so the refetch path now matches what the live path produces. Best-effort: an aux fetch failure logs and renders the replies unadorned rather than failing the thread load. Top-level messages were never affected — the channel-window path already includes edits via its server-side aux closure (include_aux). Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../messages/lib/auxBackfill.test.mjs | 52 +++++++++++++++++++ .../src/features/messages/lib/auxBackfill.ts | 48 +++++++++++++++++ .../src/features/messages/useThreadReplies.ts | 34 +++++++++++- 3 files changed, 133 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/messages/lib/auxBackfill.test.mjs b/desktop/src/features/messages/lib/auxBackfill.test.mjs index 18cfc5538fe..46f64ab229b 100644 --- a/desktop/src/features/messages/lib/auxBackfill.test.mjs +++ b/desktop/src/features/messages/lib/auxBackfill.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { collectAuxEventIdsForDeletionBackfill, collectMessageIdsForAuxBackfill, + fetchStructuralAuxForMessages, mergeAuxEventsWithDeletionBackfill, } from "./auxBackfill.ts"; @@ -126,3 +127,54 @@ test("merges deletion markers that target cached or fetched auxiliary event ids" [fetchedReactionId, cachedReactionDeletionId, fetchedReactionDeletionId], ); }); + +test("fetchStructuralAuxForMessages returns edits plus their deletion closure", async () => { + const replyId = hex("1"); + const editId = hex("2"); + const editDeletionId = hex("3"); + const edit = event(editId, 40003, { + content: "edited text", + tags: [ + ["h", CHANNEL_ID], + ["e", replyId], + ], + }); + const editDeletion = event(editDeletionId, 5, { + tags: [ + ["h", CHANNEL_ID], + ["e", editId], + ], + }); + const auxCalls = []; + const deletionCalls = []; + + const auxEvents = await fetchStructuralAuxForMessages(CHANNEL_ID, [replyId], { + fetchAuxEventsForMessages: async (channelId, ids) => { + auxCalls.push({ channelId, ids }); + return [edit]; + }, + fetchAuxDeletionEventsForAuxEvents: async (channelId, ids) => { + deletionCalls.push({ channelId, ids }); + return [editDeletion]; + }, + }); + + assert.deepEqual(auxCalls, [{ channelId: CHANNEL_ID, ids: [replyId] }]); + assert.deepEqual(deletionCalls, [{ channelId: CHANNEL_ID, ids: [editId] }]); + assert.deepEqual( + auxEvents.map((auxEvent) => auxEvent.id), + [editId, editDeletionId], + ); +}); + +test("fetchStructuralAuxForMessages skips all fetches for no message ids", async () => { + const auxEvents = await fetchStructuralAuxForMessages(CHANNEL_ID, [], { + fetchAuxEventsForMessages: async () => { + throw new Error("must not fetch aux for an empty id set"); + }, + fetchAuxDeletionEventsForAuxEvents: async () => { + throw new Error("must not fetch deletions for an empty id set"); + }, + }); + assert.deepEqual(auxEvents, []); +}); diff --git a/desktop/src/features/messages/lib/auxBackfill.ts b/desktop/src/features/messages/lib/auxBackfill.ts index df0272d7332..9e27323e14f 100644 --- a/desktop/src/features/messages/lib/auxBackfill.ts +++ b/desktop/src/features/messages/lib/auxBackfill.ts @@ -65,6 +65,54 @@ export async function mergeAuxEventsWithDeletionBackfill(input: { return [...input.fetchedAuxEvents, ...auxDeletionEvents]; } +/** + * Structural aux closure (edits/deletions + deletions of those aux events) + * for an explicit set of message ids, returned to the caller instead of being + * merged into the channel cache. The thread-replies fetch uses this: the + * server thread-subtree query resolves deletions itself but returns content + * kinds only, so a reply's kind:40003 edit never rides along — without this + * backfill a refetch (thread reopen, channel switch) renders the original, + * un-edited text. + */ +export type StructuralAuxFetchDeps = { + fetchAuxEventsForMessages: ( + channelId: string, + messageIds: string[], + ) => Promise; + fetchAuxDeletionEventsForAuxEvents: ( + channelId: string, + auxEventIds: string[], + ) => Promise; +}; + +const defaultStructuralAuxDeps: StructuralAuxFetchDeps = { + fetchAuxEventsForMessages: (channelId, messageIds) => + relayClient.fetchAuxEventsByReference( + channelId, + messageIds, + buildChannelStructuralAuxFilter, + ), + fetchAuxDeletionEventsForAuxEvents: (channelId, auxEventIds) => + relayClient.fetchAuxDeletionEventsForAuxEvents(channelId, auxEventIds), +}; + +export async function fetchStructuralAuxForMessages( + channelId: string, + messageIds: string[], + deps: StructuralAuxFetchDeps = defaultStructuralAuxDeps, +): Promise { + if (messageIds.length === 0) { + return []; + } + const auxEvents = await deps.fetchAuxEventsForMessages(channelId, messageIds); + return mergeAuxEventsWithDeletionBackfill({ + channelId, + cachedEvents: [], + fetchedAuxEvents: auxEvents, + fetchAuxEventsForMessages: deps.fetchAuxDeletionEventsForAuxEvents, + }); +} + /** * After a content-kinds-only history fetch, pull structural auxiliary events * (edits/deletions) that reference the loaded messages — keyed by `#e` over diff --git a/desktop/src/features/messages/useThreadReplies.ts b/desktop/src/features/messages/useThreadReplies.ts index 0d0d2a592eb..3c993f51393 100644 --- a/desktop/src/features/messages/useThreadReplies.ts +++ b/desktop/src/features/messages/useThreadReplies.ts @@ -1,5 +1,9 @@ import { useQuery } from "@tanstack/react-query"; +import { + collectMessageIdsForAuxBackfill, + fetchStructuralAuxForMessages, +} from "@/features/messages/lib/auxBackfill"; import { threadRepliesKey } from "@/features/messages/lib/messageQueryKeys"; import { getThreadReplies } from "@/shared/api/tauri"; import type { Channel, RelayEvent, ThreadCursor } from "@/shared/api/types"; @@ -7,6 +11,33 @@ import type { Channel, RelayEvent, ThreadCursor } from "@/shared/api/types"; const THREAD_PAGE_LIMIT = 200; const MAX_THREAD_PAGES = 500; +/** + * Append the structural aux closure (edits/deletions) for the fetched replies. + * The server thread-subtree query resolves deletions itself but omits + * kind:40003 edits, so a bare refetch would render every edited reply with its + * original text. Best-effort: an aux failure logs and returns the replies + * unadorned rather than failing the whole thread load. + */ +async function withStructuralAux( + channelId: string, + replies: RelayEvent[], +): Promise { + try { + const auxEvents = await fetchStructuralAuxForMessages( + channelId, + collectMessageIdsForAuxBackfill(replies), + ); + return auxEvents.length > 0 ? [...replies, ...auxEvents] : replies; + } catch (error) { + console.error( + "Failed to backfill thread reply edits for channel", + channelId, + error, + ); + return replies; + } +} + /** Fetch a thread subtree into a cache independent from channel window pages. */ export function useThreadReplies( activeChannel: Channel | null, @@ -31,7 +62,8 @@ export function useThreadReplies( { limit: THREAD_PAGE_LIMIT, cursor }, ); replies.push(...response.events); - if (!response.nextCursor) return replies; + if (!response.nextCursor) + return withStructuralAux(activeChannel.id, replies); cursor = response.nextCursor; } throw new Error(