From 1165f228e92a9374c8424a4bfa2dc6f031a62a43 Mon Sep 17 00:00:00 2001 From: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Date: Sat, 22 Aug 2026 17:40:09 -0400 Subject: [PATCH] feat(desktop): seed huddle thread replies Render reply events already present in the bounded channel window while the complete thread query loads. Keep the window data as placeholder-only so it does not delay the authoritative request. Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> --- .../channels/ui/useHuddleChannelMessages.ts | 22 +++ .../messages/useThreadReplies.test.mjs | 138 +++++++++++++++++- .../src/features/messages/useThreadReplies.ts | 8 +- 3 files changed, 166 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/channels/ui/useHuddleChannelMessages.ts b/desktop/src/features/channels/ui/useHuddleChannelMessages.ts index 2a90971dde..30b66f7e0a 100644 --- a/desktop/src/features/channels/ui/useHuddleChannelMessages.ts +++ b/desktop/src/features/channels/ui/useHuddleChannelMessages.ts @@ -4,8 +4,10 @@ import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; import { mergeMessages } from "@/features/messages/hooks"; import { channelWindowThreadSummaries, + flattenChannelWindowEvents, type ChannelWindowStore, } from "@/features/messages/lib/channelWindowStore"; +import { getThreadReference } from "@/features/messages/lib/threading"; import { useThreadRepliesForRoots } from "@/features/messages/useThreadReplies"; import type { Channel, RelayEvent } from "@/shared/api/types"; @@ -25,6 +27,19 @@ type HuddleChannelMessagesOptions = { windowStore?: ChannelWindowStore; }; +export function seedHuddleThreadReplies( + windowStore: ChannelWindowStore | undefined, +): ReadonlyMap { + const repliesByRoot = new Map(); + if (!windowStore) return repliesByRoot; + for (const event of flattenChannelWindowEvents(windowStore)) { + const { parentId, rootId } = getThreadReference(event.tags); + if (!parentId || !rootId) continue; + repliesByRoot.set(rootId, [...(repliesByRoot.get(rootId) ?? []), event]); + } + return repliesByRoot; +} + export function useHuddleChannelMessages({ activeChannel, isHuddleTranscript, @@ -51,9 +66,16 @@ export function useHuddleChannelMessages({ : [], [isHuddleTranscript, threadSummaries], ); + const placeholderThreadRepliesByRoot = React.useMemo( + () => seedHuddleThreadReplies(windowStore), + [windowStore], + ); const huddleThreadReplies = useThreadRepliesForRoots( activeChannel, huddleThreadRootIds, + { + placeholderDataByRoot: placeholderThreadRepliesByRoot, + }, ); const resolvedMessages = React.useMemo( () => diff --git a/desktop/src/features/messages/useThreadReplies.test.mjs b/desktop/src/features/messages/useThreadReplies.test.mjs index 48896c5c51..4e1fbc0fab 100644 --- a/desktop/src/features/messages/useThreadReplies.test.mjs +++ b/desktop/src/features/messages/useThreadReplies.test.mjs @@ -1,7 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { collectThreadAuxMessageIds } from "./useThreadReplies.ts"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { JSDOM } from "jsdom"; +import React from "react"; + +import { relayClient } from "../../shared/api/relayClient.ts"; +import { + collectThreadAuxMessageIds, + useThreadRepliesForRoots, +} from "./useThreadReplies.ts"; const ROOT_ID = "1".repeat(64); const REPLY_ID = "2".repeat(64); @@ -28,3 +36,131 @@ test("thread aux hydration includes and deduplicates root and reply ids", () => [ROOT_ID, REPLY_ID], ); }); + +async function withHookEnvironment(run) { + const dom = new JSDOM("", { + url: "http://localhost/", + }); + const previousGlobals = { + window: globalThis.window, + document: globalThis.document, + navigator: globalThis.navigator, + tauri: globalThis.__TAURI_INTERNALS__, + act: globalThis.IS_REACT_ACT_ENVIRONMENT, + }; + const pendingRequests = new Map(); + const tauriInternals = { + invoke: (command, args) => { + assert.equal(command, "get_thread_replies"); + return new Promise((resolve) => { + pendingRequests.set(args.rootEventId, resolve); + }); + }, + transformCallback: () => 1, + }; + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + __TAURI_INTERNALS__: tauriInternals, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + }); + dom.window.__TAURI_INTERNALS__ = tauriInternals; + + try { + const { act, renderHook } = await import("@testing-library/react"); + await run({ act, pendingRequests, renderHook }); + } finally { + dom.window.close(); + Object.assign(globalThis, { + window: previousGlobals.window, + document: previousGlobals.document, + __TAURI_INTERNALS__: previousGlobals.tauri, + IS_REACT_ACT_ENVIRONMENT: previousGlobals.act, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: previousGlobals.navigator, + }); + } +} + +function hookWrapper(client) { + return ({ children }) => + React.createElement(QueryClientProvider, { client }, children); +} + +const channel = { id: "huddle", channelType: "stream" }; + +test("window-seeded huddle roots fetch and settle without a visible gap", async () => { + const auxCalls = []; + const fetchAuxEventsByReference = relayClient.fetchAuxEventsByReference; + relayClient.fetchAuxEventsByReference = async ( + channelId, + referencedEventIds, + buildFilter, + ) => { + auxCalls.push({ + channelId, + referencedEventIds, + filter: buildFilter(channelId, referencedEventIds), + }); + return []; + }; + + try { + await withHookEnvironment(async ({ act, pendingRequests, renderHook }) => { + const seededRootId = "3".repeat(64); + const seededReply = reply("4".repeat(64)); + seededReply.tags = [["e", seededRootId, "", "reply"]]; + const authoritativeReply = reply("5".repeat(64)); + authoritativeReply.tags = [["e", seededRootId, "", "reply"]]; + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const placeholderDataByRoot = new Map([[seededRootId, [seededReply]]]); + const view = renderHook( + () => + useThreadRepliesForRoots(channel, [seededRootId], { + placeholderDataByRoot, + }), + { wrapper: hookWrapper(client) }, + ); + + try { + assert.deepEqual(view.result.current.events, [seededReply]); + assert.equal(pendingRequests.has(seededRootId), true); + await act(async () => { + pendingRequests.get(seededRootId)({ + events: [authoritativeReply], + nextCursor: null, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + assert.deepEqual(view.result.current.events, [authoritativeReply]); + assert.equal(auxCalls.length, 2); + for (const call of auxCalls) { + assert.equal(call.channelId, channel.id); + assert.deepEqual(call.referencedEventIds, [ + seededRootId, + authoritativeReply.id, + ]); + assert.deepEqual(call.filter["#e"], [ + seededRootId, + authoritativeReply.id, + ]); + } + } finally { + view.unmount(); + await client.cancelQueries(); + client.clear(); + client.unmount(); + } + }); + } finally { + relayClient.fetchAuxEventsByReference = fetchAuxEventsByReference; + } +}); diff --git a/desktop/src/features/messages/useThreadReplies.ts b/desktop/src/features/messages/useThreadReplies.ts index bb1c2909b6..9eb8a09087 100644 --- a/desktop/src/features/messages/useThreadReplies.ts +++ b/desktop/src/features/messages/useThreadReplies.ts @@ -4,7 +4,6 @@ import { useQuery, useQueryClient, } from "@tanstack/react-query"; - import { collectMessageIdsForAuxBackfill, fetchStructuralAuxForMessages, @@ -134,17 +133,24 @@ export function useThreadReplies( * replies into the chat timeline so companion and in-app presentations show the * same conversation without opening a transient thread surface. */ +export type ThreadRepliesForRootsOptions = { + placeholderDataByRoot?: ReadonlyMap; +}; + export function useThreadRepliesForRoots( activeChannel: Channel | null, rootIds: readonly string[], + options: ThreadRepliesForRootsOptions = {}, ) { const queryClient = useQueryClient(); const channelId = activeChannel?.id ?? "none"; + const placeholderDataByRoot = options.placeholderDataByRoot; return useQueries({ queries: rootIds.map((rootId) => ({ queryKey: threadRepliesKey(channelId, rootId), enabled: activeChannel !== null && activeChannel.channelType !== "forum", queryFn: () => loadThreadReplies(queryClient, channelId, rootId), + placeholderData: () => placeholderDataByRoot?.get(rootId), staleTime: 0, gcTime: 60 * 60 * 1_000, })),