From 3316f002716c6cddbe6ed4720a2dd45caab91b38 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 21:07:56 -0700 Subject: [PATCH] extract app shell prop assembly --- frontend/AGENTS.md | 3 + frontend/src/App.tsx | 198 ++++++------- frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useAppShellProps.ts | 306 +++++++++++++++++++++ frontend/src/test/useAppShellProps.test.ts | 189 +++++++++++++ 5 files changed, 584 insertions(+), 113 deletions(-) create mode 100644 frontend/src/hooks/useAppShellProps.ts create mode 100644 frontend/src/test/useAppShellProps.test.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 3859e9c..d6cc4ad 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -42,6 +42,7 @@ frontend/src/ │ ├── 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) +│ ├── useAppShellProps.ts # AppShell child prop assembly + cracker create/decrypt flow │ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries) │ ├── useRadioControl.ts # Radio health/config state, reconnection │ ├── useAppSettings.ts # Settings, favorites, preferences migration @@ -147,6 +148,7 @@ frontend/src/ ├── useConversationMessages.test.ts ├── useConversationMessages.race.test.ts ├── useConversationNavigation.test.ts + ├── useAppShellProps.test.ts ├── useAppShell.test.ts ├── useRepeaterDashboard.test.ts ├── useContactsAndChannels.test.ts @@ -178,6 +180,7 @@ High-level state is delegated to hooks: - `useConversationRouter`: URL hash → active conversation routing - `useConversationNavigation`: search target, conversation selection reset, and info-pane state - `useConversationActions`: send/resend/trace/block handlers and channel override updates +- `useAppShellProps`: assembles the prop bundles passed into `AppShell` children, including the cracker-created-channel historical decrypt flow - `useConversationMessages`: dedup/update helpers and pending ACK buffering - `useConversationTimeline`: conversation switch loading, cache restore, jump-target loading, pagination, reconcile - `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d0ef71a..961e313 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { takePrefetchOrFetch } from './prefetch'; import { useWebSocket } from './useWebSocket'; import { useAppShell, + useAppShellProps, useUnreadCounts, useConversationMessages, useRadioControl, @@ -222,6 +223,81 @@ export function App() { messageInputRef, }); + const { + statusProps, + sidebarProps, + conversationPaneProps, + searchProps, + settingsProps, + crackerProps, + newMessageModalProps, + contactInfoPaneProps, + channelInfoPaneProps, + } = useAppShellProps({ + contacts, + channels, + rawPackets, + undecryptedCount, + activeConversation, + config, + health, + favorites, + appSettings, + unreadCounts, + mentions, + lastMessageTimes, + showCracker, + crackerRunning, + messageInputRef, + targetMessageId, + infoPaneContactKey, + infoPaneFromChannel, + infoPaneChannelKey, + messages, + messagesLoading, + loadingOlder, + hasOlderMessages, + hasNewerMessages, + loadingNewer, + handleOpenNewMessage, + handleToggleCracker, + markAllRead, + handleSortOrderChange, + handleSelectConversationWithTargetReset, + handleNavigateToMessage, + handleSaveConfig, + handleSaveAppSettings, + handleSetPrivateKey, + handleReboot, + handleAdvertise, + handleHealthRefresh, + fetchAppSettings, + setChannels, + fetchUndecryptedCount, + handleCreateContact, + handleCreateChannel, + handleCreateHashtagChannel, + handleDeleteContact, + handleDeleteChannel, + handleToggleFavorite, + handleSetChannelFloodScopeOverride, + handleOpenContactInfo, + handleOpenChannelInfo, + handleCloseContactInfo, + handleCloseChannelInfo, + handleSenderClick, + handleResendChannelMessage, + handleTrace, + handleSendMessage, + fetchOlderMessages, + fetchNewerMessages, + jumpToBottom, + setTargetMessageId, + handleNavigateToChannel, + handleBlockKey, + handleBlockName, + }); + // Connect to WebSocket useWebSocket(wsHandlers); @@ -266,119 +342,15 @@ export function App() { 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, - }} + statusProps={statusProps} + sidebarProps={sidebarProps} + conversationPaneProps={conversationPaneProps} + searchProps={searchProps} + settingsProps={settingsProps} + crackerProps={crackerProps} + newMessageModalProps={newMessageModalProps} + contactInfoPaneProps={contactInfoPaneProps} + channelInfoPaneProps={channelInfoPaneProps} /> ); } diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index d6d1c94..d4386ad 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -9,3 +9,4 @@ export { useContactsAndChannels } from './useContactsAndChannels'; export { useRealtimeAppState } from './useRealtimeAppState'; export { useConversationActions } from './useConversationActions'; export { useConversationNavigation } from './useConversationNavigation'; +export { useAppShellProps } from './useAppShellProps'; diff --git a/frontend/src/hooks/useAppShellProps.ts b/frontend/src/hooks/useAppShellProps.ts new file mode 100644 index 0000000..dd3eba5 --- /dev/null +++ b/frontend/src/hooks/useAppShellProps.ts @@ -0,0 +1,306 @@ +import { useCallback, type ComponentProps, type Dispatch, type SetStateAction } from 'react'; + +import { api } from '../api'; +import { ChannelInfoPane } from '../components/ChannelInfoPane'; +import { ContactInfoPane } from '../components/ContactInfoPane'; +import { ConversationPane } from '../components/ConversationPane'; +import { NewMessageModal } from '../components/NewMessageModal'; +import { SearchView } from '../components/SearchView'; +import { SettingsModal } from '../components/SettingsModal'; +import { Sidebar } from '../components/Sidebar'; +import { StatusBar } from '../components/StatusBar'; +import { CrackerPanel } from '../components/CrackerPanel'; +import type { + AppSettings, + Channel, + Contact, + Conversation, + Favorite, + HealthStatus, + Message, + RadioConfig, + RawPacket, +} from '../types'; + +type StatusProps = Pick, 'health' | 'config'>; +type SidebarProps = ComponentProps; +type ConversationPaneProps = ComponentProps; +type SearchProps = ComponentProps; +type SettingsProps = Omit< + ComponentProps, + 'open' | 'pageMode' | 'externalSidebarNav' | 'desktopSection' | 'onClose' | 'onLocalLabelChange' +>; +type CrackerProps = Omit, 'visible' | 'onRunningChange'>; +type NewMessageModalProps = Omit, 'open' | 'onClose'>; +type ContactInfoPaneProps = ComponentProps; +type ChannelInfoPaneProps = ComponentProps; + +interface UseAppShellPropsArgs { + contacts: Contact[]; + channels: Channel[]; + rawPackets: RawPacket[]; + undecryptedCount: number; + activeConversation: Conversation | null; + config: RadioConfig | null; + health: HealthStatus | null; + favorites: Favorite[]; + appSettings: AppSettings | null; + unreadCounts: Record; + mentions: Record; + lastMessageTimes: Record; + showCracker: boolean; + crackerRunning: boolean; + messageInputRef: ConversationPaneProps['messageInputRef']; + targetMessageId: number | null; + infoPaneContactKey: string | null; + infoPaneFromChannel: boolean; + infoPaneChannelKey: string | null; + messages: Message[]; + messagesLoading: boolean; + loadingOlder: boolean; + hasOlderMessages: boolean; + hasNewerMessages: boolean; + loadingNewer: boolean; + handleOpenNewMessage: () => void; + handleToggleCracker: () => void; + markAllRead: () => void; + handleSortOrderChange: (sortOrder: 'recent' | 'alpha') => Promise; + handleSelectConversationWithTargetReset: ( + conv: Conversation, + options?: { preserveTarget?: boolean } + ) => void; + handleNavigateToMessage: SearchProps['onNavigateToMessage']; + handleSaveConfig: SettingsProps['onSave']; + handleSaveAppSettings: SettingsProps['onSaveAppSettings']; + handleSetPrivateKey: SettingsProps['onSetPrivateKey']; + handleReboot: SettingsProps['onReboot']; + handleAdvertise: SettingsProps['onAdvertise']; + handleHealthRefresh: SettingsProps['onHealthRefresh']; + fetchAppSettings: () => Promise; + setChannels: Dispatch>; + fetchUndecryptedCount: () => Promise; + handleCreateContact: NewMessageModalProps['onCreateContact']; + handleCreateChannel: NewMessageModalProps['onCreateChannel']; + handleCreateHashtagChannel: NewMessageModalProps['onCreateHashtagChannel']; + handleDeleteContact: ConversationPaneProps['onDeleteContact']; + handleDeleteChannel: ConversationPaneProps['onDeleteChannel']; + handleToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise; + handleSetChannelFloodScopeOverride: ConversationPaneProps['onSetChannelFloodScopeOverride']; + handleOpenContactInfo: ConversationPaneProps['onOpenContactInfo']; + handleOpenChannelInfo: ConversationPaneProps['onOpenChannelInfo']; + handleCloseContactInfo: () => void; + handleCloseChannelInfo: () => void; + handleSenderClick: NonNullable; + handleResendChannelMessage: NonNullable; + handleTrace: ConversationPaneProps['onTrace']; + handleSendMessage: ConversationPaneProps['onSendMessage']; + fetchOlderMessages: ConversationPaneProps['onLoadOlder']; + fetchNewerMessages: ConversationPaneProps['onLoadNewer']; + jumpToBottom: ConversationPaneProps['onJumpToBottom']; + setTargetMessageId: Dispatch>; + handleNavigateToChannel: ContactInfoPaneProps['onNavigateToChannel']; + handleBlockKey: NonNullable; + handleBlockName: NonNullable; +} + +interface UseAppShellPropsResult { + statusProps: StatusProps; + sidebarProps: SidebarProps; + conversationPaneProps: ConversationPaneProps; + searchProps: SearchProps; + settingsProps: SettingsProps; + crackerProps: CrackerProps; + newMessageModalProps: NewMessageModalProps; + contactInfoPaneProps: ContactInfoPaneProps; + channelInfoPaneProps: ChannelInfoPaneProps; +} + +export function useAppShellProps({ + contacts, + channels, + rawPackets, + undecryptedCount, + activeConversation, + config, + health, + favorites, + appSettings, + unreadCounts, + mentions, + lastMessageTimes, + showCracker, + crackerRunning, + messageInputRef, + targetMessageId, + infoPaneContactKey, + infoPaneFromChannel, + infoPaneChannelKey, + messages, + messagesLoading, + loadingOlder, + hasOlderMessages, + hasNewerMessages, + loadingNewer, + handleOpenNewMessage, + handleToggleCracker, + markAllRead, + handleSortOrderChange, + handleSelectConversationWithTargetReset, + handleNavigateToMessage, + handleSaveConfig, + handleSaveAppSettings, + handleSetPrivateKey, + handleReboot, + handleAdvertise, + handleHealthRefresh, + fetchAppSettings, + setChannels, + fetchUndecryptedCount, + handleCreateContact, + handleCreateChannel, + handleCreateHashtagChannel, + handleDeleteContact, + handleDeleteChannel, + handleToggleFavorite, + handleSetChannelFloodScopeOverride, + handleOpenContactInfo, + handleOpenChannelInfo, + handleCloseContactInfo, + handleCloseChannelInfo, + handleSenderClick, + handleResendChannelMessage, + handleTrace, + handleSendMessage, + fetchOlderMessages, + fetchNewerMessages, + jumpToBottom, + setTargetMessageId, + handleNavigateToChannel, + handleBlockKey, + handleBlockName, +}: UseAppShellPropsArgs): UseAppShellPropsResult { + const handleCreateCrackedChannel = useCallback( + async (name, key) => { + const created = await api.createChannel(name, key); + const updatedChannels = await api.getChannels(); + setChannels(updatedChannels); + await api.decryptHistoricalPackets({ + key_type: 'channel', + channel_key: created.key, + }); + await fetchUndecryptedCount(); + }, + [fetchUndecryptedCount, setChannels] + ); + + return { + statusProps: { health, config }, + sidebarProps: { + contacts, + channels, + activeConversation, + onSelectConversation: handleSelectConversationWithTargetReset, + onNewMessage: handleOpenNewMessage, + lastMessageTimes, + unreadCounts, + mentions, + showCracker, + crackerRunning, + onToggleCracker: handleToggleCracker, + onMarkAllRead: () => { + void markAllRead(); + }, + favorites, + sortOrder: appSettings?.sidebar_sort_order ?? 'recent', + onSortOrderChange: (sortOrder) => { + void handleSortOrderChange(sortOrder); + }, + }, + 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: handleCreateCrackedChannel, + }, + 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, + }, + }; +} diff --git a/frontend/src/test/useAppShellProps.test.ts b/frontend/src/test/useAppShellProps.test.ts new file mode 100644 index 0000000..692052e --- /dev/null +++ b/frontend/src/test/useAppShellProps.test.ts @@ -0,0 +1,189 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { useAppShellProps } from '../hooks/useAppShellProps'; +import type { + AppSettings, + Channel, + Contact, + Conversation, + Favorite, + HealthStatus, + Message, + RadioConfig, + RawPacket, +} from '../types'; + +const mocks = vi.hoisted(() => ({ + api: { + createChannel: vi.fn(), + getChannels: vi.fn(), + decryptHistoricalPackets: vi.fn(), + }, +})); + +vi.mock('../api', () => ({ + api: mocks.api, +})); + +const publicChannel: Channel = { + key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72', + name: 'Public', + is_hashtag: false, + on_radio: false, + last_read_at: null, +}; + +const config: RadioConfig = { + public_key: 'aa'.repeat(32), + name: 'TestNode', + lat: 0, + lon: 0, + tx_power: 17, + max_tx_power: 22, + radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 }, + path_hash_mode: 0, + path_hash_mode_supported: false, +}; + +const health: HealthStatus = { + status: 'connected', + radio_connected: true, + radio_initializing: false, + connection_info: null, + database_size_mb: 1, + oldest_undecrypted_timestamp: null, + fanout_statuses: {}, + bots_disabled: false, +}; + +const appSettings: AppSettings = { + max_radio_contacts: 200, + favorites: [], + auto_decrypt_dm_on_advert: false, + sidebar_sort_order: 'recent', + last_message_times: {}, + preferences_migrated: true, + advert_interval: 0, + last_advert_time: 0, + flood_scope: '', + blocked_keys: [], + blocked_names: [], +}; + +function createArgs(overrides: Partial[0]> = {}) { + const activeConversation: Conversation = { + type: 'channel', + id: publicChannel.key, + name: publicChannel.name, + }; + const contacts: Contact[] = []; + const channels: Channel[] = [publicChannel]; + const rawPackets: RawPacket[] = []; + const favorites: Favorite[] = []; + const messages: Message[] = []; + + return { + contacts, + channels, + rawPackets, + undecryptedCount: 0, + activeConversation, + config, + health, + favorites, + appSettings, + unreadCounts: {}, + mentions: {}, + lastMessageTimes: {}, + showCracker: false, + crackerRunning: false, + messageInputRef: { current: null }, + targetMessageId: null, + infoPaneContactKey: null, + infoPaneFromChannel: false, + infoPaneChannelKey: null, + messages, + messagesLoading: false, + loadingOlder: false, + hasOlderMessages: false, + hasNewerMessages: false, + loadingNewer: false, + handleOpenNewMessage: vi.fn(), + handleToggleCracker: vi.fn(), + markAllRead: vi.fn(async () => {}), + handleSortOrderChange: vi.fn(async () => {}), + handleSelectConversationWithTargetReset: vi.fn(), + handleNavigateToMessage: vi.fn(), + handleSaveConfig: vi.fn(async () => {}), + handleSaveAppSettings: vi.fn(async () => {}), + handleSetPrivateKey: vi.fn(async () => {}), + handleReboot: vi.fn(async () => {}), + handleAdvertise: vi.fn(async () => {}), + handleHealthRefresh: vi.fn(async () => {}), + fetchAppSettings: vi.fn(async () => {}), + setChannels: vi.fn(), + fetchUndecryptedCount: vi.fn(async () => {}), + handleCreateContact: vi.fn(async () => {}), + handleCreateChannel: vi.fn(async () => {}), + handleCreateHashtagChannel: vi.fn(async () => {}), + handleDeleteContact: vi.fn(async () => {}), + handleDeleteChannel: vi.fn(async () => {}), + handleToggleFavorite: vi.fn(async () => {}), + handleSetChannelFloodScopeOverride: vi.fn(async () => {}), + handleOpenContactInfo: vi.fn(), + handleOpenChannelInfo: vi.fn(), + handleCloseContactInfo: vi.fn(), + handleCloseChannelInfo: vi.fn(), + handleSenderClick: vi.fn(), + handleResendChannelMessage: vi.fn(async () => {}), + handleTrace: vi.fn(async () => {}), + handleSendMessage: vi.fn(async () => {}), + fetchOlderMessages: vi.fn(async () => {}), + fetchNewerMessages: vi.fn(async () => {}), + jumpToBottom: vi.fn(), + setTargetMessageId: vi.fn(), + handleNavigateToChannel: vi.fn(), + handleBlockKey: vi.fn(async () => {}), + handleBlockName: vi.fn(async () => {}), + ...overrides, + }; +} + +describe('useAppShellProps', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates a cracked channel, refreshes channels, decrypts history, and refreshes undecrypted count', async () => { + mocks.api.createChannel.mockResolvedValue({ + key: '11'.repeat(16), + name: 'Found', + is_hashtag: false, + }); + mocks.api.getChannels.mockResolvedValue([ + publicChannel, + { ...publicChannel, key: '11'.repeat(16), name: 'Found' }, + ]); + mocks.api.decryptHistoricalPackets.mockResolvedValue({ decrypted_count: 4 }); + + const args = createArgs(); + const { result } = renderHook(() => useAppShellProps(args)); + + await act(async () => { + await result.current.crackerProps.onChannelCreate('Found', '11'.repeat(16)); + }); + + expect(mocks.api.createChannel).toHaveBeenCalledWith('Found', '11'.repeat(16)); + expect(mocks.api.getChannels).toHaveBeenCalledTimes(1); + expect(args.setChannels).toHaveBeenCalledWith([ + publicChannel, + { ...publicChannel, key: '11'.repeat(16), name: 'Found' }, + ]); + expect(mocks.api.decryptHistoricalPackets).toHaveBeenCalledWith({ + key_type: 'channel', + channel_key: '11'.repeat(16), + }); + expect(args.fetchUndecryptedCount).toHaveBeenCalledTimes(1); + }); +});