extract frontend app shell

This commit is contained in:
Jack Kingsman
2026-03-09 20:23:24 -07:00
parent ec5b9663b2
commit f107dce920
11 changed files with 657 additions and 368 deletions

View File

@@ -173,7 +173,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
├── frontend/ # React frontend
│ ├── AGENTS.md # Frontend documentation
│ ├── src/
│ │ ├── App.tsx # Main component
│ │ ├── App.tsx # Frontend composition entry (hooks → AppShell)
│ │ ├── api.ts # REST client
│ │ ├── useWebSocket.ts # WebSocket hook
│ │ └── components/

View File

@@ -21,7 +21,7 @@ Keep it aligned with `frontend/src` source code.
```text
frontend/src/
├── main.tsx # React entry point (StrictMode, root render)
├── App.tsx # App shell and orchestration
├── App.tsx # Data/orchestration entry that wires hooks into AppShell
├── api.ts # Typed REST client
├── types.ts # Shared TS contracts
├── useWebSocket.ts # WS lifecycle + event dispatch
@@ -40,12 +40,14 @@ frontend/src/
│ ├── useConversationTimeline.ts # Fetch, cache restore, jump-target loading, pagination, reconcile
│ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps
│ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery
│ ├── useAppShell.ts # App-shell view state (settings/sidebar/modals/cracker)
│ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries)
│ ├── useRadioControl.ts # Radio health/config state, reconnection
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
│ ├── useConversationRouter.ts # URL hash → active conversation routing
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
├── components/
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty)
│ └── ...
├── utils/
@@ -143,6 +145,7 @@ frontend/src/
├── searchView.test.tsx
├── useConversationMessages.test.ts
├── useConversationMessages.race.test.ts
├── useAppShell.test.ts
├── useRepeaterDashboard.test.ts
├── useContactsAndChannels.test.ts
├── useRealtimeAppState.test.ts
@@ -157,7 +160,16 @@ frontend/src/
### State ownership
`App.tsx` orchestrates high-level state and delegates to hooks:
`App.tsx` is now a thin composition entrypoint over the hook layer. `AppShell.tsx` owns shell layout/composition:
- local label banner
- status bar
- desktop/mobile sidebar container
- search/settings surface switching
- global cracker mount/focus behavior
- new-message modal and info panes
High-level state is delegated to hooks:
- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal, target message)
- `useRadioControl`: radio health/config state, reconnect/reboot polling
- `useAppSettings`: settings CRUD, favorites, preferences migration
- `useContactsAndChannels`: contact/channel lists, creation, deletion
@@ -181,7 +193,7 @@ frontend/src/
- Initial data: REST fetches (`api.ts`) for config/settings/channels/contacts/unreads.
- WebSocket: realtime deltas/events.
- On reconnect, `App.tsx` refetches channels and contacts, refreshes unread counts, and reconciles the active conversation to recover disconnect-window drift.
- On reconnect, the app refetches channels and contacts, refreshes unread counts, and reconciles the active conversation to recover disconnect-window drift.
- On WS connect, backend sends `health` only; contacts/channels still come from REST.
### New Message modal
@@ -315,7 +327,7 @@ State: `useConversationActions` controls open/close via `infoPaneChannelKey`. Li
## Repeater Dashboard
For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`.
@@ -331,7 +343,7 @@ All state is managed by `useRepeaterDashboard` hook. State resets on conversatio
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
- **State**: `targetMessageId` is shared between `App.tsx`, `useConversationActions`, and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation.
- **State**: `targetMessageId` is shared between `useAppShell`, `useConversationActions`, and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation.
- **Same-conversation clear**: when `targetMessageId` is cleared after the target is reached, the hook preserves the around-loaded mid-history view instead of replacing it with the latest page.
- **Persistence**: `SearchView` stays mounted after first open using the same `hidden` class pattern as `CrackerPanel`, preserving search state when navigating to results.
- **Jump-to-message**: `useConversationTimeline` handles optional `targetMessageId` by calling `api.getMessagesAround()` instead of the normal latest-page fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation.

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useCallback, useRef, startTransition, lazy, Suspense } from 'react';
import { useEffect, useCallback, useRef, useState } from 'react';
import { api } from './api';
import { takePrefetchOrFetch } from './prefetch';
import { useWebSocket } from './useWebSocket';
import {
useAppShell,
useUnreadCounts,
useConversationMessages,
useRadioControl,
@@ -12,55 +13,34 @@ import {
useConversationActions,
useRealtimeAppState,
} from './hooks';
import { StatusBar } from './components/StatusBar';
import { Sidebar } from './components/Sidebar';
import { ConversationPane } from './components/ConversationPane';
import { AppShell } from './components/AppShell';
import type { MessageInputHandle } from './components/MessageInput';
import { NewMessageModal } from './components/NewMessageModal';
import {
SETTINGS_SECTION_LABELS,
SETTINGS_SECTION_ORDER,
type SettingsSection,
} from './components/settings/settingsConstants';
import { ContactInfoPane } from './components/ContactInfoPane';
import { ChannelInfoPane } from './components/ChannelInfoPane';
const SettingsModal = lazy(() =>
import('./components/SettingsModal').then((m) => ({ default: m.SettingsModal }))
);
const CrackerPanel = lazy(() =>
import('./components/CrackerPanel').then((m) => ({ default: m.CrackerPanel }))
);
const SearchView = lazy(() =>
import('./components/SearchView').then((m) => ({ default: m.SearchView }))
);
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from './components/ui/sheet';
import { Toaster } from './components/ui/sonner';
import { messageContainsMention } from './utils/messageParser';
import { getLocalLabel, getContrastTextColor } from './utils/localLabel';
import { cn } from '@/lib/utils';
import type { Conversation, RawPacket } from './types';
export function App() {
const messageInputRef = useRef<MessageInputHandle>(null);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const [showNewMessage, setShowNewMessage] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection>('radio');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showCracker, setShowCracker] = useState(false);
const [crackerRunning, setCrackerRunning] = useState(false);
const [localLabel, setLocalLabel] = useState(getLocalLabel);
const [targetMessageId, setTargetMessageId] = useState<number | null>(null);
// Defer CrackerPanel mount until first opened (lazy-loaded, but keep mounted after for state)
const crackerMounted = useRef(false);
if (showCracker) crackerMounted.current = true;
const {
showNewMessage,
showSettings,
settingsSection,
sidebarOpen,
showCracker,
crackerRunning,
localLabel,
targetMessageId,
setSettingsSection,
setSidebarOpen,
setCrackerRunning,
setLocalLabel,
setTargetMessageId,
handleCloseSettingsView,
handleToggleSettingsView,
handleOpenNewMessage,
handleCloseNewMessage,
handleToggleCracker,
} = useAppShell();
// Shared refs between useConversationRouter and useContactsAndChannels
const pendingDeleteFallbackRef = useRef(false);
@@ -157,10 +137,6 @@ export function App() {
// Wire up the ref bridge so useContactsAndChannels handlers reach the real setter
setActiveConversationRef.current = setActiveConversation;
// Keep SearchView mounted after first open to preserve search state
const searchMounted = useRef(false);
if (activeConversation?.type === 'search') searchMounted.current = true;
// Custom hooks for conversation-specific functionality
const {
messages,
@@ -271,319 +247,134 @@ export function App() {
setContacts,
setContactsLoaded,
]);
const handleCloseSettingsView = useCallback(() => {
startTransition(() => setShowSettings(false));
setSidebarOpen(false);
}, []);
const handleToggleSettingsView = useCallback(() => {
startTransition(() => {
setShowSettings((prev) => !prev);
});
setSidebarOpen(false);
}, []);
const handleNewMessage = useCallback(() => {
setShowNewMessage(true);
setSidebarOpen(false);
}, []);
const handleToggleCracker = useCallback(() => {
setShowCracker((prev) => !prev);
}, []);
// Sidebar content (shared between desktop and mobile)
const sidebarContent = (
<Sidebar
contacts={contacts}
channels={channels}
activeConversation={activeConversation}
onSelectConversation={handleSelectConversationWithTargetReset}
onNewMessage={handleNewMessage}
lastMessageTimes={lastMessageTimes}
unreadCounts={unreadCounts}
mentions={mentions}
return (
<AppShell
localLabel={localLabel}
showNewMessage={showNewMessage}
showSettings={showSettings}
settingsSection={settingsSection}
sidebarOpen={sidebarOpen}
showCracker={showCracker}
crackerRunning={crackerRunning}
onToggleCracker={handleToggleCracker}
onMarkAllRead={markAllRead}
favorites={favorites}
sortOrder={appSettings?.sidebar_sort_order ?? 'recent'}
onSortOrderChange={handleSortOrderChange}
onSettingsSectionChange={setSettingsSection}
onSidebarOpenChange={setSidebarOpen}
onCrackerRunningChange={setCrackerRunning}
onToggleSettingsView={handleToggleSettingsView}
onCloseSettingsView={handleCloseSettingsView}
onCloseNewMessage={handleCloseNewMessage}
onLocalLabelChange={setLocalLabel}
statusProps={{ health, config }}
sidebarProps={{
contacts,
channels,
activeConversation,
onSelectConversation: handleSelectConversationWithTargetReset,
onNewMessage: handleOpenNewMessage,
lastMessageTimes,
unreadCounts,
mentions,
showCracker,
crackerRunning,
onToggleCracker: handleToggleCracker,
onMarkAllRead: markAllRead,
favorites,
sortOrder: appSettings?.sidebar_sort_order ?? 'recent',
onSortOrderChange: handleSortOrderChange,
}}
conversationPaneProps={{
activeConversation,
contacts,
channels,
rawPackets,
config,
health,
favorites,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
targetMessageId,
hasNewerMessages,
loadingNewer,
messageInputRef,
onTrace: handleTrace,
onToggleFavorite: handleToggleFavorite,
onDeleteContact: handleDeleteContact,
onDeleteChannel: handleDeleteChannel,
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
onOpenContactInfo: handleOpenContactInfo,
onOpenChannelInfo: handleOpenChannelInfo,
onSenderClick: handleSenderClick,
onLoadOlder: fetchOlderMessages,
onResendChannelMessage: handleResendChannelMessage,
onTargetReached: () => setTargetMessageId(null),
onLoadNewer: fetchNewerMessages,
onJumpToBottom: jumpToBottom,
onSendMessage: handleSendMessage,
}}
searchProps={{
contacts,
channels,
onNavigateToMessage: handleNavigateToMessage,
}}
settingsProps={{
config,
health,
appSettings,
onSave: handleSaveConfig,
onSaveAppSettings: handleSaveAppSettings,
onSetPrivateKey: handleSetPrivateKey,
onReboot: handleReboot,
onAdvertise: handleAdvertise,
onHealthRefresh: handleHealthRefresh,
onRefreshAppSettings: fetchAppSettings,
blockedKeys: appSettings?.blocked_keys,
blockedNames: appSettings?.blocked_names,
onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName,
}}
crackerProps={{
packets: rawPackets,
channels,
onChannelCreate: async (name, key) => {
const created = await api.createChannel(name, key);
const data = await api.getChannels();
setChannels(data);
await api.decryptHistoricalPackets({
key_type: 'channel',
channel_key: created.key,
});
fetchUndecryptedCount();
},
}}
newMessageModalProps={{
contacts,
undecryptedCount,
onSelectConversation: handleSelectConversationWithTargetReset,
onCreateContact: handleCreateContact,
onCreateChannel: handleCreateChannel,
onCreateHashtagChannel: handleCreateHashtagChannel,
}}
contactInfoPaneProps={{
contactKey: infoPaneContactKey,
fromChannel: infoPaneFromChannel,
onClose: handleCloseContactInfo,
contacts,
config,
favorites,
onToggleFavorite: handleToggleFavorite,
onNavigateToChannel: handleNavigateToChannel,
blockedKeys: appSettings?.blocked_keys,
blockedNames: appSettings?.blocked_names,
onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName,
}}
channelInfoPaneProps={{
channelKey: infoPaneChannelKey,
onClose: handleCloseChannelInfo,
channels,
favorites,
onToggleFavorite: handleToggleFavorite,
}}
/>
);
const settingsSidebarContent = (
<nav
className="sidebar w-60 h-full min-h-0 overflow-hidden bg-card border-r border-border flex flex-col"
aria-label="Settings"
>
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
Settings
</h2>
<button
type="button"
onClick={handleCloseSettingsView}
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-status-connected/15 border border-status-connected/30 text-status-connected hover:bg-status-connected/25 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title="Back to conversations"
aria-label="Back to conversations"
>
&larr; Back to Chat
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto py-1 [contain:layout_paint]">
{SETTINGS_SECTION_ORDER.map((section) => (
<button
key={section}
type="button"
className={cn(
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
settingsSection === section && 'bg-accent border-l-primary'
)}
aria-current={settingsSection === section ? 'true' : undefined}
onClick={() => setSettingsSection(section)}
>
{SETTINGS_SECTION_LABELS[section]}
</button>
))}
</div>
</nav>
);
const activeSidebarContent = showSettings ? settingsSidebarContent : sidebarContent;
return (
<div className="flex flex-col h-full">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-2 focus:bg-primary focus:text-primary-foreground"
>
Skip to content
</a>
{localLabel.text && (
<div
style={{
backgroundColor: localLabel.color,
color: getContrastTextColor(localLabel.color),
}}
className="px-4 py-1 text-center text-sm font-medium"
>
{localLabel.text}
</div>
)}
<StatusBar
health={health}
config={config}
settingsMode={showSettings}
onSettingsClick={handleToggleSettingsView}
onMenuClick={showSettings ? undefined : () => setSidebarOpen(true)}
/>
<div className="flex flex-1 overflow-hidden">
{/* Desktop sidebar - hidden on mobile */}
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
{/* Mobile sidebar - Sheet that slides in */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetContent side="left" className="w-[280px] p-0 flex flex-col" hideCloseButton>
<SheetHeader className="sr-only">
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>Sidebar navigation</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-hidden">{activeSidebarContent}</div>
</SheetContent>
</Sheet>
<main id="main-content" className="flex-1 flex flex-col bg-background min-w-0">
<div
className={cn(
'flex-1 flex flex-col min-h-0',
(showSettings || activeConversation?.type === 'search') && 'hidden'
)}
>
<ConversationPane
activeConversation={activeConversation}
contacts={contacts}
channels={channels}
rawPackets={rawPackets}
config={config}
health={health}
favorites={favorites}
messages={messages}
messagesLoading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
targetMessageId={targetMessageId}
hasNewerMessages={hasNewerMessages}
loadingNewer={loadingNewer}
messageInputRef={messageInputRef}
onTrace={handleTrace}
onToggleFavorite={handleToggleFavorite}
onDeleteContact={handleDeleteContact}
onDeleteChannel={handleDeleteChannel}
onSetChannelFloodScopeOverride={handleSetChannelFloodScopeOverride}
onOpenContactInfo={handleOpenContactInfo}
onOpenChannelInfo={handleOpenChannelInfo}
onSenderClick={handleSenderClick}
onLoadOlder={fetchOlderMessages}
onResendChannelMessage={handleResendChannelMessage}
onTargetReached={() => setTargetMessageId(null)}
onLoadNewer={fetchNewerMessages}
onJumpToBottom={jumpToBottom}
onSendMessage={handleSendMessage}
/>
</div>
{searchMounted.current && (
<div
className={cn(
'flex-1 flex flex-col min-h-0',
(activeConversation?.type !== 'search' || showSettings) && 'hidden'
)}
>
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading search...
</div>
}
>
<SearchView
contacts={contacts}
channels={channels}
onNavigateToMessage={handleNavigateToMessage}
/>
</Suspense>
</div>
)}
{showSettings && (
<div className="flex-1 flex flex-col min-h-0">
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
<span>Radio & Settings</span>
<span className="text-sm text-muted-foreground hidden md:inline">
{SETTINGS_SECTION_LABELS[settingsSection]}
</span>
</h2>
<div className="flex-1 min-h-0 overflow-hidden">
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center p-8 text-muted-foreground">
Loading settings...
</div>
}
>
<SettingsModal
open={showSettings}
pageMode
externalSidebarNav
desktopSection={settingsSection}
config={config}
health={health}
appSettings={appSettings}
onClose={handleCloseSettingsView}
onSave={handleSaveConfig}
onSaveAppSettings={handleSaveAppSettings}
onSetPrivateKey={handleSetPrivateKey}
onReboot={handleReboot}
onAdvertise={handleAdvertise}
onHealthRefresh={handleHealthRefresh}
onRefreshAppSettings={fetchAppSettings}
onLocalLabelChange={setLocalLabel}
blockedKeys={appSettings?.blocked_keys}
blockedNames={appSettings?.blocked_names}
onToggleBlockedKey={handleBlockKey}
onToggleBlockedName={handleBlockName}
/>
</Suspense>
</div>
</div>
)}
</main>
</div>
{/* Global Cracker Panel - deferred until first opened, then kept mounted for state */}
<div
ref={(el) => {
// Focus the panel when it becomes visible
if (showCracker && el) {
const focusable = el.querySelector<HTMLElement>('input, button:not([disabled])');
if (focusable) setTimeout(() => focusable.focus(), 210);
}
}}
className={cn(
'border-t border-border bg-background transition-all duration-200 overflow-hidden',
showCracker ? 'h-[275px]' : 'h-0'
)}
>
{crackerMounted.current && (
<Suspense
fallback={
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading cracker...
</div>
}
>
<CrackerPanel
packets={rawPackets}
channels={channels}
visible={showCracker}
onChannelCreate={async (name, key) => {
const created = await api.createChannel(name, key);
const data = await api.getChannels();
setChannels(data);
await api.decryptHistoricalPackets({
key_type: 'channel',
channel_key: created.key,
});
fetchUndecryptedCount();
}}
onRunningChange={setCrackerRunning}
/>
</Suspense>
)}
</div>
<NewMessageModal
open={showNewMessage}
contacts={contacts}
undecryptedCount={undecryptedCount}
onClose={() => setShowNewMessage(false)}
onSelectConversation={(conv) => {
handleSelectConversationWithTargetReset(conv);
setShowNewMessage(false);
}}
onCreateContact={handleCreateContact}
onCreateChannel={handleCreateChannel}
onCreateHashtagChannel={handleCreateHashtagChannel}
/>
<ContactInfoPane
contactKey={infoPaneContactKey}
fromChannel={infoPaneFromChannel}
onClose={handleCloseContactInfo}
contacts={contacts}
config={config}
favorites={favorites}
onToggleFavorite={handleToggleFavorite}
onNavigateToChannel={handleNavigateToChannel}
blockedKeys={appSettings?.blocked_keys}
blockedNames={appSettings?.blocked_names}
onToggleBlockedKey={handleBlockKey}
onToggleBlockedName={handleBlockName}
/>
<ChannelInfoPane
channelKey={infoPaneChannelKey}
onClose={handleCloseChannelInfo}
channels={channels}
favorites={favorites}
onToggleFavorite={handleToggleFavorite}
/>
<Toaster position="top-right" />
</div>
);
}

View File

@@ -0,0 +1,292 @@
import { lazy, Suspense, useRef, type ComponentProps } from 'react';
import { StatusBar } from './StatusBar';
import { Sidebar } from './Sidebar';
import { ConversationPane } from './ConversationPane';
import { NewMessageModal } from './NewMessageModal';
import { ContactInfoPane } from './ContactInfoPane';
import { ChannelInfoPane } from './ChannelInfoPane';
import { Toaster } from './ui/sonner';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import {
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<typeof Sidebar>;
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
interface AppShellProps {
localLabel: LocalLabel;
showNewMessage: boolean;
showSettings: boolean;
settingsSection: SettingsSection;
sidebarOpen: boolean;
showCracker: boolean;
onSettingsSectionChange: (section: SettingsSection) => void;
onSidebarOpenChange: (open: boolean) => void;
onCrackerRunningChange: (running: boolean) => void;
onToggleSettingsView: () => void;
onCloseSettingsView: () => void;
onCloseNewMessage: () => void;
onLocalLabelChange: (label: LocalLabel) => void;
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
sidebarProps: SidebarProps;
conversationPaneProps: ConversationPaneProps;
searchProps: SearchViewProps;
settingsProps: Omit<
SettingsModalProps,
'open' | 'pageMode' | 'externalSidebarNav' | 'desktopSection' | 'onClose' | 'onLocalLabelChange'
>;
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
newMessageModalProps: NewMessageModalProps;
contactInfoPaneProps: ContactInfoPaneProps;
channelInfoPaneProps: ChannelInfoPaneProps;
}
export function AppShell({
localLabel,
showNewMessage,
showSettings,
settingsSection,
sidebarOpen,
showCracker,
onSettingsSectionChange,
onSidebarOpenChange,
onCrackerRunningChange,
onToggleSettingsView,
onCloseSettingsView,
onCloseNewMessage,
onLocalLabelChange,
statusProps,
sidebarProps,
conversationPaneProps,
searchProps,
settingsProps,
crackerProps,
newMessageModalProps,
contactInfoPaneProps,
channelInfoPaneProps,
}: AppShellProps) {
const searchMounted = useRef(false);
if (conversationPaneProps.activeConversation?.type === 'search') {
searchMounted.current = true;
}
const crackerMounted = useRef(false);
if (showCracker) {
crackerMounted.current = true;
}
const settingsSidebarContent = (
<nav
className="sidebar w-60 h-full min-h-0 overflow-hidden bg-card border-r border-border flex flex-col"
aria-label="Settings"
>
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
Settings
</h2>
<button
type="button"
onClick={onCloseSettingsView}
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-status-connected/15 border border-status-connected/30 text-status-connected hover:bg-status-connected/25 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title="Back to conversations"
aria-label="Back to conversations"
>
&larr; Back to Chat
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto py-1 [contain:layout_paint]">
{SETTINGS_SECTION_ORDER.map((section) => (
<button
key={section}
type="button"
className={cn(
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
settingsSection === section && 'bg-accent border-l-primary'
)}
aria-current={settingsSection === section ? 'true' : undefined}
onClick={() => onSettingsSectionChange(section)}
>
{SETTINGS_SECTION_LABELS[section]}
</button>
))}
</div>
</nav>
);
const activeSidebarContent = showSettings ? (
settingsSidebarContent
) : (
<Sidebar {...sidebarProps} />
);
return (
<div className="flex flex-col h-full">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-2 focus:bg-primary focus:text-primary-foreground"
>
Skip to content
</a>
{localLabel.text && (
<div
style={{
backgroundColor: localLabel.color,
color: getContrastTextColor(localLabel.color),
}}
className="px-4 py-1 text-center text-sm font-medium"
>
{localLabel.text}
</div>
)}
<StatusBar
health={statusProps.health}
config={statusProps.config}
settingsMode={showSettings}
onSettingsClick={onToggleSettingsView}
onMenuClick={showSettings ? undefined : () => onSidebarOpenChange(true)}
/>
<div className="flex flex-1 overflow-hidden">
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
<Sheet open={sidebarOpen} onOpenChange={onSidebarOpenChange}>
<SheetContent side="left" className="w-[280px] p-0 flex flex-col" hideCloseButton>
<SheetHeader className="sr-only">
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>Sidebar navigation</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-hidden">{activeSidebarContent}</div>
</SheetContent>
</Sheet>
<main id="main-content" className="flex-1 flex flex-col bg-background min-w-0">
<div
className={cn(
'flex-1 flex flex-col min-h-0',
(showSettings || conversationPaneProps.activeConversation?.type === 'search') &&
'hidden'
)}
>
<ConversationPane {...conversationPaneProps} />
</div>
{searchMounted.current && (
<div
className={cn(
'flex-1 flex flex-col min-h-0',
(conversationPaneProps.activeConversation?.type !== 'search' || showSettings) &&
'hidden'
)}
>
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading search...
</div>
}
>
<SearchView {...searchProps} />
</Suspense>
</div>
)}
{showSettings && (
<div className="flex-1 flex flex-col min-h-0">
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
<span>Radio & Settings</span>
<span className="text-sm text-muted-foreground hidden md:inline">
{SETTINGS_SECTION_LABELS[settingsSection]}
</span>
</h2>
<div className="flex-1 min-h-0 overflow-hidden">
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center p-8 text-muted-foreground">
Loading settings...
</div>
}
>
<SettingsModal
{...settingsProps}
open={showSettings}
pageMode
externalSidebarNav
desktopSection={settingsSection}
onClose={onCloseSettingsView}
onLocalLabelChange={onLocalLabelChange}
/>
</Suspense>
</div>
</div>
)}
</main>
</div>
<div
ref={(el) => {
if (showCracker && el) {
const focusable = el.querySelector<HTMLElement>('input, button:not([disabled])');
if (focusable) {
setTimeout(() => focusable.focus(), 210);
}
}
}}
className={cn(
'border-t border-border bg-background transition-all duration-200 overflow-hidden',
showCracker ? 'h-[275px]' : 'h-0'
)}
>
{crackerMounted.current && (
<Suspense
fallback={
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading cracker...
</div>
}
>
<CrackerPanel
{...crackerProps}
visible={showCracker}
onRunningChange={onCrackerRunningChange}
/>
</Suspense>
)}
</div>
<NewMessageModal
{...newMessageModalProps}
open={showNewMessage}
onClose={onCloseNewMessage}
onSelectConversation={(conv) => {
newMessageModalProps.onSelectConversation(conv);
onCloseNewMessage();
}}
/>
<ContactInfoPane {...contactInfoPaneProps} />
<ChannelInfoPane {...channelInfoPaneProps} />
<Toaster position="top-right" />
</div>
);
}

View File

@@ -22,7 +22,7 @@ interface QueueItem {
status: 'pending' | 'cracking' | 'cracked' | 'failed';
}
interface CrackerPanelProps {
export interface CrackerPanelProps {
packets: RawPacket[];
channels: Channel[];
onChannelCreate: (name: string, key: string) => Promise<void>;

View File

@@ -26,7 +26,7 @@ export interface SearchNavigateTarget {
conversation_name: string;
}
interface SearchViewProps {
export interface SearchViewProps {
contacts: Contact[];
channels: Channel[];
onNavigateToMessage: (target: SearchNavigateTarget) => void;

View File

@@ -37,7 +37,7 @@ interface SettingsModalBaseProps {
onToggleBlockedName?: (name: string) => void;
}
type SettingsModalProps = SettingsModalBaseProps &
export type SettingsModalProps = SettingsModalBaseProps &
(
| { externalSidebarNav: true; desktopSection: SettingsSection }
| { externalSidebarNav?: false; desktopSection?: never }

View File

@@ -2,6 +2,7 @@ export { useUnreadCounts } from './useUnreadCounts';
export { useConversationMessages, getMessageContentKey } from './useConversationMessages';
export { useRadioControl } from './useRadioControl';
export { useRepeaterDashboard } from './useRepeaterDashboard';
export { useAppShell } from './useAppShell';
export { useAppSettings } from './useAppSettings';
export { useConversationRouter } from './useConversationRouter';
export { useContactsAndChannels } from './useContactsAndChannels';

View File

@@ -0,0 +1,82 @@
import { startTransition, useCallback, useState, type Dispatch, type SetStateAction } from 'react';
import { getLocalLabel, type LocalLabel } from '../utils/localLabel';
import type { SettingsSection } from '../components/settings/settingsConstants';
interface UseAppShellResult {
showNewMessage: boolean;
showSettings: boolean;
settingsSection: SettingsSection;
sidebarOpen: boolean;
showCracker: boolean;
crackerRunning: boolean;
localLabel: LocalLabel;
targetMessageId: number | null;
setSettingsSection: (section: SettingsSection) => void;
setSidebarOpen: (open: boolean) => void;
setCrackerRunning: (running: boolean) => void;
setLocalLabel: (label: LocalLabel) => void;
setTargetMessageId: Dispatch<SetStateAction<number | null>>;
handleCloseSettingsView: () => void;
handleToggleSettingsView: () => void;
handleOpenNewMessage: () => void;
handleCloseNewMessage: () => void;
handleToggleCracker: () => void;
}
export function useAppShell(): UseAppShellResult {
const [showNewMessage, setShowNewMessage] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection>('radio');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showCracker, setShowCracker] = useState(false);
const [crackerRunning, setCrackerRunning] = useState(false);
const [localLabel, setLocalLabel] = useState(getLocalLabel);
const [targetMessageId, setTargetMessageId] = useState<number | null>(null);
const handleCloseSettingsView = useCallback(() => {
startTransition(() => setShowSettings(false));
setSidebarOpen(false);
}, []);
const handleToggleSettingsView = useCallback(() => {
startTransition(() => {
setShowSettings((prev) => !prev);
});
setSidebarOpen(false);
}, []);
const handleOpenNewMessage = useCallback(() => {
setShowNewMessage(true);
setSidebarOpen(false);
}, []);
const handleCloseNewMessage = useCallback(() => {
setShowNewMessage(false);
}, []);
const handleToggleCracker = useCallback(() => {
setShowCracker((prev) => !prev);
}, []);
return {
showNewMessage,
showSettings,
settingsSection,
sidebarOpen,
showCracker,
crackerRunning,
localLabel,
targetMessageId,
setSettingsSection,
setSidebarOpen,
setCrackerRunning,
setLocalLabel,
setTargetMessageId,
handleCloseSettingsView,
handleToggleSettingsView,
handleOpenNewMessage,
handleCloseNewMessage,
handleToggleCracker,
};
}

View File

@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { MessageList } from '../components/MessageList';
import type { Message } from '../types';
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 1,
type: 'CHAN',
conversation_key: 'C3B889530D4F02DB5662EA13C417F530',
text: 'Alice: hello world',
sender_timestamp: 1700000000,
received_at: 1700000001,
paths: null,
txt_type: 0,
signature: null,
sender_key: null,
outgoing: false,
acked: 0,
sender_name: null,
...overrides,
};
}
describe('MessageList channel sender rendering', () => {
it('renders explicit corrupt placeholder and warning avatar for unnamed corrupt channel packets', () => {
render(
<MessageList
messages={[
createMessage({
text: "Nv\x0ek\x16ɩ'\x7fg:",
sender_name: null,
sender_key: null,
}),
]}
contacts={[]}
loading={false}
/>
);
expect(screen.getByText('<No name -- corrupt packet?>')).toBeInTheDocument();
expect(screen.getByTestId('corrupt-avatar')).toBeInTheDocument();
});
it('prefers stored sender_name for channel messages even when text is not sender-prefixed', () => {
render(
<MessageList
messages={[
createMessage({
text: 'garbled payload with no sender prefix',
sender_name: 'Alice',
sender_key: 'ab'.repeat(32),
}),
]}
contacts={[]}
loading={false}
/>
);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,47 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useAppShell } from '../hooks/useAppShell';
describe('useAppShell', () => {
it('opens new-message modal and closes the sidebar', () => {
const { result } = renderHook(() => useAppShell());
act(() => {
result.current.setSidebarOpen(true);
result.current.handleOpenNewMessage();
});
expect(result.current.showNewMessage).toBe(true);
expect(result.current.sidebarOpen).toBe(false);
});
it('toggles settings mode and closes the sidebar', () => {
const { result } = renderHook(() => useAppShell());
act(() => {
result.current.setSidebarOpen(true);
result.current.handleToggleSettingsView();
});
expect(result.current.showSettings).toBe(true);
expect(result.current.sidebarOpen).toBe(false);
act(() => {
result.current.handleCloseSettingsView();
});
expect(result.current.showSettings).toBe(false);
});
it('supports React-style target message updates', () => {
const { result } = renderHook(() => useAppShell());
act(() => {
result.current.setTargetMessageId(10);
result.current.setTargetMessageId((prev) => (prev ?? 0) + 5);
});
expect(result.current.targetMessageId).toBe(15);
});
});