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