From 623a1ad9691f67218c39056b0db3a42697ebcb4b Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 18 Apr 2026 08:41:32 -0600 Subject: [PATCH 1/5] fix(desktop): eliminate 10+ second UI freeze on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch N per-channel relay mention subscriptions into a single multi-filter REQ (NIP-01), phase non-critical startup work behind requestIdleCallback, memoize sidebar channel filters, and defer HomeView cascading queries. - Add subscribeToBatchedMentionEvents() to RelayClient — sends one REQ with N filters instead of N separate subscriptions, eliminating N×250ms EOSE waits - Create shared useDeferredLoad/useDeferredStartup hooks to gate Tier 2 initialization (presence, notifications, unread tracking) until the shell is interactive - Wrap AppSidebar .filter() calls in useMemo - Gate HomeView pulse queries behind deferred startup flag - Add module-level flag so startup deferral only happens once per app lifecycle (no re-defer on route navigation) Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src/app/AppShell.tsx | 15 ++-- desktop/src/features/agents/hooks.ts | 3 +- .../channels/useLiveChannelUpdates.ts | 81 ++++++++----------- desktop/src/features/home/ui/HomeView.tsx | 15 ++-- .../src/features/sidebar/ui/AppSidebar.tsx | 74 +++++------------ desktop/src/shared/api/relayClientSession.ts | 42 +++++----- desktop/src/shared/api/relayClientShared.ts | 2 +- .../src/shared/hooks/useDeferredStartup.ts | 63 +++++++++++++++ 8 files changed, 152 insertions(+), 143 deletions(-) create mode 100644 desktop/src/shared/hooks/useDeferredStartup.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index fe1cb2e10..0bcf5271f 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -33,6 +33,7 @@ import { HuddleBar, HuddleProvider } from "@/features/huddle"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; import { relayClient } from "@/shared/api/relayClient"; import { useIdentityQuery } from "@/shared/api/hooks"; +import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; import { joinChannel } from "@/shared/api/tauri"; import type { SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; @@ -130,14 +131,14 @@ export function AppShell() { [location.pathname], ); + const startupReady = useDeferredStartup(); + const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); - const presenceSession = usePresenceSession(identityQuery.data?.pubkey); + const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; + const presenceSession = usePresenceSession(deferredPubkey); const { homeBadgeCount, homeFeedQuery, notificationSettings } = - useHomeFeedNotifications( - identityQuery.data?.pubkey, - selectedView === "home", - ); + useHomeFeedNotifications(deferredPubkey, selectedView === "home"); const refetchHomeFeedOnLiveMention = React.useEffectEvent(() => { void homeFeedQuery.refetch(); }); @@ -168,8 +169,8 @@ export function AppShell() { // advancing unread state for the active channel. null, { - currentPubkey: identityQuery.data?.pubkey, - onLiveMention: refetchHomeFeedOnLiveMention, + currentPubkey: deferredPubkey, + onLiveMention: startupReady ? refetchHomeFeedOnLiveMention : undefined, }, ); diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index c4c368fab..4a49efcab 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -145,12 +145,13 @@ export function useManagedAgentPrereqsQuery( }); } -export function useRelayAgentsQuery() { +export function useRelayAgentsQuery(options?: { enabled?: boolean }) { return useQuery({ queryKey: relayAgentsQueryKey, queryFn: listRelayAgents, staleTime: 30_000, refetchInterval: 30_000, + enabled: options?.enabled, }); } diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index c04efac2e..5486b08bc 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -45,12 +45,6 @@ function rememberMentionEvent( return true; } -async function disposeLiveSubscriptions( - subscriptions: Array<() => Promise>, -) { - await Promise.allSettled(subscriptions.map((dispose) => dispose())); -} - export function useLiveChannelUpdates( channels: Channel[], activeChannelId: string | null, @@ -162,54 +156,41 @@ export function useLiveChannelUpdates( } let isDisposed = false; - let cleanup: Array<() => Promise> = []; + let cleanup: (() => Promise) | undefined; let retryTimeout: ReturnType | undefined; const subscribeToMentionChannels = async () => { - const settled = await Promise.allSettled( - mentionChannelIds.map((channelId) => - relayClient.subscribeToChannelMentionEvents( - channelId, - normalizedCurrentPubkey, - (event) => { - if (!isDisposed) { - handleMentionEvent(event); - } - }, - ), - ), - ); - - const nextCleanup = settled.flatMap((result) => - result.status === "fulfilled" ? [result.value] : [], - ); - - if (isDisposed) { - await disposeLiveSubscriptions(nextCleanup); - return; - } + try { + const dispose = await relayClient.subscribeToBatchedMentionEvents( + mentionChannelIds, + normalizedCurrentPubkey, + (event) => { + if (!isDisposed) { + handleMentionEvent(event); + } + }, + ); - const firstFailure = settled.find( - (result) => result.status === "rejected", - ); - if (!firstFailure) { - cleanup = nextCleanup; - return; - } + if (isDisposed) { + await dispose(); + return; + } - await disposeLiveSubscriptions(nextCleanup); - if (isDisposed) { - return; - } + cleanup = dispose; + } catch (error) { + if (isDisposed) { + return; + } - console.error( - "Failed to subscribe to all Home mention updates; retrying", - firstFailure.reason, - ); - retryTimeout = window.setTimeout(() => { - retryTimeout = undefined; - void subscribeToMentionChannels(); - }, LIVE_MENTION_SUBSCRIPTION_RETRY_MS); + console.error( + "Failed to subscribe to batched Home mention updates; retrying", + error, + ); + retryTimeout = window.setTimeout(() => { + retryTimeout = undefined; + void subscribeToMentionChannels(); + }, LIVE_MENTION_SUBSCRIPTION_RETRY_MS); + } }; void subscribeToMentionChannels(); @@ -219,7 +200,9 @@ export function useLiveChannelUpdates( if (retryTimeout !== undefined) { window.clearTimeout(retryTimeout); } - void disposeLiveSubscriptions(cleanup); + if (cleanup) { + void cleanup(); + } }; }, [mentionChannelIds, normalizedCurrentPubkey, options.onLiveMention]); } diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index ea0469242..5373dc217 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -5,6 +5,7 @@ import { useRelayAgentsQuery } from "@/features/agents/hooks"; import { useFeedItemState } from "@/features/home/useFeedItemState"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useContactListQuery, useTimelineQuery } from "@/features/pulse/hooks"; +import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { Skeleton } from "@/shared/ui/skeleton"; @@ -79,18 +80,22 @@ export function HomeView({ const [filter, setFilter] = React.useState("all"); const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey); + // Defer Pulse widget queries until the shell is interactive + const startupReady = useDeferredStartup(); + const deferredPubkey = startupReady ? currentPubkey : undefined; + // Recent notes for the Pulse widget - const contactListQuery = useContactListQuery(currentPubkey); + const contactListQuery = useContactListQuery(deferredPubkey); const contactPubkeys = React.useMemo( () => (contactListQuery.data?.contacts ?? []).map((c) => c.pubkey), [contactListQuery.data], ); const notesPubkeys = React.useMemo( () => - currentPubkey - ? [...new Set([currentPubkey, ...contactPubkeys])] + deferredPubkey + ? [...new Set([deferredPubkey, ...contactPubkeys])] : contactPubkeys, - [currentPubkey, contactPubkeys], + [deferredPubkey, contactPubkeys], ); const notesTimelineQuery = useTimelineQuery( notesPubkeys, @@ -105,7 +110,7 @@ export function HomeView({ enabled: noteAuthorPubkeys.length > 0, }); const noteProfiles = noteProfilesQuery.data?.profiles ?? {}; - const relayAgentsQuery = useRelayAgentsQuery(); + const relayAgentsQuery = useRelayAgentsQuery({ enabled: startupReady }); const agentPubkeySet = React.useMemo( () => new Set((relayAgentsQuery.data ?? []).map((a) => a.pubkey)), [relayAgentsQuery.data], diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index b885f9799..325c8fbad 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -3,6 +3,7 @@ import { Activity, Bot, Home, PenSquare, Plus, Search, Zap } from "lucide-react" import * as React from "react"; import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { useDeferredLoad } from "@/shared/hooks/useDeferredStartup"; import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; @@ -93,44 +94,6 @@ type AppSidebarProps = { isPresencePending?: boolean; }; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function useDeferredSidebarLoad( - activateImmediately: boolean, - timeoutMs: number, -) { - const [shouldLoad, setShouldLoad] = React.useState(activateImmediately); - - React.useEffect(() => { - if (shouldLoad || activateImmediately) { - if (!shouldLoad) { - setShouldLoad(true); - } - return; - } - - const load = () => { - setShouldLoad(true); - }; - - if ("requestIdleCallback" in window) { - const idleId = window.requestIdleCallback(load, { timeout: timeoutMs }); - return () => { - window.cancelIdleCallback(idleId); - }; - } - - const timeoutId = globalThis.setTimeout(load, timeoutMs); - return () => { - globalThis.clearTimeout(timeoutId); - }; - }, [activateImmediately, shouldLoad, timeoutMs]); - - return shouldLoad; -} - // --------------------------------------------------------------------------- // SectionHeaderActions — browse + create icon buttons for section headers // --------------------------------------------------------------------------- @@ -275,26 +238,25 @@ export function AppSidebar({ const [createDialogKind, setCreateDialogKind] = React.useState(null); - const visibleChannels = channels.filter( - (channel) => channel.archivedAt === null, - ); - - const streamChannels = visibleChannels.filter( - (channel) => channel.channelType === "stream", + const streamChannels = React.useMemo( + () => channels.filter((channel) => channel.channelType === "stream"), + [channels], ); - const forumChannels = visibleChannels.filter( - (channel) => channel.channelType === "forum", + const forumChannels = React.useMemo( + () => channels.filter((channel) => channel.channelType === "forum"), + [channels], ); - const directMessages = visibleChannels.filter( - (channel) => channel.channelType === "dm", + const directMessages = React.useMemo( + () => channels.filter((channel) => channel.channelType === "dm"), + [channels], ); const isSelectedDirectMessage = selectedView === "channel" && directMessages.some((channel) => channel.id === selectedChannelId); - const shouldLoadDmMetadata = useDeferredSidebarLoad( - isSelectedDirectMessage, - 400, - ); + const shouldLoadDmMetadata = useDeferredLoad({ + immediate: isSelectedDirectMessage, + timeoutMs: 400, + }); const { dmChannelLabels, dmParticipantsByChannelId, dmPresenceByChannelId } = useDmSidebarMetadata({ currentPubkey, @@ -303,10 +265,10 @@ export function AppSidebar({ fallbackDisplayName, profileDisplayName: profile?.displayName, }); - const shouldLoadAgentCount = useDeferredSidebarLoad( - selectedView === "agents", - 250, - ); + const shouldLoadAgentCount = useDeferredLoad({ + immediate: selectedView === "agents", + timeoutMs: 250, + }); const managedAgentsQuery = useManagedAgentsQuery({ enabled: shouldLoadAgentCount, }); diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 721cce432..c3e0d7c86 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -225,15 +225,20 @@ export class RelayClient { return this.subscribe(this.buildGlobalStreamFilter(50), onEvent); } - async subscribeToChannelMentionEvents( - channelId: string, + async subscribeToBatchedMentionEvents( + channelIds: string[], pubkey: string, onEvent: (event: RelayEvent) => void, ) { - return this.subscribe( - this.buildChannelMentionFilter(channelId, pubkey, 50), - onEvent, - ); + const since = Math.floor(Date.now() / 1_000); + const filters = channelIds.map((channelId) => ({ + kinds: [...HOME_MENTION_EVENT_KINDS], + "#h": [channelId], + "#p": [pubkey], + limit: 50, + since, + })); + return this.subscribe(filters, onEvent); } async preconnect() { @@ -337,26 +342,13 @@ export class RelayClient { }; } - private buildChannelMentionFilter( - channelId: string, - pubkey: string, - limit: number, - ): RelaySubscriptionFilter { - return { - kinds: [...HOME_MENTION_EVENT_KINDS], - "#h": [channelId], - "#p": [pubkey], - limit, - since: Math.floor(Date.now() / 1_000), - }; - } - private async subscribe( - filter: RelaySubscriptionFilter, + input: RelaySubscriptionFilter | RelaySubscriptionFilter[], onEvent: (event: RelayEvent) => void, ) { await this.ensureConnected(); + const filters = Array.isArray(input) ? input : [input]; const subId = `live-${crypto.randomUUID()}`; let resolveReady = () => { return; @@ -373,14 +365,14 @@ export class RelayClient { this.subscriptions.set(subId, { mode: "live", - filter, + filters, onEvent, resolveReady, }); try { await this.sendRawWithReconnectRetry( - ["REQ", subId, filter], + ["REQ", subId, ...filters], "Failed to restore relay subscription.", ); } catch (error) { @@ -715,7 +707,9 @@ export class RelayClient { await this.sendRaw([ "REQ", subId, - this.buildReplayFilter(subscription.filter, replaySince), + ...subscription.filters.map((f) => + this.buildReplayFilter(f, replaySince), + ), ]); } catch (error) { const reconnectError = diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index 010bbc3cb..39e3a5304 100644 --- a/desktop/src/shared/api/relayClientShared.ts +++ b/desktop/src/shared/api/relayClientShared.ts @@ -17,7 +17,7 @@ type HistorySubscription = { type LiveSubscription = { mode: "live"; - filter: RelaySubscriptionFilter; + filters: RelaySubscriptionFilter[]; onEvent: (event: RelayEvent) => void; resolveReady?: () => void; lastSeenCreatedAt?: number; diff --git a/desktop/src/shared/hooks/useDeferredStartup.ts b/desktop/src/shared/hooks/useDeferredStartup.ts new file mode 100644 index 000000000..b31301f9b --- /dev/null +++ b/desktop/src/shared/hooks/useDeferredStartup.ts @@ -0,0 +1,63 @@ +import * as React from "react"; + +const DEFAULT_TIMEOUT_MS = 2_000; + +/** + * Defers activation until the browser is idle (via `requestIdleCallback`) or + * a timeout elapses — whichever comes first. + * + * Pass `immediate: true` to skip deferral and return `true` on the first render + * (useful when the deferred content is already in view). + */ +export function useDeferredLoad( + options: { immediate?: boolean; timeoutMs?: number } = {}, +): boolean { + const { immediate = false, timeoutMs = DEFAULT_TIMEOUT_MS } = options; + const [isReady, setIsReady] = React.useState(immediate); + + React.useEffect(() => { + if (isReady) { + return; + } + + if (immediate) { + setIsReady(true); + return; + } + + const activate = () => { + setIsReady(true); + }; + + if ("requestIdleCallback" in window) { + const idleId = window.requestIdleCallback(activate, { + timeout: timeoutMs, + }); + return () => { + window.cancelIdleCallback(idleId); + }; + } + + const timeoutId = globalThis.setTimeout(activate, timeoutMs); + return () => { + globalThis.clearTimeout(timeoutId); + }; + }, [immediate, isReady, timeoutMs]); + + return isReady; +} + +let hasCompletedStartup = false; + +/** + * Convenience wrapper: defers non-critical startup work (presence, notifications, + * subscriptions) until the main shell is interactive. Uses a module-level flag + * so the deferral only happens once per app lifecycle — remounts skip the delay. + */ +export function useDeferredStartup(): boolean { + const ready = useDeferredLoad(); + if (ready) { + hasCompletedStartup = true; + } + return hasCompletedStartup || ready; +} From 05c545f79c7d11b22c1c956e8cb1719589a7a7a0 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 18 Apr 2026 08:58:23 -0600 Subject: [PATCH 2/5] fix(desktop): don't defer mention subscription (fixes E2E live mention tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mention subscription must start immediately to catch real-time mentions for sidebar badge updates. The batching already solved the N×250ms perf problem — deferring the subscription itself caused E2E test failures where mentions during the deferral window were missed. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src/app/AppShell.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 0bcf5271f..4aec475ec 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -169,8 +169,8 @@ export function AppShell() { // advancing unread state for the active channel. null, { - currentPubkey: deferredPubkey, - onLiveMention: startupReady ? refetchHomeFeedOnLiveMention : undefined, + currentPubkey: identityQuery.data?.pubkey, + onLiveMention: refetchHomeFeedOnLiveMention, }, ); From eb28fd77bb2e3fc077f523677d95fffd6758a8bc Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 18 Apr 2026 09:19:38 -0600 Subject: [PATCH 3/5] fix(desktop): un-defer home feed notifications (fixes live mention badge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home feed query must use the real pubkey immediately so that when onLiveMention triggers a refetch, the query is enabled and can update the sidebar badge count. Deferring it caused the badge to never appear because refetch() on a disabled query is a no-op. Only usePresenceSession remains deferred — everything else that powers real-time mention detection and badge updates runs immediately. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src/app/AppShell.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 4aec475ec..b7d173643 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -138,7 +138,10 @@ export function AppShell() { const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; const presenceSession = usePresenceSession(deferredPubkey); const { homeBadgeCount, homeFeedQuery, notificationSettings } = - useHomeFeedNotifications(deferredPubkey, selectedView === "home"); + useHomeFeedNotifications( + identityQuery.data?.pubkey, + selectedView === "home", + ); const refetchHomeFeedOnLiveMention = React.useEffectEvent(() => { void homeFeedQuery.refetch(); }); From 0da90e4bbbe1ec7882891bf4f063ada9ba7eb427 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 18 Apr 2026 10:05:06 -0600 Subject: [PATCH 4/5] fix(desktop): handle multi-filter REQs in E2E mock relay bridge The batched mention subscription sends a single REQ with N filters (one per channel), but the mock bridge only read the first filter to extract the channel ID. Mentions in other channels were silently dropped, breaking the live mention E2E tests. Now collects channel IDs from all filters. Single-channel subs store that ID; multi-channel subs store the "*" wildcard (app-side handleMentionEvent already filters by pubkey). Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src/testing/e2eBridge.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 953fbe357..b02f87c96 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -3964,15 +3964,29 @@ function sendToMockSocket(args: { if (type === "REQ") { const subId = rest[0] as string; - const filter = rest[1] as { "#h"?: string[] }; - const channelId = filter["#h"]?.[0]; if (subId.startsWith("live-")) { - socket.subscriptions.set(subId, channelId ?? GLOBAL_MOCK_SUBSCRIPTION); + // Collect channel IDs from all filters in the REQ + const channelIds = new Set(); + for (let i = 1; i < rest.length; i++) { + const f = rest[i] as { "#h"?: string[] }; + const cid = f["#h"]?.[0]; + if (cid) channelIds.add(cid); + } + const onlyChannelId = + channelIds.size === 1 + ? (channelIds.values().next().value as string) + : undefined; + socket.subscriptions.set( + subId, + onlyChannelId ?? GLOBAL_MOCK_SUBSCRIPTION, + ); sendWsText(socket.handler, ["EOSE", subId]); return; } + const filter = rest[1] as { "#h"?: string[] }; + const channelId = filter["#h"]?.[0]; if (!channelId) { sendWsText(socket.handler, ["EOSE", subId]); return; From 3db9d4de33f671ea2ce0bd11c31e46332e4f0c27 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 18 Apr 2026 10:46:55 -0600 Subject: [PATCH 5/5] fix(desktop): revert batched mention subscriptions to per-channel The batched approach (one REQ with N filters) is incompatible with the relay's fan-out architecture: multi-channel subscriptions get registered as global (channel_id = None), and fan_out() enforces strict scoping where global subscriptions never receive channel-scoped events. This silently dropped all mention events. Restores per-channel subscribeToChannelMentionEvents which registers each subscription with a specific channel_id, ensuring fan_out() indexes and delivers mention events correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../channels/useLiveChannelUpdates.ts | 81 +++++++++++-------- desktop/src/shared/api/relayClientSession.ts | 42 +++++----- desktop/src/shared/api/relayClientShared.ts | 2 +- 3 files changed, 74 insertions(+), 51 deletions(-) diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 5486b08bc..c04efac2e 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -45,6 +45,12 @@ function rememberMentionEvent( return true; } +async function disposeLiveSubscriptions( + subscriptions: Array<() => Promise>, +) { + await Promise.allSettled(subscriptions.map((dispose) => dispose())); +} + export function useLiveChannelUpdates( channels: Channel[], activeChannelId: string | null, @@ -156,41 +162,54 @@ export function useLiveChannelUpdates( } let isDisposed = false; - let cleanup: (() => Promise) | undefined; + let cleanup: Array<() => Promise> = []; let retryTimeout: ReturnType | undefined; const subscribeToMentionChannels = async () => { - try { - const dispose = await relayClient.subscribeToBatchedMentionEvents( - mentionChannelIds, - normalizedCurrentPubkey, - (event) => { - if (!isDisposed) { - handleMentionEvent(event); - } - }, - ); - - if (isDisposed) { - await dispose(); - return; - } + const settled = await Promise.allSettled( + mentionChannelIds.map((channelId) => + relayClient.subscribeToChannelMentionEvents( + channelId, + normalizedCurrentPubkey, + (event) => { + if (!isDisposed) { + handleMentionEvent(event); + } + }, + ), + ), + ); + + const nextCleanup = settled.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ); + + if (isDisposed) { + await disposeLiveSubscriptions(nextCleanup); + return; + } - cleanup = dispose; - } catch (error) { - if (isDisposed) { - return; - } + const firstFailure = settled.find( + (result) => result.status === "rejected", + ); + if (!firstFailure) { + cleanup = nextCleanup; + return; + } - console.error( - "Failed to subscribe to batched Home mention updates; retrying", - error, - ); - retryTimeout = window.setTimeout(() => { - retryTimeout = undefined; - void subscribeToMentionChannels(); - }, LIVE_MENTION_SUBSCRIPTION_RETRY_MS); + await disposeLiveSubscriptions(nextCleanup); + if (isDisposed) { + return; } + + console.error( + "Failed to subscribe to all Home mention updates; retrying", + firstFailure.reason, + ); + retryTimeout = window.setTimeout(() => { + retryTimeout = undefined; + void subscribeToMentionChannels(); + }, LIVE_MENTION_SUBSCRIPTION_RETRY_MS); }; void subscribeToMentionChannels(); @@ -200,9 +219,7 @@ export function useLiveChannelUpdates( if (retryTimeout !== undefined) { window.clearTimeout(retryTimeout); } - if (cleanup) { - void cleanup(); - } + void disposeLiveSubscriptions(cleanup); }; }, [mentionChannelIds, normalizedCurrentPubkey, options.onLiveMention]); } diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index c3e0d7c86..721cce432 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -225,20 +225,15 @@ export class RelayClient { return this.subscribe(this.buildGlobalStreamFilter(50), onEvent); } - async subscribeToBatchedMentionEvents( - channelIds: string[], + async subscribeToChannelMentionEvents( + channelId: string, pubkey: string, onEvent: (event: RelayEvent) => void, ) { - const since = Math.floor(Date.now() / 1_000); - const filters = channelIds.map((channelId) => ({ - kinds: [...HOME_MENTION_EVENT_KINDS], - "#h": [channelId], - "#p": [pubkey], - limit: 50, - since, - })); - return this.subscribe(filters, onEvent); + return this.subscribe( + this.buildChannelMentionFilter(channelId, pubkey, 50), + onEvent, + ); } async preconnect() { @@ -342,13 +337,26 @@ export class RelayClient { }; } + private buildChannelMentionFilter( + channelId: string, + pubkey: string, + limit: number, + ): RelaySubscriptionFilter { + return { + kinds: [...HOME_MENTION_EVENT_KINDS], + "#h": [channelId], + "#p": [pubkey], + limit, + since: Math.floor(Date.now() / 1_000), + }; + } + private async subscribe( - input: RelaySubscriptionFilter | RelaySubscriptionFilter[], + filter: RelaySubscriptionFilter, onEvent: (event: RelayEvent) => void, ) { await this.ensureConnected(); - const filters = Array.isArray(input) ? input : [input]; const subId = `live-${crypto.randomUUID()}`; let resolveReady = () => { return; @@ -365,14 +373,14 @@ export class RelayClient { this.subscriptions.set(subId, { mode: "live", - filters, + filter, onEvent, resolveReady, }); try { await this.sendRawWithReconnectRetry( - ["REQ", subId, ...filters], + ["REQ", subId, filter], "Failed to restore relay subscription.", ); } catch (error) { @@ -707,9 +715,7 @@ export class RelayClient { await this.sendRaw([ "REQ", subId, - ...subscription.filters.map((f) => - this.buildReplayFilter(f, replaySince), - ), + this.buildReplayFilter(subscription.filter, replaySince), ]); } catch (error) { const reconnectError = diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index 39e3a5304..010bbc3cb 100644 --- a/desktop/src/shared/api/relayClientShared.ts +++ b/desktop/src/shared/api/relayClientShared.ts @@ -17,7 +17,7 @@ type HistorySubscription = { type LiveSubscription = { mode: "live"; - filters: RelaySubscriptionFilter[]; + filter: RelaySubscriptionFilter; onEvent: (event: RelayEvent) => void; resolveReady?: () => void; lastSeenCreatedAt?: number;