Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions desktop/src/features/messages/lib/auxBackfill.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import {
collectAuxEventIdsForDeletionBackfill,
collectMessageIdsForAuxBackfill,
fetchStructuralAuxForMessages,
mergeAuxEventsWithDeletionBackfill,
} from "./auxBackfill.ts";

Expand Down Expand Up @@ -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, []);
});
48 changes: 48 additions & 0 deletions desktop/src/features/messages/lib/auxBackfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 33 additions & 1 deletion desktop/src/features/messages/useThreadReplies.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
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";

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,
Expand All @@ -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(
Expand Down