Files
buzz/desktop/src/app/AppShell.tsx
T
Michael Neale 84f84931f1 feat(serverless): run channels, DMs, messages, and agents against a generic Nostr relay
Adds a 'serverless' workspace mode that points the desktop app at any generic
public Nostr relay (e.g. wss://relay.damus.io) with zero Sprout server
infrastructure — no Postgres, no HTTP /query|/events bridge, no NIP-98 auth.
Reuses Sprout's native event kinds (39000 channel metadata, 39002 membership,
kind 9 messages, h-tag scoping); 'serverless' is purely a transport + auth
concern, so the server-mode path is untouched.

Transport: reads/writes go over plain WebSocket (REQ/EOSE, EVENT/OK) instead
of the HTTP bridge; NIP-42 AUTH is answered only if the relay challenges.

Desktop backend:
- AppState.serverless flag + is_serverless(); apply_workspace gains a flag
- ws_relay.rs: query_relay_ws / submit_event_ws / publish_signed_event_ws
- relay.rs: query_relay/submit_event + agent profile sync branch to WS
- events.rs: build_channel_metadata_serverless (39000) + members (39002)
- create_channel/open_dm publish those events directly (DM ids = UUIDv5 over
  sorted participants so both sides converge without a server)
- managed agents inherit SPROUT_SERVERLESS into the ACP subprocess env

Agents (full support, not a follow-up):
- sprout-acp: RestClient + HarnessRelay gain serverless mode; query/submit use
  plain WS, NIP-42 handshake skipped on (re)connect; SPROUT_SERVERLESS env/arg
- sprout-cli: SproutClient serverless WS query/submit; --serverless flag,
  to_ws_url helper, NetworkMsg error variant
- npub respond-to permissions are in-process and work unchanged

Frontend:
- Workspace.mode + workspaceMode()/isServerlessWorkspace() helpers
- relayClient.setServerless() skips the AUTH-wait on connect; late challenges
  still answered so writes succeed on relays that require auth
- ServerlessContext/useIsServerless() hides search, pulse, projects, workflows
- Serverless toggle in the Add Workspace dialog and Welcome screen

Docs: docs/SPROUT_LITE_MODE.md updated with the implementation + testing steps.
2026-06-01 20:42:29 +10:00

860 lines
28 KiB
TypeScript

import * as React from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useQueryClient } from "@tanstack/react-query";
import { Outlet, useLocation } from "@tanstack/react-router";
import { AppShellProvider } from "@/app/AppShellContext";
import {
AppShellOverlays,
type BrowseDialogType,
} from "@/app/AppShellOverlays";
import { AppTopChrome } from "@/app/AppTopChrome";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useBackForwardControls } from "@/app/navigation/useBackForwardControls";
import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts";
import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts";
import {
channelsQueryKey,
useChannelsQuery,
useCreateChannelMutation,
useHideDmMutation,
useOpenDmMutation,
} from "@/features/channels/hooks";
import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
import { useThreadFollows } from "@/features/messages/lib/useThreadFollows";
import {
useHomeFeedNotifications,
useHomeFeedNotificationState,
} from "@/features/notifications/hooks";
import {
listenForDesktopNotificationActions,
requestDockBounce,
revealDesktopAppWindow,
sendDesktopNotification,
setDesktopAppBadgeCount,
type DesktopNotificationTarget,
} from "@/features/notifications/lib/desktop";
import { playNotificationSound } from "@/features/notifications/lib/sound";
import { PreventSleepProvider } from "@/features/agents/usePreventSleep";
import {
usePresenceSession,
usePresenceSubscription,
} from "@/features/presence/hooks";
import {
useSetUserStatusMutation,
useUserStatusQuery,
useUserStatusSubscription,
} from "@/features/user-status/hooks";
import { useProfileQuery } from "@/features/profile/hooks";
import {
DEFAULT_SETTINGS_SECTION,
type SettingsSection,
} from "@/features/settings/ui/SettingsPanels";
import { HuddleBar, HuddleProvider } from "@/features/huddle";
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import { ServerlessProvider } from "@/features/workspaces/ServerlessContext";
import { isServerlessWorkspace } from "@/features/workspaces/workspaceStorage";
import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate";
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 { Channel, RelayEvent, SearchHit } from "@/shared/api/types";
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks";
import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar";
type AppView =
| "home"
| "channel"
| "agents"
| "workflows"
| "pulse"
| "projects";
const LazySettingsScreen = React.lazy(async () => {
const module = await import("@/features/settings/ui/SettingsScreen");
return { default: module.SettingsScreen };
});
const WINDOW_DRAG_HANDLE_HEIGHT = 44;
const WINDOW_DRAG_INTERACTIVE_SELECTOR =
'button, a, input, textarea, select, [role="button"], [contenteditable="true"]';
function isWindowDragHandleEvent(event: MouseEvent | PointerEvent) {
if (event.clientY > WINDOW_DRAG_HANDLE_HEIGHT) {
return false;
}
const target = event.target;
return !(
target instanceof Element &&
target.closest(WINDOW_DRAG_INTERACTIVE_SELECTOR)
);
}
function toSearchHit(target: DesktopNotificationTarget): SearchHit | null {
if (!target.eventId) {
return null;
}
return {
eventId: target.eventId,
content: target.content ?? "",
kind: target.kind ?? 9,
pubkey: target.pubkey ?? "",
channelId: target.channelId,
channelName: target.channelName ?? null,
createdAt: target.createdAt ?? Math.floor(Date.now() / 1_000),
score: 0,
};
}
function deriveShellRoute(pathname: string): {
selectedChannelId: string | null;
selectedView: AppView;
} {
if (pathname.startsWith("/channels/")) {
const [, , rawChannelId] = pathname.split("/");
return {
selectedChannelId: rawChannelId ? decodeURIComponent(rawChannelId) : null,
selectedView: "channel",
};
}
if (pathname === "/agents") {
return {
selectedChannelId: null,
selectedView: "agents",
};
}
if (pathname === "/workflows" || pathname.startsWith("/workflows/")) {
return {
selectedChannelId: null,
selectedView: "workflows",
};
}
if (pathname === "/projects" || pathname.startsWith("/projects/")) {
return {
selectedChannelId: null,
selectedView: "projects",
};
}
if (pathname === "/pulse") {
return {
selectedChannelId: null,
selectedView: "pulse",
};
}
return {
selectedChannelId: null,
selectedView: "home",
};
}
export function AppShell() {
useWebviewZoomShortcuts();
const workspacesHook = useWorkspaces();
const serverless = isServerlessWorkspace(workspacesHook.activeWorkspace);
const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = React.useState(false);
const [settingsOpen, setSettingsOpen] = React.useState(false);
const [settingsSection, setSettingsSection] = React.useState<SettingsSection>(
DEFAULT_SETTINGS_SECTION,
);
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
const [browseDialogType, setBrowseDialogType] =
React.useState<BrowseDialogType>(null);
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
const location = useLocation();
const queryClient = useQueryClient();
const {
goAgents,
goChannel,
goHome,
goProjects,
goPulse,
goWorkflows,
openSearchHit,
} = useAppNavigation();
const { canGoBack, canGoForward, goBack, goForward } =
useBackForwardControls();
const { selectedChannelId, selectedView } = React.useMemo(
() => deriveShellRoute(location.pathname),
[location.pathname],
);
const startupReady = useDeferredStartup();
const identityQuery = useIdentityQuery();
const profileQuery = useProfileQuery();
const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined;
usePresenceSubscription();
useUserStatusSubscription();
const presenceSession = usePresenceSession(deferredPubkey);
const selfStatusQuery = useUserStatusQuery(
deferredPubkey ? [deferredPubkey] : [],
);
const setUserStatusMutation = useSetUserStatusMutation(deferredPubkey);
const { feedProfilesQuery, homeFeedQuery, notificationSettings } =
useHomeFeedNotifications(identityQuery.data?.pubkey);
const refetchHomeFeedOnLiveMention = React.useEffectEvent(() => {
void homeFeedQuery.refetch();
});
const handleChannelNotification = React.useEffectEvent(() => {
if (!notificationSettings.settings.desktopEnabled) return;
void requestDockBounce();
});
const handleDmNotification = React.useEffectEvent(
(event: RelayEvent, channel: Channel) => {
if (!notificationSettings.settings.desktopEnabled) {
return;
}
const channelName = channel.name?.trim() || "Direct message";
const content = event.content.trim();
const body =
content.length > 0
? content.length > 140
? `${content.slice(0, 137).trimEnd()}...`
: content
: "New message";
void sendDesktopNotification({
title: channelName,
body,
target: {
channelId: channel.id,
channelName,
content: event.content,
createdAt: event.created_at,
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
},
}).then((didSend) => {
if (!didSend) return;
if (notificationSettings.settings.soundEnabled) playNotificationSound();
void requestDockBounce();
});
},
);
const channelsQuery = useChannelsQuery();
const { refetch: refetchChannels } = channelsQuery;
const channels = channelsQuery.data ?? [];
const memberChannels = React.useMemo(
() => channels.filter((channel) => channel.isMember),
[channels],
);
const sidebarChannels = React.useMemo(
() => memberChannels.filter((channel) => channel.archivedAt === null),
[memberChannels],
);
const activeChannel = React.useMemo(
() =>
selectedChannelId
? (channels.find((channel) => channel.id === selectedChannelId) ?? null)
: null,
[channels, selectedChannelId],
);
const {
followedRootIds,
isFollowing: isFollowingThread,
followThread,
unfollowThread,
} = useThreadFollows(identityQuery.data?.pubkey);
const {
markAllChannelsRead,
markChannelRead,
markChannelUnread,
unreadChannelIds,
getEffectiveTimestamp: getChannelReadAt,
readStateVersion,
participatedRootIds,
authoredRootIds,
threadActivityItems,
mutedRootIds,
muteThread,
unmuteThread,
} = useUnreadChannels(
sidebarChannels,
activeChannel,
// Wait for ChannelScreen to report the latest loaded message before
// advancing unread state for the active channel.
null,
{
pubkey: identityQuery.data?.pubkey,
relayClient,
currentPubkey: identityQuery.data?.pubkey,
onChannelMessage: handleChannelNotification,
onDmMessage: handleDmNotification,
onLiveMention: refetchHomeFeedOnLiveMention,
followedRootIds,
},
);
// Badge count is computed here (rather than inside useHomeFeedNotifications)
// so it can consume the NIP-RS read-state lifted from the single
// ReadStateManager mounted via useUnreadChannels above. Channel-backed
// feed items contribute to the badge iff strictly newer than that
// channel's read marker; non-channel items keep their seen-set fallback.
const homeBadgeCount = useHomeFeedNotificationState(
homeFeedQuery.data,
identityQuery.data?.pubkey,
notificationSettings.settings,
notificationSettings.setDesktopEnabled,
selectedView === "home",
getChannelReadAt,
readStateVersion,
feedProfilesQuery.data?.profiles,
);
const isNotifiedForThread = React.useCallback(
(rootId: string) =>
!mutedRootIds.has(rootId) &&
(followedRootIds.has(rootId) ||
participatedRootIds.has(rootId) ||
authoredRootIds.has(rootId)),
[followedRootIds, mutedRootIds, participatedRootIds, authoredRootIds],
);
const handleFollowThread = React.useCallback(
(rootId: string) => {
followThread(rootId);
unmuteThread(rootId);
},
[followThread, unmuteThread],
);
const handleUnfollowThread = React.useCallback(
(rootId: string) => {
unfollowThread(rootId);
muteThread(rootId);
},
[unfollowThread, muteThread],
);
const createChannelMutation = useCreateChannelMutation();
const createForumMutation = useCreateChannelMutation();
const { applyCanvas, applyAgents } = useApplyTemplate();
const openDmMutation = useOpenDmMutation();
const hideDmMutation = useHideDmMutation();
const handleOpenBrowseChannels = React.useCallback(() => {
setBrowseDialogType("stream");
void refetchChannels();
}, [refetchChannels]);
const handleOpenBrowseForums = React.useCallback(() => {
setBrowseDialogType("forum");
void refetchChannels();
}, [refetchChannels]);
const handleOpenSearch = React.useCallback(() => {
setSearchFocusRequest((request) => request + 1);
void refetchChannels();
}, [refetchChannels]);
const handleBrowseDialogOpenChange = React.useCallback((open: boolean) => {
if (!open) {
setBrowseDialogType(null);
}
}, []);
const handleBrowseChannelJoin = React.useCallback(
async (channelId: string) => {
await joinChannel(channelId);
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
},
[queryClient],
);
const handleHideDm = React.useCallback(
async (channelId: string) => {
try {
await hideDmMutation.mutateAsync(channelId);
} catch {
return;
}
if (selectedChannelId === channelId) {
void goHome();
}
},
[goHome, hideDmMutation, selectedChannelId],
);
const handleOpenSettings = React.useCallback(
(section: SettingsSection = DEFAULT_SETTINGS_SECTION) => {
setIsChannelManagementOpen(false);
setSettingsSection(section);
setSettingsOpen(true);
},
[],
);
const handleCloseSettings = React.useCallback(() => {
setSettingsOpen(false);
}, []);
const handleOpenSearchResult = React.useCallback(
(hit: SearchHit) => {
void openSearchHit(hit);
},
[openSearchHit],
);
const handleDesktopNotificationAction = React.useEffectEvent(
async (target: DesktopNotificationTarget) => {
await revealDesktopAppWindow();
if (!target.channelId) {
void goHome();
return;
}
const anchor = toSearchHit(target);
if (!anchor) {
await goChannel(target.channelId);
return;
}
await openSearchHit(anchor);
},
);
// Prevent webview file:/// navigation on file drop outside the composer.
// Scoped to file drags only (text drag-and-drop into inputs still works).
// Composer's onDrop fires first (React synthetic before window bubble).
React.useEffect(() => {
function preventNavigation(e: DragEvent) {
if (e.dataTransfer?.types.includes("Files")) {
e.preventDefault();
}
}
window.addEventListener("dragover", preventNavigation);
window.addEventListener("drop", preventNavigation);
return () => {
window.removeEventListener("dragover", preventNavigation);
window.removeEventListener("drop", preventNavigation);
};
}, []);
React.useEffect(() => {
let isCancelled = false;
const startPreconnect = () => {
if (isCancelled) {
return;
}
void relayClient.preconnect().catch((error) => {
if (!isCancelled) {
console.error("Failed to preconnect to relay", error);
}
});
};
if ("requestIdleCallback" in window) {
const idleId = window.requestIdleCallback(startPreconnect, {
timeout: 1_500,
});
return () => {
isCancelled = true;
window.cancelIdleCallback(idleId);
};
}
const timeoutId = globalThis.setTimeout(startPreconnect, 250);
return () => {
isCancelled = true;
globalThis.clearTimeout(timeoutId);
};
}, []);
React.useEffect(() => {
void setDesktopAppBadgeCount(unreadChannelIds.size + homeBadgeCount);
}, [homeBadgeCount, unreadChannelIds.size]);
// Dispatch `sprout://message` deep links into the router.
useMessageDeepLinks();
React.useEffect(() => {
let isCancelled = false;
let cleanup = () => {};
void listenForDesktopNotificationActions((target) => {
if (isCancelled) {
return;
}
void handleDesktopNotificationAction(target);
}).then((dispose) => {
if (isCancelled) {
dispose();
return;
}
cleanup = dispose;
});
return () => {
isCancelled = true;
cleanup();
};
}, []);
const handleOpenNewDm = React.useCallback(() => {
setIsNewDmOpen(true);
}, []);
React.useLayoutEffect(() => {
if (settingsOpen) {
return;
}
function handleKeyDown(event: KeyboardEvent) {
if (!hasPrimaryShortcutModifier(event) || event.altKey) {
return;
}
const key = event.key.toLowerCase();
if (key === "k" && !event.shiftKey) {
event.preventDefault();
handleOpenSearch();
return;
}
if (key === "k" && event.shiftKey) {
event.preventDefault();
handleOpenNewDm();
return;
}
if (key === "o" && event.shiftKey) {
event.preventDefault();
handleOpenBrowseChannels();
return;
}
if (key === "a" && event.shiftKey) {
event.preventDefault();
void goHome();
return;
}
}
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [
handleOpenBrowseChannels,
handleOpenNewDm,
handleOpenSearch,
goHome,
settingsOpen,
]);
React.useLayoutEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
const isSettingsShortcut =
(event.key === "," || event.code === "Comma") &&
hasPrimaryShortcutModifier(event) &&
!event.altKey &&
!event.shiftKey;
if (!isSettingsShortcut) {
return;
}
event.preventDefault();
if (settingsOpen) {
handleCloseSettings();
return;
}
handleOpenSettings();
}
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleCloseSettings, handleOpenSettings, settingsOpen]);
useMarkAsReadShortcuts({
activeChannelId: activeChannel?.id ?? null,
activeChannelLastMessageAt: activeChannel?.lastMessageAt,
markAllChannelsRead,
markChannelRead,
selectedView,
});
React.useEffect(() => {
function handlePointerDown(event: PointerEvent) {
if (event.button !== 0 || event.detail > 1) {
return;
}
if (!isWindowDragHandleEvent(event)) {
return;
}
void getCurrentWindow().startDragging();
}
function handleDoubleClick(event: MouseEvent) {
if (event.button !== 0 || !isWindowDragHandleEvent(event)) {
return;
}
event.preventDefault();
void getCurrentWindow().toggleMaximize();
}
window.addEventListener("pointerdown", handlePointerDown, true);
window.addEventListener("dblclick", handleDoubleClick, true);
return () => {
window.removeEventListener("pointerdown", handlePointerDown, true);
window.removeEventListener("dblclick", handleDoubleClick, true);
};
}, []);
return (
<PreventSleepProvider>
<ChannelNavigationProvider channels={channels}>
<AppShellProvider
value={{
markAllChannelsRead,
markChannelRead,
markChannelUnread,
openChannelManagement: () => {
setIsChannelManagementOpen(true);
},
getChannelReadAt,
readStateVersion,
followThread: handleFollowThread,
unfollowThread: handleUnfollowThread,
isFollowingThread,
isNotifiedForThread,
threadActivityItems,
}}
>
<ServerlessProvider serverless={serverless}>
<HuddleProvider>
<div className="flex h-dvh flex-col overflow-hidden overscroll-none">
<SidebarProvider className="min-h-0 flex-1 overflow-hidden">
<AppTopChrome
canGoBack={canGoBack}
canGoForward={canGoForward}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
onGoBack={goBack}
onGoForward={goForward}
onOpenChannel={(channelId) => {
void goChannel(channelId);
}}
onOpenResult={handleOpenSearchResult}
searchFocusRequest={searchFocusRequest}
/>
<AppSidebar
activeWorkspace={workspacesHook.activeWorkspace}
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount}
isAddWorkspaceOpen={isAddWorkspaceOpen}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
isNewDmOpen={isNewDmOpen}
isPresencePending={presenceSession.isPending}
onAddWorkspace={(workspace) => {
const id = workspacesHook.addWorkspace(workspace);
workspacesHook.switchWorkspace(id);
}}
onAddWorkspaceOpenChange={setIsAddWorkspaceOpen}
onNewDmOpenChange={setIsNewDmOpen}
onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)}
onUpdateWorkspace={workspacesHook.updateWorkspace}
onRemoveWorkspace={workspacesHook.removeWorkspace}
onSwitchWorkspace={workspacesHook.switchWorkspace}
selfPresenceStatus={presenceSession.currentStatus}
workspaces={workspacesHook.workspaces}
onCreateChannel={async ({
description,
name,
visibility,
ttlSeconds,
templateId,
}) => {
const createdChannel =
await createChannelMutation.mutateAsync({
name,
description,
channelType: "stream",
visibility,
ttlSeconds,
});
await applyCanvas(templateId, createdChannel.id, name);
await goChannel(createdChannel.id);
void applyAgents(templateId, createdChannel.id);
}}
onCreateForum={async ({
description,
name,
visibility,
ttlSeconds,
templateId,
}) => {
const createdForum =
await createForumMutation.mutateAsync({
name,
description,
channelType: "forum",
visibility,
ttlSeconds,
});
await applyCanvas(templateId, createdForum.id, name);
await goChannel(createdForum.id);
void applyAgents(templateId, createdForum.id);
}}
onHideDm={handleHideDm}
onMarkAllChannelsRead={markAllChannelsRead}
onMarkChannelRead={markChannelRead}
onMarkChannelUnread={markChannelUnread}
onOpenBrowseChannels={handleOpenBrowseChannels}
onOpenBrowseForums={handleOpenBrowseForums}
onOpenDm={async ({ pubkeys }) => {
const directMessage = await openDmMutation.mutateAsync({
pubkeys,
});
await goChannel(directMessage.id);
}}
onSelectAgents={() => {
void goAgents();
}}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
onSelectHome={() => {
void goHome();
}}
onSelectProjects={() => {
void goProjects();
}}
onSelectPulse={() => {
void goPulse();
}}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => {
void goWorkflows();
}}
onSetPresenceStatus={(status) =>
presenceSession.setStatus(status)
}
onSetUserStatus={(text, emoji) =>
setUserStatusMutation.mutate({ text, emoji })
}
onClearUserStatus={() =>
setUserStatusMutation.mutate({ text: "", emoji: "" })
}
profile={profileQuery.data}
selfUserStatus={
deferredPubkey
? (selfStatusQuery.data?.[
deferredPubkey.toLowerCase()
] ?? undefined)
: undefined
}
selectedChannelId={selectedChannelId}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
/>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<Outlet />
</SidebarInset>
<AppShellOverlays
activeChannel={activeChannel}
browseDialogType={browseDialogType}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={setIsChannelManagementOpen}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
void goHome({ replace: true });
}}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
/>
{settingsOpen ? (
<React.Suspense fallback={null}>
<LazySettingsScreen
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={
notificationSettings.errorMessage
}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={
notificationSettings.setHomeBadgeEnabled
}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
onSetSoundEnabled={notificationSettings.setSoundEnabled}
section={settingsSection}
/>
</React.Suspense>
) : null}
</SidebarProvider>
<HuddleBar />
</div>
</HuddleProvider>
</ServerlessProvider>
</AppShellProvider>
</ChannelNavigationProvider>
</PreventSleepProvider>
);
}