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
6 changes: 5 additions & 1 deletion desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/features/agents/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand Down
15 changes: 10 additions & 5 deletions desktop/src/features/home/ui/HomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -79,18 +80,22 @@ export function HomeView({
const [filter, setFilter] = React.useState<FeedFilter>("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,
Expand All @@ -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],
Expand Down
74 changes: 18 additions & 56 deletions desktop/src/features/sidebar/ui/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -275,26 +238,25 @@ export function AppSidebar({
const [createDialogKind, setCreateDialogKind] =
React.useState<CreateChannelKind | null>(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,
Expand All @@ -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,
});
Expand Down
63 changes: 63 additions & 0 deletions desktop/src/shared/hooks/useDeferredStartup.ts
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 17 additions & 3 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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;
Expand Down