mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Way better options dialog
This commit is contained in:
@@ -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"
|
||||
>
|
||||
🛎
|
||||
</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"
|
||||
>
|
||||
🛎
|
||||
</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">★</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">☆</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"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
)}
|
||||
) ? (
|
||||
<span className="text-yellow-500">★</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">☆</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"
|
||||
>
|
||||
🗑
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
🔧
|
||||
</span>{' '}
|
||||
Radio & Config
|
||||
{settingsMode ? 'Back to Chat' : 'Radio & Config'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user