diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 5fd7641fea..4c56f91f81 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -50,7 +50,11 @@ import { import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions"; import { Button } from "@/shared/ui/button"; import type { useChannelFind } from "@/features/search/useChannelFind"; -import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; +import { + buildMainTimelineEntries, + type MainTimelineEntry, +} from "@/features/messages/lib/threadPanel"; +import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { isWelcomeChannel } from "@/features/onboarding/welcome"; @@ -563,6 +567,16 @@ export const ChannelPane = React.memo(function ChannelPane({ return messages.filter((message) => !isWelcomeSetupSystemMessage(message)); }, [activeChannel, messages]); + const mainTimelineEntries = React.useMemo( + () => buildMainTimelineEntries(visibleMessages), + [visibleMessages], + ); + useRenderScopedReactionHydration({ + activeChannel, + mainTimelineEntries, + threadHeadMessage, + threadMessages, + }); const videoReviewCommentsByRootId = React.useMemo( () => buildVideoReviewCommentsByRootId(messages), [messages], @@ -677,6 +691,7 @@ export const ChannelPane = React.memo(function ChannelPane({ : "No channel selected" } isLoading={isTimelineLoading} + mainEntries={mainTimelineEntries} messages={visibleMessages} firstUnreadMessageId={firstUnreadMessageId} unreadCount={unreadCount} diff --git a/desktop/src/features/messages/lib/auxBackfill.ts b/desktop/src/features/messages/lib/auxBackfill.ts index cac1eec44d..df0272d733 100644 --- a/desktop/src/features/messages/lib/auxBackfill.ts +++ b/desktop/src/features/messages/lib/auxBackfill.ts @@ -5,6 +5,7 @@ import { sortMessages, } from "@/features/messages/lib/messageQueryKeys"; import { relayClient } from "@/shared/api/relayClient"; +import { buildChannelStructuralAuxFilter } from "@/shared/api/relayChannelFilters"; import type { RelayEvent } from "@/shared/api/types"; import { CHANNEL_TIMELINE_CONTENT_KINDS, @@ -65,16 +66,16 @@ export async function mergeAuxEventsWithDeletionBackfill(input: { } /** - * After a content-kinds-only history fetch, pull the auxiliary events - * (reactions, edits, deletions) that reference the loaded messages — keyed by - * `#e` over their ids, not by a time window — and merge them into the same - * channel cache. + * After a content-kinds-only history fetch, pull structural auxiliary events + * (edits/deletions) that reference the loaded messages — keyed by `#e` over + * their ids, not by a time window — and merge them into the same channel cache. + * Reactions are hydrated separately for the rows the GUI renders. * * History fetches request content kinds only so the `limit` budget buys - * visible message depth (a reaction-heavy 200-event window was only ~136 - * messages). The cost is that an edit/deletion for a visible message can fall - * outside any fetched time window — so aux must be pulled by reference, or a - * visible message renders stale (un-edited / not-deleted). + * visible message depth. The cost is that an edit/deletion for a visible + * message can fall outside any fetched time window — so structural aux must be + * pulled by reference, or a visible message renders stale (un-edited / + * not-deleted). * * Best-effort: failures are logged but never reject, so a flaky overlay fetch * can't blank the freshly-loaded messages. @@ -92,9 +93,10 @@ export async function backfillAuxForMessages( try { const cacheKey = channelMessagesKey(channelId); const cachedEvents = queryClient.getQueryData(cacheKey) ?? []; - const auxEvents = await relayClient.fetchAuxEventsForMessages( + const auxEvents = await relayClient.fetchAuxEventsByReference( channelId, messageIds, + buildChannelStructuralAuxFilter, ); const mergedAuxEvents = await mergeAuxEventsWithDeletionBackfill({ channelId, diff --git a/desktop/src/features/messages/lib/renderScopedReactions.test.mjs b/desktop/src/features/messages/lib/renderScopedReactions.test.mjs new file mode 100644 index 0000000000..6429ecf811 --- /dev/null +++ b/desktop/src/features/messages/lib/renderScopedReactions.test.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + claimUnhydratedRenderScopedReactionIds, + collectRenderScopedReactionMessageIds, + hydrateRenderScopedReactions, + releaseRenderScopedReactionIds, + resetRenderScopedReactionHydration, +} from "./renderScopedReactions.ts"; +import { formatTimelineMessages } from "./formatTimelineMessages.ts"; +import { channelMessagesKey } from "./messageQueryKeys.ts"; + +const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; + +function hex(char) { + return char.repeat(64); +} + +function event(id, kind, overrides = {}) { + return { + id, + pubkey: hex("a"), + kind, + created_at: 1_700_000_000, + content: "", + tags: [["h", CHANNEL_ID]], + sig: "sig", + ...overrides, + }; +} + +function entry(message) { + return { message, summary: null }; +} + +function makeQueryClientStub(initialEvents = []) { + const store = new Map([ + [JSON.stringify(channelMessagesKey(CHANNEL_ID)), initialEvents], + ]); + return { + getQueryData(key) { + return store.get(JSON.stringify(key)); + }, + setQueryData(key, updater) { + const k = JSON.stringify(key); + const next = + typeof updater === "function" ? updater(store.get(k) ?? []) : updater; + store.set(k, next); + return next; + }, + }; +} + +test.afterEach(() => { + resetRenderScopedReactionHydration(); +}); + +test("collects main timeline and open thread messages without hidden replies", () => { + const main = event(hex("1"), 9); + const hiddenCollapsedReply = event(hex("2"), 9, { + tags: [["e", main.id]], + }); + const threadHead = event(hex("3"), 9); + const visibleThreadReply = event(hex("4"), 9, { + tags: [["e", threadHead.id]], + }); + + assert.deepEqual( + collectRenderScopedReactionMessageIds({ + mainEntries: [entry(main), entry(threadHead)], + threadHeadMessage: threadHead, + threadEntries: [entry(visibleThreadReply)], + }), + [main.id, threadHead.id, visibleThreadReply.id], + ); + + assert.ok( + !collectRenderScopedReactionMessageIds({ + mainEntries: [entry(main), entry(threadHead)], + threadHeadMessage: threadHead, + threadEntries: [entry(visibleThreadReply)], + }).includes(hiddenCollapsedReply.id), + ); +}); + +test("claims each rendered message id once per channel and can retry released ids", () => { + assert.deepEqual( + claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [ + hex("1"), + hex("2"), + hex("1"), + ]), + [hex("1"), hex("2")], + ); + assert.deepEqual( + claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [hex("1"), hex("2")]), + [], + ); + assert.deepEqual( + claimUnhydratedRenderScopedReactionIds("other-channel", [hex("1")]), + [hex("1")], + ); + + releaseRenderScopedReactionIds(CHANNEL_ID, [hex("2")]); + assert.deepEqual( + claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [hex("1"), hex("2")]), + [hex("2")], + ); +}); + +test("hydrates visible reactions into the channel timeline cache", async () => { + const messageId = hex("1"); + const reactionId = hex("2"); + const currentUser = hex("c"); + const message = event(messageId, 9, { + pubkey: hex("a"), + content: "ship it?", + }); + const reaction = event(reactionId, 7, { + pubkey: currentUser, + content: "✅", + tags: [["e", messageId]], + }); + const queryClient = makeQueryClientStub([message]); + const calls = []; + + await hydrateRenderScopedReactions({ + channelId: CHANNEL_ID, + messageIds: [messageId], + queryClient, + deps: { + fetchReactionEventsForMessages: async (channelId, messageIds) => { + calls.push({ channelId, messageIds }); + return [reaction]; + }, + }, + }); + + assert.deepEqual(calls, [{ channelId: CHANNEL_ID, messageIds: [messageId] }]); + const cached = queryClient.getQueryData(channelMessagesKey(CHANNEL_ID)); + assert.ok(cached.some((e) => e.id === reactionId)); + + const timeline = formatTimelineMessages(cached, null, currentUser, null); + assert.deepEqual( + timeline + .find((m) => m.id === messageId) + ?.reactions?.map((r) => ({ + count: r.count, + emoji: r.emoji, + mine: r.reactedByCurrentUser, + })), + [{ count: 1, emoji: "✅", mine: true }], + ); +}); + +test("failed hydration releases ids so the next render can retry", async () => { + const messageId = hex("1"); + const queryClient = makeQueryClientStub([event(messageId, 9)]); + + await hydrateRenderScopedReactions({ + channelId: CHANNEL_ID, + messageIds: [messageId], + queryClient, + deps: { + fetchReactionEventsForMessages: async () => { + throw new Error("relay timeout"); + }, + }, + }); + + assert.deepEqual( + claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [messageId]), + [messageId], + ); +}); diff --git a/desktop/src/features/messages/lib/renderScopedReactions.ts b/desktop/src/features/messages/lib/renderScopedReactions.ts new file mode 100644 index 0000000000..2c35cdf79e --- /dev/null +++ b/desktop/src/features/messages/lib/renderScopedReactions.ts @@ -0,0 +1,140 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { channelMessagesKey, sortMessages } from "./messageQueryKeys"; +import type { MainTimelineEntry } from "./threadPanel"; +import type { TimelineMessage } from "../types"; +import { relayClient } from "@/shared/api/relayClient"; +import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters"; +import type { RelayEvent } from "@/shared/api/types"; + +export type RenderScopedReactionDeps = { + fetchReactionEventsForMessages: ( + channelId: string, + messageIds: string[], + ) => Promise; +}; + +const defaultDeps: RenderScopedReactionDeps = { + fetchReactionEventsForMessages: (channelId, messageIds) => + relayClient.fetchAuxEventsByReference( + channelId, + messageIds, + buildChannelReactionAuxFilter, + ), +}; + +const hydratedMessageIdsByChannel = new Map>(); + +export function resetRenderScopedReactionHydration() { + hydratedMessageIdsByChannel.clear(); +} + +function hydratedSetForChannel(channelId: string): Set { + let hydrated = hydratedMessageIdsByChannel.get(channelId); + if (!hydrated) { + hydrated = new Set(); + hydratedMessageIdsByChannel.set(channelId, hydrated); + } + return hydrated; +} + +function pushUniqueMessageId(ids: string[], seen: Set, id?: string) { + if (!id || seen.has(id)) { + return; + } + seen.add(id); + ids.push(id); +} + +export function collectRenderScopedReactionMessageIds(input: { + mainEntries: readonly MainTimelineEntry[]; + threadHeadMessage?: TimelineMessage | null; + threadEntries?: readonly MainTimelineEntry[]; +}): string[] { + const ids: string[] = []; + const seen = new Set(); + + for (const entry of input.mainEntries) { + pushUniqueMessageId(ids, seen, entry.message.id); + } + + pushUniqueMessageId(ids, seen, input.threadHeadMessage?.id); + + for (const entry of input.threadEntries ?? []) { + pushUniqueMessageId(ids, seen, entry.message.id); + } + + return ids; +} + +export function claimUnhydratedRenderScopedReactionIds( + channelId: string, + messageIds: readonly string[], +): string[] { + const hydrated = hydratedSetForChannel(channelId); + const claimed: string[] = []; + + for (const id of messageIds) { + if (hydrated.has(id)) { + continue; + } + hydrated.add(id); + claimed.push(id); + } + + return claimed; +} + +export function releaseRenderScopedReactionIds( + channelId: string, + messageIds: readonly string[], +) { + const hydrated = hydratedMessageIdsByChannel.get(channelId); + if (!hydrated) { + return; + } + + for (const id of messageIds) { + hydrated.delete(id); + } + + if (hydrated.size === 0) { + hydratedMessageIdsByChannel.delete(channelId); + } +} + +export async function hydrateRenderScopedReactions(input: { + channelId: string; + messageIds: readonly string[]; + queryClient: QueryClient; + deps?: RenderScopedReactionDeps; +}): Promise { + const messageIds = claimUnhydratedRenderScopedReactionIds( + input.channelId, + input.messageIds, + ); + if (messageIds.length === 0) { + return; + } + + try { + const reactionEvents = await ( + input.deps ?? defaultDeps + ).fetchReactionEventsForMessages(input.channelId, messageIds); + if (reactionEvents.length === 0) { + return; + } + + input.queryClient.setQueryData( + channelMessagesKey(input.channelId), + (current = []) => sortMessages([...current, ...reactionEvents]), + ); + } catch (error) { + releaseRenderScopedReactionIds(input.channelId, messageIds); + console.error( + "Failed to hydrate visible reactions for channel", + input.channelId, + error, + ); + } +} diff --git a/desktop/src/features/messages/lib/useRenderScopedReactionHydration.ts b/desktop/src/features/messages/lib/useRenderScopedReactionHydration.ts new file mode 100644 index 0000000000..8bc3f7724c --- /dev/null +++ b/desktop/src/features/messages/lib/useRenderScopedReactionHydration.ts @@ -0,0 +1,49 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import type { MainTimelineEntry } from "./threadPanel"; +import type { TimelineMessage } from "../types"; +import type { Channel } from "@/shared/api/types"; +import { + collectRenderScopedReactionMessageIds, + hydrateRenderScopedReactions, +} from "./renderScopedReactions"; + +export function useRenderScopedReactionHydration(input: { + activeChannel: Channel | null; + mainTimelineEntries: MainTimelineEntry[]; + threadHeadMessage: TimelineMessage | null; + threadMessages: MainTimelineEntry[]; +}) { + const queryClient = useQueryClient(); + + React.useEffect(() => { + const channelId = input.activeChannel?.id; + if (!channelId || input.activeChannel?.channelType === "forum") { + return; + } + + const messageIds = collectRenderScopedReactionMessageIds({ + mainEntries: input.mainTimelineEntries, + threadHeadMessage: input.threadHeadMessage, + threadEntries: input.threadMessages, + }); + if (messageIds.length === 0) return; + + const timeout = window.setTimeout(() => { + void hydrateRenderScopedReactions({ + channelId, + messageIds, + queryClient, + }); + }, 0); + + return () => window.clearTimeout(timeout); + }, [ + input.activeChannel, + input.mainTimelineEntries, + input.threadHeadMessage, + input.threadMessages, + queryClient, + ]); +} diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 6c63acc7de..62ee43abc3 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -9,6 +9,7 @@ import { } from "@/features/messages/lib/timelineSnapshot"; import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay"; import type { TimelineMessage } from "@/features/messages/types"; +import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -32,6 +33,7 @@ type MessageTimelineProps = { channelName?: string; channelType?: ChannelType | null; messages: TimelineMessage[]; + mainEntries?: MainTimelineEntry[]; directMessageIntro?: { displayName: string; participants: DirectMessageIntroParticipant[]; @@ -137,6 +139,7 @@ const MessageTimelineBase = React.forwardRef< channelIntro = null, directMessageIntro = null, messages, + mainEntries, isLoading = false, emptyTitle = "No messages yet", emptyDescription = "Send the first message to start the thread.", @@ -539,6 +542,9 @@ const MessageTimelineBase = React.forwardRef< isFollowingThreadById={isFollowingThreadById} isMessageUnreadById={isMessageUnreadById} messageFooters={messageFooters} + mainEntries={ + deferredMessages === messages ? mainEntries : undefined + } messages={deferredMessages} onDelete={onDelete} onEdit={onEdit} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index fef2b9984b..15ea61a5f1 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -38,6 +38,7 @@ type TimelineMessageListProps = { isFollowingThreadById?: (rootId: string) => boolean; isMessageUnreadById?: (messageId: string) => boolean; messageFooters?: Record; + mainEntries?: ReturnType; messages: TimelineMessage[]; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; @@ -103,13 +104,15 @@ type TimelineDayGroup = { }; function buildTimelineRenderRows({ + entries, firstUnreadMessageId, messages, }: { + entries?: ReturnType; firstUnreadMessageId: string | null; messages: TimelineMessage[]; }): TimelineRenderRow[] { - const entries = buildMainTimelineEntries(messages); + entries ??= buildMainTimelineEntries(messages); const rows: TimelineRenderRow[] = []; let previousMessage: TimelineMessage | null = null; @@ -191,6 +194,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ isFollowingThreadById, isMessageUnreadById, messageFooters, + mainEntries, messages, onDelete, onEdit, @@ -208,8 +212,13 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ unfollowThreadById, }: TimelineMessageListProps) { const rows = React.useMemo( - () => buildTimelineRenderRows({ firstUnreadMessageId, messages }), - [firstUnreadMessageId, messages], + () => + buildTimelineRenderRows({ + entries: mainEntries, + firstUnreadMessageId, + messages, + }), + [firstUnreadMessageId, mainEntries, messages], ); const dayGroups = React.useMemo(() => buildTimelineDayGroups(rows), [rows]); diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts index a678c34d4a..c7026ecc19 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/workspaces/useWorkspaceInit.ts @@ -9,6 +9,7 @@ import { import { resetMediaCaches } from "@/shared/lib/mediaUrl"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; import { clearAllDrafts } from "@/features/messages/lib/useDrafts"; +import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState"; @@ -29,6 +30,7 @@ function resetWorkspaceState(): void { resetSidebarRelayConnectionCardState(); resetMediaCaches(); resetVideoPlayerState(); + resetRenderScopedReactionHydration(); clearSearchHitEventCache(); clearAllDrafts(); } diff --git a/desktop/src/shared/api/relayChannelFilters.test.mjs b/desktop/src/shared/api/relayChannelFilters.test.mjs index 76380a708e..3519c82146 100644 --- a/desktop/src/shared/api/relayChannelFilters.test.mjs +++ b/desktop/src/shared/api/relayChannelFilters.test.mjs @@ -4,6 +4,8 @@ import test from "node:test"; import { buildChannelAuxDeletionFilter, buildChannelAuxFilter, + buildChannelReactionAuxFilter, + buildChannelStructuralAuxFilter, } from "./relayChannelFilters.ts"; const CHANNEL = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; @@ -27,3 +29,17 @@ test("buildChannelAuxDeletionFilter keys on #e only, no #h", () => { assert.deepEqual(filter["#e"], IDS); assert.equal("#h" in filter, false); }); + +test("buildChannelReactionAuxFilter fetches only kind:7 by #e", () => { + const filter = buildChannelReactionAuxFilter(CHANNEL, IDS); + assert.deepEqual(filter.kinds, [7]); + assert.deepEqual(filter["#e"], IDS); + assert.equal("#h" in filter, false); +}); + +test("buildChannelStructuralAuxFilter excludes reactions", () => { + const filter = buildChannelStructuralAuxFilter(CHANNEL, IDS); + assert.deepEqual(filter.kinds, [5, 9005, 40003]); + assert.deepEqual(filter["#e"], IDS); + assert.equal("#h" in filter, false); +}); diff --git a/desktop/src/shared/api/relayChannelFilters.ts b/desktop/src/shared/api/relayChannelFilters.ts index 922c934398..d0c7e7938e 100644 --- a/desktop/src/shared/api/relayChannelFilters.ts +++ b/desktop/src/shared/api/relayChannelFilters.ts @@ -5,6 +5,8 @@ import { HOME_MENTION_EVENT_KINDS, KIND_DELETION, KIND_NIP29_DELETE_EVENT, + KIND_REACTION, + KIND_STREAM_MESSAGE_EDIT, } from "@/shared/constants/kinds"; import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared"; @@ -42,9 +44,10 @@ export function buildChannelFilter( * History filter for cold-load and scrollback: message kinds *only*, so the * `limit` budget buys visible message depth. Auxiliary events (reactions, * edits, deletions) are backfilled separately by `#e` reference via - * {@link buildChannelAuxFilter}, and arrive for future messages through the - * live subscription ({@link buildChannelFilter}, which keeps the broad - * {@link CHANNEL_EVENT_KINDS} set). + * {@link buildChannelStructuralAuxFilter} and + * {@link buildChannelReactionAuxFilter}, and arrive for future messages + * through the live subscription ({@link buildChannelFilter}, which keeps the + * broad {@link CHANNEL_EVENT_KINDS} set). */ export function buildChannelHistoryFilter( channelId: string, @@ -65,10 +68,10 @@ export function buildChannelHistoryFilter( } /** - * Aux-backfill filter for one chunk of loaded message ids: pulls reactions/ - * edits/deletions ({@link CHANNEL_AUX_EVENT_KINDS}) that reference those ids - * by `#e`. Keyed by reference, not time, so a late edit/deletion for an old - * visible message still applies — see {@link buildChannelHistoryFilter}. + * Aux-backfill filter for one chunk of loaded message ids: pulls auxiliary + * events ({@link CHANNEL_AUX_EVENT_KINDS}) that reference those ids by `#e`. + * Keyed by reference, not time, so a late edit/deletion for an old visible + * message still applies — see {@link buildChannelHistoryFilter}. */ export function buildChannelAuxFilter( _channelId: string, @@ -77,6 +80,34 @@ export function buildChannelAuxFilter( return buildChannelAuxKindFilter(messageIds, [...CHANNEL_AUX_EVENT_KINDS]); } +/** + * Structural aux filter for history backfill: edits/deletions only. Reactions + * are hydrated from the rows the GUI actually renders, so the slow kind:5 scan + * never shares a request with first-paint reaction pills. + */ +export function buildChannelStructuralAuxFilter( + _channelId: string, + messageIds: string[], +): RelaySubscriptionFilter { + return buildChannelAuxKindFilter(messageIds, [ + KIND_DELETION, + KIND_NIP29_DELETE_EVENT, + KIND_STREAM_MESSAGE_EDIT, + ]); +} + +/** + * Reactions-only filter for the message rows the GUI is currently rendering. + * Keep this separate from structural aux backfill so the slow kind:5 deletion + * scan cannot delay reaction pills that affect visible pixels right now. + */ +export function buildChannelReactionAuxFilter( + _channelId: string, + messageIds: string[], +): RelaySubscriptionFilter { + return buildChannelAuxKindFilter(messageIds, [KIND_REACTION]); +} + export function buildChannelAuxDeletionFilter( _channelId: string, auxEventIds: string[], diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 8db85397b7..1e693ddf22 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -22,7 +22,6 @@ import { import { AUX_BACKFILL_CHUNK_SIZE, buildChannelAuxDeletionFilter, - buildChannelAuxFilter, buildChannelFilter, buildChannelHistoryFilter, buildChannelMentionFilter, @@ -168,14 +167,18 @@ export class RelayClient { ); } - async fetchAuxEventsForMessages( + async fetchAuxEventsByReference( channelId: string, - messageIds: string[], - ): Promise { + referencedEventIds: string[], + buildFilter: ( + channelId: string, + eventIds: string[], + ) => RelaySubscriptionFilter, + ) { return this.fetchChunkedAuxEvents( channelId, - messageIds, - buildChannelAuxFilter, + referencedEventIds, + buildFilter, ); } diff --git a/desktop/tests/e2e/reaction-coldload-repro.spec.ts b/desktop/tests/e2e/reaction-coldload-repro.spec.ts new file mode 100644 index 0000000000..2c5925d9b1 --- /dev/null +++ b/desktop/tests/e2e/reaction-coldload-repro.spec.ts @@ -0,0 +1,434 @@ +import { expect, type Page, test } from "@playwright/test"; +import { decode } from "nostr-tools/nip19"; +import { getPublicKey } from "nostr-tools/pure"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; + +// Ground-truth reproduction for Tyler's "reactions don't show on first load" +// bug. Drives the REAL GUI (relay-mode e2e bridge) against the staging relay +// with Eva's real key, cold-loads #buzz-bugs, and observes whether messages +// known to carry reactions render their reaction pills on first paint vs only +// after a channel switch-away-and-back. +// +// NOT a CI test — points at a live external relay and a real member identity. +// Run explicitly: pnpm exec playwright test reaction-coldload-repro + +const RELAY_WS = "wss://sprout-oss.stage.blox.sqprod.co"; +const RELAY_HTTP = "https://sprout-oss.stage.blox.sqprod.co"; +const BUZZ_BUGS_NAME = "buzz-bugs"; + +function loadTestIdentity() { + const rawPrivateKey = process.env.BUZZ_PRIVATE_KEY ?? ""; + if (!rawPrivateKey) { + throw new Error("BUZZ_PRIVATE_KEY is required for the live staging repro"); + } + + const privateKey = rawPrivateKey.startsWith("nsec") + ? bytesToHex(decode(rawPrivateKey).data) + : bytesToHex(hexToBytes(rawPrivateKey)); + return { + privateKey, + pubkey: getPublicKey(hexToBytes(privateKey)), + username: "Max", + }; +} + +const TEST_IDENTITY = loadTestIdentity(); + +async function collectRenderedReactionState(page: Page) { + return page.locator("[data-message-id]").evaluateAll((rows) => + rows.map((row) => { + const el = row as HTMLElement; + const reactions = Array.from( + el.querySelectorAll( + '[data-testid="message-reactions"] button', + ), + ).map( + (button) => + button.getAttribute("aria-label") ?? button.textContent?.trim() ?? "", + ); + return { + id: el.dataset.messageId ?? "", + hasReactions: reactions.length > 0, + reactions, + text: el.innerText.replace(/\s+/g, " ").slice(0, 120), + }; + }), + ); +} + +test("cold-load buzz-bugs and observe reaction render", async ({ page }) => { + test.setTimeout(180_000); + // Seed workspace + onboarding for Eva's pubkey so the app skips WelcomeSetup + // and boots straight into the workspace pointed at staging. + await page.addInitScript( + ({ relayUrl, pubkey }) => { + const workspaceId = "e2e-repro-workspace"; + window.localStorage.setItem( + "buzz-workspaces", + JSON.stringify([ + { + id: workspaceId, + name: "Staging Repro", + relayUrl, + addedAt: new Date().toISOString(), + }, + ]), + ); + window.localStorage.setItem("buzz-active-workspace-id", workspaceId); + const scope = encodeURIComponent(relayUrl); + window.localStorage.setItem( + `buzz-onboarding-complete.v1:${pubkey}`, + "true", + ); + window.localStorage.setItem( + `buzz-welcome-channel-ensured.v1:${scope}:${pubkey}`, + "true", + ); + }, + { relayUrl: RELAY_WS, pubkey: TEST_IDENTITY.pubkey }, + ); + + await page.addInitScript( + ({ identity, relayHttpUrl, relayWsUrl }) => { + (window as unknown as { __BUZZ_E2E__: unknown }).__BUZZ_E2E__ = { + mode: "relay", + identity, + relayHttpUrl, + relayWsUrl, + }; + }, + { identity: TEST_IDENTITY, relayHttpUrl: RELAY_HTTP, relayWsUrl: RELAY_WS }, + ); + + const consoleLines: string[] = []; + page.on("console", (msg) => { + consoleLines.push(`[${msg.type()}] ${msg.text()}`); + }); + page.on("pageerror", (err) => { + consoleLines.push(`[pageerror] ${err.message}`); + }); + + // The staging relay's HTTP API does not return Access-Control-Allow-Origin, + // so a browser fetch from the test origin (127.0.0.1:4173) is blocked by + // CORS — every get_channels / backfill /query fails with "Failed to fetch". + // That's a harness artifact, NOT Tyler's bug. Proxy the relay's HTTP origin + // through Node fetch (no CORS) and inject the missing CORS header so the + // real GUI data path runs exactly as shipped, just reachable from the test. + const relayQueryBodies: string[] = []; + await page.route(`${RELAY_HTTP}/**`, async (route) => { + const req = route.request(); + const url = req.url(); + const postData = req.postData() ?? undefined; + if (url.endsWith("/query") && postData) { + relayQueryBodies.push(postData.slice(0, 400)); + } + const headers = { ...req.headers() }; + delete headers.origin; + // The staging relay sits behind a WAF that 403s any request whose + // User-Agent contains the `Mozilla/` token (confirmed: `Mozilla/5.0` -> + // 403, `curl`/`buzz-desktop`/empty -> 200). Chromium always sends a + // Mozilla UA, so the browser data path is blocked at infra — a harness + // artifact, NOT Tyler's bug (the real Tauri app queries via the Rust + // reqwest client, which is not browser-UA-shaped). Rewrite the UA so the + // shipped data path can run from the test browser. + headers["user-agent"] = "buzz-desktop-e2e"; + const upstream = await fetch(url, { + method: req.method(), + headers, + body: + req.method() === "GET" || req.method() === "HEAD" + ? undefined + : postData, + }); + const bodyBuf = Buffer.from(await upstream.arrayBuffer()); + const respHeaders: Record = {}; + upstream.headers.forEach((v, k) => { + respHeaders[k] = v; + }); + respHeaders["access-control-allow-origin"] = "*"; + respHeaders["access-control-allow-headers"] = "*"; + respHeaders["access-control-allow-methods"] = "*"; + await route.fulfill({ + status: upstream.status, + headers: respHeaders, + body: bodyBuf, + }); + }); + + // Capture the aux backfill REQ + EVENT traffic over the websocket. + const wsFrames: string[] = []; + // The same `Mozilla/`-UA WAF that 403s the HTTP path also 403s the browser's + // WS upgrade (confirmed by probe), and message history + reaction backfill + // both ride the WebSocket, not the HTTP /query bridge. Playwright's + // `connectToServer()` is ALSO WAF-blocked (its upstream handshake closes + // 1006), and it exposes no way to set the UA. Node's built-in global + // `WebSocket` (undici) connects fine — its UA is not `Mozilla/`-shaped — so + // bridge the page's WS to a Node-side undici WebSocket by hand. The shipped + // WS data path (auth, history, aux reaction backfill) then runs as-is, just + // reachable from the test browser. + // Per-REQ latency tracking: maps a subscription id to the wall-clock time + // its REQ was sent, so we can measure REQ -> EOSE latency for each aux + // backfill chunk. This is what separates "deterministic >8s timeout" (a + // consistent GUI bug, as Tyler insists) from "random latency". + const reqSentAt = new Map(); + const reqFilters = new Map(); + // Aux-backfill subscription tracking + a bridge-throughput counter, to + // distinguish "relay never sent the EOSE" from "bridge stalled and dropped + // it" (harness artifact). auxSubIds holds the aux REQ ids (kinds 5,7,9005, + // 40003 keyed by #e); auxFrameLog records every upstream frame for them. + const auxSubIds = new Set(); + const auxFrameLog: string[] = []; + let upstreamFrameCount = 0; + const t0 = Date.now(); + const rel = () => `${((Date.now() - t0) / 1000).toFixed(2)}s`; + await page.routeWebSocket(/sprout-oss/, (ws) => { + wsFrames.push(`ROUTE HIT url=${ws.url()}`); + const upstream = new WebSocket(ws.url()); + const pageQueue: (string | Buffer)[] = []; + let upstreamOpen = false; + upstream.addEventListener("open", () => { + upstreamOpen = true; + for (const m of pageQueue) upstream.send(m); + pageQueue.length = 0; + }); + upstream.addEventListener("message", (ev: MessageEvent) => { + const m = ev.data as string; + upstreamFrameCount++; + if (typeof m === "string" && m.startsWith("[")) { + try { + const arr = JSON.parse(m) as unknown[]; + const verb = arr[0]; + const sid = arr[1]; + // Track every frame for the aux-backfill sub specifically, so we can + // tell whether the relay delivered its EVENTs/EOSE to the bridge even + // if the page never rendered them (= harness bottleneck, not the bug). + if (typeof sid === "string" && auxSubIds.has(sid)) { + auxFrameLog.push( + `[${rel()}] AUX-FRAME #${upstreamFrameCount} verb=${verb} sid=${sid.slice(0, 20)}`, + ); + } + if (verb === "EOSE" && typeof sid === "string") { + const sent = reqSentAt.get(sid); + const filt = reqFilters.get(sid) ?? ""; + if (sent !== undefined) { + wsFrames.push( + `EOSE sid=${sid} latency=${((Date.now() - sent) / 1000).toFixed(2)}s filter=${filt}`, + ); + reqSentAt.delete(sid); + } + } + } catch { + /* not JSON array */ + } + } + if ( + typeof m === "string" && + (m.includes('"kind":7') || + m.includes("EOSE") || + m.includes("OK") || + m.includes("AUTH")) + ) + wsFrames.push(`[${rel()}] << ${m.slice(0, 200)}`); + ws.send(m); + }); + upstream.addEventListener("close", (ev: CloseEvent) => { + wsFrames.push(`UPSTREAM CLOSE code=${ev.code}`); + void ws + .close({ code: ev.code || 1000, reason: "upstream closed" }) + .catch(() => {}); + }); + upstream.addEventListener("error", () => wsFrames.push(`UPSTREAM ERROR`)); + ws.onMessage((m) => { + const p = typeof m === "string" ? m : ""; + if (typeof m === "string" && m.startsWith("[")) { + try { + const arr = JSON.parse(m) as unknown[]; + if (arr[0] === "REQ" && typeof arr[1] === "string") { + const sid = arr[1]; + reqSentAt.set(sid, Date.now()); + // Record the kinds + which #e tag count so we can attribute the + // latency to aux-backfill (kinds 5,7,9005,40003 + #e) vs other REQs. + const filterObj = arr[2] as + | { kinds?: number[]; "#e"?: string[] } + | undefined; + const kinds = filterObj?.kinds?.join(",") ?? "?"; + const eCount = filterObj?.["#e"]?.length ?? 0; + reqFilters.set(sid, `kinds=[${kinds}] #e=${eCount}`); + // Aux backfill REQs are keyed by #e. Post kind-split (the fix), + // reactions ride a kind:7-only REQ and the structural overlay rides + // a 5/9005/40003 REQ; pre-fix they were one bundled 5+7+... REQ. + // Track all aux REQs so we can measure each one's REQ->EOSE latency + // and prove the reaction REQ now beats the 8s timeout. + const kindSet = new Set(filterObj?.kinds ?? []); + const isReactionReq = + eCount > 0 && kindSet.has(7) && !kindSet.has(5); + const isStructuralReq = + eCount > 0 && kindSet.has(5) && !kindSet.has(7); + const isBundledReq = eCount > 0 && kindSet.has(7) && kindSet.has(5); + if (isReactionReq || isStructuralReq || isBundledReq) { + auxSubIds.add(sid); + } + wsFrames.push( + `[${rel()}] REQ sid=${sid} kinds=[${kinds}] #e=${eCount}${ + isReactionReq + ? " [REACTION-AUX]" + : isStructuralReq + ? " [STRUCTURAL-AUX]" + : isBundledReq + ? " [BUNDLED-AUX]" + : "" + }`, + ); + } + } catch { + /* not JSON array */ + } + } + if (p.includes("REQ") || p.includes("kinds") || p.includes("AUTH")) + wsFrames.push(`[${rel()}] >> ${p.slice(0, 300)}`); + if (upstreamOpen) upstream.send(m); + else pageQueue.push(m); + }); + ws.onClose(() => { + try { + upstream.close(); + } catch { + /* noop */ + } + }); + }); + + await page.goto("/"); + + try { + // Wait for the sidebar + buzz-bugs channel entry to materialize from the + // relay (kind:39002 membership -> kind:39000 metadata). + const channelEntry = page.getByTestId(`channel-${BUZZ_BUGS_NAME}`); + await channelEntry.waitFor({ state: "visible", timeout: 60_000 }); + + // ---- COLD LOAD: first entry into the channel ---- + await channelEntry.click(); + await expect(page.getByTestId("chat-title")).toHaveText(BUZZ_BUGS_NAME, { + timeout: 20_000, + }); + // Let messages render. + await page.getByTestId("message-row").first().waitFor({ timeout: 20_000 }); + + // Give the cold-load aux backfill a generous window to commit. + await page.waitForTimeout(6_000); + + const coldLoadReactionCount = await page + .getByTestId("message-reactions") + .count(); + const coldLoadState = await collectRenderedReactionState(page); + const coldLoadMessageCount = coldLoadState.length; + await page.screenshot({ + path: "test-results/reaction-coldload/01-coldload.png", + fullPage: true, + }); + + // ---- DISAMBIGUATE: switch away then back ---- + // Click any other channel, then return to buzz-bugs. + const otherChannel = page + .locator('[data-testid^="channel-"]') + .filter({ hasNot: channelEntry }) + .first(); + await otherChannel.click(); + await page.waitForTimeout(1_500); + await channelEntry.click(); + await expect(page.getByTestId("chat-title")).toHaveText(BUZZ_BUGS_NAME); + await page.getByTestId("message-row").first().waitFor({ timeout: 20_000 }); + await page.waitForTimeout(6_000); + + const afterSwitchReactionCount = await page + .getByTestId("message-reactions") + .count(); + const afterSwitchState = await collectRenderedReactionState(page); + await page.screenshot({ + path: "test-results/reaction-coldload/02-afterswitch.png", + fullPage: true, + }); + + console.log("=== REPRO RESULT ==="); + console.log("cold-load message rows:", coldLoadMessageCount); + console.log("after-switch message rows:", afterSwitchState.length); + console.log("cold-load reaction containers:", coldLoadReactionCount); + console.log("after-switch reaction containers:", afterSwitchReactionCount); + const coldReactionIds = new Set( + coldLoadState.filter((r) => r.hasReactions).map((r) => r.id), + ); + const afterReactionIds = new Set( + afterSwitchState.filter((r) => r.hasReactions).map((r) => r.id), + ); + const afterSwitchOnlyReactionRows = afterSwitchState.filter( + (r) => r.hasReactions && !coldReactionIds.has(r.id), + ); + const coldLoadOnlyReactionRows = coldLoadState.filter( + (r) => r.hasReactions && !afterReactionIds.has(r.id), + ); + console.log( + "cold-load reaction row ids:", + JSON.stringify([...coldReactionIds]), + ); + console.log( + "after-switch reaction row ids:", + JSON.stringify([...afterReactionIds]), + ); + console.log( + "after-switch-only reaction rows:", + JSON.stringify(afterSwitchOnlyReactionRows), + ); + console.log( + "cold-load-only reaction rows:", + JSON.stringify(coldLoadOnlyReactionRows), + ); + + // The proof: switching away-and-back is what "fixes" the symptom for the + // user today, so after-switch is the ground-truth count of reactions that + // exist for the loaded window. The fix means the FIRST cold-load paint must + // already show them — not zero, and not far short of after-switch. Pre-fix + // this was 0 on a busy workspace (all-or-nothing drop on the bundled REQ + // timeout); post-fix the kind:7 REQ lands well inside the 8s budget. + expect( + coldLoadReactionCount, + "cold-load must render reactions on first paint (pre-fix: 0)", + ).toBeGreaterThan(0); + expect( + coldLoadState.map((row) => row.id), + "cold-load and after-switch must compare the same rendered message rows", + ).toEqual(afterSwitchState.map((row) => row.id)); + + expect( + afterSwitchOnlyReactionRows, + "cold-load must hydrate every reaction-bearing row visible after switch-back", + ).toEqual([]); + } finally { + // Always dump diagnostics, even if a waitFor above timed out — this is the + // whole point of the harness. Without the finally, a timeout fires before + // the log block and we learn nothing. + const channelTestIds = await page + .locator('[data-testid^="channel-"]') + .evaluateAll((els) => + els.map((e) => (e as HTMLElement).dataset.testid ?? ""), + ) + .catch(() => [] as string[]); + console.log("=== CHANNELS RENDERED IN SIDEBAR ==="); + console.log(JSON.stringify(channelTestIds)); + console.log("=== WS FRAMES (reaction/REQ) ==="); + for (const f of wsFrames) console.log(f); + console.log("=== AUX-BACKFILL SUB FRAMES (relay -> bridge) ==="); + console.log("total upstream frames bridged:", upstreamFrameCount); + console.log("aux sub ids tracked:", [...auxSubIds].join(", ")); + for (const f of auxFrameLog) console.log(f); + console.log("=== RELAY /query BODIES ==="); + for (const b of relayQueryBodies.slice(0, 40)) console.log(b); + console.log("=== CONSOLE (last 40) ==="); + for (const l of consoleLines.slice(-40)) console.log(l); + await page + .screenshot({ + path: "test-results/reaction-coldload/99-final.png", + fullPage: true, + }) + .catch(() => {}); + } +});