import { useState, useEffect, useCallback, useMemo, useRef, startTransition, lazy, Suspense, } from 'react'; import { api } from './api'; import { takePrefetchOrFetch } from './prefetch'; import { useWebSocket } from './useWebSocket'; import { useUnreadCounts, useConversationMessages, getMessageContentKey, useRadioControl, useAppSettings, useConversationRouter, useContactsAndChannels, } from './hooks'; import * as messageCache from './messageCache'; import { StatusBar } from './components/StatusBar'; import { Sidebar } from './components/Sidebar'; import { ChatHeader } from './components/ChatHeader'; import { MessageList } from './components/MessageList'; import { MessageInput, type MessageInputHandle } from './components/MessageInput'; import { NewMessageModal } from './components/NewMessageModal'; import { SETTINGS_SECTION_LABELS, SETTINGS_SECTION_ORDER, type SettingsSection, } from './components/settingsConstants'; import { RawPacketList } from './components/RawPacketList'; import { ContactInfoPane } from './components/ContactInfoPane'; import { CONTACT_TYPE_REPEATER } from './types'; // Lazy-load heavy components to reduce initial bundle const RepeaterDashboard = lazy(() => import('./components/RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard })) ); const MapView = lazy(() => import('./components/MapView').then((m) => ({ default: m.MapView }))); const VisualizerView = lazy(() => import('./components/VisualizerView').then((m) => ({ default: m.VisualizerView })) ); const SettingsModal = lazy(() => import('./components/SettingsModal').then((m) => ({ default: m.SettingsModal })) ); const CrackerPanel = lazy(() => import('./components/CrackerPanel').then((m) => ({ default: m.CrackerPanel })) ); import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet'; import { Toaster, toast } from './components/ui/sonner'; import { getStateKey } from './utils/conversationState'; import { appendRawPacketUnique } from './utils/rawPacketIdentity'; import { messageContainsMention } from './utils/messageParser'; import { mergeContactIntoList } from './utils/contactMerge'; import { getLocalLabel, getContrastTextColor } from './utils/localLabel'; import { cn } from '@/lib/utils'; import type { Contact, Conversation, HealthStatus, Message, MessagePath, RawPacket } from './types'; const MAX_RAW_PACKETS = 500; 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 [infoPaneContactKey, setInfoPaneContactKey] = 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; // Shared refs between useConversationRouter and useContactsAndChannels const pendingDeleteFallbackRef = useRef(false); const hasSetDefaultConversation = useRef(false); // Stable ref bridge: useContactsAndChannels needs setActiveConversation from // useConversationRouter, but useConversationRouter needs channels/contacts from // useContactsAndChannels. We break the cycle with a ref-based indirection. const setActiveConversationRef = useRef<(conv: Conversation | null) => void>(() => {}); // --- Extracted hooks --- const { health, setHealth, config, setConfig, prevHealthRef, fetchConfig, handleSaveConfig, handleSetPrivateKey, handleReboot, handleAdvertise, handleHealthRefresh, } = useRadioControl(); const { appSettings, favorites, fetchAppSettings, handleSaveAppSettings, handleSortOrderChange, handleToggleFavorite, } = useAppSettings(); // Keep user's name in ref for mention detection in WebSocket callback const myNameRef = useRef(null); useEffect(() => { myNameRef.current = config?.name ?? null; }, [config?.name]); // Check if a message mentions the user const checkMention = useCallback( (text: string): boolean => messageContainsMention(text, myNameRef.current), [] ); // useContactsAndChannels is called first — it uses the ref bridge for setActiveConversation const { contacts, contactsLoaded, channels, undecryptedCount, setContacts, setContactsLoaded, setChannels, fetchAllContacts, fetchUndecryptedCount, handleCreateContact, handleCreateChannel, handleCreateHashtagChannel, handleDeleteChannel, handleDeleteContact, } = useContactsAndChannels({ setActiveConversation: (conv) => setActiveConversationRef.current(conv), pendingDeleteFallbackRef, hasSetDefaultConversation, }); // useConversationRouter is called second — it receives channels/contacts as inputs const { activeConversation, setActiveConversation, activeConversationRef, handleSelectConversation, } = useConversationRouter({ channels, contacts, contactsLoaded, setSidebarOpen, pendingDeleteFallbackRef, hasSetDefaultConversation, }); // Wire up the ref bridge so useContactsAndChannels handlers reach the real setter setActiveConversationRef.current = setActiveConversation; // Custom hooks for conversation-specific functionality const { messages, messagesLoading, loadingOlder, hasOlderMessages, fetchOlderMessages, addMessageIfNew, updateMessageAck, triggerReconcile, } = useConversationMessages(activeConversation); const { unreadCounts, mentions, lastMessageTimes, incrementUnread, markAllRead, trackNewMessage, refreshUnreads, } = useUnreadCounts(channels, contacts, activeConversation); // Determine if active contact is a repeater (used for routing to dashboard) const activeContactIsRepeater = useMemo(() => { if (!activeConversation || activeConversation.type !== 'contact') return false; const contact = contacts.find((c) => c.public_key === activeConversation.id); return contact?.type === CONTACT_TYPE_REPEATER; }, [activeConversation, contacts]); // WebSocket handlers - memoized to prevent reconnection loops const wsHandlers = useMemo( () => ({ onHealth: (data: HealthStatus) => { const prev = prevHealthRef.current; prevHealthRef.current = data; setHealth(data); // Show toast on connection status change if (prev !== null && prev.radio_connected !== data.radio_connected) { if (data.radio_connected) { toast.success('Radio connected', { description: data.connection_info ? `Connected via ${data.connection_info}` : undefined, }); // Refresh config after reconnection (may have changed after reboot) api.getRadioConfig().then(setConfig).catch(console.error); } else { toast.error('Radio disconnected', { description: 'Check radio connection and power', }); } } }, onError: (error: { message: string; details?: string }) => { toast.error(error.message, { description: error.details, }); }, onSuccess: (success: { message: string; details?: string }) => { toast.success(success.message, { description: success.details, }); }, onReconnect: () => { // Clear raw packets: observation_id is a process-local counter that resets // on backend restart, so stale packets would cause new ones to be deduped away. setRawPackets([]); // Silently recover any data missed during the disconnect window triggerReconcile(); refreshUnreads(); fetchAllContacts() .then((data) => setContacts(data)) .catch(console.error); }, onMessage: (msg: Message) => { const activeConv = activeConversationRef.current; // Check if message belongs to the active conversation const isForActiveConversation = (() => { if (!activeConv) return false; if (msg.type === 'CHAN' && activeConv.type === 'channel') { return msg.conversation_key === activeConv.id; } if (msg.type === 'PRIV' && activeConv.type === 'contact') { return msg.conversation_key === activeConv.id; } return false; })(); // Only add to message list if it's for the active conversation if (isForActiveConversation) { addMessageIfNew(msg); } // Track for unread counts and sorting trackNewMessage(msg); const contentKey = getMessageContentKey(msg); // For non-active conversations: update cache and count unreads if (!isForActiveConversation) { // Update message cache (instant restore on switch) — returns true if new const isNew = messageCache.addMessage(msg.conversation_key, msg, contentKey); // Count unread for incoming messages (skip duplicates from multiple mesh paths) if (!msg.outgoing && isNew) { let stateKey: string | null = null; if (msg.type === 'CHAN' && msg.conversation_key) { stateKey = getStateKey('channel', msg.conversation_key); } else if (msg.type === 'PRIV' && msg.conversation_key) { stateKey = getStateKey('contact', msg.conversation_key); } if (stateKey) { const hasMention = checkMention(msg.text); incrementUnread(stateKey, hasMention); } } } }, onContact: (contact: Contact) => { setContacts((prev) => mergeContactIntoList(prev, contact)); }, onRawPacket: (packet: RawPacket) => { setRawPackets((prev) => appendRawPacketUnique(prev, packet, MAX_RAW_PACKETS)); }, onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => { updateMessageAck(messageId, ackCount, paths); messageCache.updateAck(messageId, ackCount, paths); }, }), [ addMessageIfNew, trackNewMessage, incrementUnread, updateMessageAck, checkMention, prevHealthRef, setHealth, setConfig, activeConversationRef, setContacts, triggerReconcile, refreshUnreads, fetchAllContacts, ] ); // Connect to WebSocket useWebSocket(wsHandlers); // Initial fetch for config, settings, and data useEffect(() => { fetchConfig(); fetchAppSettings(); fetchUndecryptedCount(); // Fetch contacts and channels via REST (parallel, faster than WS serial push) takePrefetchOrFetch('channels', api.getChannels).then(setChannels).catch(console.error); fetchAllContacts() .then((data) => { setContacts(data); setContactsLoaded(true); }) .catch((err) => { console.error(err); setContactsLoaded(true); }); }, [ fetchConfig, fetchAppSettings, fetchUndecryptedCount, fetchAllContacts, setChannels, setContacts, setContactsLoaded, ]); // Send message handler const handleSendMessage = useCallback( async (text: string) => { if (!activeConversation) return; const conversationId = activeConversation.id; let sent: Message; if (activeConversation.type === 'channel') { sent = await api.sendChannelMessage(activeConversation.id, text); } else { sent = await api.sendDirectMessage(activeConversation.id, text); } if (activeConversationRef.current?.id === conversationId) { addMessageIfNew(sent); } }, [activeConversation, addMessageIfNew, activeConversationRef] ); // Handle resend channel message const handleResendChannelMessage = useCallback( async (messageId: number, newTimestamp?: boolean) => { try { // New-timestamp resend creates a new message; the backend broadcast_event // will add it to the conversation via WebSocket. await api.resendChannelMessage(messageId, newTimestamp); toast.success(newTimestamp ? 'Message resent with new timestamp' : 'Message resent'); } catch (err) { toast.error('Failed to resend', { description: err instanceof Error ? err.message : 'Unknown error', }); } }, [] ); // Handle sender click to add mention const handleSenderClick = useCallback((sender: string) => { messageInputRef.current?.appendText(`@[${sender}] `); }, []); // Handle direct trace request const handleTrace = useCallback(async () => { if (!activeConversation || activeConversation.type !== 'contact') return; toast('Trace started...'); try { const result = await api.requestTrace(activeConversation.id); const parts: string[] = []; if (result.remote_snr !== null) parts.push(`Remote SNR: ${result.remote_snr.toFixed(1)} dB`); if (result.local_snr !== null) parts.push(`Local SNR: ${result.local_snr.toFixed(1)} dB`); const detail = parts.join(', '); toast.success(detail ? `Trace complete! ${detail}` : 'Trace complete!'); } catch (err) { toast.error('Trace failed', { description: err instanceof Error ? err.message : 'Unknown error', }); } }, [activeConversation]); 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); }, []); const handleOpenContactInfo = useCallback((publicKey: string) => { setInfoPaneContactKey(publicKey); }, []); const handleCloseContactInfo = useCallback(() => { setInfoPaneContactKey(null); }, []); const handleNavigateToChannel = useCallback( (channelKey: string) => { const channel = channels.find((c) => c.key === channelKey); if (channel) { handleSelectConversation({ type: 'channel', id: channel.key, name: channel.name }); setInfoPaneContactKey(null); } }, [channels, handleSelectConversation] ); // Sidebar content (shared between desktop and mobile) const sidebarContent = ( ); const settingsSidebarContent = (

Settings

{SETTINGS_SECTION_ORDER.map((section) => ( ))}
); const activeSidebarContent = showSettings ? settingsSidebarContent : sidebarContent; return (
{localLabel.text && (
{localLabel.text}
)} setSidebarOpen(true)} />
{/* Desktop sidebar - hidden on mobile */}
{activeSidebarContent}
{/* Mobile sidebar - Sheet that slides in */} Navigation
{activeSidebarContent}
{activeConversation ? ( activeConversation.type === 'map' ? ( <>
Node Map
Loading map...
} >
) : activeConversation.type === 'visualizer' ? ( Loading visualizer...
} > ) : activeConversation.type === 'raw' ? ( <>
Raw Packet Feed
) : activeContactIsRepeater ? ( Loading dashboard...
} > ) : ( <> ) ) : (
Select a conversation or start a new one
)} {showSettings && (
Radio & Settings {SETTINGS_SECTION_LABELS[settingsSection]}
Loading settings...
} >
)} {/* Global Cracker Panel - deferred until first opened, then kept mounted for state */}
{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) => { setActiveConversation(conv); setShowNewMessage(false); }} onCreateContact={handleCreateContact} onCreateChannel={handleCreateChannel} onCreateHashtagChannel={handleCreateHashtagChannel} /> ); }