From 39b745f8b00f2b6b20e8574c29a5c1d335f57f76 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 23:53:19 -0700 Subject: [PATCH] Compactify some things for LLM wins --- app/services/radio_runtime.py | 54 +-- frontend/src/App.tsx | 174 +++++--- frontend/src/hooks/index.ts | 1 - frontend/src/hooks/useAppShellProps.ts | 308 -------------- frontend/src/hooks/useConversationMessages.ts | 347 ++++++++++++++- frontend/src/hooks/useConversationTimeline.ts | 399 ------------------ frontend/src/test/useAppShellProps.test.ts | 225 ---------- 7 files changed, 453 insertions(+), 1055 deletions(-) delete mode 100644 frontend/src/hooks/useAppShellProps.ts delete mode 100644 frontend/src/hooks/useConversationTimeline.ts delete mode 100644 frontend/src/test/useAppShellProps.test.ts diff --git a/app/services/radio_runtime.py b/app/services/radio_runtime.py index bb2b700..177dc72 100644 --- a/app/services/radio_runtime.py +++ b/app/services/radio_runtime.py @@ -15,7 +15,7 @@ import app.radio as radio_module class RadioRuntime: - """Thin wrapper around the process-global RadioManager.""" + """Thin forwarding wrapper around the process-global RadioManager.""" def __init__(self, manager_or_getter=None): if manager_or_getter is None: @@ -29,45 +29,25 @@ class RadioRuntime: def manager(self) -> Any: return self._manager_getter() - @property - def meshcore(self): - return self.manager.meshcore + def __getattr__(self, name: str) -> Any: + """Forward unknown attributes to the current global manager.""" + return getattr(self.manager, name) - @property - def connection_info(self) -> str | None: - return self.manager.connection_info + @staticmethod + def _is_local_runtime_attr(name: str) -> bool: + return name.startswith("_") or hasattr(RadioRuntime, name) - @property - def is_connected(self) -> bool: - return self.manager.is_connected + def __setattr__(self, name: str, value: Any) -> None: + if self._is_local_runtime_attr(name): + object.__setattr__(self, name, value) + return + setattr(self.manager, name, value) - @property - def is_reconnecting(self) -> bool: - return self.manager.is_reconnecting - - @property - def is_setup_in_progress(self) -> bool: - return self.manager.is_setup_in_progress - - @property - def is_setup_complete(self) -> bool: - return self.manager.is_setup_complete - - @property - def path_hash_mode(self) -> int: - return self.manager.path_hash_mode - - @path_hash_mode.setter - def path_hash_mode(self, mode: int) -> None: - self.manager.path_hash_mode = mode - - @property - def path_hash_mode_supported(self) -> bool: - return self.manager.path_hash_mode_supported - - @path_hash_mode_supported.setter - def path_hash_mode_supported(self, supported: bool) -> None: - self.manager.path_hash_mode_supported = supported + def __delattr__(self, name: str) -> None: + if self._is_local_runtime_attr(name): + object.__delattr__(self, name) + return + delattr(self.manager, name) def require_connected(self): """Return MeshCore when available, mirroring existing HTTP semantics.""" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 961e313..9bec14b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,6 @@ import { takePrefetchOrFetch } from './prefetch'; import { useWebSocket } from './useWebSocket'; import { useAppShell, - useAppShellProps, useUnreadCounts, useConversationMessages, useRadioControl, @@ -222,81 +221,130 @@ export function App() { handleToggleBlockedName, messageInputRef, }); + const handleCreateCrackedChannel = useCallback( + async (name: string, key: string) => { + const created = await api.createChannel(name, key); + const updatedChannels = await api.getChannels(); + setChannels(updatedChannels); + await api.decryptHistoricalPackets({ + key_type: 'channel', + channel_key: created.key, + }); + void fetchUndecryptedCount().catch((error) => { + console.error('Failed to refresh undecrypted count after cracked channel create:', error); + }); + }, + [fetchUndecryptedCount, setChannels] + ); - const { - statusProps, - sidebarProps, - conversationPaneProps, - searchProps, - settingsProps, - crackerProps, - newMessageModalProps, - contactInfoPaneProps, - channelInfoPaneProps, - } = useAppShellProps({ + const statusProps = { health, config }; + const 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: 'recent' | 'alpha') => { + void handleSortOrderChange(sortOrder); + }, + }; + const conversationPaneProps = { + activeConversation, contacts, channels, rawPackets, - undecryptedCount, - activeConversation, config, health, favorites, - appSettings, - unreadCounts, - mentions, - lastMessageTimes, - showCracker, - crackerRunning, - messageInputRef, - targetMessageId, - infoPaneContactKey, - infoPaneFromChannel, - infoPaneChannelKey, messages, messagesLoading, loadingOlder, hasOlderMessages, + targetMessageId, 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, - }); + 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, + }; + const searchProps = { + contacts, + channels, + onNavigateToMessage: handleNavigateToMessage, + }; + const 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, + }; + const crackerProps = { + packets: rawPackets, + channels, + onChannelCreate: handleCreateCrackedChannel, + }; + const newMessageModalProps = { + contacts, + undecryptedCount, + onSelectConversation: handleSelectConversationWithTargetReset, + onCreateContact: handleCreateContact, + onCreateChannel: handleCreateChannel, + onCreateHashtagChannel: handleCreateHashtagChannel, + }; + const contactInfoPaneProps = { + contactKey: infoPaneContactKey, + fromChannel: infoPaneFromChannel, + onClose: handleCloseContactInfo, + contacts, + config, + favorites, + onToggleFavorite: handleToggleFavorite, + onNavigateToChannel: handleNavigateToChannel, + onToggleBlockedKey: handleBlockKey, + onToggleBlockedName: handleBlockName, + blockedKeys: appSettings?.blocked_keys ?? [], + blockedNames: appSettings?.blocked_names ?? [], + }; + const channelInfoPaneProps = { + channelKey: infoPaneChannelKey, + onClose: handleCloseChannelInfo, + channels, + favorites, + onToggleFavorite: handleToggleFavorite, + }; // Connect to WebSocket useWebSocket(wsHandlers); diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index d4386ad..d6d1c94 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -9,4 +9,3 @@ 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 deleted file mode 100644 index 46a4a9d..0000000 --- a/frontend/src/hooks/useAppShellProps.ts +++ /dev/null @@ -1,308 +0,0 @@ -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, - }); - void fetchUndecryptedCount().catch((error) => { - console.error('Failed to refresh undecrypted count after cracked channel create:', error); - }); - }, - [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/hooks/useConversationMessages.ts b/frontend/src/hooks/useConversationMessages.ts index e85d146..9c507d8 100644 --- a/frontend/src/hooks/useConversationMessages.ts +++ b/frontend/src/hooks/useConversationMessages.ts @@ -1,14 +1,19 @@ import { useCallback, + useEffect, useRef, + useState, type Dispatch, type MutableRefObject, type SetStateAction, } from 'react'; -import { useConversationTimeline } from './useConversationTimeline'; +import { toast } from '../components/ui/sonner'; +import { api, isAbortError } from '../api'; +import * as messageCache from '../messageCache'; import type { Conversation, Message, MessagePath } from '../types'; const MAX_PENDING_ACKS = 500; +const MESSAGE_PAGE_SIZE = 200; interface PendingAckUpdate { ackCount: number; @@ -77,6 +82,10 @@ interface UseConversationMessagesResult { triggerReconcile: () => void; } +function isMessageConversation(conversation: Conversation | null): conversation is Conversation { + return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type); +} + export function useConversationMessages( activeConversation: Conversation | null, targetMessageId?: number | null @@ -119,28 +128,322 @@ export function useConversationMessages( ...(pending.paths !== undefined && { paths: pending.paths }), }; }, []); + const [messages, setMessages] = useState([]); + const [messagesLoading, setMessagesLoading] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [hasOlderMessages, setHasOlderMessages] = useState(false); + const [hasNewerMessages, setHasNewerMessages] = useState(false); + const [loadingNewer, setLoadingNewer] = useState(false); - const { - messages, - messagesRef, - messagesLoading, - loadingOlder, - hasOlderMessages, - hasNewerMessages, - loadingNewer, - hasNewerMessagesRef, - setMessages, - fetchOlderMessages, - fetchNewerMessages, - jumpToBottom, - triggerReconcile, - } = useConversationTimeline({ - activeConversation, - targetMessageId, - applyPendingAck, - getMessageContentKey, - seenMessageContentRef: seenMessageContent, - }); + const abortControllerRef = useRef(null); + const fetchingConversationIdRef = useRef(null); + const messagesRef = useRef([]); + const hasOlderMessagesRef = useRef(false); + const hasNewerMessagesRef = useRef(false); + const prevConversationIdRef = useRef(null); + + useEffect(() => { + messagesRef.current = messages; + }, [messages]); + + useEffect(() => { + hasOlderMessagesRef.current = hasOlderMessages; + }, [hasOlderMessages]); + + useEffect(() => { + hasNewerMessagesRef.current = hasNewerMessages; + }, [hasNewerMessages]); + + const syncSeenContent = useCallback( + (nextMessages: Message[]) => { + seenMessageContent.current.clear(); + for (const msg of nextMessages) { + seenMessageContent.current.add(getMessageContentKey(msg)); + } + }, + [seenMessageContent] + ); + + const fetchLatestMessages = useCallback( + async (showLoading = false, signal?: AbortSignal) => { + if (!isMessageConversation(activeConversation)) { + setMessages([]); + setHasOlderMessages(false); + return; + } + + const conversationId = activeConversation.id; + + if (showLoading) { + setMessagesLoading(true); + setMessages([]); + } + + try { + const data = await api.getMessages( + { + type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', + conversation_key: activeConversation.id, + limit: MESSAGE_PAGE_SIZE, + }, + signal + ); + + if (fetchingConversationIdRef.current !== conversationId) { + return; + } + + const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg)); + setMessages(messagesWithPendingAck); + syncSeenContent(messagesWithPendingAck); + setHasOlderMessages(messagesWithPendingAck.length >= MESSAGE_PAGE_SIZE); + } catch (err) { + if (isAbortError(err)) { + return; + } + console.error('Failed to fetch messages:', err); + toast.error('Failed to load messages', { + description: err instanceof Error ? err.message : 'Check your connection', + }); + } finally { + if (showLoading) { + setMessagesLoading(false); + } + } + }, + [activeConversation, applyPendingAck, syncSeenContent] + ); + + const reconcileFromBackend = useCallback( + (conversation: Conversation, signal: AbortSignal) => { + const conversationId = conversation.id; + api + .getMessages( + { + type: conversation.type === 'channel' ? 'CHAN' : 'PRIV', + conversation_key: conversationId, + limit: MESSAGE_PAGE_SIZE, + }, + signal + ) + .then((data) => { + if (fetchingConversationIdRef.current !== conversationId) return; + + const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); + const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck); + if (!merged) return; + + setMessages(merged); + syncSeenContent(merged); + if (dataWithPendingAck.length >= MESSAGE_PAGE_SIZE) { + setHasOlderMessages(true); + } + }) + .catch((err) => { + if (isAbortError(err)) return; + console.debug('Background reconciliation failed:', err); + }); + }, + [applyPendingAck, syncSeenContent] + ); + + const fetchOlderMessages = useCallback(async () => { + if (!isMessageConversation(activeConversation) || loadingOlder || !hasOlderMessages) return; + + const conversationId = activeConversation.id; + const oldestMessage = messages.reduce( + (oldest, msg) => { + if (!oldest) return msg; + if (msg.received_at < oldest.received_at) return msg; + if (msg.received_at === oldest.received_at && msg.id < oldest.id) return msg; + return oldest; + }, + null as Message | null + ); + if (!oldestMessage) return; + + setLoadingOlder(true); + try { + const data = await api.getMessages({ + type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', + conversation_key: conversationId, + limit: MESSAGE_PAGE_SIZE, + before: oldestMessage.received_at, + before_id: oldestMessage.id, + }); + + if (fetchingConversationIdRef.current !== conversationId) return; + + const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); + + if (dataWithPendingAck.length > 0) { + setMessages((prev) => [...prev, ...dataWithPendingAck]); + for (const msg of dataWithPendingAck) { + seenMessageContent.current.add(getMessageContentKey(msg)); + } + } + setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); + } catch (err) { + console.error('Failed to fetch older messages:', err); + toast.error('Failed to load older messages', { + description: err instanceof Error ? err.message : 'Check your connection', + }); + } finally { + setLoadingOlder(false); + } + }, [activeConversation, applyPendingAck, hasOlderMessages, loadingOlder, messages]); + + const fetchNewerMessages = useCallback(async () => { + if (!isMessageConversation(activeConversation) || loadingNewer || !hasNewerMessages) return; + + const conversationId = activeConversation.id; + const newestMessage = messages.reduce( + (newest, msg) => { + if (!newest) return msg; + if (msg.received_at > newest.received_at) return msg; + if (msg.received_at === newest.received_at && msg.id > newest.id) return msg; + return newest; + }, + null as Message | null + ); + if (!newestMessage) return; + + setLoadingNewer(true); + try { + const data = await api.getMessages({ + type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', + conversation_key: conversationId, + limit: MESSAGE_PAGE_SIZE, + after: newestMessage.received_at, + after_id: newestMessage.id, + }); + + if (fetchingConversationIdRef.current !== conversationId) return; + + const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); + const newMessages = dataWithPendingAck.filter( + (msg) => !seenMessageContent.current.has(getMessageContentKey(msg)) + ); + + if (newMessages.length > 0) { + setMessages((prev) => [...prev, ...newMessages]); + for (const msg of newMessages) { + seenMessageContent.current.add(getMessageContentKey(msg)); + } + } + setHasNewerMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); + } catch (err) { + console.error('Failed to fetch newer messages:', err); + toast.error('Failed to load newer messages', { + description: err instanceof Error ? err.message : 'Check your connection', + }); + } finally { + setLoadingNewer(false); + } + }, [activeConversation, applyPendingAck, hasNewerMessages, loadingNewer, messages]); + + const jumpToBottom = useCallback(() => { + if (!activeConversation) return; + setHasNewerMessages(false); + messageCache.remove(activeConversation.id); + void fetchLatestMessages(true); + }, [activeConversation, fetchLatestMessages]); + + const triggerReconcile = useCallback(() => { + if (!isMessageConversation(activeConversation)) return; + const controller = new AbortController(); + reconcileFromBackend(activeConversation, controller.signal); + }, [activeConversation, reconcileFromBackend]); + + useEffect(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const prevId = prevConversationIdRef.current; + const newId = activeConversation?.id ?? null; + const conversationChanged = prevId !== newId; + fetchingConversationIdRef.current = newId; + prevConversationIdRef.current = newId; + + // Preserve around-loaded context on the same conversation when search clears targetMessageId. + if (!conversationChanged && !targetMessageId) { + return; + } + + setLoadingOlder(false); + setLoadingNewer(false); + if (conversationChanged) { + setHasNewerMessages(false); + } + + if ( + conversationChanged && + prevId && + messagesRef.current.length > 0 && + !hasNewerMessagesRef.current + ) { + messageCache.set(prevId, { + messages: messagesRef.current, + seenContent: new Set(seenMessageContent.current), + hasOlderMessages: hasOlderMessagesRef.current, + }); + } + + if (!isMessageConversation(activeConversation)) { + setMessages([]); + setHasOlderMessages(false); + return; + } + + const controller = new AbortController(); + abortControllerRef.current = controller; + + if (targetMessageId) { + setMessagesLoading(true); + setMessages([]); + const msgType = activeConversation.type === 'channel' ? 'CHAN' : 'PRIV'; + void api + .getMessagesAround( + targetMessageId, + msgType as 'PRIV' | 'CHAN', + activeConversation.id, + controller.signal + ) + .then((response) => { + if (fetchingConversationIdRef.current !== activeConversation.id) return; + const withAcks = response.messages.map((msg) => applyPendingAck(msg)); + setMessages(withAcks); + syncSeenContent(withAcks); + setHasOlderMessages(response.has_older); + setHasNewerMessages(response.has_newer); + }) + .catch((err) => { + if (isAbortError(err)) return; + console.error('Failed to fetch messages around target:', err); + toast.error('Failed to jump to message'); + }) + .finally(() => { + setMessagesLoading(false); + }); + } else { + const cached = messageCache.get(activeConversation.id); + if (cached) { + setMessages(cached.messages); + seenMessageContent.current = new Set(cached.seenContent); + setHasOlderMessages(cached.hasOlderMessages); + setMessagesLoading(false); + reconcileFromBackend(activeConversation, controller.signal); + } else { + void fetchLatestMessages(true, controller.signal); + } + } + + return () => { + controller.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeConversation?.id, activeConversation?.type, targetMessageId]); // Add a message if it's new (deduplication) // Returns true if the message was added, false if it was a duplicate diff --git a/frontend/src/hooks/useConversationTimeline.ts b/frontend/src/hooks/useConversationTimeline.ts deleted file mode 100644 index a080338..0000000 --- a/frontend/src/hooks/useConversationTimeline.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type Dispatch, - type MutableRefObject, - type SetStateAction, -} from 'react'; -import { toast } from '../components/ui/sonner'; -import { api, isAbortError } from '../api'; -import * as messageCache from '../messageCache'; -import type { Conversation, Message } from '../types'; - -const MESSAGE_PAGE_SIZE = 200; - -interface UseConversationTimelineArgs { - activeConversation: Conversation | null; - targetMessageId?: number | null; - applyPendingAck: (msg: Message) => Message; - getMessageContentKey: (msg: Message) => string; - seenMessageContentRef: MutableRefObject>; -} - -interface UseConversationTimelineResult { - messages: Message[]; - messagesRef: MutableRefObject; - messagesLoading: boolean; - loadingOlder: boolean; - hasOlderMessages: boolean; - hasNewerMessages: boolean; - loadingNewer: boolean; - hasNewerMessagesRef: MutableRefObject; - setMessages: Dispatch>; - fetchOlderMessages: () => Promise; - fetchNewerMessages: () => Promise; - jumpToBottom: () => void; - triggerReconcile: () => void; -} - -function isMessageConversation(conversation: Conversation | null): conversation is Conversation { - return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type); -} - -export function useConversationTimeline({ - activeConversation, - targetMessageId, - applyPendingAck, - getMessageContentKey, - seenMessageContentRef, -}: UseConversationTimelineArgs): UseConversationTimelineResult { - const [messages, setMessages] = useState([]); - const [messagesLoading, setMessagesLoading] = useState(false); - const [loadingOlder, setLoadingOlder] = useState(false); - const [hasOlderMessages, setHasOlderMessages] = useState(false); - const [hasNewerMessages, setHasNewerMessages] = useState(false); - const [loadingNewer, setLoadingNewer] = useState(false); - - const abortControllerRef = useRef(null); - const fetchingConversationIdRef = useRef(null); - const messagesRef = useRef([]); - const hasOlderMessagesRef = useRef(false); - const hasNewerMessagesRef = useRef(false); - const prevConversationIdRef = useRef(null); - - useEffect(() => { - messagesRef.current = messages; - }, [messages]); - - useEffect(() => { - hasOlderMessagesRef.current = hasOlderMessages; - }, [hasOlderMessages]); - - useEffect(() => { - hasNewerMessagesRef.current = hasNewerMessages; - }, [hasNewerMessages]); - - const syncSeenContent = useCallback( - (nextMessages: Message[]) => { - seenMessageContentRef.current.clear(); - for (const msg of nextMessages) { - seenMessageContentRef.current.add(getMessageContentKey(msg)); - } - }, - [getMessageContentKey, seenMessageContentRef] - ); - - const fetchLatestMessages = useCallback( - async (showLoading = false, signal?: AbortSignal) => { - if (!isMessageConversation(activeConversation)) { - setMessages([]); - setHasOlderMessages(false); - return; - } - - const conversationId = activeConversation.id; - - if (showLoading) { - setMessagesLoading(true); - setMessages([]); - } - - try { - const data = await api.getMessages( - { - type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', - conversation_key: activeConversation.id, - limit: MESSAGE_PAGE_SIZE, - }, - signal - ); - - if (fetchingConversationIdRef.current !== conversationId) { - return; - } - - const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg)); - setMessages(messagesWithPendingAck); - syncSeenContent(messagesWithPendingAck); - setHasOlderMessages(messagesWithPendingAck.length >= MESSAGE_PAGE_SIZE); - } catch (err) { - if (isAbortError(err)) { - return; - } - console.error('Failed to fetch messages:', err); - toast.error('Failed to load messages', { - description: err instanceof Error ? err.message : 'Check your connection', - }); - } finally { - if (showLoading) { - setMessagesLoading(false); - } - } - }, - [activeConversation, applyPendingAck, syncSeenContent] - ); - - const reconcileFromBackend = useCallback( - (conversation: Conversation, signal: AbortSignal) => { - const conversationId = conversation.id; - api - .getMessages( - { - type: conversation.type === 'channel' ? 'CHAN' : 'PRIV', - conversation_key: conversationId, - limit: MESSAGE_PAGE_SIZE, - }, - signal - ) - .then((data) => { - if (fetchingConversationIdRef.current !== conversationId) return; - - const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); - const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck); - if (!merged) return; - - setMessages(merged); - syncSeenContent(merged); - if (dataWithPendingAck.length >= MESSAGE_PAGE_SIZE) { - setHasOlderMessages(true); - } - }) - .catch((err) => { - if (isAbortError(err)) return; - console.debug('Background reconciliation failed:', err); - }); - }, - [applyPendingAck, syncSeenContent] - ); - - const fetchOlderMessages = useCallback(async () => { - if (!isMessageConversation(activeConversation) || loadingOlder || !hasOlderMessages) return; - - const conversationId = activeConversation.id; - const oldestMessage = messages.reduce( - (oldest, msg) => { - if (!oldest) return msg; - if (msg.received_at < oldest.received_at) return msg; - if (msg.received_at === oldest.received_at && msg.id < oldest.id) return msg; - return oldest; - }, - null as Message | null - ); - if (!oldestMessage) return; - - setLoadingOlder(true); - try { - const data = await api.getMessages({ - type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', - conversation_key: conversationId, - limit: MESSAGE_PAGE_SIZE, - before: oldestMessage.received_at, - before_id: oldestMessage.id, - }); - - if (fetchingConversationIdRef.current !== conversationId) return; - - const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); - - if (dataWithPendingAck.length > 0) { - setMessages((prev) => [...prev, ...dataWithPendingAck]); - for (const msg of dataWithPendingAck) { - seenMessageContentRef.current.add(getMessageContentKey(msg)); - } - } - setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); - } catch (err) { - console.error('Failed to fetch older messages:', err); - toast.error('Failed to load older messages', { - description: err instanceof Error ? err.message : 'Check your connection', - }); - } finally { - setLoadingOlder(false); - } - }, [ - activeConversation, - applyPendingAck, - getMessageContentKey, - hasOlderMessages, - loadingOlder, - messages, - seenMessageContentRef, - ]); - - const fetchNewerMessages = useCallback(async () => { - if (!isMessageConversation(activeConversation) || loadingNewer || !hasNewerMessages) return; - - const conversationId = activeConversation.id; - const newestMessage = messages.reduce( - (newest, msg) => { - if (!newest) return msg; - if (msg.received_at > newest.received_at) return msg; - if (msg.received_at === newest.received_at && msg.id > newest.id) return msg; - return newest; - }, - null as Message | null - ); - if (!newestMessage) return; - - setLoadingNewer(true); - try { - const data = await api.getMessages({ - type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', - conversation_key: conversationId, - limit: MESSAGE_PAGE_SIZE, - after: newestMessage.received_at, - after_id: newestMessage.id, - }); - - if (fetchingConversationIdRef.current !== conversationId) return; - - const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); - const newMessages = dataWithPendingAck.filter( - (msg) => !seenMessageContentRef.current.has(getMessageContentKey(msg)) - ); - - if (newMessages.length > 0) { - setMessages((prev) => [...prev, ...newMessages]); - for (const msg of newMessages) { - seenMessageContentRef.current.add(getMessageContentKey(msg)); - } - } - setHasNewerMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); - } catch (err) { - console.error('Failed to fetch newer messages:', err); - toast.error('Failed to load newer messages', { - description: err instanceof Error ? err.message : 'Check your connection', - }); - } finally { - setLoadingNewer(false); - } - }, [ - activeConversation, - applyPendingAck, - getMessageContentKey, - hasNewerMessages, - loadingNewer, - messages, - seenMessageContentRef, - ]); - - const jumpToBottom = useCallback(() => { - if (!activeConversation) return; - setHasNewerMessages(false); - messageCache.remove(activeConversation.id); - fetchLatestMessages(true); - }, [activeConversation, fetchLatestMessages]); - - const triggerReconcile = useCallback(() => { - if (!isMessageConversation(activeConversation)) return; - const controller = new AbortController(); - reconcileFromBackend(activeConversation, controller.signal); - }, [activeConversation, reconcileFromBackend]); - - useEffect(() => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - - const prevId = prevConversationIdRef.current; - const newId = activeConversation?.id ?? null; - const conversationChanged = prevId !== newId; - fetchingConversationIdRef.current = newId; - prevConversationIdRef.current = newId; - - if (!conversationChanged && !targetMessageId) { - return; - } - - setLoadingOlder(false); - setLoadingNewer(false); - if (conversationChanged) { - setHasNewerMessages(false); - } - - if ( - conversationChanged && - prevId && - messagesRef.current.length > 0 && - !hasNewerMessagesRef.current - ) { - messageCache.set(prevId, { - messages: messagesRef.current, - seenContent: new Set(seenMessageContentRef.current), - hasOlderMessages: hasOlderMessagesRef.current, - }); - } - - if (!isMessageConversation(activeConversation)) { - setMessages([]); - setHasOlderMessages(false); - return; - } - - const controller = new AbortController(); - abortControllerRef.current = controller; - - if (targetMessageId) { - setMessagesLoading(true); - setMessages([]); - const msgType = activeConversation.type === 'channel' ? 'CHAN' : 'PRIV'; - api - .getMessagesAround( - targetMessageId, - msgType as 'PRIV' | 'CHAN', - activeConversation.id, - controller.signal - ) - .then((response) => { - if (fetchingConversationIdRef.current !== activeConversation.id) return; - const withAcks = response.messages.map((msg) => applyPendingAck(msg)); - setMessages(withAcks); - syncSeenContent(withAcks); - setHasOlderMessages(response.has_older); - setHasNewerMessages(response.has_newer); - }) - .catch((err) => { - if (isAbortError(err)) return; - console.error('Failed to fetch messages around target:', err); - toast.error('Failed to jump to message'); - }) - .finally(() => { - setMessagesLoading(false); - }); - } else { - const cached = messageCache.get(activeConversation.id); - if (cached) { - setMessages(cached.messages); - seenMessageContentRef.current = new Set(cached.seenContent); - setHasOlderMessages(cached.hasOlderMessages); - setMessagesLoading(false); - reconcileFromBackend(activeConversation, controller.signal); - } else { - fetchLatestMessages(true, controller.signal); - } - } - - return () => { - controller.abort(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeConversation?.id, activeConversation?.type, targetMessageId]); - - return { - messages, - messagesRef, - messagesLoading, - loadingOlder, - hasOlderMessages, - hasNewerMessages, - loadingNewer, - hasNewerMessagesRef, - setMessages, - fetchOlderMessages, - fetchNewerMessages, - jumpToBottom, - triggerReconcile, - }; -} diff --git a/frontend/src/test/useAppShellProps.test.ts b/frontend/src/test/useAppShellProps.test.ts deleted file mode 100644 index c5d057e..0000000 --- a/frontend/src/test/useAppShellProps.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -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); - }); - - it('does not fail cracked channel creation when undecrypted count refresh rejects', async () => { - mocks.api.createChannel.mockResolvedValue({ - key: '22'.repeat(16), - name: 'Found', - is_hashtag: false, - }); - mocks.api.getChannels.mockResolvedValue([ - publicChannel, - { ...publicChannel, key: '22'.repeat(16), name: 'Found' }, - ]); - mocks.api.decryptHistoricalPackets.mockResolvedValue({ decrypted_count: 4 }); - - const args = createArgs({ - fetchUndecryptedCount: vi.fn(async () => { - throw new Error('refresh failed'); - }), - }); - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); - const { result } = renderHook(() => useAppShellProps(args)); - - await act(async () => { - await result.current.crackerProps.onChannelCreate('Found', '22'.repeat(16)); - }); - - expect(mocks.api.decryptHistoricalPackets).toHaveBeenCalledWith({ - key_type: 'channel', - channel_key: '22'.repeat(16), - }); - expect(consoleError).toHaveBeenCalledWith( - 'Failed to refresh undecrypted count after cracked channel create:', - expect.any(Error) - ); - - consoleError.mockRestore(); - }); -});