fix(desktop): backfill edits for thread replies so edited replies survive refetch (#1610)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-07 19:38:21 -07:00
committed by GitHub
co-authored by Brain
parent 36fd41c5ce
commit d1f3194e73
3 changed files with 133 additions and 1 deletions
@@ -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, []);
});
@@ -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<RelayEvent[]>;
fetchAuxDeletionEventsForAuxEvents: (
channelId: string,
auxEventIds: string[],
) => Promise<RelayEvent[]>;
};
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<RelayEvent[]> {
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
@@ -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<RelayEvent[]> {
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(