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

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 { 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<Conversation | null>(null);
const [showNewMessage, setShowNewMessage] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection>('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 = (
<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 (
<div className="flex flex-col h-full">
<StatusBar
health={health}
config={config}
onSettingsClick={() => setShowSettings(true)}
onMenuClick={() => setSidebarOpen(true)}
settingsMode={showSettings}
onSettingsClick={handleToggleSettingsView}
onMenuClick={showSettings ? undefined : () => setSidebarOpen(true)}
/>
<div className="flex flex-1 overflow-hidden">
{/* 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 */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
@@ -863,231 +926,269 @@ export function App() {
<SheetHeader className="sr-only">
<SheetTitle>Navigation</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-hidden">{sidebarContent}</div>
<div className="flex-1 overflow-hidden">{activeSidebarContent}</div>
</SheetContent>
</Sheet>
<div className="flex-1 flex flex-col bg-background min-w-0">
{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>
<div className="flex-1 overflow-hidden">
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
</div>
</>
) : activeConversation.type === 'visualizer' ? (
<VisualizerView
packets={rawPackets}
contacts={contacts}
config={config}
onClearPackets={() => setRawPackets([])}
/>
) : 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>
<div className="flex-1 overflow-hidden">
<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">
<span className="flex-shrink-0">
{activeConversation.type === 'channel' &&
!activeConversation.name.startsWith('#') &&
activeConversation.name !== 'Public'
? '#'
: ''}
{activeConversation.name}
</span>
<span
className="font-normal text-sm text-muted-foreground font-mono truncate cursor-pointer hover:text-primary"
onClick={(e) => {
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}
</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' : ''}`
<div className={cn('flex-1 flex flex-col min-h-0', showSettings && 'hidden')}>
{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>
<div className="flex-1 overflow-hidden">
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
</div>
</>
) : activeConversation.type === 'visualizer' ? (
<VisualizerView
packets={rawPackets}
contacts={contacts}
config={config}
onClearPackets={() => setRawPackets([])}
/>
) : 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>
<div className="flex-1 overflow-hidden">
<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">
<span className="flex-shrink-0">
{activeConversation.type === 'channel' &&
!activeConversation.name.startsWith('#') &&
activeConversation.name !== 'Public'
? '#'
: ''}
{activeConversation.name}
</span>
<span
className="font-normal text-sm text-muted-foreground font-mono truncate cursor-pointer hover:text-primary"
onClick={(e) => {
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(
<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"
}}
title="Click to copy"
>
&#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(
{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
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,
activeConversation.type as 'channel' | 'contact',
activeConversation.id
)
? 'Remove from favorites'
: 'Add to favorites'
}
>
{isFavorite(
favorites,
activeConversation.type as 'channel' | 'contact',
activeConversation.id
) ? (
<span className="text-yellow-500">&#9733;</span>
) : (
<span className="text-muted-foreground">&#9734;</span>
)}
</button>
)}
{/* Delete button */}
{!(
activeConversation.type === 'channel' && activeConversation.name === 'Public'
) && (
<button
className="p-1.5 rounded hover:bg-destructive/20 text-destructive text-xl leading-none"
onClick={() => {
if (activeConversation.type === 'channel') {
handleDeleteChannel(activeConversation.id);
} else {
handleDeleteContact(activeConversation.id);
}
}}
title="Delete"
>
&#128465;
</button>
)}
) ? (
<span className="text-yellow-500">&#9733;</span>
) : (
<span className="text-muted-foreground">&#9734;</span>
)}
</button>
)}
{/* Delete button */}
{!(
activeConversation.type === 'channel' &&
activeConversation.name === 'Public'
) && (
<button
className="p-1.5 rounded hover:bg-destructive/20 text-destructive text-xl leading-none"
onClick={() => {
if (activeConversation.type === 'channel') {
handleDeleteChannel(activeConversation.id);
} else {
handleDeleteContact(activeConversation.id);
}
}}
title="Delete"
>
&#128465;
</button>
)}
</div>
</div>
</div>
<MessageList
key={activeConversation.id}
messages={messages}
contacts={contacts}
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
onSenderClick={
activeConversation.type === 'channel' ? handleSenderClick : undefined
}
onLoadOlder={fetchOlderMessages}
radioName={config?.name}
config={config}
/>
<MessageInput
ref={messageInputRef}
onSend={
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
<MessageList
key={activeConversation.id}
messages={messages}
contacts={contacts}
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
onSenderClick={
activeConversation.type === 'channel' ? handleSenderClick : undefined
}
onLoadOlder={fetchOlderMessages}
radioName={config?.name}
config={config}
/>
<MessageInput
ref={messageInputRef}
onSend={
activeContactIsRepeater
? repeaterLoggedIn
? 'Send CLI command (requires admin login)...'
: `Enter password for ${activeConversation.name} (or . for none)...`
: `Message ${activeConversation.name}...`
}
? 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
? '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 className="flex-1 flex items-center justify-center text-muted-foreground">
Select a conversation or start a new one
</div>
</div>
)}
</div>
@@ -1132,24 +1233,6 @@ export function App() {
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" />
</div>
);

View File

@@ -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 (
<div className="w-full overflow-hidden rounded-md border border-input">
<CodeMirror
@@ -16,7 +17,7 @@ export function BotCodeEditor({ value, onChange, id }: BotCodeEditorProps) {
onChange={onChange}
extensions={[python()]}
theme={oneDark}
height="256px"
height={height}
basicSetup={{
lineNumbers: true,
foldGutter: false,

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(() =>
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<SettingsSection, string> = {
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<void>;
}
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<SettingsTab>('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<Record<SettingsSection, boolean>>(() => {
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<SettingsSection | null>(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 (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[50vw] sm:min-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Radio & Settings</DialogTitle>
<DialogDescription className="sr-only">
{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>
const toggleSection = (section: SettingsSection) => {
setExpandedSections((prev) => ({
...prev,
[section]: !prev[section],
}));
setSectionError(null);
};
{!config ? (
<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>
const externalDesktopSidebarMode = externalSidebarNav && !isMobileLayout;
{/* Radio Config Tab */}
<TabsContent value="radio" className="space-y-4 mt-4">
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 (
<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">
<Label htmlFor="preset">Preset</Label>
<select
@@ -634,19 +749,29 @@ export function SettingsModal({
</div>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
{getSectionError('radio') && (
<div className="text-sm text-destructive">{getSectionError('radio')}</div>
)}
<Button
onClick={handleSaveRadioConfig}
disabled={loading || rebooting}
disabled={isSectionBusy('radio') || rebooting}
className="w-full"
>
{loading || rebooting ? 'Saving & Rebooting...' : 'Save Radio Config & Reboot'}
{isSectionBusy('radio') || rebooting
? 'Saving & Rebooting...'
: 'Save Radio Config & Reboot'}
</Button>
</TabsContent>
</div>
)}
</div>
)}
{/* Identity Tab */}
<TabsContent value="identity" className="space-y-4 mt-4">
{shouldRenderSection('identity') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('identity')}
{isSectionVisible('identity') && (
<div className={sectionContentClass}>
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input
@@ -681,8 +806,12 @@ export function SettingsModal({
</p>
</div>
<Button onClick={handleSaveIdentity} disabled={loading} className="w-full">
{loading ? 'Saving...' : 'Save Identity Settings'}
<Button
onClick={handleSaveIdentity}
disabled={isSectionBusy('identity')}
className="w-full"
>
{isSectionBusy('identity') ? 'Saving...' : 'Save Identity Settings'}
</Button>
<Separator />
@@ -699,10 +828,12 @@ export function SettingsModal({
/>
<Button
onClick={handleSetPrivateKey}
disabled={loading || rebooting || !privateKey.trim()}
disabled={isSectionBusy('identity') || rebooting || !privateKey.trim()}
className="w-full"
>
{loading || rebooting ? 'Setting & Rebooting...' : 'Set Private Key & Reboot'}
{isSectionBusy('identity') || rebooting
? 'Setting & Rebooting...'
: 'Set Private Key & Reboot'}
</Button>
</div>
@@ -725,11 +856,19 @@ export function SettingsModal({
)}
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
</TabsContent>
{getSectionError('identity') && (
<div className="text-sm text-destructive">{getSectionError('identity')}</div>
)}
</div>
)}
</div>
)}
{/* Connectivity Tab */}
<TabsContent value="connectivity" className="space-y-4 mt-4">
{shouldRenderSection('connectivity') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('connectivity')}
{isSectionVisible('connectivity') && (
<div className={sectionContentClass}>
<div className="space-y-2">
<Label>Connection</Label>
{health?.connection_info ? (
@@ -786,8 +925,12 @@ export function SettingsModal({
</p>
</div>
<Button onClick={handleSaveConnectivity} disabled={loading} className="w-full">
{loading ? 'Saving...' : 'Save Settings'}
<Button
onClick={handleSaveConnectivity}
disabled={isSectionBusy('connectivity')}
className="w-full"
>
{isSectionBusy('connectivity') ? 'Saving...' : 'Save Settings'}
</Button>
<Separator />
@@ -795,17 +938,25 @@ export function SettingsModal({
<Button
variant="outline"
onClick={handleReboot}
disabled={rebooting || loading}
disabled={rebooting || isSectionBusy('connectivity')}
className="w-full border-red-500/50 text-red-400 hover:bg-red-500/10"
>
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
</Button>
{error && <div className="text-sm text-destructive">{error}</div>}
</TabsContent>
{getSectionError('connectivity') && (
<div className="text-sm text-destructive">{getSectionError('connectivity')}</div>
)}
</div>
)}
</div>
)}
{/* Database Tab */}
<TabsContent value="database" className="space-y-4 mt-4">
{shouldRenderSection('database') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('database')}
{isSectionVisible('database') && (
<div className={sectionContentClass}>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Database size</span>
@@ -885,15 +1036,27 @@ export function SettingsModal({
</p>
</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">
{loading ? 'Saving...' : 'Save Settings'}
<Button
onClick={handleSaveDatabaseSettings}
disabled={isSectionBusy('database')}
className="w-full"
>
{isSectionBusy('database') ? 'Saving...' : 'Save Settings'}
</Button>
</TabsContent>
</div>
)}
</div>
)}
{/* Bot Tab */}
<TabsContent value="bot" className="space-y-4 mt-4">
{shouldRenderSection('bot') && (
<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">
<p className="text-sm text-red-500">
<strong>Experimental:</strong> This is an alpha feature and introduces automated
@@ -935,11 +1098,9 @@ export function SettingsModal({
<div className="space-y-2">
{bots.map((bot) => (
<div key={bot.id} className="border border-input rounded-md overflow-hidden">
{/* Bot header row */}
<div
className="flex items-center gap-2 px-3 py-2 bg-muted/50 cursor-pointer hover:bg-muted/80"
onClick={(e) => {
// 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 ? '' : ''}
</span>
{/* Bot name (click to edit) */}
{editingNameId === bot.id ? (
<input
type="text"
@@ -979,7 +1139,6 @@ export function SettingsModal({
</span>
)}
{/* Enabled checkbox */}
<label
className="flex items-center gap-1.5 cursor-pointer"
onClick={(e) => e.stopPropagation()}
@@ -993,7 +1152,6 @@ export function SettingsModal({
<span className="text-xs text-muted-foreground">Enabled</span>
</label>
{/* Delete button */}
<Button
type="button"
variant="ghost"
@@ -1009,7 +1167,6 @@ export function SettingsModal({
</Button>
</div>
{/* Bot expanded content */}
{expandedBotId === bot.id && (
<div className="p-3 space-y-3 border-t border-input">
<div className="flex items-center justify-between">
@@ -1028,7 +1185,7 @@ export function SettingsModal({
</div>
<Suspense
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...
</div>
}
@@ -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'}
/>
</Suspense>
</div>
@@ -1064,15 +1222,21 @@ export function SettingsModal({
</p>
</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">
{loading ? 'Saving...' : 'Save Bot Settings'}
<Button
onClick={handleSaveBotSettings}
disabled={isSectionBusy('bot')}
className="w-full"
>
{isSectionBusy('bot') ? 'Saving...' : 'Save Bot Settings'}
</Button>
</TabsContent>
</Tabs>
)}
</DialogContent>
</Dialog>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -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
<span role="img" aria-label="Settings">
&#128295;
</span>{' '}
Radio & Config
{settingsMode ? 'Back to Chat' : 'Radio & Config'}
</button>
</div>
);

View File

@@ -88,7 +88,17 @@ vi.mock('../messageCache', () => ({
}));
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', () => ({
@@ -111,7 +121,17 @@ vi.mock('../components/NewMessageModal', () => ({
}));
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', () => ({
@@ -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(<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();
});
});
});

View File

@@ -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<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 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(
<SettingsModal
open
config={baseConfig}
health={baseHealth}
appSettings={overrides?.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={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(
<SettingsModal
{...commonProps}
externalSidebarNav
desktopSection={overrides.desktopSection ?? 'radio'}
/>
)
: render(<SettingsModal {...commonProps} />);
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(
<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();
});
});

View File

@@ -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();

View File

@@ -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 });
});
});