Merge pull request #17 from jkingsman/better-options

Way better options dialog!
This commit is contained in:
Jack Kingsman
2026-02-11 22:58:01 -08:00
committed by GitHub
8 changed files with 881 additions and 410 deletions
+320 -237
View File
@@ -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 { api } from './api';
import { useWebSocket } from './useWebSocket'; import { useWebSocket } from './useWebSocket';
import { import {
@@ -13,7 +13,12 @@ import { Sidebar } from './components/Sidebar';
import { MessageList } from './components/MessageList'; import { MessageList } from './components/MessageList';
import { MessageInput, type MessageInputHandle } from './components/MessageInput'; import { MessageInput, type MessageInputHandle } from './components/MessageInput';
import { NewMessageModal } from './components/NewMessageModal'; 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 { RawPacketList } from './components/RawPacketList';
import { MapView } from './components/MapView'; import { MapView } from './components/MapView';
import { VisualizerView } from './components/VisualizerView'; import { VisualizerView } from './components/VisualizerView';
@@ -78,6 +83,7 @@ export function App() {
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null); const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
const [showNewMessage, setShowNewMessage] = useState(false); const [showNewMessage, setShowNewMessage] = useState(false);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection>('radio');
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [undecryptedCount, setUndecryptedCount] = useState(0); const [undecryptedCount, setUndecryptedCount] = useState(0);
const [showCracker, setShowCracker] = useState(false); const [showCracker, setShowCracker] = useState(false);
@@ -510,7 +516,8 @@ export function App() {
} }
const publicChannel = 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; if (!publicChannel) return;
hasSetDefaultConversation.current = true; 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 // Handle sender click to add mention
const handleSenderClick = useCallback((sender: string) => { const handleSenderClick = useCallback((sender: string) => {
messageInputRef.current?.appendText(`@[${sender}] `); messageInputRef.current?.appendText(`@[${sender}] `);
@@ -820,6 +836,18 @@ export function App() {
[appSettings?.sidebar_sort_order] [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) // Sidebar content (shared between desktop and mobile)
const sidebarContent = ( const sidebarContent = (
<Sidebar <Sidebar
@@ -844,18 +872,53 @@ export function App() {
/> />
); );
const settingsSidebarContent = (
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
<div className="flex justify-between items-center px-3 py-3 border-b border-border">
<h2 className="text-xs uppercase text-muted-foreground font-medium">Settings</h2>
<button
type="button"
onClick={handleCloseSettingsView}
className="h-6 w-6 rounded text-muted-foreground hover:text-foreground hover:bg-accent"
title="Back to conversations"
aria-label="Back to conversations"
>
</button>
</div>
<div className="flex-1 overflow-y-auto py-1">
{SETTINGS_SECTION_ORDER.map((section) => (
<button
key={section}
type="button"
className={cn(
'w-full px-3 py-2.5 text-left border-l-2 border-transparent hover:bg-accent',
settingsSection === section && 'bg-accent border-l-primary'
)}
onClick={() => setSettingsSection(section)}
>
{SETTINGS_SECTION_LABELS[section]}
</button>
))}
</div>
</div>
);
const activeSidebarContent = showSettings ? settingsSidebarContent : sidebarContent;
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<StatusBar <StatusBar
health={health} health={health}
config={config} config={config}
onSettingsClick={() => setShowSettings(true)} settingsMode={showSettings}
onMenuClick={() => setSidebarOpen(true)} onSettingsClick={handleToggleSettingsView}
onMenuClick={showSettings ? undefined : () => setSidebarOpen(true)}
/> />
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Desktop sidebar - hidden on mobile */} {/* Desktop sidebar - hidden on mobile */}
<div className="hidden md:block">{sidebarContent}</div> <div className="hidden md:block">{activeSidebarContent}</div>
{/* Mobile sidebar - Sheet that slides in */} {/* Mobile sidebar - Sheet that slides in */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}> <Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
@@ -863,231 +926,269 @@ export function App() {
<SheetHeader className="sr-only"> <SheetHeader className="sr-only">
<SheetTitle>Navigation</SheetTitle> <SheetTitle>Navigation</SheetTitle>
</SheetHeader> </SheetHeader>
<div className="flex-1 overflow-hidden">{sidebarContent}</div> <div className="flex-1 overflow-hidden">{activeSidebarContent}</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<div className="flex-1 flex flex-col bg-background min-w-0"> <div className="flex-1 flex flex-col bg-background min-w-0">
{activeConversation ? ( <div className={cn('flex-1 flex flex-col min-h-0', showSettings && 'hidden')}>
activeConversation.type === 'map' ? ( {activeConversation ? (
<> activeConversation.type === 'map' ? (
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg"> <>
Node Map <div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
</div> Node Map
<div className="flex-1 overflow-hidden"> </div>
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} /> <div className="flex-1 overflow-hidden">
</div> <MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
</> </div>
) : activeConversation.type === 'visualizer' ? ( </>
<VisualizerView ) : activeConversation.type === 'visualizer' ? (
packets={rawPackets} <VisualizerView
contacts={contacts} packets={rawPackets}
config={config} contacts={contacts}
onClearPackets={() => setRawPackets([])} config={config}
/> onClearPackets={() => setRawPackets([])}
) : activeConversation.type === 'raw' ? ( />
<> ) : activeConversation.type === 'raw' ? (
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg"> <>
Raw Packet Feed <div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
</div> Raw Packet Feed
<div className="flex-1 overflow-hidden"> </div>
<RawPacketList packets={rawPackets} /> <div className="flex-1 overflow-hidden">
</div> <RawPacketList packets={rawPackets} />
</> </div>
) : ( </>
<> ) : (
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg gap-2"> <>
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1"> <div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg gap-2">
<span className="flex-shrink-0"> <span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
{activeConversation.type === 'channel' && <span className="flex-shrink-0">
!activeConversation.name.startsWith('#') && {activeConversation.type === 'channel' &&
activeConversation.name !== 'Public' !activeConversation.name.startsWith('#') &&
? '#' activeConversation.name !== 'Public'
: ''} ? '#'
{activeConversation.name} : ''}
</span> {activeConversation.name}
<span </span>
className="font-normal text-sm text-muted-foreground font-mono truncate cursor-pointer hover:text-primary" <span
onClick={(e) => { className="font-normal text-sm text-muted-foreground font-mono truncate cursor-pointer hover:text-primary"
e.stopPropagation(); onClick={(e) => {
navigator.clipboard.writeText(activeConversation.id); e.stopPropagation();
toast.success( navigator.clipboard.writeText(activeConversation.id);
activeConversation.type === 'channel' toast.success(
? 'Room key copied!' activeConversation.type === 'channel'
: 'Contact key copied!' ? 'Room key copied!'
); : 'Contact key copied!'
}}
title="Click to copy"
>
{activeConversation.type === 'channel'
? activeConversation.id.toLowerCase()
: activeConversation.id}
</span>
{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 title="Click to copy"
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(
<span key="coords">
<span
className="font-mono cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
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)}
</span>
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
</span>
);
}
return parts.length > 0 ? (
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
(
{parts.map((part, i) => (
<span key={i}>
{i > 0 && ', '}
{part}
</span>
))}
)
</span>
) : null;
})()}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Direct trace button (contacts only) */}
{activeConversation.type === 'contact' && (
<button
className="p-1.5 rounded hover:bg-accent text-xl leading-none"
onClick={handleTrace}
title="Direct Trace"
> >
&#x1F6CE; {activeConversation.type === 'channel'
</button> ? activeConversation.id.toLowerCase()
)} : activeConversation.id}
{/* Favorite button */} </span>
{(activeConversation.type === 'channel' || {activeConversation.type === 'contact' &&
activeConversation.type === 'contact') && ( (() => {
<button const contact = contacts.find(
className="p-1.5 rounded hover:bg-accent text-xl leading-none" (c) => c.public_key === activeConversation.id
onClick={() => );
handleToggleFavorite( if (!contact) return null;
activeConversation.type as 'channel' | 'contact', const parts: React.ReactNode[] = [];
activeConversation.id if (contact.last_seen) {
) parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
} }
title={ if (contact.last_path_len === -1) {
isFavorite( 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(
<span key="coords">
<span
className="font-mono cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
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)}
</span>
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
</span>
);
}
return parts.length > 0 ? (
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
(
{parts.map((part, i) => (
<span key={i}>
{i > 0 && ', '}
{part}
</span>
))}
)
</span>
) : null;
})()}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Direct trace button (contacts only) */}
{activeConversation.type === 'contact' && (
<button
className="p-1.5 rounded hover:bg-accent text-xl leading-none"
onClick={handleTrace}
title="Direct Trace"
>
&#x1F6CE;
</button>
)}
{/* Favorite button */}
{(activeConversation.type === 'channel' ||
activeConversation.type === 'contact') && (
<button
className="p-1.5 rounded hover:bg-accent text-xl leading-none"
onClick={() =>
handleToggleFavorite(
activeConversation.type as 'channel' | 'contact',
activeConversation.id
)
}
title={
isFavorite(
favorites,
activeConversation.type as 'channel' | 'contact',
activeConversation.id
)
? 'Remove from favorites'
: 'Add to favorites'
}
>
{isFavorite(
favorites, favorites,
activeConversation.type as 'channel' | 'contact', activeConversation.type as 'channel' | 'contact',
activeConversation.id activeConversation.id
) ) ? (
? 'Remove from favorites' <span className="text-yellow-500">&#9733;</span>
: 'Add to favorites' ) : (
} <span className="text-muted-foreground">&#9734;</span>
> )}
{isFavorite( </button>
favorites, )}
activeConversation.type as 'channel' | 'contact', {/* Delete button */}
activeConversation.id {!(
) ? ( activeConversation.type === 'channel' &&
<span className="text-yellow-500">&#9733;</span> activeConversation.name === 'Public'
) : ( ) && (
<span className="text-muted-foreground">&#9734;</span> <button
)} className="p-1.5 rounded hover:bg-destructive/20 text-destructive text-xl leading-none"
</button> onClick={() => {
)} if (activeConversation.type === 'channel') {
{/* Delete button */} handleDeleteChannel(activeConversation.id);
{!( } else {
activeConversation.type === 'channel' && activeConversation.name === 'Public' handleDeleteContact(activeConversation.id);
) && ( }
<button }}
className="p-1.5 rounded hover:bg-destructive/20 text-destructive text-xl leading-none" title="Delete"
onClick={() => { >
if (activeConversation.type === 'channel') { &#128465;
handleDeleteChannel(activeConversation.id); </button>
} else { )}
handleDeleteContact(activeConversation.id); </div>
}
}}
title="Delete"
>
&#128465;
</button>
)}
</div> </div>
</div> <MessageList
<MessageList key={activeConversation.id}
key={activeConversation.id} messages={messages}
messages={messages} contacts={contacts}
contacts={contacts} loading={messagesLoading}
loading={messagesLoading} loadingOlder={loadingOlder}
loadingOlder={loadingOlder} hasOlderMessages={hasOlderMessages}
hasOlderMessages={hasOlderMessages} onSenderClick={
onSenderClick={ activeConversation.type === 'channel' ? handleSenderClick : undefined
activeConversation.type === 'channel' ? handleSenderClick : undefined }
} onLoadOlder={fetchOlderMessages}
onLoadOlder={fetchOlderMessages} radioName={config?.name}
radioName={config?.name} config={config}
config={config} />
/> <MessageInput
<MessageInput ref={messageInputRef}
ref={messageInputRef} onSend={
onSend={ activeContactIsRepeater
activeContactIsRepeater
? repeaterLoggedIn
? handleRepeaterCommand
: handleTelemetryRequest
: handleSendMessage
}
disabled={!health?.radio_connected}
isRepeaterMode={activeContactIsRepeater && !repeaterLoggedIn}
conversationType={activeConversation.type}
senderName={config?.name}
placeholder={
!health?.radio_connected
? 'Radio not connected'
: activeContactIsRepeater
? repeaterLoggedIn ? repeaterLoggedIn
? 'Send CLI command (requires admin login)...' ? handleRepeaterCommand
: `Enter password for ${activeConversation.name} (or . for none)...` : handleTelemetryRequest
: `Message ${activeConversation.name}...` : handleSendMessage
} }
disabled={!health?.radio_connected}
isRepeaterMode={activeContactIsRepeater && !repeaterLoggedIn}
conversationType={activeConversation.type}
senderName={config?.name}
placeholder={
!health?.radio_connected
? 'Radio not connected'
: activeContactIsRepeater
? repeaterLoggedIn
? 'Send CLI command (requires admin login)...'
: `Enter password for ${activeConversation.name} (or . for none)...`
: `Message ${activeConversation.name}...`
}
/>
</>
)
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Select a conversation or start a new one
</div>
)}
</div>
{showSettings && (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
<span>Radio & Settings</span>
<span className="text-sm text-muted-foreground hidden sm:inline">
{SETTINGS_SECTION_LABELS[settingsSection]}
</span>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<SettingsModal
open={showSettings}
pageMode
externalSidebarNav
desktopSection={settingsSection}
config={config}
health={health}
appSettings={appSettings}
onClose={handleCloseSettingsView}
onSave={handleSaveConfig}
onSaveAppSettings={handleSaveAppSettings}
onSetPrivateKey={handleSetPrivateKey}
onReboot={handleReboot}
onAdvertise={handleAdvertise}
onHealthRefresh={handleHealthRefresh}
onRefreshAppSettings={fetchAppSettings}
/> />
</> </div>
)
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Select a conversation or start a new one
</div> </div>
)} )}
</div> </div>
@@ -1132,24 +1233,6 @@ export function App() {
onCreateHashtagChannel={handleCreateHashtagChannel} onCreateHashtagChannel={handleCreateHashtagChannel}
/> />
<SettingsModal
open={showSettings}
config={config}
health={health}
appSettings={appSettings}
onClose={() => setShowSettings(false)}
onSave={handleSaveConfig}
onSaveAppSettings={handleSaveAppSettings}
onSetPrivateKey={handleSetPrivateKey}
onReboot={handleReboot}
onAdvertise={handleAdvertise}
onHealthRefresh={async () => {
const data = await api.getHealth();
setHealth(data);
}}
onRefreshAppSettings={fetchAppSettings}
/>
<Toaster position="top-right" /> <Toaster position="top-right" />
</div> </div>
); );
+3 -2
View File
@@ -6,9 +6,10 @@ interface BotCodeEditorProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
id?: string; id?: string;
height?: string;
} }
export function BotCodeEditor({ value, onChange, id }: BotCodeEditorProps) { export function BotCodeEditor({ value, onChange, id, height = '256px' }: BotCodeEditorProps) {
return ( return (
<div className="w-full overflow-hidden rounded-md border border-input"> <div className="w-full overflow-hidden rounded-md border border-input">
<CodeMirror <CodeMirror
@@ -16,7 +17,7 @@ export function BotCodeEditor({ value, onChange, id }: BotCodeEditorProps) {
onChange={onChange} onChange={onChange}
extensions={[python()]} extensions={[python()]}
theme={oneDark} theme={oneDark}
height="256px" height={height}
basicSetup={{ basicSetup={{
lineNumbers: true, lineNumbers: true,
foldGutter: false, foldGutter: false,
+297 -133
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, lazy, Suspense } from 'react'; import { useState, useEffect, useMemo, lazy, Suspense, type ReactNode } from 'react';
const BotCodeEditor = lazy(() => const BotCodeEditor = lazy(() =>
import('./BotCodeEditor').then((m) => ({ default: m.BotCodeEditor })) import('./BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
@@ -11,8 +11,6 @@ import type {
RadioConfig, RadioConfig,
RadioConfigUpdate, RadioConfigUpdate,
} from '../types'; } 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 { Input } from './ui/input';
import { Label } from './ui/label'; import { Label } from './ui/label';
import { Button } from './ui/button'; import { Button } from './ui/button';
@@ -48,8 +46,27 @@ const RADIO_PRESETS: RadioPreset[] = [
{ name: 'Vietnam', freq: 920.25, bw: 250, sf: 11, cr: 5 }, { 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<SettingsSection, string> = {
radio: '📻 Radio',
identity: '🪪 Identity',
connectivity: '📡 Connectivity',
database: '🗄️ Database',
bot: '🤖 Bot',
};
interface SettingsModalBaseProps {
open: boolean; open: boolean;
pageMode?: boolean;
config: RadioConfig | null; config: RadioConfig | null;
health: HealthStatus | null; health: HealthStatus | null;
appSettings: AppSettings | null; appSettings: AppSettings | null;
@@ -63,23 +80,47 @@ interface SettingsModalProps {
onRefreshAppSettings: () => Promise<void>; onRefreshAppSettings: () => Promise<void>;
} }
export function SettingsModal({ type SettingsModalProps = SettingsModalBaseProps &
open, (
config, | { externalSidebarNav: true; desktopSection: SettingsSection }
health, | { externalSidebarNav?: false; desktopSection?: never }
appSettings, );
onClose,
onSave, export function SettingsModal(props: SettingsModalProps) {
onSaveAppSettings, const {
onSetPrivateKey, open,
onReboot, pageMode = false,
onAdvertise, config,
onHealthRefresh, health,
onRefreshAppSettings, appSettings,
}: SettingsModalProps) { onClose,
// Tab state onSave,
type SettingsTab = 'radio' | 'identity' | 'connectivity' | 'database' | 'bot'; onSaveAppSettings,
const [activeTab, setActiveTab] = useState<SettingsTab>('radio'); 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<Record<SettingsSection, boolean>>(() => {
const isMobile = getIsMobileLayout();
return {
radio: !isMobile,
identity: false,
connectivity: false,
database: false,
bot: false,
};
});
// Radio config state // Radio config state
const [name, setName] = useState(''); const [name, setName] = useState('');
@@ -95,11 +136,14 @@ export function SettingsModal({
const [experimentalChannelDoubleSend, setExperimentalChannelDoubleSend] = useState(false); const [experimentalChannelDoubleSend, setExperimentalChannelDoubleSend] = useState(false);
// Loading states // Loading states
const [loading, setLoading] = useState(false); const [busySection, setBusySection] = useState<SettingsSection | null>(null);
const [rebooting, setRebooting] = useState(false); const [rebooting, setRebooting] = useState(false);
const [advertising, setAdvertising] = useState(false); const [advertising, setAdvertising] = useState(false);
const [gettingLocation, setGettingLocation] = useState(false); const [gettingLocation, setGettingLocation] = useState(false);
const [error, setError] = useState(''); const [sectionError, setSectionError] = useState<{
section: SettingsSection;
message: string;
} | null>(null);
// Database maintenance state // Database maintenance state
const [retentionDays, setRetentionDays] = useState('14'); const [retentionDays, setRetentionDays] = useState('14');
@@ -172,10 +216,34 @@ export function SettingsModal({
// Refresh settings from server when modal opens // Refresh settings from server when modal opens
// This ensures UI reflects actual server state (prevents stale UI after checkbox toggle without save) // This ensures UI reflects actual server state (prevents stale UI after checkbox toggle without save)
useEffect(() => { useEffect(() => {
if (open) { if (open || pageMode) {
onRefreshAppSettings(); 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 // Detect current preset from form values
const currentPreset = useMemo(() => { const currentPreset = useMemo(() => {
@@ -235,8 +303,8 @@ export function SettingsModal({
}; };
const handleSaveRadioConfig = async () => { const handleSaveRadioConfig = async () => {
setError(''); setSectionError(null);
setLoading(true); setBusySection('radio');
try { try {
const update: RadioConfigUpdate = { const update: RadioConfigUpdate = {
@@ -252,21 +320,25 @@ export function SettingsModal({
}; };
await onSave(update); await onSave(update);
toast.success('Radio config saved, rebooting...'); toast.success('Radio config saved, rebooting...');
setLoading(false);
setRebooting(true); setRebooting(true);
await onReboot(); await onReboot();
onClose(); if (!pageMode) {
onClose();
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save'); setSectionError({
setLoading(false); section: 'radio',
message: err instanceof Error ? err.message : 'Failed to save',
});
} finally { } finally {
setRebooting(false); setRebooting(false);
setBusySection(null);
} }
}; };
const handleSaveIdentity = async () => { const handleSaveIdentity = async () => {
setError(''); setSectionError(null);
setLoading(true); setBusySection('identity');
try { try {
// Save radio name // Save radio name
@@ -281,15 +353,18 @@ export function SettingsModal({
toast.success('Identity settings saved'); toast.success('Identity settings saved');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save'); setSectionError({
section: 'identity',
message: err instanceof Error ? err.message : 'Failed to save',
});
} finally { } finally {
setLoading(false); setBusySection(null);
} }
}; };
const handleSaveConnectivity = async () => { const handleSaveConnectivity = async () => {
setError(''); setSectionError(null);
setLoading(true); setBusySection('connectivity');
try { try {
const update: AppSettingsUpdate = {}; const update: AppSettingsUpdate = {};
@@ -305,33 +380,40 @@ export function SettingsModal({
} }
toast.success('Connectivity settings saved'); toast.success('Connectivity settings saved');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save'); setSectionError({
section: 'connectivity',
message: err instanceof Error ? err.message : 'Failed to save',
});
} finally { } finally {
setLoading(false); setBusySection(null);
} }
}; };
const handleSetPrivateKey = async () => { const handleSetPrivateKey = async () => {
if (!privateKey.trim()) { if (!privateKey.trim()) {
setError('Private key is required'); setSectionError({ section: 'identity', message: 'Private key is required' });
return; return;
} }
setError(''); setSectionError(null);
setLoading(true); setBusySection('identity');
try { try {
await onSetPrivateKey(privateKey.trim()); await onSetPrivateKey(privateKey.trim());
setPrivateKey(''); setPrivateKey('');
toast.success('Private key set, rebooting...'); toast.success('Private key set, rebooting...');
setLoading(false);
setRebooting(true); setRebooting(true);
await onReboot(); await onReboot();
onClose(); if (!pageMode) {
onClose();
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to set private key'); setSectionError({
setLoading(false); section: 'identity',
message: err instanceof Error ? err.message : 'Failed to set private key',
});
} finally { } finally {
setRebooting(false); setRebooting(false);
setBusySection(null);
} }
}; };
@@ -341,16 +423,23 @@ export function SettingsModal({
) { ) {
return; return;
} }
setError(''); setSectionError(null);
setBusySection('connectivity');
setRebooting(true); setRebooting(true);
try { try {
await onReboot(); await onReboot();
onClose(); if (!pageMode) {
onClose();
}
} catch (err) { } 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 { } finally {
setRebooting(false); setRebooting(false);
setBusySection(null);
} }
}; };
@@ -391,24 +480,27 @@ export function SettingsModal({
}; };
const handleSaveDatabaseSettings = async () => { const handleSaveDatabaseSettings = async () => {
setLoading(true); setBusySection('database');
setError(''); setSectionError(null);
try { try {
await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert }); await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert });
toast.success('Database settings saved'); toast.success('Database settings saved');
} catch (err) { } catch (err) {
console.error('Failed to save database settings:', 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'); toast.error('Failed to save settings');
} finally { } finally {
setLoading(false); setBusySection(null);
} }
}; };
const handleSaveBotSettings = async () => { const handleSaveBotSettings = async () => {
setLoading(true); setBusySection('bot');
setError(''); setSectionError(null);
try { try {
await onSaveAppSettings({ bots }); await onSaveAppSettings({ bots });
@@ -416,10 +508,10 @@ export function SettingsModal({
} catch (err) { } catch (err) {
console.error('Failed to save bot settings:', err); console.error('Failed to save bot settings:', err);
const errorMsg = err instanceof Error ? err.message : 'Failed to save'; const errorMsg = err instanceof Error ? err.message : 'Failed to save';
setError(errorMsg); setSectionError({ section: 'bot', message: errorMsg });
toast.error(errorMsg); toast.error(errorMsg);
} finally { } 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))); setBots(bots.map((b) => (b.id === botId ? { ...b, code: DEFAULT_BOT_CODE } : b)));
}; };
return ( const toggleSection = (section: SettingsSection) => {
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}> setExpandedSections((prev) => ({
<DialogContent className="sm:max-w-[50vw] sm:min-w-[500px] max-h-[90vh] overflow-y-auto"> ...prev,
<DialogHeader> [section]: !prev[section],
<DialogTitle>Radio & Settings</DialogTitle> }));
<DialogDescription className="sr-only"> setSectionError(null);
{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'}
</DialogDescription>
</DialogHeader>
{!config ? ( const externalDesktopSidebarMode = externalSidebarNav && !isMobileLayout;
<div className="py-8 text-center text-muted-foreground">Loading configuration...</div>
) : (
<Tabs
value={activeTab}
onValueChange={(v) => {
setActiveTab(v as SettingsTab);
setError('');
}}
className="w-full"
>
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="radio">Radio</TabsTrigger>
<TabsTrigger value="identity">Identity</TabsTrigger>
<TabsTrigger value="connectivity">Connectivity</TabsTrigger>
<TabsTrigger value="database">Database</TabsTrigger>
<TabsTrigger value="bot">Bot</TabsTrigger>
</TabsList>
{/* Radio Config Tab */} const isSectionVisible = (section: SettingsSection) =>
<TabsContent value="radio" className="space-y-4 mt-4"> 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 (
<button type="button" className={sectionButtonClasses} onClick={() => toggleSection(section)}>
<span className="font-medium">{SETTINGS_SECTION_LABELS[section]}</span>
<span className="text-muted-foreground md:hidden">
{expandedSections[section] ? '' : '+'}
</span>
</button>
);
};
const isSectionBusy = (section: SettingsSection) => busySection === section;
const getSectionError = (section: SettingsSection) =>
sectionError?.section === section ? sectionError.message : null;
if (!pageMode && !open) {
return null;
}
return !config ? (
<div className="py-8 text-center text-muted-foreground">Loading configuration...</div>
) : (
<div className={settingsContainerClass}>
{shouldRenderSection('radio') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('radio')}
{isSectionVisible('radio') && (
<div className={sectionContentClass}>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="preset">Preset</Label> <Label htmlFor="preset">Preset</Label>
<select <select
@@ -634,19 +749,29 @@ export function SettingsModal({
</div> </div>
</div> </div>
{error && <div className="text-sm text-destructive">{error}</div>} {getSectionError('radio') && (
<div className="text-sm text-destructive">{getSectionError('radio')}</div>
)}
<Button <Button
onClick={handleSaveRadioConfig} onClick={handleSaveRadioConfig}
disabled={loading || rebooting} disabled={isSectionBusy('radio') || rebooting}
className="w-full" className="w-full"
> >
{loading || rebooting ? 'Saving & Rebooting...' : 'Save Radio Config & Reboot'} {isSectionBusy('radio') || rebooting
? 'Saving & Rebooting...'
: 'Save Radio Config & Reboot'}
</Button> </Button>
</TabsContent> </div>
)}
</div>
)}
{/* Identity Tab */} {shouldRenderSection('identity') && (
<TabsContent value="identity" className="space-y-4 mt-4"> <div className={sectionWrapperClass}>
{renderSectionHeader('identity')}
{isSectionVisible('identity') && (
<div className={sectionContentClass}>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label> <Label htmlFor="public-key">Public Key</Label>
<Input <Input
@@ -681,8 +806,12 @@ export function SettingsModal({
</p> </p>
</div> </div>
<Button onClick={handleSaveIdentity} disabled={loading} className="w-full"> <Button
{loading ? 'Saving...' : 'Save Identity Settings'} onClick={handleSaveIdentity}
disabled={isSectionBusy('identity')}
className="w-full"
>
{isSectionBusy('identity') ? 'Saving...' : 'Save Identity Settings'}
</Button> </Button>
<Separator /> <Separator />
@@ -699,10 +828,12 @@ export function SettingsModal({
/> />
<Button <Button
onClick={handleSetPrivateKey} onClick={handleSetPrivateKey}
disabled={loading || rebooting || !privateKey.trim()} disabled={isSectionBusy('identity') || rebooting || !privateKey.trim()}
className="w-full" className="w-full"
> >
{loading || rebooting ? 'Setting & Rebooting...' : 'Set Private Key & Reboot'} {isSectionBusy('identity') || rebooting
? 'Setting & Rebooting...'
: 'Set Private Key & Reboot'}
</Button> </Button>
</div> </div>
@@ -725,11 +856,19 @@ export function SettingsModal({
)} )}
</div> </div>
{error && <div className="text-sm text-destructive">{error}</div>} {getSectionError('identity') && (
</TabsContent> <div className="text-sm text-destructive">{getSectionError('identity')}</div>
)}
</div>
)}
</div>
)}
{/* Connectivity Tab */} {shouldRenderSection('connectivity') && (
<TabsContent value="connectivity" className="space-y-4 mt-4"> <div className={sectionWrapperClass}>
{renderSectionHeader('connectivity')}
{isSectionVisible('connectivity') && (
<div className={sectionContentClass}>
<div className="space-y-2"> <div className="space-y-2">
<Label>Connection</Label> <Label>Connection</Label>
{health?.connection_info ? ( {health?.connection_info ? (
@@ -786,8 +925,12 @@ export function SettingsModal({
</p> </p>
</div> </div>
<Button onClick={handleSaveConnectivity} disabled={loading} className="w-full"> <Button
{loading ? 'Saving...' : 'Save Settings'} onClick={handleSaveConnectivity}
disabled={isSectionBusy('connectivity')}
className="w-full"
>
{isSectionBusy('connectivity') ? 'Saving...' : 'Save Settings'}
</Button> </Button>
<Separator /> <Separator />
@@ -795,17 +938,25 @@ export function SettingsModal({
<Button <Button
variant="outline" variant="outline"
onClick={handleReboot} onClick={handleReboot}
disabled={rebooting || loading} disabled={rebooting || isSectionBusy('connectivity')}
className="w-full border-red-500/50 text-red-400 hover:bg-red-500/10" className="w-full border-red-500/50 text-red-400 hover:bg-red-500/10"
> >
{rebooting ? 'Rebooting...' : 'Reboot Radio'} {rebooting ? 'Rebooting...' : 'Reboot Radio'}
</Button> </Button>
{error && <div className="text-sm text-destructive">{error}</div>} {getSectionError('connectivity') && (
</TabsContent> <div className="text-sm text-destructive">{getSectionError('connectivity')}</div>
)}
</div>
)}
</div>
)}
{/* Database Tab */} {shouldRenderSection('database') && (
<TabsContent value="database" className="space-y-4 mt-4"> <div className={sectionWrapperClass}>
{renderSectionHeader('database')}
{isSectionVisible('database') && (
<div className={sectionContentClass}>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Database size</span> <span className="text-sm text-muted-foreground">Database size</span>
@@ -885,15 +1036,27 @@ export function SettingsModal({
</p> </p>
</div> </div>
{error && <div className="text-sm text-destructive">{error}</div>} {getSectionError('database') && (
<div className="text-sm text-destructive">{getSectionError('database')}</div>
)}
<Button onClick={handleSaveDatabaseSettings} disabled={loading} className="w-full"> <Button
{loading ? 'Saving...' : 'Save Settings'} onClick={handleSaveDatabaseSettings}
disabled={isSectionBusy('database')}
className="w-full"
>
{isSectionBusy('database') ? 'Saving...' : 'Save Settings'}
</Button> </Button>
</TabsContent> </div>
)}
</div>
)}
{/* Bot Tab */} {shouldRenderSection('bot') && (
<TabsContent value="bot" className="space-y-4 mt-4"> <div className={sectionWrapperClass}>
{renderSectionHeader('bot')}
{isSectionVisible('bot') && (
<div className={sectionContentClass}>
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-md"> <div className="p-3 bg-red-500/10 border border-red-500/30 rounded-md">
<p className="text-sm text-red-500"> <p className="text-sm text-red-500">
<strong>Experimental:</strong> This is an alpha feature and introduces automated <strong>Experimental:</strong> This is an alpha feature and introduces automated
@@ -935,11 +1098,9 @@ export function SettingsModal({
<div className="space-y-2"> <div className="space-y-2">
{bots.map((bot) => ( {bots.map((bot) => (
<div key={bot.id} className="border border-input rounded-md overflow-hidden"> <div key={bot.id} className="border border-input rounded-md overflow-hidden">
{/* Bot header row */}
<div <div
className="flex items-center gap-2 px-3 py-2 bg-muted/50 cursor-pointer hover:bg-muted/80" className="flex items-center gap-2 px-3 py-2 bg-muted/50 cursor-pointer hover:bg-muted/80"
onClick={(e) => { onClick={(e) => {
// Don't toggle if clicking on interactive elements
if ((e.target as HTMLElement).closest('input, button')) return; if ((e.target as HTMLElement).closest('input, button')) return;
setExpandedBotId(expandedBotId === bot.id ? null : bot.id); setExpandedBotId(expandedBotId === bot.id ? null : bot.id);
}} }}
@@ -948,7 +1109,6 @@ export function SettingsModal({
{expandedBotId === bot.id ? '' : ''} {expandedBotId === bot.id ? '' : ''}
</span> </span>
{/* Bot name (click to edit) */}
{editingNameId === bot.id ? ( {editingNameId === bot.id ? (
<input <input
type="text" type="text"
@@ -979,7 +1139,6 @@ export function SettingsModal({
</span> </span>
)} )}
{/* Enabled checkbox */}
<label <label
className="flex items-center gap-1.5 cursor-pointer" className="flex items-center gap-1.5 cursor-pointer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -993,7 +1152,6 @@ export function SettingsModal({
<span className="text-xs text-muted-foreground">Enabled</span> <span className="text-xs text-muted-foreground">Enabled</span>
</label> </label>
{/* Delete button */}
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -1009,7 +1167,6 @@ export function SettingsModal({
</Button> </Button>
</div> </div>
{/* Bot expanded content */}
{expandedBotId === bot.id && ( {expandedBotId === bot.id && (
<div className="p-3 space-y-3 border-t border-input"> <div className="p-3 space-y-3 border-t border-input">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -1028,7 +1185,7 @@ export function SettingsModal({
</div> </div>
<Suspense <Suspense
fallback={ fallback={
<div className="h-64 rounded-md border border-input bg-[#282c34] flex items-center justify-center text-muted-foreground"> <div className="h-64 md:h-96 rounded-md border border-input bg-[#282c34] flex items-center justify-center text-muted-foreground">
Loading editor... Loading editor...
</div> </div>
} }
@@ -1037,6 +1194,7 @@ export function SettingsModal({
value={bot.code} value={bot.code}
onChange={(code) => handleBotCodeChange(bot.id, code)} onChange={(code) => handleBotCodeChange(bot.id, code)}
id={`bot-code-${bot.id}`} id={`bot-code-${bot.id}`}
height={isMobileLayout ? '256px' : '384px'}
/> />
</Suspense> </Suspense>
</div> </div>
@@ -1064,15 +1222,21 @@ export function SettingsModal({
</p> </p>
</div> </div>
{error && <div className="text-sm text-destructive">{error}</div>} {getSectionError('bot') && (
<div className="text-sm text-destructive">{getSectionError('bot')}</div>
)}
<Button onClick={handleSaveBotSettings} disabled={loading} className="w-full"> <Button
{loading ? 'Saving...' : 'Save Bot Settings'} onClick={handleSaveBotSettings}
disabled={isSectionBusy('bot')}
className="w-full"
>
{isSectionBusy('bot') ? 'Saving...' : 'Save Bot Settings'}
</Button> </Button>
</TabsContent> </div>
</Tabs> )}
)} </div>
</DialogContent> )}
</Dialog> </div>
); );
} }
+9 -2
View File
@@ -7,11 +7,18 @@ import { toast } from './ui/sonner';
interface StatusBarProps { interface StatusBarProps {
health: HealthStatus | null; health: HealthStatus | null;
config: RadioConfig | null; config: RadioConfig | null;
settingsMode?: boolean;
onSettingsClick: () => void; onSettingsClick: () => void;
onMenuClick?: () => 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 connected = health?.radio_connected ?? false;
const [reconnecting, setReconnecting] = useState(false); const [reconnecting, setReconnecting] = useState(false);
@@ -85,7 +92,7 @@ export function StatusBar({ health, config, onSettingsClick, onMenuClick }: Stat
<span role="img" aria-label="Settings"> <span role="img" aria-label="Settings">
&#128295; &#128295;
</span>{' '} </span>{' '}
Radio & Config {settingsMode ? 'Back to Chat' : 'Radio & Config'}
</button> </button>
</div> </div>
); );
+50 -2
View File
@@ -88,7 +88,17 @@ vi.mock('../messageCache', () => ({
})); }));
vi.mock('../components/StatusBar', () => ({ vi.mock('../components/StatusBar', () => ({
StatusBar: () => <div data-testid="status-bar" />, StatusBar: ({
settingsMode,
onSettingsClick,
}: {
settingsMode?: boolean;
onSettingsClick: () => void;
}) => (
<button type="button" onClick={onSettingsClick} data-testid="status-bar-settings-toggle">
{settingsMode ? 'Back to Chat' : 'Radio & Config'}
</button>
),
})); }));
vi.mock('../components/Sidebar', () => ({ vi.mock('../components/Sidebar', () => ({
@@ -111,7 +121,17 @@ vi.mock('../components/NewMessageModal', () => ({
})); }));
vi.mock('../components/SettingsModal', () => ({ vi.mock('../components/SettingsModal', () => ({
SettingsModal: () => null, SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
<div data-testid="settings-modal-section">{desktopSection ?? 'none'}</div>
),
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', () => ({ vi.mock('../components/RawPacketList', () => ({
@@ -244,4 +264,32 @@ describe('App favorite toggle flow', () => {
expect(screen.getByTitle('Add to favorites')).toBeInTheDocument(); expect(screen.getByTitle('Add to favorites')).toBeInTheDocument();
}); });
}); });
it('toggles settings page mode and syncs selected section into SettingsModal', async () => {
render(<App />);
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();
});
});
}); });
+195 -27
View File
@@ -1,8 +1,15 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; 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 { 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 = { const baseConfig: RadioConfig = {
public_key: 'aa'.repeat(32), public_key: 'aa'.repeat(32),
@@ -43,37 +50,88 @@ function renderModal(overrides?: {
appSettings?: AppSettings; appSettings?: AppSettings;
onSaveAppSettings?: (update: AppSettingsUpdate) => Promise<void>; onSaveAppSettings?: (update: AppSettingsUpdate) => Promise<void>;
onRefreshAppSettings?: () => Promise<void>; onRefreshAppSettings?: () => Promise<void>;
onSave?: (update: RadioConfigUpdate) => Promise<void>;
onClose?: () => void;
onSetPrivateKey?: (key: string) => Promise<void>;
onReboot?: () => Promise<void>;
open?: boolean;
pageMode?: boolean;
externalSidebarNav?: boolean;
desktopSection?: SettingsSection;
mobile?: boolean;
}) { }) {
setMatchMedia(overrides?.mobile ?? false);
const onSaveAppSettings = overrides?.onSaveAppSettings ?? vi.fn(async () => {}); const onSaveAppSettings = overrides?.onSaveAppSettings ?? vi.fn(async () => {});
const onRefreshAppSettings = overrides?.onRefreshAppSettings ?? 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( const commonProps = {
<SettingsModal open: overrides?.open ?? true,
open pageMode: overrides?.pageMode,
config={baseConfig} config: baseConfig,
health={baseHealth} health: baseHealth,
appSettings={overrides?.appSettings ?? baseSettings} appSettings: overrides?.appSettings ?? baseSettings,
onClose={vi.fn()} onClose,
onSave={vi.fn(async () => {})} onSave,
onSaveAppSettings={onSaveAppSettings} onSaveAppSettings,
onSetPrivateKey={vi.fn(async () => {})} onSetPrivateKey,
onReboot={vi.fn(async () => {})} onReboot,
onAdvertise={vi.fn(async () => {})} onAdvertise: vi.fn(async () => {}),
onHealthRefresh={vi.fn(async () => {})} onHealthRefresh: vi.fn(async () => {}),
onRefreshAppSettings={onRefreshAppSettings} onRefreshAppSettings,
/> };
);
return { onSaveAppSettings, onRefreshAppSettings }; const view = overrides?.externalSidebarNav
? render(
<SettingsModal
{...commonProps}
externalSidebarNav
desktopSection={overrides.desktopSection ?? 'radio'}
/>
)
: render(<SettingsModal {...commonProps} />);
return {
onSaveAppSettings,
onRefreshAppSettings,
onSave,
onClose,
onSetPrivateKey,
onReboot,
view,
};
} }
function openConnectivityTab() { function setMatchMedia(matches: boolean) {
const connectivityTab = screen.getByRole('tab', { name: 'Connectivity' }); Object.defineProperty(window, 'matchMedia', {
fireEvent.mouseDown(connectivityTab); writable: true,
fireEvent.click(connectivityTab); 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', () => { describe('SettingsModal', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('refreshes app settings when opened', async () => { it('refreshes app settings when opened', async () => {
const { onRefreshAppSettings } = renderModal(); 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 () => { it('shows favorite-first contact sync helper text in connectivity tab', async () => {
renderModal(); renderModal();
openConnectivityTab(); openConnectivitySection();
expect( expect(
screen.getByText( screen.getByText(
@@ -97,7 +168,7 @@ describe('SettingsModal', () => {
it('saves changed max contacts value through onSaveAppSettings', async () => { it('saves changed max contacts value through onSaveAppSettings', async () => {
const { onSaveAppSettings } = renderModal(); const { onSaveAppSettings } = renderModal();
openConnectivityTab(); openConnectivitySection();
const maxContactsInput = screen.getByLabelText('Max Contacts on Radio'); const maxContactsInput = screen.getByLabelText('Max Contacts on Radio');
fireEvent.change(maxContactsInput, { target: { value: '250' } }); fireEvent.change(maxContactsInput, { target: { value: '250' } });
@@ -114,7 +185,7 @@ describe('SettingsModal', () => {
appSettings: { ...baseSettings, max_radio_contacts: 200 }, appSettings: { ...baseSettings, max_radio_contacts: 200 },
}); });
openConnectivityTab(); openConnectivitySection();
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' })); fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
await waitFor(() => { await waitFor(() => {
@@ -127,7 +198,7 @@ describe('SettingsModal', () => {
appSettings: { ...baseSettings, experimental_channel_double_send: false }, appSettings: { ...baseSettings, experimental_channel_double_send: false },
}); });
openConnectivityTab(); openConnectivitySection();
const toggle = screen.getByLabelText('Always send channel messages twice'); const toggle = screen.getByLabelText('Always send channel messages twice');
fireEvent.click(toggle); 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(
<SettingsModal
open
externalSidebarNav
desktopSection="bot"
config={baseConfig}
health={baseHealth}
appSettings={baseSettings}
onClose={vi.fn()}
onSave={vi.fn(async () => {})}
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();
});
}); });
+3 -3
View File
@@ -44,13 +44,13 @@ test.describe('Bot functionality', () => {
await expect(page.getByText('Connected')).toBeVisible(); await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Radio & Config').click(); 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 // The bot name should be visible in the bot list
await expect(page.getByText('E2E Test Bot')).toBeVisible(); await expect(page.getByText('E2E Test Bot')).toBeVisible();
// Close settings // Exit settings page mode
await page.keyboard.press('Escape'); await page.getByRole('button', { name: /Back to Chat/i }).click();
// --- Step 3: Trigger the bot --- // --- Step 3: Trigger the bot ---
await page.getByText('#flightless', { exact: true }).first().click(); await page.getByText('#flightless', { exact: true }).first().click();
+4 -4
View File
@@ -27,7 +27,7 @@ test.describe('Radio settings', () => {
// --- Step 1: Change the name via settings UI --- // --- Step 1: Change the name via settings UI ---
await page.getByText('Radio & Config').click(); 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'); const nameInput = page.locator('#name');
await nameInput.clear(); await nameInput.clear();
@@ -36,8 +36,8 @@ test.describe('Radio settings', () => {
await page.getByRole('button', { name: 'Save Identity Settings' }).click(); await page.getByRole('button', { name: 'Save Identity Settings' }).click();
await expect(page.getByText('Identity settings saved')).toBeVisible({ timeout: 10_000 }); await expect(page.getByText('Identity settings saved')).toBeVisible({ timeout: 10_000 });
// Close settings // Exit settings page mode
await page.keyboard.press('Escape'); await page.getByRole('button', { name: /Back to Chat/i }).click();
// --- Step 2: Verify via API (now returns fresh data after send_appstart fix) --- // --- Step 2: Verify via API (now returns fresh data after send_appstart fix) ---
const config = await getRadioConfig(); const config = await getRadioConfig();
@@ -48,7 +48,7 @@ test.describe('Radio settings', () => {
await expect(page.getByText('Connected')).toBeVisible({ timeout: 15_000 }); await expect(page.getByText('Connected')).toBeVisible({ timeout: 15_000 });
await page.getByText('Radio & Config').click(); 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 }); await expect(page.locator('#name')).toHaveValue(testName, { timeout: 10_000 });
}); });
}); });