diff --git a/desktop/src/features/messages/lib/auxBackfill.test.mjs b/desktop/src/features/messages/lib/auxBackfill.test.mjs index 18cfc5538f..46f64ab229 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 df0272d733..9e27323e14 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 0d0d2a592e..3c993f5139 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(