import { ChevronLeft, ChevronRight } from "lucide-react"; 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 { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useBackForwardControls } from "@/app/navigation/useBackForwardControls"; import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts"; import { channelsQueryKey, useChannelsQuery, useCreateChannelMutation, useHideDmMutation, useOpenDmMutation, } from "@/features/channels/hooks"; import { useUnreadChannels } from "@/features/channels/useUnreadChannels"; import { useHomeFeedNotifications } from "@/features/notifications/hooks"; import { listenForDesktopNotificationActions, 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 { 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 { Button } from "@/shared/ui/button"; import { SidebarInset, SidebarProvider, SidebarTrigger, } 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 [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = React.useState(false); const [settingsOpen, setSettingsOpen] = React.useState(false); const [settingsSection, setSettingsSection] = React.useState( DEFAULT_SETTINGS_SECTION, ); const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); const [isSearchOpen, setIsSearchOpen] = React.useState(false); const [browseDialogType, setBrowseDialogType] = React.useState(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 { homeBadgeCount, homeFeedQuery, notificationSettings } = useHomeFeedNotifications( identityQuery.data?.pubkey, selectedView === "home", ); const refetchHomeFeedOnLiveMention = React.useEffectEvent(() => { void homeFeedQuery.refetch(); }); 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 && notificationSettings.settings.soundEnabled) { playNotificationSound(); } }); }, ); 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 { markChannelRead, unreadChannelIds } = useUnreadChannels( channels, 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, onDmMessage: handleDmNotification, onLiveMention: refetchHomeFeedOnLiveMention, }, ); 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(() => { setIsSearchOpen(true); 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) => { setIsSearchOpen(false); 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(homeBadgeCount); }, [homeBadgeCount]); 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]); 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 ( { setIsChannelManagementOpen(true); }, }} >
); }