diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png index 42bc267..f7e475f 100644 Binary files a/frontend/public/apple-touch-icon.png and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon-96x96.png b/frontend/public/favicon-96x96.png index 8bae0b2..4ebc89c 100644 Binary files a/frontend/public/favicon-96x96.png and b/frontend/public/favicon-96x96.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 36e4185..5e38c16 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 31392e6..746ec3e 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1,3 +1,17 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/public/glyph.png b/frontend/public/glyph.png index 7468ee6..5504ce6 100644 Binary files a/frontend/public/glyph.png and b/frontend/public/glyph.png differ diff --git a/frontend/public/glyph.svg b/frontend/public/glyph.svg index 430323c..e66f46e 100644 --- a/frontend/public/glyph.svg +++ b/frontend/public/glyph.svg @@ -1,8 +1,6 @@ - - - - - - + + + + diff --git a/frontend/public/glyph.xcf b/frontend/public/glyph.xcf deleted file mode 100644 index a4109d5..0000000 Binary files a/frontend/public/glyph.xcf and /dev/null differ diff --git a/frontend/public/web-app-manifest-192x192.png b/frontend/public/web-app-manifest-192x192.png index c2b1e36..66da6da 100644 Binary files a/frontend/public/web-app-manifest-192x192.png and b/frontend/public/web-app-manifest-192x192.png differ diff --git a/frontend/public/web-app-manifest-512x512.png b/frontend/public/web-app-manifest-512x512.png index cd7b9c0..a1dc2cb 100644 Binary files a/frontend/public/web-app-manifest-512x512.png and b/frontend/public/web-app-manifest-512x512.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6487d2f..5cb9811 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,10 +16,15 @@ 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'; @@ -43,68 +48,28 @@ const CrackerPanel = lazy(() => ); import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet'; import { Toaster, toast } from './components/ui/sonner'; -import { - getStateKey, - initLastMessageTimes, - loadLocalStorageLastMessageTimes, - loadLocalStorageSortOrder, - clearLocalStorageConversationState, -} from './utils/conversationState'; -import { formatTime } from './utils/messageParser'; -import { getContactDisplayName } from './utils/pubkey'; -import { - parseHashConversation, - updateUrlHash, - getMapFocusHash, - resolveChannelFromHashToken, - resolveContactFromHashToken, -} from './utils/urlHash'; -import { isValidLocation, calculateDistance, formatDistance } from './utils/pathUtils'; -import { - isFavorite, - loadLocalStorageFavorites, - clearLocalStorageFavorites, -} from './utils/favorites'; +import { getStateKey } from './utils/conversationState'; import { cn } from '@/lib/utils'; import type { - AppSettings, - AppSettingsUpdate, Contact, - Channel, Conversation, - Favorite, HealthStatus, Message, MessagePath, RawPacket, - RadioConfig, - RadioConfigUpdate, } from './types'; const MAX_RAW_PACKETS = 500; -const PUBLIC_CHANNEL_KEY = '8B3387E9C5CDEA6AC9E5EDBAA115CD72'; export function App() { const messageInputRef = useRef(null); - const activeConversationRef = useRef(null); - const rebootPollTokenRef = useRef(0); - const pendingDeleteFallbackRef = useRef(false); // Track seen message content to prevent duplicate unread increments - // Uses content-based key (type-conversation_key-text-sender_timestamp) for deduplication const seenMessageContentRef = useRef>(new Set()); - const [health, setHealth] = useState(null); - const [config, setConfig] = useState(null); - const [appSettings, setAppSettings] = useState(null); - const [contacts, setContacts] = useState([]); - const [contactsLoaded, setContactsLoaded] = useState(false); - const [channels, setChannels] = useState([]); const [rawPackets, setRawPackets] = useState([]); - const [activeConversation, setActiveConversation] = useState(null); const [showNewMessage, setShowNewMessage] = useState(false); const [showSettings, setShowSettings] = useState(false); const [settingsSection, setSettingsSection] = useState('radio'); const [sidebarOpen, setSidebarOpen] = useState(false); - const [undecryptedCount, setUndecryptedCount] = useState(0); const [showCracker, setShowCracker] = useState(false); const [crackerRunning, setCrackerRunning] = useState(false); @@ -112,18 +77,39 @@ export function App() { const crackerMounted = useRef(false); if (showCracker) crackerMounted.current = true; - // Favorites are now stored server-side in appSettings. - // Stable empty array prevents a new reference every render when there are none. - const emptyFavorites = useRef([]).current; - const favorites: Favorite[] = appSettings?.favorites ?? emptyFavorites; + // Shared refs between useConversationRouter and useContactsAndChannels + const pendingDeleteFallbackRef = useRef(false); + const hasSetDefaultConversation = useRef(false); - // Track previous health status to detect changes - const prevHealthRef = useRef(null); - useEffect(() => { - return () => { - rebootPollTokenRef.current += 1; - }; - }, []); + // 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); @@ -140,7 +126,47 @@ export function App() { return mentionPattern.test(text); }, []); - // Custom hooks for extracted functionality + // 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, @@ -229,10 +255,6 @@ export function App() { const contentKey = getMessageContentKey(msg); // Dedup: check if we've already seen this content BEFORE marking it seen. - // This must happen before the active/non-active branch so that messages - // first seen in the active conversation are tracked — otherwise a mesh - // duplicate arriving after the user switches away would create a phantom - // unread badge. const alreadySeen = !msg.outgoing && seenMessageContentRef.current.has(contentKey); if (!msg.outgoing) { seenMessageContentRef.current.add(contentKey); @@ -269,8 +291,6 @@ export function App() { const idx = prev.findIndex((c) => c.public_key === contact.public_key); if (idx >= 0) { const existing = prev[idx]; - // Skip update if all incoming fields are identical — avoids a new - // array reference (and Sidebar re-render) on every advertisement. const merged = { ...existing, ...contact }; const unchanged = (Object.keys(merged) as (keyof Contact)[]).every( (k) => existing[k] === merged[k] @@ -300,60 +320,12 @@ export function App() { messageCache.updateAck(messageId, ackCount, paths); }, }), - [addMessageIfNew, trackNewMessage, incrementUnread, updateMessageAck, checkMention] + [addMessageIfNew, trackNewMessage, incrementUnread, updateMessageAck, checkMention, prevHealthRef, setHealth, setConfig, activeConversationRef, setContacts] ); // Connect to WebSocket useWebSocket(wsHandlers); - // Fetch radio config (not sent via WebSocket) - const fetchConfig = useCallback(async () => { - try { - const data = await (takePrefetch('config') ?? api.getRadioConfig()); - setConfig(data); - } catch (err) { - console.error('Failed to fetch config:', err); - } - }, []); - - // Fetch app settings - const fetchAppSettings = useCallback(async () => { - try { - const data = await (takePrefetch('settings') ?? api.getSettings()); - setAppSettings(data); - // Initialize in-memory cache with server data - initLastMessageTimes(data.last_message_times ?? {}); - } catch (err) { - console.error('Failed to fetch app settings:', err); - } - }, []); - - // Fetch undecrypted packet count - const fetchUndecryptedCount = useCallback(async () => { - try { - const data = await (takePrefetch('undecryptedCount') ?? api.getUndecryptedPacketCount()); - setUndecryptedCount(data.count); - } catch (err) { - console.error('Failed to fetch undecrypted count:', err); - } - }, []); - - // Fetch all contacts, paginating if >1000 - const fetchAllContacts = useCallback(async (): Promise => { - const pageSize = 1000; - const first = await (takePrefetch('contacts') ?? api.getContacts(pageSize, 0)); - if (first.length < pageSize) return first; - let all = [...first]; - let offset = pageSize; - while (true) { - const page = await api.getContacts(pageSize, offset); - all = all.concat(page); - if (page.length < pageSize) break; - offset += pageSize; - } - return all; - }, []); - // Initial fetch for config, settings, and data useEffect(() => { fetchConfig(); @@ -371,204 +343,13 @@ export function App() { console.error(err); setContactsLoaded(true); }); - }, [fetchConfig, fetchAppSettings, fetchUndecryptedCount, fetchAllContacts]); - - // One-time migration of localStorage preferences to server - const hasMigratedRef = useRef(false); - useEffect(() => { - // Only run once we have appSettings loaded - if (!appSettings || hasMigratedRef.current) return; - - // Skip if already migrated on server - if (appSettings.preferences_migrated) { - // Just clear any leftover localStorage - clearLocalStorageFavorites(); - clearLocalStorageConversationState(); - hasMigratedRef.current = true; - return; - } - - // Check if we have any localStorage data to migrate - const localFavorites = loadLocalStorageFavorites(); - const localSortOrder = loadLocalStorageSortOrder(); - const localLastMessageTimes = loadLocalStorageLastMessageTimes(); - - const hasLocalData = - localFavorites.length > 0 || - localSortOrder !== 'recent' || - Object.keys(localLastMessageTimes).length > 0; - - if (!hasLocalData) { - // No local data to migrate, just mark as done - hasMigratedRef.current = true; - return; - } - - // Mark as migrating immediately to prevent duplicate calls - hasMigratedRef.current = true; - - // Migrate localStorage to server - const migratePreferences = async () => { - try { - const result = await api.migratePreferences({ - favorites: localFavorites, - sort_order: localSortOrder, - last_message_times: localLastMessageTimes, - }); - - if (result.migrated) { - toast.success('Preferences migrated', { - description: `Migrated ${localFavorites.length} favorites to server`, - }); - } - - // Update local state with migrated settings - setAppSettings(result.settings); - // Reinitialize cache with migrated data - initLastMessageTimes(result.settings.last_message_times ?? {}); - - // Clear localStorage after successful migration - clearLocalStorageFavorites(); - clearLocalStorageConversationState(); - } catch (err) { - console.error('Failed to migrate preferences:', err); - // Don't block the app on migration failure - } - }; - - migratePreferences(); - }, [appSettings]); - - // Phase 1: Set initial conversation from URL hash or default to Public channel - // Only needs channels (fast path) - doesn't wait for contacts - const hasSetDefaultConversation = useRef(false); - useEffect(() => { - if (hasSetDefaultConversation.current || activeConversation) return; - if (channels.length === 0) return; - - const hashConv = parseHashConversation(); - - // Handle non-data views immediately - if (hashConv?.type === 'raw') { - setActiveConversation({ type: 'raw', id: 'raw', name: 'Raw Packet Feed' }); - hasSetDefaultConversation.current = true; - return; - } - if (hashConv?.type === 'map') { - setActiveConversation({ - type: 'map', - id: 'map', - name: 'Node Map', - mapFocusKey: hashConv.mapFocusKey, - }); - hasSetDefaultConversation.current = true; - return; - } - if (hashConv?.type === 'visualizer') { - setActiveConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' }); - hasSetDefaultConversation.current = true; - return; - } - - // Handle channel hash (ID-first with legacy-name fallback) - if (hashConv?.type === 'channel') { - const channel = resolveChannelFromHashToken(hashConv.name, channels); - if (channel) { - setActiveConversation({ type: 'channel', id: channel.key, name: channel.name }); - hasSetDefaultConversation.current = true; - return; - } - } - - // Contact hash — wait for phase 2 - if (hashConv?.type === 'contact') return; - - // No hash or unresolvable — default to Public - const publicChannel = channels.find((c) => c.name === 'Public'); - if (publicChannel) { - setActiveConversation({ - type: 'channel', - id: publicChannel.key, - name: publicChannel.name, - }); - hasSetDefaultConversation.current = true; - } - }, [channels, activeConversation]); - - // Phase 2: Resolve contact hash (only if phase 1 didn't set a conversation) - useEffect(() => { - if (hasSetDefaultConversation.current || activeConversation) return; - - const hashConv = parseHashConversation(); - if (hashConv?.type === 'contact') { - // Wait until the initial contacts load finishes so we don't fall back early. - if (!contactsLoaded) return; - - const contact = resolveContactFromHashToken(hashConv.name, contacts); - if (contact) { - setActiveConversation({ - type: 'contact', - id: contact.public_key, - name: getContactDisplayName(contact.name, contact.public_key), - }); - hasSetDefaultConversation.current = true; - return; - } - - // Contact hash didn't match — fall back to Public if channels loaded. - if (channels.length > 0) { - const publicChannel = channels.find((c) => c.name === 'Public'); - if (publicChannel) { - setActiveConversation({ - type: 'channel', - id: publicChannel.key, - name: publicChannel.name, - }); - hasSetDefaultConversation.current = true; - } - } - } - }, [contacts, channels, activeConversation, contactsLoaded]); - - // Keep ref in sync and update URL hash - useEffect(() => { - activeConversationRef.current = activeConversation; - if (activeConversation) { - updateUrlHash(activeConversation); - } - }, [activeConversation]); - - // If a delete action left us without an active conversation, recover to Public - // once channels are available. This is scoped to delete flows only so it doesn't - // interfere with hash-based startup resolution. - useEffect(() => { - if (!pendingDeleteFallbackRef.current) return; - if (activeConversation) { - pendingDeleteFallbackRef.current = false; - return; - } - - const publicChannel = - channels.find((c) => c.key === PUBLIC_CHANNEL_KEY) || - channels.find((c) => c.name === 'Public'); - if (!publicChannel) return; - - hasSetDefaultConversation.current = true; - pendingDeleteFallbackRef.current = false; - setActiveConversation({ - type: 'channel', - id: publicChannel.key, - name: publicChannel.name, - }); - }, [activeConversation, channels]); + }, [fetchConfig, fetchAppSettings, fetchUndecryptedCount, fetchAllContacts, setChannels, setContacts, setContactsLoaded]); // Send message handler const handleSendMessage = useCallback( async (text: string) => { if (!activeConversation) return; - // Capture conversation identity before the await — the user could switch - // conversations while the send is in flight. const conversationId = activeConversation.id; let sent: Message; @@ -578,94 +359,13 @@ export function App() { sent = await api.sendDirectMessage(activeConversation.id, text); } - // Add the returned message directly instead of re-fetching all messages. - // A full fetchMessages() replaces the array, which resets messagesAdded to 0 - // and skips the scroll-to-bottom — especially when racing with the initial - // conversation load or a WebSocket delivery that already incremented the count. - // The send endpoints do NOT broadcast via WebSocket, so for DMs this is the - // only path that shows the sent message. For channels, the radio echo may - // eventually arrive via WS, but addMessageIfNew deduplicates by content key. if (activeConversationRef.current?.id === conversationId) { addMessageIfNew(sent); } }, - [activeConversation, addMessageIfNew] + [activeConversation, addMessageIfNew, activeConversationRef] ); - // Config save handler - const handleSaveConfig = useCallback( - async (update: RadioConfigUpdate) => { - await api.updateRadioConfig(update); - await fetchConfig(); - }, - [fetchConfig] - ); - - // App settings save handler - const handleSaveAppSettings = useCallback( - async (update: AppSettingsUpdate) => { - await api.updateSettings(update); - await fetchAppSettings(); - }, - [fetchAppSettings] - ); - - // Set private key handler - const handleSetPrivateKey = useCallback( - async (key: string) => { - await api.setPrivateKey(key); - await fetchConfig(); - }, - [fetchConfig] - ); - - // Reboot radio handler - const handleReboot = useCallback(async () => { - await api.rebootRadio(); - setHealth((prev) => (prev ? { ...prev, radio_connected: false } : prev)); - const pollToken = ++rebootPollTokenRef.current; - const pollUntilReconnected = async () => { - for (let i = 0; i < 30; i++) { - await new Promise((r) => setTimeout(r, 1000)); - if (rebootPollTokenRef.current !== pollToken) return; - try { - const data = await api.getHealth(); - if (rebootPollTokenRef.current !== pollToken) return; - setHealth(data); - if (data.radio_connected) { - fetchConfig(); - return; - } - } catch { - // Keep polling - } - } - }; - pollUntilReconnected(); - }, [fetchConfig]); - - // Send flood advertisement handler - const handleAdvertise = useCallback(async () => { - try { - await api.sendAdvertisement(); - toast.success('Advertisement sent'); - } catch (err) { - console.error('Failed to send advertisement:', err); - toast.error('Failed to send advertisement', { - description: err instanceof Error ? err.message : 'Check radio connection', - }); - } - }, []); - - const handleHealthRefresh = useCallback(async () => { - try { - const data = await api.getHealth(); - setHealth(data); - } catch (err) { - console.error('Failed to refresh health:', err); - } - }, []); - // Handle resend channel message const handleResendChannelMessage = useCallback(async (messageId: number) => { try { @@ -683,164 +383,6 @@ export function App() { messageInputRef.current?.appendText(`@[${sender}] `); }, []); - // Handle conversation selection (closes sidebar on mobile) - const handleSelectConversation = useCallback((conv: Conversation) => { - setActiveConversation(conv); - setSidebarOpen(false); - }, []); - - // Toggle favorite status for a conversation (via API) with optimistic update - const handleToggleFavorite = useCallback(async (type: 'channel' | 'contact', id: string) => { - // Read current favorites inside the callback to avoid a dependency on the - // derived `favorites` array (which creates a new reference every render). - setAppSettings((prev) => { - if (!prev) return prev; - const currentFavorites = prev.favorites ?? []; - const wasFavorited = isFavorite(currentFavorites, type, id); - const optimisticFavorites = wasFavorited - ? currentFavorites.filter((f) => !(f.type === type && f.id === id)) - : [...currentFavorites, { type, id }]; - return { ...prev, favorites: optimisticFavorites }; - }); - - try { - const updatedSettings = await api.toggleFavorite(type, id); - setAppSettings(updatedSettings); - } catch (err) { - console.error('Failed to toggle favorite:', err); - // Revert: re-fetch would be safest, but restoring from server state on next sync - // is acceptable. For now, just refetch settings. - try { - const settings = await api.getSettings(); - setAppSettings(settings); - } catch { - // If refetch also fails, leave optimistic state - } - toast.error('Failed to update favorite'); - } - }, []); - - // Delete channel handler - const handleDeleteChannel = useCallback(async (key: string) => { - if (!confirm('Delete this channel? Message history will be preserved.')) return; - try { - pendingDeleteFallbackRef.current = true; - await api.deleteChannel(key); - messageCache.remove(key); - const refreshedChannels = await api.getChannels(); - setChannels(refreshedChannels); - const publicChannel = - refreshedChannels.find((c) => c.key === PUBLIC_CHANNEL_KEY) || - refreshedChannels.find((c) => c.name === 'Public'); - hasSetDefaultConversation.current = true; - setActiveConversation({ - type: 'channel', - id: publicChannel?.key || PUBLIC_CHANNEL_KEY, - name: publicChannel?.name || 'Public', - }); - toast.success('Channel deleted'); - } catch (err) { - console.error('Failed to delete channel:', err); - toast.error('Failed to delete channel', { - description: err instanceof Error ? err.message : undefined, - }); - } - }, []); - - // Delete contact handler - const handleDeleteContact = useCallback(async (publicKey: string) => { - if (!confirm('Delete this contact? Message history will be preserved.')) return; - try { - pendingDeleteFallbackRef.current = true; - await api.deleteContact(publicKey); - messageCache.remove(publicKey); - setContacts((prev) => prev.filter((c) => c.public_key !== publicKey)); - const refreshedChannels = await api.getChannels(); - setChannels(refreshedChannels); - const publicChannel = - refreshedChannels.find((c) => c.key === PUBLIC_CHANNEL_KEY) || - refreshedChannels.find((c) => c.name === 'Public'); - hasSetDefaultConversation.current = true; - setActiveConversation({ - type: 'channel', - id: publicChannel?.key || PUBLIC_CHANNEL_KEY, - name: publicChannel?.name || 'Public', - }); - toast.success('Contact deleted'); - } catch (err) { - console.error('Failed to delete contact:', err); - toast.error('Failed to delete contact', { - description: err instanceof Error ? err.message : undefined, - }); - } - }, []); - - // Create contact handler - const handleCreateContact = useCallback( - async (name: string, publicKey: string, tryHistorical: boolean) => { - const created = await api.createContact(publicKey, name || undefined, tryHistorical); - const data = await fetchAllContacts(); - setContacts(data); - - setActiveConversation({ - type: 'contact', - id: created.public_key, - name: getContactDisplayName(created.name, created.public_key), - }); - }, - [fetchAllContacts] - ); - - // Create channel handler - const handleCreateChannel = useCallback( - async (name: string, key: string, tryHistorical: boolean) => { - const created = await api.createChannel(name, key); - const data = await api.getChannels(); - setChannels(data); - - setActiveConversation({ - type: 'channel', - id: created.key, - name, - }); - - if (tryHistorical) { - await api.decryptHistoricalPackets({ - key_type: 'channel', - channel_key: created.key, - }); - fetchUndecryptedCount(); - } - }, - [fetchUndecryptedCount] - ); - - // Create hashtag channel handler - const handleCreateHashtagChannel = useCallback( - async (name: string, tryHistorical: boolean) => { - const channelName = name.startsWith('#') ? name : `#${name}`; - - const created = await api.createChannel(channelName); - const data = await api.getChannels(); - setChannels(data); - - setActiveConversation({ - type: 'channel', - id: created.key, - name: channelName, - }); - - if (tryHistorical) { - await api.decryptHistoricalPackets({ - key_type: 'channel', - channel_name: channelName, - }); - fetchUndecryptedCount(); - } - }, - [fetchUndecryptedCount] - ); - // Handle direct trace request const handleTrace = useCallback(async () => { if (!activeConversation || activeConversation.type !== 'contact') return; @@ -859,28 +401,6 @@ export function App() { } }, [activeConversation]); - // Handle sort order change via API with optimistic update - const handleSortOrderChange = useCallback( - async (order: 'recent' | 'alpha') => { - // Capture previous value for rollback on error - const previousOrder = appSettings?.sidebar_sort_order ?? 'recent'; - - // Optimistic update for responsive UI - setAppSettings((prev) => (prev ? { ...prev, sidebar_sort_order: order } : prev)); - - try { - const updatedSettings = await api.updateSettings({ sidebar_sort_order: order }); - setAppSettings(updatedSettings); - } catch (err) { - console.error('Failed to update sort order:', err); - // Revert to previous value on error (not inverting the new value) - setAppSettings((prev) => (prev ? { ...prev, sidebar_sort_order: previousOrder } : prev)); - toast.error('Failed to save sort preference'); - } - }, - [appSettings?.sidebar_sort_order] - ); - const handleCloseSettingsView = useCallback(() => { startTransition(() => setShowSettings(false)); setSidebarOpen(false); @@ -936,7 +456,7 @@ export function App() { title="Back to conversations" aria-label="Back to conversations" > - ← + ←
@@ -1029,162 +549,16 @@ export function App() { ) : ( <> -
- - - {activeConversation.type === 'channel' && - !activeConversation.name.startsWith('#') && - activeConversation.name !== 'Public' - ? '#' - : ''} - {activeConversation.name} - - { - e.stopPropagation(); - navigator.clipboard.writeText(activeConversation.id); - toast.success( - activeConversation.type === 'channel' - ? 'Room key copied!' - : 'Contact key copied!' - ); - }} - title="Click to copy" - > - {activeConversation.type === 'channel' - ? activeConversation.id.toLowerCase() - : activeConversation.id} - - {activeConversation.type === 'contact' && - (() => { - const contact = contacts.find( - (c) => c.public_key === activeConversation.id - ); - if (!contact) return null; - const parts: React.ReactNode[] = []; - if (contact.last_seen) { - parts.push(`Last heard: ${formatTime(contact.last_seen)}`); - } - if (contact.last_path_len === -1) { - parts.push('flood'); - } else if (contact.last_path_len === 0) { - parts.push('direct'); - } else if (contact.last_path_len > 0) { - parts.push( - `${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}` - ); - } - // Add coordinate link if contact has valid location - if (isValidLocation(contact.lat, contact.lon)) { - // Calculate distance from us if we have valid location - const distFromUs = - config && isValidLocation(config.lat, config.lon) - ? calculateDistance( - config.lat, - config.lon, - contact.lat, - contact.lon - ) - : null; - parts.push( - - { - e.stopPropagation(); - const url = - window.location.origin + - window.location.pathname + - getMapFocusHash(contact.public_key); - window.open(url, '_blank'); - }} - title="View on map" - > - {contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)} - - {distFromUs !== null && ` (${formatDistance(distFromUs)})`} - - ); - } - return parts.length > 0 ? ( - - ( - {parts.map((part, i) => ( - - {i > 0 && ', '} - {part} - - ))} - ) - - ) : null; - })()} - -
- {/* Direct trace button (contacts only) */} - {activeConversation.type === 'contact' && ( - - )} - {/* Favorite button */} - {(activeConversation.type === 'channel' || - activeConversation.type === 'contact') && ( - - )} - {/* Delete button */} - {!( - activeConversation.type === 'channel' && - activeConversation.name === 'Public' - ) && ( - - )} -
-
+ void; + onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; + onDeleteChannel: (key: string) => void; + onDeleteContact: (publicKey: string) => void; +} + +export function ChatHeader({ + conversation, + contacts, + config, + favorites, + onTrace, + onToggleFavorite, + onDeleteChannel, + onDeleteContact, +}: ChatHeaderProps) { + return ( +
+ + + {conversation.type === 'channel' && + !conversation.name.startsWith('#') && + conversation.name !== 'Public' + ? '#' + : ''} + {conversation.name} + + { + e.stopPropagation(); + navigator.clipboard.writeText(conversation.id); + toast.success( + conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!' + ); + }} + title="Click to copy" + > + {conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id} + + {conversation.type === 'contact' && + (() => { + const contact = contacts.find((c) => c.public_key === conversation.id); + if (!contact) return null; + const parts: React.ReactNode[] = []; + if (contact.last_seen) { + parts.push(`Last heard: ${formatTime(contact.last_seen)}`); + } + if (contact.last_path_len === -1) { + parts.push('flood'); + } else if (contact.last_path_len === 0) { + parts.push('direct'); + } else if (contact.last_path_len > 0) { + parts.push(`${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`); + } + if (isValidLocation(contact.lat, contact.lon)) { + const distFromUs = + config && isValidLocation(config.lat, config.lon) + ? calculateDistance(config.lat, config.lon, contact.lat, contact.lon) + : null; + parts.push( + + { + e.stopPropagation(); + const url = + window.location.origin + + window.location.pathname + + getMapFocusHash(contact.public_key); + window.open(url, '_blank'); + }} + title="View on map" + > + {contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)} + + {distFromUs !== null && ` (${formatDistance(distFromUs)})`} + + ); + } + return parts.length > 0 ? ( + + ( + {parts.map((part, i) => ( + + {i > 0 && ', '} + {part} + + ))} + ) + + ) : null; + })()} + +
+ {/* Direct trace button (contacts only) */} + {conversation.type === 'contact' && ( + + )} + {/* Favorite button */} + {(conversation.type === 'channel' || conversation.type === 'contact') && ( + + )} + {/* Delete button */} + {!(conversation.type === 'channel' && conversation.name === 'Public') && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx index 20805a8..29e01d2 100644 --- a/frontend/src/components/StatusBar.tsx +++ b/frontend/src/components/StatusBar.tsx @@ -52,18 +52,16 @@ export function StatusBar({ )} -

+

RemoteTerm

diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 9904fef..d34f9af 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1,3 +1,7 @@ export { useRepeaterMode } from './useRepeaterMode'; export { useUnreadCounts } from './useUnreadCounts'; export { useConversationMessages, getMessageContentKey } from './useConversationMessages'; +export { useRadioControl } from './useRadioControl'; +export { useAppSettings } from './useAppSettings'; +export { useConversationRouter } from './useConversationRouter'; +export { useContactsAndChannels } from './useContactsAndChannels'; diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts new file mode 100644 index 0000000..e6496e9 --- /dev/null +++ b/frontend/src/hooks/useAppSettings.ts @@ -0,0 +1,154 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { api } from '../api'; +import { takePrefetch } from '../prefetch'; +import { toast } from '../components/ui/sonner'; +import { + initLastMessageTimes, + loadLocalStorageLastMessageTimes, + loadLocalStorageSortOrder, + clearLocalStorageConversationState, +} from '../utils/conversationState'; +import { + isFavorite, + loadLocalStorageFavorites, + clearLocalStorageFavorites, +} from '../utils/favorites'; +import type { AppSettings, AppSettingsUpdate, Favorite } from '../types'; + +export function useAppSettings() { + const [appSettings, setAppSettings] = useState(null); + + // Stable empty array prevents a new reference every render when there are none. + const emptyFavorites = useRef([]).current; + const favorites: Favorite[] = appSettings?.favorites ?? emptyFavorites; + + // One-time migration guard + const hasMigratedRef = useRef(false); + + const fetchAppSettings = useCallback(async () => { + try { + const data = await (takePrefetch('settings') ?? api.getSettings()); + setAppSettings(data); + initLastMessageTimes(data.last_message_times ?? {}); + } catch (err) { + console.error('Failed to fetch app settings:', err); + } + }, []); + + const handleSaveAppSettings = useCallback( + async (update: AppSettingsUpdate) => { + await api.updateSettings(update); + await fetchAppSettings(); + }, + [fetchAppSettings] + ); + + const handleSortOrderChange = useCallback( + async (order: 'recent' | 'alpha') => { + const previousOrder = appSettings?.sidebar_sort_order ?? 'recent'; + + // Optimistic update for responsive UI + setAppSettings((prev) => (prev ? { ...prev, sidebar_sort_order: order } : prev)); + + try { + const updatedSettings = await api.updateSettings({ sidebar_sort_order: order }); + setAppSettings(updatedSettings); + } catch (err) { + console.error('Failed to update sort order:', err); + setAppSettings((prev) => (prev ? { ...prev, sidebar_sort_order: previousOrder } : prev)); + toast.error('Failed to save sort preference'); + } + }, + [appSettings?.sidebar_sort_order] + ); + + const handleToggleFavorite = useCallback(async (type: 'channel' | 'contact', id: string) => { + setAppSettings((prev) => { + if (!prev) return prev; + const currentFavorites = prev.favorites ?? []; + const wasFavorited = isFavorite(currentFavorites, type, id); + const optimisticFavorites = wasFavorited + ? currentFavorites.filter((f) => !(f.type === type && f.id === id)) + : [...currentFavorites, { type, id }]; + return { ...prev, favorites: optimisticFavorites }; + }); + + try { + const updatedSettings = await api.toggleFavorite(type, id); + setAppSettings(updatedSettings); + } catch (err) { + console.error('Failed to toggle favorite:', err); + try { + const settings = await api.getSettings(); + setAppSettings(settings); + } catch { + // If refetch also fails, leave optimistic state + } + toast.error('Failed to update favorite'); + } + }, []); + + // One-time migration of localStorage preferences to server + useEffect(() => { + if (!appSettings || hasMigratedRef.current) return; + + if (appSettings.preferences_migrated) { + clearLocalStorageFavorites(); + clearLocalStorageConversationState(); + hasMigratedRef.current = true; + return; + } + + const localFavorites = loadLocalStorageFavorites(); + const localSortOrder = loadLocalStorageSortOrder(); + const localLastMessageTimes = loadLocalStorageLastMessageTimes(); + + const hasLocalData = + localFavorites.length > 0 || + localSortOrder !== 'recent' || + Object.keys(localLastMessageTimes).length > 0; + + if (!hasLocalData) { + hasMigratedRef.current = true; + return; + } + + hasMigratedRef.current = true; + + const migratePreferences = async () => { + try { + const result = await api.migratePreferences({ + favorites: localFavorites, + sort_order: localSortOrder, + last_message_times: localLastMessageTimes, + }); + + if (result.migrated) { + toast.success('Preferences migrated', { + description: `Migrated ${localFavorites.length} favorites to server`, + }); + } + + setAppSettings(result.settings); + initLastMessageTimes(result.settings.last_message_times ?? {}); + + clearLocalStorageFavorites(); + clearLocalStorageConversationState(); + } catch (err) { + console.error('Failed to migrate preferences:', err); + } + }; + + migratePreferences(); + }, [appSettings]); + + return { + appSettings, + setAppSettings, + favorites, + fetchAppSettings, + handleSaveAppSettings, + handleSortOrderChange, + handleToggleFavorite, + }; +} diff --git a/frontend/src/hooks/useContactsAndChannels.ts b/frontend/src/hooks/useContactsAndChannels.ts new file mode 100644 index 0000000..f147d32 --- /dev/null +++ b/frontend/src/hooks/useContactsAndChannels.ts @@ -0,0 +1,190 @@ +import { useState, useCallback, type MutableRefObject } from 'react'; +import { api } from '../api'; +import { takePrefetch } from '../prefetch'; +import { toast } from '../components/ui/sonner'; +import * as messageCache from '../messageCache'; +import { getContactDisplayName } from '../utils/pubkey'; +import type { Channel, Contact, Conversation } from '../types'; + +const PUBLIC_CHANNEL_KEY = '8B3387E9C5CDEA6AC9E5EDBAA115CD72'; + +interface UseContactsAndChannelsArgs { + setActiveConversation: (conv: Conversation | null) => void; + pendingDeleteFallbackRef: MutableRefObject; + hasSetDefaultConversation: MutableRefObject; +} + +export function useContactsAndChannels({ + setActiveConversation, + pendingDeleteFallbackRef, + hasSetDefaultConversation, +}: UseContactsAndChannelsArgs) { + const [contacts, setContacts] = useState([]); + const [contactsLoaded, setContactsLoaded] = useState(false); + const [channels, setChannels] = useState([]); + const [undecryptedCount, setUndecryptedCount] = useState(0); + + const fetchUndecryptedCountInternal = useCallback(async () => { + try { + const data = await (takePrefetch('undecryptedCount') ?? api.getUndecryptedPacketCount()); + setUndecryptedCount(data.count); + } catch (err) { + console.error('Failed to fetch undecrypted count:', err); + } + }, []); + + // Fetch all contacts, paginating if >1000 + const fetchAllContacts = useCallback(async (): Promise => { + const pageSize = 1000; + const first = await (takePrefetch('contacts') ?? api.getContacts(pageSize, 0)); + if (first.length < pageSize) return first; + let all = [...first]; + let offset = pageSize; + while (true) { + const page = await api.getContacts(pageSize, offset); + all = all.concat(page); + if (page.length < pageSize) break; + offset += pageSize; + } + return all; + }, []); + + const handleCreateContact = useCallback( + async (name: string, publicKey: string, tryHistorical: boolean) => { + const created = await api.createContact(publicKey, name || undefined, tryHistorical); + const data = await fetchAllContacts(); + setContacts(data); + + setActiveConversation({ + type: 'contact', + id: created.public_key, + name: getContactDisplayName(created.name, created.public_key), + }); + }, + [fetchAllContacts, setActiveConversation] + ); + + const handleCreateChannel = useCallback( + async (name: string, key: string, tryHistorical: boolean) => { + const created = await api.createChannel(name, key); + const data = await api.getChannels(); + setChannels(data); + + setActiveConversation({ + type: 'channel', + id: created.key, + name, + }); + + if (tryHistorical) { + await api.decryptHistoricalPackets({ + key_type: 'channel', + channel_key: created.key, + }); + fetchUndecryptedCountInternal(); + } + }, + [fetchUndecryptedCountInternal, setActiveConversation] + ); + + const handleCreateHashtagChannel = useCallback( + async (name: string, tryHistorical: boolean) => { + const channelName = name.startsWith('#') ? name : `#${name}`; + + const created = await api.createChannel(channelName); + const data = await api.getChannels(); + setChannels(data); + + setActiveConversation({ + type: 'channel', + id: created.key, + name: channelName, + }); + + if (tryHistorical) { + await api.decryptHistoricalPackets({ + key_type: 'channel', + channel_name: channelName, + }); + fetchUndecryptedCountInternal(); + } + }, + [fetchUndecryptedCountInternal, setActiveConversation] + ); + + const handleDeleteChannel = useCallback( + async (key: string) => { + if (!confirm('Delete this channel? Message history will be preserved.')) return; + try { + pendingDeleteFallbackRef.current = true; + await api.deleteChannel(key); + messageCache.remove(key); + const refreshedChannels = await api.getChannels(); + setChannels(refreshedChannels); + const publicChannel = + refreshedChannels.find((c) => c.key === PUBLIC_CHANNEL_KEY) || + refreshedChannels.find((c) => c.name === 'Public'); + hasSetDefaultConversation.current = true; + setActiveConversation({ + type: 'channel', + id: publicChannel?.key || PUBLIC_CHANNEL_KEY, + name: publicChannel?.name || 'Public', + }); + toast.success('Channel deleted'); + } catch (err) { + console.error('Failed to delete channel:', err); + toast.error('Failed to delete channel', { + description: err instanceof Error ? err.message : undefined, + }); + } + }, + [setActiveConversation, pendingDeleteFallbackRef, hasSetDefaultConversation] + ); + + const handleDeleteContact = useCallback( + async (publicKey: string) => { + if (!confirm('Delete this contact? Message history will be preserved.')) return; + try { + pendingDeleteFallbackRef.current = true; + await api.deleteContact(publicKey); + messageCache.remove(publicKey); + setContacts((prev) => prev.filter((c) => c.public_key !== publicKey)); + const refreshedChannels = await api.getChannels(); + setChannels(refreshedChannels); + const publicChannel = + refreshedChannels.find((c) => c.key === PUBLIC_CHANNEL_KEY) || + refreshedChannels.find((c) => c.name === 'Public'); + hasSetDefaultConversation.current = true; + setActiveConversation({ + type: 'channel', + id: publicChannel?.key || PUBLIC_CHANNEL_KEY, + name: publicChannel?.name || 'Public', + }); + toast.success('Contact deleted'); + } catch (err) { + console.error('Failed to delete contact:', err); + toast.error('Failed to delete contact', { + description: err instanceof Error ? err.message : undefined, + }); + } + }, + [setActiveConversation, pendingDeleteFallbackRef, hasSetDefaultConversation] + ); + + return { + contacts, + contactsLoaded, + channels, + undecryptedCount, + setContacts, + setContactsLoaded, + setChannels, + fetchAllContacts, + fetchUndecryptedCount: fetchUndecryptedCountInternal, + handleCreateContact, + handleCreateChannel, + handleCreateHashtagChannel, + handleDeleteChannel, + handleDeleteContact, + }; +} diff --git a/frontend/src/hooks/useConversationRouter.ts b/frontend/src/hooks/useConversationRouter.ts new file mode 100644 index 0000000..0b67223 --- /dev/null +++ b/frontend/src/hooks/useConversationRouter.ts @@ -0,0 +1,167 @@ +import { useState, useCallback, useEffect, useRef, type MutableRefObject } from 'react'; +import { + parseHashConversation, + updateUrlHash, + resolveChannelFromHashToken, + resolveContactFromHashToken, +} from '../utils/urlHash'; +import { getContactDisplayName } from '../utils/pubkey'; +import type { Channel, Contact, Conversation } from '../types'; + +const PUBLIC_CHANNEL_KEY = '8B3387E9C5CDEA6AC9E5EDBAA115CD72'; + +interface UseConversationRouterArgs { + channels: Channel[]; + contacts: Contact[]; + contactsLoaded: boolean; + setSidebarOpen: (open: boolean) => void; + pendingDeleteFallbackRef: MutableRefObject; + hasSetDefaultConversation: MutableRefObject; +} + +export function useConversationRouter({ + channels, + contacts, + contactsLoaded, + setSidebarOpen, + pendingDeleteFallbackRef, + hasSetDefaultConversation, +}: UseConversationRouterArgs) { + const [activeConversation, setActiveConversation] = useState(null); + const activeConversationRef = useRef(null); + + // Phase 1: Set initial conversation from URL hash or default to Public channel + // Only needs channels (fast path) - doesn't wait for contacts + useEffect(() => { + if (hasSetDefaultConversation.current || activeConversation) return; + if (channels.length === 0) return; + + const hashConv = parseHashConversation(); + + // Handle non-data views immediately + if (hashConv?.type === 'raw') { + setActiveConversation({ type: 'raw', id: 'raw', name: 'Raw Packet Feed' }); + hasSetDefaultConversation.current = true; + return; + } + if (hashConv?.type === 'map') { + setActiveConversation({ + type: 'map', + id: 'map', + name: 'Node Map', + mapFocusKey: hashConv.mapFocusKey, + }); + hasSetDefaultConversation.current = true; + return; + } + if (hashConv?.type === 'visualizer') { + setActiveConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' }); + hasSetDefaultConversation.current = true; + return; + } + + // Handle channel hash (ID-first with legacy-name fallback) + if (hashConv?.type === 'channel') { + const channel = resolveChannelFromHashToken(hashConv.name, channels); + if (channel) { + setActiveConversation({ type: 'channel', id: channel.key, name: channel.name }); + hasSetDefaultConversation.current = true; + return; + } + } + + // Contact hash — wait for phase 2 + if (hashConv?.type === 'contact') return; + + // No hash or unresolvable — default to Public + const publicChannel = channels.find((c) => c.name === 'Public'); + if (publicChannel) { + setActiveConversation({ + type: 'channel', + id: publicChannel.key, + name: publicChannel.name, + }); + hasSetDefaultConversation.current = true; + } + }, [channels, activeConversation]); + + // Phase 2: Resolve contact hash (only if phase 1 didn't set a conversation) + useEffect(() => { + if (hasSetDefaultConversation.current || activeConversation) return; + + const hashConv = parseHashConversation(); + if (hashConv?.type === 'contact') { + if (!contactsLoaded) return; + + const contact = resolveContactFromHashToken(hashConv.name, contacts); + if (contact) { + setActiveConversation({ + type: 'contact', + id: contact.public_key, + name: getContactDisplayName(contact.name, contact.public_key), + }); + hasSetDefaultConversation.current = true; + return; + } + + // Contact hash didn't match — fall back to Public if channels loaded. + if (channels.length > 0) { + const publicChannel = channels.find((c) => c.name === 'Public'); + if (publicChannel) { + setActiveConversation({ + type: 'channel', + id: publicChannel.key, + name: publicChannel.name, + }); + hasSetDefaultConversation.current = true; + } + } + } + }, [contacts, channels, activeConversation, contactsLoaded]); + + // Keep ref in sync and update URL hash + useEffect(() => { + activeConversationRef.current = activeConversation; + if (activeConversation) { + updateUrlHash(activeConversation); + } + }, [activeConversation]); + + // If a delete action left us without an active conversation, recover to Public + useEffect(() => { + if (!pendingDeleteFallbackRef.current) return; + if (activeConversation) { + pendingDeleteFallbackRef.current = false; + return; + } + + const publicChannel = + channels.find((c) => c.key === PUBLIC_CHANNEL_KEY) || + channels.find((c) => c.name === 'Public'); + if (!publicChannel) return; + + hasSetDefaultConversation.current = true; + pendingDeleteFallbackRef.current = false; + setActiveConversation({ + type: 'channel', + id: publicChannel.key, + name: publicChannel.name, + }); + }, [activeConversation, channels]); + + // Handle conversation selection (closes sidebar on mobile) + const handleSelectConversation = useCallback( + (conv: Conversation) => { + setActiveConversation(conv); + setSidebarOpen(false); + }, + [setSidebarOpen] + ); + + return { + activeConversation, + setActiveConversation, + activeConversationRef, + handleSelectConversation, + }; +} diff --git a/frontend/src/hooks/useRadioControl.ts b/frontend/src/hooks/useRadioControl.ts new file mode 100644 index 0000000..c63fc19 --- /dev/null +++ b/frontend/src/hooks/useRadioControl.ts @@ -0,0 +1,104 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { api } from '../api'; +import { takePrefetch } from '../prefetch'; +import { toast } from '../components/ui/sonner'; +import type { HealthStatus, RadioConfig, RadioConfigUpdate } from '../types'; + +export function useRadioControl() { + const [health, setHealth] = useState(null); + const [config, setConfig] = useState(null); + + const prevHealthRef = useRef(null); + const rebootPollTokenRef = useRef(0); + + // Cancel any in-flight reboot polling on unmount + useEffect(() => { + return () => { + rebootPollTokenRef.current += 1; + }; + }, []); + + const fetchConfig = useCallback(async () => { + try { + const data = await (takePrefetch('config') ?? api.getRadioConfig()); + setConfig(data); + } catch (err) { + console.error('Failed to fetch config:', err); + } + }, []); + + const handleSaveConfig = useCallback( + async (update: RadioConfigUpdate) => { + await api.updateRadioConfig(update); + await fetchConfig(); + }, + [fetchConfig] + ); + + const handleSetPrivateKey = useCallback( + async (key: string) => { + await api.setPrivateKey(key); + await fetchConfig(); + }, + [fetchConfig] + ); + + const handleReboot = useCallback(async () => { + await api.rebootRadio(); + setHealth((prev) => (prev ? { ...prev, radio_connected: false } : prev)); + const pollToken = ++rebootPollTokenRef.current; + const pollUntilReconnected = async () => { + for (let i = 0; i < 30; i++) { + await new Promise((r) => setTimeout(r, 1000)); + if (rebootPollTokenRef.current !== pollToken) return; + try { + const data = await api.getHealth(); + if (rebootPollTokenRef.current !== pollToken) return; + setHealth(data); + if (data.radio_connected) { + fetchConfig(); + return; + } + } catch { + // Keep polling + } + } + }; + pollUntilReconnected(); + }, [fetchConfig]); + + const handleAdvertise = useCallback(async () => { + try { + await api.sendAdvertisement(); + toast.success('Advertisement sent'); + } catch (err) { + console.error('Failed to send advertisement:', err); + toast.error('Failed to send advertisement', { + description: err instanceof Error ? err.message : 'Check radio connection', + }); + } + }, []); + + const handleHealthRefresh = useCallback(async () => { + try { + const data = await api.getHealth(); + setHealth(data); + } catch (err) { + console.error('Failed to refresh health:', err); + } + }, []); + + return { + health, + setHealth, + config, + setConfig, + prevHealthRef, + fetchConfig, + handleSaveConfig, + handleSetPrivateKey, + handleReboot, + handleAdvertise, + handleHealthRefresh, + }; +} diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index d048372..6f4c071 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -52,34 +52,38 @@ vi.mock('../useWebSocket', () => ({ useWebSocket: vi.fn(), })); -vi.mock('../hooks', () => ({ - useConversationMessages: () => ({ - messages: [], - messagesLoading: false, - loadingOlder: false, - hasOlderMessages: false, - setMessages: mocks.hookFns.setMessages, - fetchMessages: mocks.hookFns.fetchMessages, - fetchOlderMessages: mocks.hookFns.fetchOlderMessages, - addMessageIfNew: mocks.hookFns.addMessageIfNew, - updateMessageAck: mocks.hookFns.updateMessageAck, - }), - useUnreadCounts: () => ({ - unreadCounts: {}, - mentions: {}, - lastMessageTimes: {}, - incrementUnread: mocks.hookFns.incrementUnread, - markAllRead: mocks.hookFns.markAllRead, - trackNewMessage: mocks.hookFns.trackNewMessage, - }), - useRepeaterMode: () => ({ - repeaterLoggedIn: false, - activeContactIsRepeater: false, - handleTelemetryRequest: mocks.hookFns.handleTelemetryRequest, - handleRepeaterCommand: mocks.hookFns.handleRepeaterCommand, - }), - getMessageContentKey: () => 'content-key', -})); +vi.mock('../hooks', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useConversationMessages: () => ({ + messages: [], + messagesLoading: false, + loadingOlder: false, + hasOlderMessages: false, + setMessages: mocks.hookFns.setMessages, + fetchMessages: mocks.hookFns.fetchMessages, + fetchOlderMessages: mocks.hookFns.fetchOlderMessages, + addMessageIfNew: mocks.hookFns.addMessageIfNew, + updateMessageAck: mocks.hookFns.updateMessageAck, + }), + useUnreadCounts: () => ({ + unreadCounts: {}, + mentions: {}, + lastMessageTimes: {}, + incrementUnread: mocks.hookFns.incrementUnread, + markAllRead: mocks.hookFns.markAllRead, + trackNewMessage: mocks.hookFns.trackNewMessage, + }), + useRepeaterMode: () => ({ + repeaterLoggedIn: false, + activeContactIsRepeater: false, + handleTelemetryRequest: mocks.hookFns.handleTelemetryRequest, + handleRepeaterCommand: mocks.hookFns.handleRepeaterCommand, + }), + getMessageContentKey: () => 'content-key', + }; +}); vi.mock('../messageCache', () => ({ addMessage: vi.fn(), diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index 3bb5e10..8752e54 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -21,34 +21,38 @@ vi.mock('../useWebSocket', () => ({ useWebSocket: vi.fn(), })); -vi.mock('../hooks', () => ({ - useConversationMessages: () => ({ - messages: [], - messagesLoading: false, - loadingOlder: false, - hasOlderMessages: false, - setMessages: vi.fn(), - fetchMessages: vi.fn(async () => {}), - fetchOlderMessages: vi.fn(async () => {}), - addMessageIfNew: vi.fn(), - updateMessageAck: vi.fn(), - }), - useUnreadCounts: () => ({ - unreadCounts: {}, - mentions: {}, - lastMessageTimes: {}, - incrementUnread: vi.fn(), - markAllRead: vi.fn(), - trackNewMessage: vi.fn(), - }), - useRepeaterMode: () => ({ - repeaterLoggedIn: false, - activeContactIsRepeater: false, - handleTelemetryRequest: vi.fn(), - handleRepeaterCommand: vi.fn(), - }), - getMessageContentKey: () => 'content-key', -})); +vi.mock('../hooks', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useConversationMessages: () => ({ + messages: [], + messagesLoading: false, + loadingOlder: false, + hasOlderMessages: false, + setMessages: vi.fn(), + fetchMessages: vi.fn(async () => {}), + fetchOlderMessages: vi.fn(async () => {}), + addMessageIfNew: vi.fn(), + updateMessageAck: vi.fn(), + }), + useUnreadCounts: () => ({ + unreadCounts: {}, + mentions: {}, + lastMessageTimes: {}, + incrementUnread: vi.fn(), + markAllRead: vi.fn(), + trackNewMessage: vi.fn(), + }), + useRepeaterMode: () => ({ + repeaterLoggedIn: false, + activeContactIsRepeater: false, + handleTelemetryRequest: vi.fn(), + handleRepeaterCommand: vi.fn(), + }), + getMessageContentKey: () => 'content-key', + }; +}); vi.mock('../messageCache', () => ({ addMessage: vi.fn(), diff --git a/tests/e2e/specs/bot.spec.ts b/tests/e2e/specs/bot.spec.ts index db18bcd..ab38dc1 100644 --- a/tests/e2e/specs/bot.spec.ts +++ b/tests/e2e/specs/bot.spec.ts @@ -43,7 +43,7 @@ test.describe('Bot functionality', () => { await page.goto('/'); await expect(page.getByText('Connected')).toBeVisible(); - await page.getByText('Radio & Config').click(); + await page.getByText('Settings').click(); await page.getByRole('button', { name: /Bot/i }).click(); // The bot name should be visible in the bot list diff --git a/tests/e2e/specs/radio-settings.spec.ts b/tests/e2e/specs/radio-settings.spec.ts index 5e8faed..9ea342b 100644 --- a/tests/e2e/specs/radio-settings.spec.ts +++ b/tests/e2e/specs/radio-settings.spec.ts @@ -26,7 +26,7 @@ test.describe('Radio settings', () => { await expect(page.getByText('Connected')).toBeVisible(); // --- Step 1: Change the name via settings UI --- - await page.getByText('Radio & Config').click(); + await page.getByText('Settings').click(); await page.getByRole('button', { name: /Identity/i }).click(); const nameInput = page.locator('#name'); @@ -47,7 +47,7 @@ test.describe('Radio settings', () => { await page.reload(); await expect(page.getByText('Connected')).toBeVisible({ timeout: 15_000 }); - await page.getByText('Radio & Config').click(); + await page.getByText('Settings').click(); await page.getByRole('button', { name: /Identity/i }).click(); await expect(page.locator('#name')).toHaveValue(testName, { timeout: 10_000 }); });