diff --git a/AGENTS.md b/AGENTS.md index 7e3e156..7247ef5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/ diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 81ab0eb..010d50d 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 23a321b..de0e846 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const [rawPackets, setRawPackets] = useState([]); - const [showNewMessage, setShowNewMessage] = useState(false); - const [showSettings, setShowSettings] = useState(false); - const [settingsSection, setSettingsSection] = useState('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(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 = ( - 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 = ( - - ); - - const activeSidebarContent = showSettings ? settingsSidebarContent : sidebarContent; - - return ( -
- - Skip to content - - {localLabel.text && ( -
- {localLabel.text} -
- )} - setSidebarOpen(true)} - /> - -
- {/* Desktop sidebar - hidden on mobile */} -
{activeSidebarContent}
- - {/* Mobile sidebar - Sheet that slides in */} - - - - Navigation - Sidebar navigation - -
{activeSidebarContent}
-
-
- -
-
- setTargetMessageId(null)} - onLoadNewer={fetchNewerMessages} - onJumpToBottom={jumpToBottom} - onSendMessage={handleSendMessage} - /> -
- - {searchMounted.current && ( -
- - Loading search... -
- } - > - - -
- )} - - {showSettings && ( -
-

- Radio & Settings - - {SETTINGS_SECTION_LABELS[settingsSection]} - -

-
- - Loading settings... -
- } - > - - -
-
- )} - - - - {/* Global Cracker Panel - deferred until first opened, then kept mounted for state */} -
{ - // Focus the panel when it becomes visible - if (showCracker && el) { - const focusable = el.querySelector('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 && ( - - Loading cracker... -
- } - > - { - 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} - /> - - )} - - - setShowNewMessage(false)} - onSelectConversation={(conv) => { - handleSelectConversationWithTargetReset(conv); - setShowNewMessage(false); - }} - onCreateContact={handleCreateContact} - onCreateChannel={handleCreateChannel} - onCreateHashtagChannel={handleCreateHashtagChannel} - /> - - - - - - - - ); } diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..558e3e1 --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -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; +type ConversationPaneProps = ComponentProps; +type NewMessageModalProps = Omit, 'open' | 'onClose'>; +type ContactInfoPaneProps = ComponentProps; +type ChannelInfoPaneProps = ComponentProps; + +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, 'health' | 'config'>; + sidebarProps: SidebarProps; + conversationPaneProps: ConversationPaneProps; + searchProps: SearchViewProps; + settingsProps: Omit< + SettingsModalProps, + 'open' | 'pageMode' | 'externalSidebarNav' | 'desktopSection' | 'onClose' | 'onLocalLabelChange' + >; + crackerProps: Omit; + 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 = ( + + ); + + const activeSidebarContent = showSettings ? ( + settingsSidebarContent + ) : ( + + ); + + return ( +
+ + Skip to content + + {localLabel.text && ( +
+ {localLabel.text} +
+ )} + + onSidebarOpenChange(true)} + /> + +
+
{activeSidebarContent}
+ + + + + Navigation + Sidebar navigation + +
{activeSidebarContent}
+
+
+ +
+
+ +
+ + {searchMounted.current && ( +
+ + Loading search... +
+ } + > + + +
+ )} + + {showSettings && ( +
+

+ Radio & Settings + + {SETTINGS_SECTION_LABELS[settingsSection]} + +

+
+ + Loading settings... +
+ } + > + + +
+
+ )} + + + +
{ + if (showCracker && el) { + const focusable = el.querySelector('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 && ( + + Loading cracker... +
+ } + > + + + )} + + + { + newMessageModalProps.onSelectConversation(conv); + onCloseNewMessage(); + }} + /> + + + + + + ); +} diff --git a/frontend/src/components/CrackerPanel.tsx b/frontend/src/components/CrackerPanel.tsx index 0e94a26..669df5a 100644 --- a/frontend/src/components/CrackerPanel.tsx +++ b/frontend/src/components/CrackerPanel.tsx @@ -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; diff --git a/frontend/src/components/SearchView.tsx b/frontend/src/components/SearchView.tsx index bc07f96..f28f46a 100644 --- a/frontend/src/components/SearchView.tsx +++ b/frontend/src/components/SearchView.tsx @@ -26,7 +26,7 @@ export interface SearchNavigateTarget { conversation_name: string; } -interface SearchViewProps { +export interface SearchViewProps { contacts: Contact[]; channels: Channel[]; onNavigateToMessage: (target: SearchNavigateTarget) => void; diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 99feb44..46c62be 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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 } diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index a563ebc..f2bc570 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -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'; diff --git a/frontend/src/hooks/useAppShell.ts b/frontend/src/hooks/useAppShell.ts new file mode 100644 index 0000000..030b3cd --- /dev/null +++ b/frontend/src/hooks/useAppShell.ts @@ -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>; + 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('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(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, + }; +} diff --git a/frontend/src/test/messageList.test.tsx b/frontend/src/test/messageList.test.tsx new file mode 100644 index 0000000..e418d6f --- /dev/null +++ b/frontend/src/test/messageList.test.tsx @@ -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 { + 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( + + ); + + expect(screen.getByText('')).toBeInTheDocument(); + expect(screen.getByTestId('corrupt-avatar')).toBeInTheDocument(); + }); + + it('prefers stored sender_name for channel messages even when text is not sender-prefixed', () => { + render( + + ); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/useAppShell.test.ts b/frontend/src/test/useAppShell.test.ts new file mode 100644 index 0000000..2cc1dca --- /dev/null +++ b/frontend/src/test/useAppShell.test.ts @@ -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); + }); +});