diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index fe1cb2e10..b7d173643 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,9 +131,12 @@ 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, 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/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/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; +} 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;