import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ComponentProps, } from 'react'; import { useSwipeable } from 'react-swipeable'; import { StatusBar } from './StatusBar'; import { Sidebar } from './Sidebar'; import { ConversationPane } from './ConversationPane'; import { NewMessageModal } from './NewMessageModal'; import { BulkAddChannelResultModal } from './BulkAddChannelResultModal'; import { ContactInfoPane } from './ContactInfoPane'; import { ChannelInfoPane } from './ChannelInfoPane'; import { CommandPalette } from './CommandPalette'; import { SecurityWarningModal } from './SecurityWarningModal'; import { Toaster } from './ui/sonner'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { SETTINGS_SECTION_ICONS, SETTINGS_SECTION_LABELS, SETTINGS_SECTION_ORDER, type SettingsSection, } from './settings/settingsConstants'; import { getContrastTextColor, type LocalLabel } from '../utils/localLabel'; import type { CrackerPanelProps } from './CrackerPanel'; import type { SearchViewProps } from './SearchView'; import type { SettingsModalProps } from './SettingsModal'; import { cn } from '@/lib/utils'; const SettingsModal = lazy(() => import('./SettingsModal').then((m) => ({ default: m.SettingsModal })) ); const CrackerPanel = lazy(() => import('./CrackerPanel').then((m) => ({ default: m.CrackerPanel })) ); const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.SearchView }))); type SidebarProps = ComponentProps; type ConversationPaneProps = ComponentProps; type NewMessageModalProps = Omit, 'open' | 'onClose'>; type BulkAddChannelResultModalProps = Omit< ComponentProps, 'open' | 'onClose' >; type ContactInfoPaneProps = ComponentProps; type ChannelInfoPaneProps = ComponentProps; interface AppShellProps { localLabel: LocalLabel; showNewMessage: boolean; showBulkAddResults: boolean; showSettings: boolean; settingsSection: SettingsSection; sidebarOpen: boolean; showCracker: boolean; disabledSettingsSections?: SettingsSection[]; onSettingsSectionChange: (section: SettingsSection) => void; onSidebarOpenChange: (open: boolean) => void; onCrackerRunningChange: (running: boolean) => void; onToggleSettingsView: () => void; onCloseSettingsView: () => void; onCloseNewMessage: () => void; onCloseBulkAddResults: () => void; onLocalLabelChange: (label: LocalLabel) => void; statusProps: Pick, 'health' | 'config'>; sidebarProps: SidebarProps; conversationPaneProps: ConversationPaneProps; searchProps: SearchViewProps; settingsProps: Omit< SettingsModalProps, 'open' | 'pageMode' | 'externalSidebarNav' | 'desktopSection' | 'onClose' | 'onLocalLabelChange' >; crackerProps: Omit; newMessageModalProps: NewMessageModalProps; bulkAddChannelResultModalProps: BulkAddChannelResultModalProps; contactInfoPaneProps: ContactInfoPaneProps; channelInfoPaneProps: ChannelInfoPaneProps; onRepeaterAutoLogin: (publicKey: string, displayName: string) => void; } export function AppShell({ localLabel, showNewMessage, showBulkAddResults, showSettings, settingsSection, sidebarOpen, showCracker, disabledSettingsSections = [], onSettingsSectionChange, onSidebarOpenChange, onCrackerRunningChange, onToggleSettingsView, onCloseSettingsView, onCloseNewMessage, onCloseBulkAddResults, onLocalLabelChange, statusProps, sidebarProps, conversationPaneProps, searchProps, settingsProps, crackerProps, newMessageModalProps, bulkAddChannelResultModalProps, contactInfoPaneProps, channelInfoPaneProps, onRepeaterAutoLogin, }: AppShellProps) { const swipeHandlers = useSwipeable({ onSwipedRight: ({ initial }) => { if (initial[0] < 30 && !sidebarOpen && window.innerWidth < 768) { onSidebarOpenChange(true); } }, trackTouch: true, trackMouse: false, preventScrollOnSwipe: true, }); const closeSwipeHandlers = useSwipeable({ onSwipedLeft: () => onSidebarOpenChange(false), trackTouch: true, trackMouse: false, preventScrollOnSwipe: false, }); const handleOpenSettings = useCallback( (section: SettingsSection) => { onSettingsSectionChange(section); if (!showSettings) onToggleSettingsView(); }, [onSettingsSectionChange, onToggleSettingsView, showSettings] ); const searchMounted = useRef(false); if (conversationPaneProps.activeConversation?.type === 'search') { searchMounted.current = true; } const crackerMounted = useRef(false); if (showCracker) { crackerMounted.current = true; } // Position toasts below the conversation header when in chat, otherwise below the status bar const TOAST_TOP_PADDING = 10; const [toastTopOffset, setToastTopOffset] = useState(undefined); const hasLocalLabel = !!localLabel.text; const activeType = conversationPaneProps.activeConversation?.type; const activeId = conversationPaneProps.activeConversation?.id; useEffect(() => { const measure = () => { const anchor = document.querySelector('[data-toast-anchor="conversation"]') ?? document.querySelector('[data-toast-anchor="statusbar"]'); setToastTopOffset( anchor ? anchor.getBoundingClientRect().top + TOAST_TOP_PADDING : undefined ); }; measure(); window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure); }, [hasLocalLabel, activeType, activeId, showSettings]); const settingsSidebarContent = ( ); const activeSidebarContent = showSettings ? ( settingsSidebarContent ) : ( ); return (
Skip to content {localLabel.text && (
{localLabel.text}
)} onSidebarOpenChange(true)} /> )}
{crackerMounted.current && ( Loading channel finder...
} > )} ); }