From f3c3b84210c67a318fb29d980305c0fe289cc326 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 11 Feb 2026 21:49:12 -0800 Subject: [PATCH] Way better options dialog --- frontend/src/App.tsx | 557 +++++++++++++--------- frontend/src/components/BotCodeEditor.tsx | 5 +- frontend/src/components/SettingsModal.tsx | 430 +++++++++++------ frontend/src/components/StatusBar.tsx | 11 +- frontend/src/test/appFavorites.test.tsx | 52 +- frontend/src/test/settingsModal.test.tsx | 222 +++++++-- tests/e2e/specs/bot.spec.ts | 6 +- tests/e2e/specs/radio-settings.spec.ts | 8 +- 8 files changed, 881 insertions(+), 410 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 43ec9d6..d808059 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef, startTransition } from 'react'; import { api } from './api'; import { useWebSocket } from './useWebSocket'; import { @@ -13,7 +13,12 @@ import { Sidebar } from './components/Sidebar'; import { MessageList } from './components/MessageList'; import { MessageInput, type MessageInputHandle } from './components/MessageInput'; import { NewMessageModal } from './components/NewMessageModal'; -import { SettingsModal } from './components/SettingsModal'; +import { + SettingsModal, + SETTINGS_SECTION_LABELS, + SETTINGS_SECTION_ORDER, + type SettingsSection, +} from './components/SettingsModal'; import { RawPacketList } from './components/RawPacketList'; import { MapView } from './components/MapView'; import { VisualizerView } from './components/VisualizerView'; @@ -78,6 +83,7 @@ export function App() { 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); @@ -510,7 +516,8 @@ export function App() { } const publicChannel = - channels.find((c) => c.key === PUBLIC_CHANNEL_KEY) || channels.find((c) => c.name === 'Public'); + channels.find((c) => c.key === PUBLIC_CHANNEL_KEY) || + channels.find((c) => c.name === 'Public'); if (!publicChannel) return; hasSetDefaultConversation.current = true; @@ -617,6 +624,15 @@ export function App() { } }, []); + const handleHealthRefresh = useCallback(async () => { + try { + const data = await api.getHealth(); + setHealth(data); + } catch (err) { + console.error('Failed to refresh health:', err); + } + }, []); + // Handle sender click to add mention const handleSenderClick = useCallback((sender: string) => { messageInputRef.current?.appendText(`@[${sender}] `); @@ -820,6 +836,18 @@ export function App() { [appSettings?.sidebar_sort_order] ); + const handleCloseSettingsView = useCallback(() => { + startTransition(() => setShowSettings(false)); + setSidebarOpen(false); + }, []); + + const handleToggleSettingsView = useCallback(() => { + startTransition(() => { + setShowSettings((prev) => !prev); + }); + setSidebarOpen(false); + }, []); + // Sidebar content (shared between desktop and mobile) const sidebarContent = ( ); + const settingsSidebarContent = ( +
+
+

Settings

+ +
+
+ {SETTINGS_SECTION_ORDER.map((section) => ( + + ))} +
+
+ ); + + const activeSidebarContent = showSettings ? settingsSidebarContent : sidebarContent; + return (
setShowSettings(true)} - onMenuClick={() => setSidebarOpen(true)} + settingsMode={showSettings} + onSettingsClick={handleToggleSettingsView} + onMenuClick={showSettings ? undefined : () => setSidebarOpen(true)} />
{/* Desktop sidebar - hidden on mobile */} -
{sidebarContent}
+
{activeSidebarContent}
{/* Mobile sidebar - Sheet that slides in */} @@ -863,231 +926,269 @@ export function App() { Navigation -
{sidebarContent}
+
{activeSidebarContent}
- {activeConversation ? ( - activeConversation.type === 'map' ? ( - <> -
- Node Map -
-
- -
- - ) : activeConversation.type === 'visualizer' ? ( - setRawPackets([])} - /> - ) : activeConversation.type === 'raw' ? ( - <> -
- Raw Packet Feed -
-
- -
- - ) : ( - <> -
- - - {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' : ''}` +
+ {activeConversation ? ( + activeConversation.type === 'map' ? ( + <> +
+ Node Map +
+
+ +
+ + ) : activeConversation.type === 'visualizer' ? ( + setRawPackets([])} + /> + ) : activeConversation.type === 'raw' ? ( + <> +
+ Raw Packet Feed +
+
+ +
+ + ) : ( + <> +
+ + + {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!' ); - } - // 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') && ( - + )} + {/* Favorite button */} + {(activeConversation.type === 'channel' || + activeConversation.type === 'contact') && ( + - )} - {/* Delete button */} - {!( - activeConversation.type === 'channel' && activeConversation.name === 'Public' - ) && ( - - )} + ) ? ( + + ) : ( + + )} + + )} + {/* Delete button */} + {!( + activeConversation.type === 'channel' && + activeConversation.name === 'Public' + ) && ( + + )} +
-
- - + + + ) + ) : ( +
+ Select a conversation or start a new one +
+ )} +
+ + {showSettings && ( +
+
+ Radio & Settings + + {SETTINGS_SECTION_LABELS[settingsSection]} + +
+
+ - - ) - ) : ( -
- Select a conversation or start a new one +
)}
@@ -1132,24 +1233,6 @@ export function App() { onCreateHashtagChannel={handleCreateHashtagChannel} /> - setShowSettings(false)} - onSave={handleSaveConfig} - onSaveAppSettings={handleSaveAppSettings} - onSetPrivateKey={handleSetPrivateKey} - onReboot={handleReboot} - onAdvertise={handleAdvertise} - onHealthRefresh={async () => { - const data = await api.getHealth(); - setHealth(data); - }} - onRefreshAppSettings={fetchAppSettings} - /> -
); diff --git a/frontend/src/components/BotCodeEditor.tsx b/frontend/src/components/BotCodeEditor.tsx index afa1f9b..33e716b 100644 --- a/frontend/src/components/BotCodeEditor.tsx +++ b/frontend/src/components/BotCodeEditor.tsx @@ -6,9 +6,10 @@ interface BotCodeEditorProps { value: string; onChange: (value: string) => void; id?: string; + height?: string; } -export function BotCodeEditor({ value, onChange, id }: BotCodeEditorProps) { +export function BotCodeEditor({ value, onChange, id, height = '256px' }: BotCodeEditorProps) { return (
import('./BotCodeEditor').then((m) => ({ default: m.BotCodeEditor })) @@ -11,8 +11,6 @@ import type { RadioConfig, RadioConfigUpdate, } from '../types'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { Input } from './ui/input'; import { Label } from './ui/label'; import { Button } from './ui/button'; @@ -48,8 +46,27 @@ const RADIO_PRESETS: RadioPreset[] = [ { name: 'Vietnam', freq: 920.25, bw: 250, sf: 11, cr: 5 }, ]; -interface SettingsModalProps { +export type SettingsSection = 'radio' | 'identity' | 'connectivity' | 'database' | 'bot'; + +export const SETTINGS_SECTION_ORDER: SettingsSection[] = [ + 'radio', + 'identity', + 'connectivity', + 'database', + 'bot', +]; + +export const SETTINGS_SECTION_LABELS: Record = { + radio: '📻 Radio', + identity: '🪪 Identity', + connectivity: '📡 Connectivity', + database: '🗄️ Database', + bot: '🤖 Bot', +}; + +interface SettingsModalBaseProps { open: boolean; + pageMode?: boolean; config: RadioConfig | null; health: HealthStatus | null; appSettings: AppSettings | null; @@ -63,23 +80,47 @@ interface SettingsModalProps { onRefreshAppSettings: () => Promise; } -export function SettingsModal({ - open, - config, - health, - appSettings, - onClose, - onSave, - onSaveAppSettings, - onSetPrivateKey, - onReboot, - onAdvertise, - onHealthRefresh, - onRefreshAppSettings, -}: SettingsModalProps) { - // Tab state - type SettingsTab = 'radio' | 'identity' | 'connectivity' | 'database' | 'bot'; - const [activeTab, setActiveTab] = useState('radio'); +type SettingsModalProps = SettingsModalBaseProps & + ( + | { externalSidebarNav: true; desktopSection: SettingsSection } + | { externalSidebarNav?: false; desktopSection?: never } + ); + +export function SettingsModal(props: SettingsModalProps) { + const { + open, + pageMode = false, + config, + health, + appSettings, + onClose, + onSave, + onSaveAppSettings, + onSetPrivateKey, + onReboot, + onAdvertise, + onHealthRefresh, + onRefreshAppSettings, + } = props; + const externalSidebarNav = props.externalSidebarNav === true; + const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined; + + const getIsMobileLayout = () => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false; + return window.matchMedia('(max-width: 767px)').matches; + }; + + const [isMobileLayout, setIsMobileLayout] = useState(getIsMobileLayout); + const [expandedSections, setExpandedSections] = useState>(() => { + const isMobile = getIsMobileLayout(); + return { + radio: !isMobile, + identity: false, + connectivity: false, + database: false, + bot: false, + }; + }); // Radio config state const [name, setName] = useState(''); @@ -95,11 +136,14 @@ export function SettingsModal({ const [experimentalChannelDoubleSend, setExperimentalChannelDoubleSend] = useState(false); // Loading states - const [loading, setLoading] = useState(false); + const [busySection, setBusySection] = useState(null); const [rebooting, setRebooting] = useState(false); const [advertising, setAdvertising] = useState(false); const [gettingLocation, setGettingLocation] = useState(false); - const [error, setError] = useState(''); + const [sectionError, setSectionError] = useState<{ + section: SettingsSection; + message: string; + } | null>(null); // Database maintenance state const [retentionDays, setRetentionDays] = useState('14'); @@ -172,10 +216,34 @@ export function SettingsModal({ // Refresh settings from server when modal opens // This ensures UI reflects actual server state (prevents stale UI after checkbox toggle without save) useEffect(() => { - if (open) { + if (open || pageMode) { onRefreshAppSettings(); } - }, [open, onRefreshAppSettings]); + }, [open, pageMode, onRefreshAppSettings]); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + + const query = window.matchMedia('(max-width: 767px)'); + const onChange = (event: MediaQueryListEvent) => { + setIsMobileLayout(event.matches); + }; + + setIsMobileLayout(query.matches); + + if (typeof query.addEventListener === 'function') { + query.addEventListener('change', onChange); + return () => query.removeEventListener('change', onChange); + } + + query.addListener(onChange); + return () => query.removeListener(onChange); + }, []); + + useEffect(() => { + if (!externalSidebarNav) return; + setSectionError(null); + }, [externalSidebarNav, desktopSection]); // Detect current preset from form values const currentPreset = useMemo(() => { @@ -235,8 +303,8 @@ export function SettingsModal({ }; const handleSaveRadioConfig = async () => { - setError(''); - setLoading(true); + setSectionError(null); + setBusySection('radio'); try { const update: RadioConfigUpdate = { @@ -252,21 +320,25 @@ export function SettingsModal({ }; await onSave(update); toast.success('Radio config saved, rebooting...'); - setLoading(false); setRebooting(true); await onReboot(); - onClose(); + if (!pageMode) { + onClose(); + } } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save'); - setLoading(false); + setSectionError({ + section: 'radio', + message: err instanceof Error ? err.message : 'Failed to save', + }); } finally { setRebooting(false); + setBusySection(null); } }; const handleSaveIdentity = async () => { - setError(''); - setLoading(true); + setSectionError(null); + setBusySection('identity'); try { // Save radio name @@ -281,15 +353,18 @@ export function SettingsModal({ toast.success('Identity settings saved'); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save'); + setSectionError({ + section: 'identity', + message: err instanceof Error ? err.message : 'Failed to save', + }); } finally { - setLoading(false); + setBusySection(null); } }; const handleSaveConnectivity = async () => { - setError(''); - setLoading(true); + setSectionError(null); + setBusySection('connectivity'); try { const update: AppSettingsUpdate = {}; @@ -305,33 +380,40 @@ export function SettingsModal({ } toast.success('Connectivity settings saved'); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save'); + setSectionError({ + section: 'connectivity', + message: err instanceof Error ? err.message : 'Failed to save', + }); } finally { - setLoading(false); + setBusySection(null); } }; const handleSetPrivateKey = async () => { if (!privateKey.trim()) { - setError('Private key is required'); + setSectionError({ section: 'identity', message: 'Private key is required' }); return; } - setError(''); - setLoading(true); + setSectionError(null); + setBusySection('identity'); try { await onSetPrivateKey(privateKey.trim()); setPrivateKey(''); toast.success('Private key set, rebooting...'); - setLoading(false); setRebooting(true); await onReboot(); - onClose(); + if (!pageMode) { + onClose(); + } } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to set private key'); - setLoading(false); + setSectionError({ + section: 'identity', + message: err instanceof Error ? err.message : 'Failed to set private key', + }); } finally { setRebooting(false); + setBusySection(null); } }; @@ -341,16 +423,23 @@ export function SettingsModal({ ) { return; } - setError(''); + setSectionError(null); + setBusySection('connectivity'); setRebooting(true); try { await onReboot(); - onClose(); + if (!pageMode) { + onClose(); + } } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to reboot radio'); + setSectionError({ + section: 'connectivity', + message: err instanceof Error ? err.message : 'Failed to reboot radio', + }); } finally { setRebooting(false); + setBusySection(null); } }; @@ -391,24 +480,27 @@ export function SettingsModal({ }; const handleSaveDatabaseSettings = async () => { - setLoading(true); - setError(''); + setBusySection('database'); + setSectionError(null); try { await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert }); toast.success('Database settings saved'); } catch (err) { console.error('Failed to save database settings:', err); - setError(err instanceof Error ? err.message : 'Failed to save'); + setSectionError({ + section: 'database', + message: err instanceof Error ? err.message : 'Failed to save', + }); toast.error('Failed to save settings'); } finally { - setLoading(false); + setBusySection(null); } }; const handleSaveBotSettings = async () => { - setLoading(true); - setError(''); + setBusySection('bot'); + setSectionError(null); try { await onSaveAppSettings({ bots }); @@ -416,10 +508,10 @@ export function SettingsModal({ } catch (err) { console.error('Failed to save bot settings:', err); const errorMsg = err instanceof Error ? err.message : 'Failed to save'; - setError(errorMsg); + setSectionError({ section: 'bot', message: errorMsg }); toast.error(errorMsg); } finally { - setLoading(false); + setBusySection(null); } }; @@ -474,42 +566,65 @@ export function SettingsModal({ setBots(bots.map((b) => (b.id === botId ? { ...b, code: DEFAULT_BOT_CODE } : b))); }; - return ( - !isOpen && onClose()}> - - - Radio & Settings - - {activeTab === 'radio' && 'Configure radio frequency, power, and location settings'} - {activeTab === 'identity' && - 'Manage radio name, public key, private key, and advertising settings'} - {activeTab === 'connectivity' && 'View connection status and configure contact sync'} - {activeTab === 'database' && 'View database statistics and clean up old packets'} - {activeTab === 'bot' && 'Configure automatic message bot with Python code'} - - + const toggleSection = (section: SettingsSection) => { + setExpandedSections((prev) => ({ + ...prev, + [section]: !prev[section], + })); + setSectionError(null); + }; - {!config ? ( -
Loading configuration...
- ) : ( - { - setActiveTab(v as SettingsTab); - setError(''); - }} - className="w-full" - > - - Radio - Identity - Connectivity - Database - Bot - + const externalDesktopSidebarMode = externalSidebarNav && !isMobileLayout; - {/* Radio Config Tab */} - + const isSectionVisible = (section: SettingsSection) => + externalDesktopSidebarMode ? desktopSection === section : expandedSections[section]; + + const showSectionButton = !externalDesktopSidebarMode; + const shouldRenderSection = (section: SettingsSection) => + !externalDesktopSidebarMode || desktopSection === section; + + const sectionWrapperClass = 'border border-input rounded-md overflow-hidden'; + + const sectionContentClass = externalDesktopSidebarMode + ? 'space-y-4 p-4 h-full overflow-y-auto' + : 'space-y-4 p-4 border-t border-input'; + + const settingsContainerClass = externalDesktopSidebarMode + ? 'w-full h-full' + : 'w-full h-full overflow-y-auto space-y-3'; + + const sectionButtonClasses = + 'w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/40'; + + const renderSectionHeader = (section: SettingsSection): ReactNode => { + if (!showSectionButton) return null; + return ( + + ); + }; + + const isSectionBusy = (section: SettingsSection) => busySection === section; + const getSectionError = (section: SettingsSection) => + sectionError?.section === section ? sectionError.message : null; + + if (!pageMode && !open) { + return null; + } + + return !config ? ( +
Loading configuration...
+ ) : ( +
+ {shouldRenderSection('radio') && ( +
+ {renderSectionHeader('radio')} + {isSectionVisible('radio') && ( +
- @@ -699,10 +828,12 @@ export function SettingsModal({ />
@@ -725,11 +856,19 @@ export function SettingsModal({ )}
- {error &&
{error}
} - + {getSectionError('identity') && ( +
{getSectionError('identity')}
+ )} +
+ )} +
+ )} - {/* Connectivity Tab */} - + {shouldRenderSection('connectivity') && ( +
+ {renderSectionHeader('connectivity')} + {isSectionVisible('connectivity') && ( +
{health?.connection_info ? ( @@ -786,8 +925,12 @@ export function SettingsModal({

- @@ -795,17 +938,25 @@ export function SettingsModal({ - {error &&
{error}
} - + {getSectionError('connectivity') && ( +
{getSectionError('connectivity')}
+ )} +
+ )} +
+ )} - {/* Database Tab */} - + {shouldRenderSection('database') && ( +
+ {renderSectionHeader('database')} + {isSectionVisible('database') && ( +
Database size @@ -885,15 +1036,27 @@ export function SettingsModal({

- {error &&
{error}
} + {getSectionError('database') && ( +
{getSectionError('database')}
+ )} - - +
+ )} +
+ )} - {/* Bot Tab */} - + {shouldRenderSection('bot') && ( +
+ {renderSectionHeader('bot')} + {isSectionVisible('bot') && ( +

Experimental: This is an alpha feature and introduces automated @@ -935,11 +1098,9 @@ export function SettingsModal({

{bots.map((bot) => (
- {/* Bot header row */}
{ - // Don't toggle if clicking on interactive elements if ((e.target as HTMLElement).closest('input, button')) return; setExpandedBotId(expandedBotId === bot.id ? null : bot.id); }} @@ -948,7 +1109,6 @@ export function SettingsModal({ {expandedBotId === bot.id ? '▼' : '▶'} - {/* Bot name (click to edit) */} {editingNameId === bot.id ? ( )} - {/* Enabled checkbox */} - {/* Delete button */}
- {/* Bot expanded content */} {expandedBotId === bot.id && (
@@ -1028,7 +1185,7 @@ export function SettingsModal({
+
Loading editor...
} @@ -1037,6 +1194,7 @@ export function SettingsModal({ value={bot.code} onChange={(code) => handleBotCodeChange(bot.id, code)} id={`bot-code-${bot.id}`} + height={isMobileLayout ? '256px' : '384px'} />
@@ -1064,15 +1222,21 @@ export function SettingsModal({

- {error &&
{error}
} + {getSectionError('bot') && ( +
{getSectionError('bot')}
+ )} - - - - )} - - +
+ )} +
+ )} +
); } diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx index 7ca901a..36f7774 100644 --- a/frontend/src/components/StatusBar.tsx +++ b/frontend/src/components/StatusBar.tsx @@ -7,11 +7,18 @@ import { toast } from './ui/sonner'; interface StatusBarProps { health: HealthStatus | null; config: RadioConfig | null; + settingsMode?: boolean; onSettingsClick: () => void; onMenuClick?: () => void; } -export function StatusBar({ health, config, onSettingsClick, onMenuClick }: StatusBarProps) { +export function StatusBar({ + health, + config, + settingsMode = false, + onSettingsClick, + onMenuClick, +}: StatusBarProps) { const connected = health?.radio_connected ?? false; const [reconnecting, setReconnecting] = useState(false); @@ -85,7 +92,7 @@ export function StatusBar({ health, config, onSettingsClick, onMenuClick }: Stat 🔧 {' '} - Radio & Config + {settingsMode ? 'Back to Chat' : 'Radio & Config'}
); diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index 4956632..a52747a 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -88,7 +88,17 @@ vi.mock('../messageCache', () => ({ })); vi.mock('../components/StatusBar', () => ({ - StatusBar: () =>
, + StatusBar: ({ + settingsMode, + onSettingsClick, + }: { + settingsMode?: boolean; + onSettingsClick: () => void; + }) => ( + + ), })); vi.mock('../components/Sidebar', () => ({ @@ -111,7 +121,17 @@ vi.mock('../components/NewMessageModal', () => ({ })); vi.mock('../components/SettingsModal', () => ({ - SettingsModal: () => null, + SettingsModal: ({ desktopSection }: { desktopSection?: string }) => ( +
{desktopSection ?? 'none'}
+ ), + SETTINGS_SECTION_ORDER: ['radio', 'identity', 'connectivity', 'database', 'bot'], + SETTINGS_SECTION_LABELS: { + radio: '📻 Radio', + identity: '🪪 Identity', + connectivity: '📡 Connectivity', + database: '🗄️ Database', + bot: '🤖 Bot', + }, })); vi.mock('../components/RawPacketList', () => ({ @@ -244,4 +264,32 @@ describe('App favorite toggle flow', () => { expect(screen.getByTitle('Add to favorites')).toBeInTheDocument(); }); }); + + it('toggles settings page mode and syncs selected section into SettingsModal', async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Radio & Config' })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Radio & Config' })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Back to Chat' })).toBeInTheDocument(); + expect(screen.getByTestId('settings-modal-section')).toHaveTextContent('radio'); + }); + + fireEvent.click(screen.getAllByRole('button', { name: /Identity/i })[0]); + + await waitFor(() => { + expect(screen.getByTestId('settings-modal-section')).toHaveTextContent('identity'); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Back to Chat' })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Radio & Config' })).toBeInTheDocument(); + expect(screen.queryByTestId('settings-modal-section')).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 24a1ff7..dee3fcc 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -1,8 +1,15 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { SettingsModal } from '../components/SettingsModal'; -import type { AppSettings, AppSettingsUpdate, HealthStatus, RadioConfig } from '../types'; +import type { + AppSettings, + AppSettingsUpdate, + HealthStatus, + RadioConfig, + RadioConfigUpdate, +} from '../types'; +import type { SettingsSection } from '../components/SettingsModal'; const baseConfig: RadioConfig = { public_key: 'aa'.repeat(32), @@ -43,37 +50,88 @@ function renderModal(overrides?: { appSettings?: AppSettings; onSaveAppSettings?: (update: AppSettingsUpdate) => Promise; onRefreshAppSettings?: () => Promise; + onSave?: (update: RadioConfigUpdate) => Promise; + onClose?: () => void; + onSetPrivateKey?: (key: string) => Promise; + onReboot?: () => Promise; + open?: boolean; + pageMode?: boolean; + externalSidebarNav?: boolean; + desktopSection?: SettingsSection; + mobile?: boolean; }) { + setMatchMedia(overrides?.mobile ?? false); + const onSaveAppSettings = overrides?.onSaveAppSettings ?? vi.fn(async () => {}); const onRefreshAppSettings = overrides?.onRefreshAppSettings ?? vi.fn(async () => {}); + const onSave = overrides?.onSave ?? vi.fn(async (_update: RadioConfigUpdate) => {}); + const onClose = overrides?.onClose ?? vi.fn(); + const onSetPrivateKey = overrides?.onSetPrivateKey ?? vi.fn(async () => {}); + const onReboot = overrides?.onReboot ?? vi.fn(async () => {}); - render( - {})} - onSaveAppSettings={onSaveAppSettings} - onSetPrivateKey={vi.fn(async () => {})} - onReboot={vi.fn(async () => {})} - onAdvertise={vi.fn(async () => {})} - onHealthRefresh={vi.fn(async () => {})} - onRefreshAppSettings={onRefreshAppSettings} - /> - ); + const commonProps = { + open: overrides?.open ?? true, + pageMode: overrides?.pageMode, + config: baseConfig, + health: baseHealth, + appSettings: overrides?.appSettings ?? baseSettings, + onClose, + onSave, + onSaveAppSettings, + onSetPrivateKey, + onReboot, + onAdvertise: vi.fn(async () => {}), + onHealthRefresh: vi.fn(async () => {}), + onRefreshAppSettings, + }; - return { onSaveAppSettings, onRefreshAppSettings }; + const view = overrides?.externalSidebarNav + ? render( + + ) + : render(); + + return { + onSaveAppSettings, + onRefreshAppSettings, + onSave, + onClose, + onSetPrivateKey, + onReboot, + view, + }; } -function openConnectivityTab() { - const connectivityTab = screen.getByRole('tab', { name: 'Connectivity' }); - fireEvent.mouseDown(connectivityTab); - fireEvent.click(connectivityTab); +function setMatchMedia(matches: boolean) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(() => ({ + matches, + media: '(max-width: 767px)', + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function openConnectivitySection() { + const connectivityToggle = screen.getByRole('button', { name: /Connectivity/i }); + fireEvent.click(connectivityToggle); } describe('SettingsModal', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('refreshes app settings when opened', async () => { const { onRefreshAppSettings } = renderModal(); @@ -82,10 +140,23 @@ describe('SettingsModal', () => { }); }); + it('refreshes app settings in page mode even when open is false', async () => { + const { onRefreshAppSettings } = renderModal({ open: false, pageMode: true }); + + await waitFor(() => { + expect(onRefreshAppSettings).toHaveBeenCalledTimes(1); + }); + }); + + it('does not render when closed outside page mode', () => { + renderModal({ open: false }); + expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument(); + }); + it('shows favorite-first contact sync helper text in connectivity tab', async () => { renderModal(); - openConnectivityTab(); + openConnectivitySection(); expect( screen.getByText( @@ -97,7 +168,7 @@ describe('SettingsModal', () => { it('saves changed max contacts value through onSaveAppSettings', async () => { const { onSaveAppSettings } = renderModal(); - openConnectivityTab(); + openConnectivitySection(); const maxContactsInput = screen.getByLabelText('Max Contacts on Radio'); fireEvent.change(maxContactsInput, { target: { value: '250' } }); @@ -114,7 +185,7 @@ describe('SettingsModal', () => { appSettings: { ...baseSettings, max_radio_contacts: 200 }, }); - openConnectivityTab(); + openConnectivitySection(); fireEvent.click(screen.getByRole('button', { name: 'Save Settings' })); await waitFor(() => { @@ -127,7 +198,7 @@ describe('SettingsModal', () => { appSettings: { ...baseSettings, experimental_channel_double_send: false }, }); - openConnectivityTab(); + openConnectivitySection(); const toggle = screen.getByLabelText('Always send channel messages twice'); fireEvent.click(toggle); @@ -139,4 +210,101 @@ describe('SettingsModal', () => { }); }); }); + + it('renders selected section from external sidebar nav on desktop mode', async () => { + renderModal({ + externalSidebarNav: true, + desktopSection: 'bot', + }); + + expect(screen.getByText('No bots configured')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Connectivity/i })).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument(); + }); + + it('toggles sections in mobile accordion mode', () => { + renderModal({ mobile: true }); + const identityToggle = screen.getAllByRole('button', { name: /Identity/i })[0]; + + expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Public Key')).not.toBeInTheDocument(); + + fireEvent.click(identityToggle); + expect(screen.getByLabelText('Public Key')).toBeInTheDocument(); + + fireEvent.click(identityToggle); + expect(screen.queryByLabelText('Public Key')).not.toBeInTheDocument(); + }); + + it('clears stale errors when switching external desktop sections', async () => { + const onSaveAppSettings = vi.fn(async () => { + throw new Error('Save failed'); + }); + + const { view } = renderModal({ + externalSidebarNav: true, + desktopSection: 'database', + onSaveAppSettings, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Save Settings' })); + await waitFor(() => { + expect(screen.getByText('Save failed')).toBeInTheDocument(); + }); + + view.rerender( + {})} + onSaveAppSettings={onSaveAppSettings} + onSetPrivateKey={vi.fn(async () => {})} + onReboot={vi.fn(async () => {})} + onAdvertise={vi.fn(async () => {})} + onHealthRefresh={vi.fn(async () => {})} + onRefreshAppSettings={vi.fn(async () => {})} + /> + ); + + expect(screen.queryByText('Save failed')).not.toBeInTheDocument(); + }); + + it('does not call onClose after save/reboot flows in page mode', async () => { + const onClose = vi.fn(); + const onSave = vi.fn(async () => {}); + const onSetPrivateKey = vi.fn(async () => {}); + const onReboot = vi.fn(async () => {}); + + renderModal({ + pageMode: true, + onClose, + onSave, + onSetPrivateKey, + onReboot, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Save Radio Config & Reboot' })); + await waitFor(() => { + expect(onSave).toHaveBeenCalledTimes(1); + expect(onReboot).toHaveBeenCalledTimes(1); + }); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: /Identity/i })); + fireEvent.change(screen.getByLabelText('Set Private Key (write-only)'), { + target: { value: 'a'.repeat(64) }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Set Private Key & Reboot' })); + + await waitFor(() => { + expect(onSetPrivateKey).toHaveBeenCalledWith('a'.repeat(64)); + expect(onReboot).toHaveBeenCalledTimes(2); + }); + expect(onClose).not.toHaveBeenCalled(); + }); }); diff --git a/tests/e2e/specs/bot.spec.ts b/tests/e2e/specs/bot.spec.ts index 40ffc0c..db18bcd 100644 --- a/tests/e2e/specs/bot.spec.ts +++ b/tests/e2e/specs/bot.spec.ts @@ -44,13 +44,13 @@ test.describe('Bot functionality', () => { await expect(page.getByText('Connected')).toBeVisible(); await page.getByText('Radio & Config').click(); - await page.getByRole('tab', { name: 'Bot' }).click(); + await page.getByRole('button', { name: /Bot/i }).click(); // The bot name should be visible in the bot list await expect(page.getByText('E2E Test Bot')).toBeVisible(); - // Close settings - await page.keyboard.press('Escape'); + // Exit settings page mode + await page.getByRole('button', { name: /Back to Chat/i }).click(); // --- Step 3: Trigger the bot --- await page.getByText('#flightless', { exact: true }).first().click(); diff --git a/tests/e2e/specs/radio-settings.spec.ts b/tests/e2e/specs/radio-settings.spec.ts index 07b9c86..5e8faed 100644 --- a/tests/e2e/specs/radio-settings.spec.ts +++ b/tests/e2e/specs/radio-settings.spec.ts @@ -27,7 +27,7 @@ test.describe('Radio settings', () => { // --- Step 1: Change the name via settings UI --- await page.getByText('Radio & Config').click(); - await page.getByRole('tab', { name: 'Identity' }).click(); + await page.getByRole('button', { name: /Identity/i }).click(); const nameInput = page.locator('#name'); await nameInput.clear(); @@ -36,8 +36,8 @@ test.describe('Radio settings', () => { await page.getByRole('button', { name: 'Save Identity Settings' }).click(); await expect(page.getByText('Identity settings saved')).toBeVisible({ timeout: 10_000 }); - // Close settings - await page.keyboard.press('Escape'); + // Exit settings page mode + await page.getByRole('button', { name: /Back to Chat/i }).click(); // --- Step 2: Verify via API (now returns fresh data after send_appstart fix) --- const config = await getRadioConfig(); @@ -48,7 +48,7 @@ test.describe('Radio settings', () => { await expect(page.getByText('Connected')).toBeVisible({ timeout: 15_000 }); await page.getByText('Radio & Config').click(); - await page.getByRole('tab', { name: 'Identity' }).click(); + await page.getByRole('button', { name: /Identity/i }).click(); await expect(page.locator('#name')).toHaveValue(testName, { timeout: 10_000 }); }); });