mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
extract frontend app shell
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
|
||||
292
frontend/src/components/AppShell.tsx
Normal file
292
frontend/src/components/AppShell.tsx
Normal 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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface SearchNavigateTarget {
|
||||
conversation_name: string;
|
||||
}
|
||||
|
||||
interface SearchViewProps {
|
||||
export interface SearchViewProps {
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
onNavigateToMessage: (target: SearchNavigateTarget) => void;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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';
|
||||
|
||||
82
frontend/src/hooks/useAppShell.ts
Normal file
82
frontend/src/hooks/useAppShell.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
64
frontend/src/test/messageList.test.tsx
Normal file
64
frontend/src/test/messageList.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
47
frontend/src/test/useAppShell.test.ts
Normal file
47
frontend/src/test/useAppShell.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user