Reorganize the settings panes a bit

This commit is contained in:
Jack Kingsman
2026-03-04 19:46:20 -08:00
parent d5fe9c677f
commit e439bc913a
12 changed files with 407 additions and 534 deletions
+15 -50
View File
@@ -10,8 +10,7 @@ import type { LocalLabel } from '../utils/localLabel';
import { SETTINGS_SECTION_LABELS, type SettingsSection } from './settings/settingsConstants';
import { SettingsRadioSection } from './settings/SettingsRadioSection';
import { SettingsIdentitySection } from './settings/SettingsIdentitySection';
import { SettingsConnectivitySection } from './settings/SettingsConnectivitySection';
import { SettingsLocalSection } from './settings/SettingsLocalSection';
import { SettingsMqttSection } from './settings/SettingsMqttSection';
import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection';
import { SettingsBotSection } from './settings/SettingsBotSection';
@@ -76,18 +75,14 @@ export function SettingsModal(props: SettingsModalProps) {
const [isMobileLayout, setIsMobileLayout] = useState(getIsMobileLayout);
const externalDesktopSidebarMode = externalSidebarNav && !isMobileLayout;
const [expandedSections, setExpandedSections] = useState<Record<SettingsSection, boolean>>(() => {
const isMobile = getIsMobileLayout();
return {
radio: !isMobile,
identity: false,
connectivity: false,
mqtt: false,
database: false,
bot: false,
statistics: false,
about: false,
};
const [expandedSections, setExpandedSections] = useState<Record<SettingsSection, boolean>>({
radio: false,
local: false,
mqtt: false,
database: false,
bot: false,
statistics: false,
about: false,
});
// Refresh settings from server when modal opens
@@ -116,14 +111,6 @@ export function SettingsModal(props: SettingsModalProps) {
return () => query.removeListener(onChange);
}, []);
// On mobile with external sidebar nav, auto-expand the selected section
useEffect(() => {
if (!externalSidebarNav || !isMobileLayout || !desktopSection) return;
setExpandedSections((prev) =>
prev[desktopSection] ? prev : { ...prev, [desktopSection]: true }
);
}, [externalSidebarNav, isMobileLayout, desktopSection]);
const toggleSection = (section: SettingsSection) => {
setExpandedSections((prev) => ({
...prev,
@@ -181,24 +168,8 @@ export function SettingsModal(props: SettingsModalProps) {
{shouldRenderSection('radio') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('radio')}
{isSectionVisible('radio') && (
{isSectionVisible('radio') && appSettings && (
<SettingsRadioSection
config={config}
pageMode={pageMode}
onSave={onSave}
onReboot={onReboot}
onClose={onClose}
className={sectionContentClass}
/>
)}
</section>
)}
{shouldRenderSection('identity') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('identity')}
{isSectionVisible('identity') && appSettings && (
<SettingsIdentitySection
config={config}
health={health}
appSettings={appSettings}
@@ -215,17 +186,12 @@ export function SettingsModal(props: SettingsModalProps) {
</section>
)}
{shouldRenderSection('connectivity') && (
{shouldRenderSection('local') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('connectivity')}
{isSectionVisible('connectivity') && appSettings && (
<SettingsConnectivitySection
appSettings={appSettings}
health={health}
pageMode={pageMode}
onSaveAppSettings={onSaveAppSettings}
onReboot={onReboot}
onClose={onClose}
{renderSectionHeader('local')}
{isSectionVisible('local') && (
<SettingsLocalSection
onLocalLabelChange={onLocalLabelChange}
className={sectionContentClass}
/>
)}
@@ -241,7 +207,6 @@ export function SettingsModal(props: SettingsModalProps) {
health={health}
onSaveAppSettings={onSaveAppSettings}
onHealthRefresh={onHealthRefresh}
onLocalLabelChange={onLocalLabelChange}
blockedKeys={blockedKeys}
blockedNames={blockedNames}
onToggleBlockedKey={onToggleBlockedKey}
@@ -1,138 +0,0 @@
import { useState, useEffect } from 'react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
export function SettingsConnectivitySection({
appSettings,
health,
pageMode,
onSaveAppSettings,
onReboot,
onClose,
className,
}: {
appSettings: AppSettings;
health: HealthStatus | null;
pageMode: boolean;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onReboot: () => Promise<void>;
onClose: () => void;
className?: string;
}) {
const [maxRadioContacts, setMaxRadioContacts] = useState('');
const [busy, setBusy] = useState(false);
const [rebooting, setRebooting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setMaxRadioContacts(String(appSettings.max_radio_contacts));
}, [appSettings]);
const handleSave = async () => {
setError(null);
setBusy(true);
try {
const update: AppSettingsUpdate = {};
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings.max_radio_contacts) {
update.max_radio_contacts = newMaxRadioContacts;
}
if (Object.keys(update).length > 0) {
await onSaveAppSettings(update);
}
toast.success('Connectivity settings saved');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setBusy(false);
}
};
const handleReboot = async () => {
if (
!confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.')
) {
return;
}
setError(null);
setBusy(true);
setRebooting(true);
try {
await onReboot();
if (!pageMode) {
onClose();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reboot radio');
} finally {
setRebooting(false);
setBusy(false);
}
};
return (
<div className={className}>
<div className="space-y-2">
<Label>Connection</Label>
{health?.connection_info ? (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-status-connected" />
<code className="px-2 py-1 bg-muted rounded text-foreground text-sm">
{health.connection_info}
</code>
</div>
) : (
<div className="flex items-center gap-2 text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-status-disconnected" />
<span>Not connected</span>
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="max-contacts">Max Contacts on Radio</Label>
<Input
id="max-contacts"
type="number"
min="1"
max="1000"
value={maxRadioContacts}
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Favorite contacts load first, then recent non-repeater contacts until this limit is
reached (1-1000)
</p>
</div>
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Settings'}
</Button>
<Separator />
<Button
variant="outline"
onClick={handleReboot}
disabled={rebooting || busy}
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
>
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
</Button>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
</div>
);
}
@@ -6,13 +6,6 @@ import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { api } from '../../api';
import { formatTime } from '../../utils/messageParser';
import {
captureLastViewedConversationFromHash,
getReopenLastConversationEnabled,
setReopenLastConversationEnabled,
} from '../../utils/lastViewedConversation';
import { ThemeSelector } from './ThemeSelector';
import { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel';
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
export function SettingsDatabaseSection({
@@ -20,7 +13,6 @@ export function SettingsDatabaseSection({
health,
onSaveAppSettings,
onHealthRefresh,
onLocalLabelChange,
blockedKeys = [],
blockedNames = [],
onToggleBlockedKey,
@@ -31,7 +23,6 @@ export function SettingsDatabaseSection({
health: HealthStatus | null;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onHealthRefresh: () => Promise<void>;
onLocalLabelChange?: (label: LocalLabel) => void;
blockedKeys?: string[];
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
@@ -42,11 +33,6 @@ export function SettingsDatabaseSection({
const [cleaning, setCleaning] = useState(false);
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
const [reopenLastConversation, setReopenLastConversation] = useState(
getReopenLastConversationEnabled
);
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -117,14 +103,6 @@ export function SettingsDatabaseSection({
}
};
const handleToggleReopenLastConversation = (enabled: boolean) => {
setReopenLastConversation(enabled);
setReopenLastConversationEnabled(enabled);
if (enabled) {
captureLastViewedConversationFromHash();
}
};
return (
<div className={className}>
<div className="space-y-3">
@@ -230,66 +208,6 @@ export function SettingsDatabaseSection({
<Separator />
<div className="space-y-3">
<Label>Interface</Label>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Color Scheme</span>
<ThemeSelector />
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={reopenLastConversation}
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Reopen to last viewed channel/conversation</span>
</label>
<p className="text-xs text-muted-foreground">
These settings apply only to this device/browser. They do not sync to server settings.
</p>
</div>
<Separator />
<div className="space-y-3">
<Label>Local Label</Label>
<div className="flex items-center gap-2">
<Input
value={localLabelText}
onChange={(e) => {
const text = e.target.value;
setLocalLabelText(text);
setLocalLabel(text, localLabelColor);
onLocalLabelChange?.({ text, color: localLabelColor });
}}
placeholder="e.g. Home Base, Field Radio 2"
aria-label="Local label text"
className="flex-1"
/>
<input
type="color"
value={localLabelColor}
onChange={(e) => {
const color = e.target.value;
setLocalLabelColor(color);
setLocalLabel(localLabelText, color);
onLocalLabelChange?.({ text: localLabelText, color });
}}
aria-label="Local label color"
className="w-10 h-9 rounded border border-input cursor-pointer bg-transparent p-0.5"
/>
</div>
<p className="text-xs text-muted-foreground">
Display a colored banner at the top of the page to identify this instance. This applies
only to this device/browser.
</p>
</div>
<Separator />
<div className="space-y-3">
<Label>Blocked Contacts</Label>
<p className="text-xs text-muted-foreground">
@@ -1,217 +0,0 @@
import { useState, useEffect } from 'react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import type {
AppSettings,
AppSettingsUpdate,
HealthStatus,
RadioConfig,
RadioConfigUpdate,
} from '../../types';
export function SettingsIdentitySection({
config,
health,
appSettings,
pageMode,
onSave,
onSaveAppSettings,
onSetPrivateKey,
onReboot,
onAdvertise,
onClose,
className,
}: {
config: RadioConfig;
health: HealthStatus | null;
appSettings: AppSettings;
pageMode: boolean;
onSave: (update: RadioConfigUpdate) => Promise<void>;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onSetPrivateKey: (key: string) => Promise<void>;
onReboot: () => Promise<void>;
onAdvertise: () => Promise<void>;
onClose: () => void;
className?: string;
}) {
const [name, setName] = useState('');
const [privateKey, setPrivateKey] = useState('');
const [advertIntervalHours, setAdvertIntervalHours] = useState('0');
const [floodScope, setFloodScope] = useState('');
const [busy, setBusy] = useState(false);
const [rebooting, setRebooting] = useState(false);
const [advertising, setAdvertising] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setName(config.name);
}, [config]);
useEffect(() => {
setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600)));
setFloodScope(appSettings.flood_scope);
}, [appSettings]);
const handleSaveIdentity = async () => {
setError(null);
setBusy(true);
try {
const update: RadioConfigUpdate = { name };
await onSave(update);
const appUpdate: AppSettingsUpdate = {};
const hours = parseInt(advertIntervalHours, 10);
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
if (newAdvertInterval !== appSettings.advert_interval) {
appUpdate.advert_interval = newAdvertInterval;
}
if (floodScope !== appSettings.flood_scope) {
appUpdate.flood_scope = floodScope;
}
if (Object.keys(appUpdate).length > 0) {
await onSaveAppSettings(appUpdate);
}
toast.success('Identity settings saved');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setBusy(false);
}
};
const handleSetPrivateKey = async () => {
if (!privateKey.trim()) {
setError('Private key is required');
return;
}
setError(null);
setBusy(true);
try {
await onSetPrivateKey(privateKey.trim());
setPrivateKey('');
toast.success('Private key set, rebooting...');
setRebooting(true);
await onReboot();
if (!pageMode) {
onClose();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to set private key');
} finally {
setRebooting(false);
setBusy(false);
}
};
const handleAdvertise = async () => {
setAdvertising(true);
try {
await onAdvertise();
} finally {
setAdvertising(false);
}
};
return (
<div className={className}>
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
</div>
<div className="space-y-2">
<Label htmlFor="name">Radio Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="flood-scope">Flood Scope / Region</Label>
<Input
id="flood-scope"
value={floodScope}
onChange={(e) => setFloodScope(e.target.value)}
placeholder="#MyRegion"
/>
<p className="text-xs text-muted-foreground">
Tag outgoing flood messages with a region name (e.g. #MyRegion). Repeaters with this
region configured will prioritize your traffic. Leave empty to disable.
</p>
</div>
<Button onClick={handleSaveIdentity} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Identity Settings'}
</Button>
<Separator />
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
/>
<Button
onClick={handleSetPrivateKey}
disabled={busy || rebooting || !privateKey.trim()}
className="w-full"
>
{busy || rebooting ? 'Setting & Rebooting...' : 'Set Private Key & Reboot'}
</Button>
</div>
<Separator />
<div className="space-y-2">
<Label>Send Advertisement</Label>
<p className="text-xs text-muted-foreground">
Send a flood advertisement to announce your presence on the mesh network.
</p>
<Button
onClick={handleAdvertise}
disabled={advertising || !health?.radio_connected}
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
>
{advertising ? 'Sending...' : 'Send Advertisement'}
</Button>
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}
</div>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
</div>
);
}
@@ -0,0 +1,93 @@
import { useState } from 'react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Separator } from '../ui/separator';
import {
captureLastViewedConversationFromHash,
getReopenLastConversationEnabled,
setReopenLastConversationEnabled,
} from '../../utils/lastViewedConversation';
import { ThemeSelector } from './ThemeSelector';
import { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel';
export function SettingsLocalSection({
onLocalLabelChange,
className,
}: {
onLocalLabelChange?: (label: LocalLabel) => void;
className?: string;
}) {
const [reopenLastConversation, setReopenLastConversation] = useState(
getReopenLastConversationEnabled
);
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
const handleToggleReopenLastConversation = (enabled: boolean) => {
setReopenLastConversation(enabled);
setReopenLastConversationEnabled(enabled);
if (enabled) {
captureLastViewedConversationFromHash();
}
};
return (
<div className={className}>
<p className="text-sm text-muted-foreground">
These settings apply only to this device/browser.
</p>
<div className="space-y-1">
<Label>Color Scheme</Label>
<ThemeSelector />
</div>
<Separator />
<div className="space-y-3">
<Label>Local Label</Label>
<div className="flex items-center gap-2">
<Input
value={localLabelText}
onChange={(e) => {
const text = e.target.value;
setLocalLabelText(text);
setLocalLabel(text, localLabelColor);
onLocalLabelChange?.({ text, color: localLabelColor });
}}
placeholder="e.g. Home Base, Field Radio 2"
aria-label="Local label text"
className="flex-1"
/>
<input
type="color"
value={localLabelColor}
onChange={(e) => {
const color = e.target.value;
setLocalLabelColor(color);
setLocalLabel(localLabelText, color);
onLocalLabelChange?.({ text: localLabelText, color });
}}
aria-label="Local label color"
className="w-10 h-9 rounded border border-input cursor-pointer bg-transparent p-0.5"
/>
</div>
<p className="text-xs text-muted-foreground">
Display a colored banner at the top of the page to identify this instance.
</p>
</div>
<Separator />
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={reopenLastConversation}
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Reopen to last viewed channel/conversation</span>
</label>
</div>
);
}
@@ -5,23 +5,41 @@ import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { RADIO_PRESETS } from '../../utils/radioPresets';
import type { RadioConfig, RadioConfigUpdate } from '../../types';
import type {
AppSettings,
AppSettingsUpdate,
HealthStatus,
RadioConfig,
RadioConfigUpdate,
} from '../../types';
export function SettingsRadioSection({
config,
health,
appSettings,
pageMode,
onSave,
onSaveAppSettings,
onSetPrivateKey,
onReboot,
onAdvertise,
onClose,
className,
}: {
config: RadioConfig;
health: HealthStatus | null;
appSettings: AppSettings;
pageMode: boolean;
onSave: (update: RadioConfigUpdate) => Promise<void>;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onSetPrivateKey: (key: string) => Promise<void>;
onReboot: () => Promise<void>;
onAdvertise: () => Promise<void>;
onClose: () => void;
className?: string;
}) {
// Radio config state
const [name, setName] = useState('');
const [lat, setLat] = useState('');
const [lon, setLon] = useState('');
const [txPower, setTxPower] = useState('');
@@ -30,12 +48,28 @@ export function SettingsRadioSection({
const [sf, setSf] = useState('');
const [cr, setCr] = useState('');
const [gettingLocation, setGettingLocation] = useState(false);
const [busy, setBusy] = useState(false);
const [rebooting, setRebooting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Identity state
const [privateKey, setPrivateKey] = useState('');
const [identityBusy, setIdentityBusy] = useState(false);
const [identityRebooting, setIdentityRebooting] = useState(false);
const [identityError, setIdentityError] = useState<string | null>(null);
// Flood & advert control state
const [advertIntervalHours, setAdvertIntervalHours] = useState('0');
const [floodScope, setFloodScope] = useState('');
const [maxRadioContacts, setMaxRadioContacts] = useState('');
const [floodBusy, setFloodBusy] = useState(false);
const [floodError, setFloodError] = useState<string | null>(null);
// Advertise state
const [advertising, setAdvertising] = useState(false);
useEffect(() => {
setName(config.name);
setLat(String(config.lat));
setLon(String(config.lon));
setTxPower(String(config.tx_power));
@@ -45,6 +79,12 @@ export function SettingsRadioSection({
setCr(String(config.radio.cr));
}, [config]);
useEffect(() => {
setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600)));
setFloodScope(appSettings.flood_scope);
setMaxRadioContacts(String(appSettings.max_radio_contacts));
}, [appSettings]);
const currentPreset = useMemo(() => {
const freqNum = parseFloat(freq);
const bwNum = parseFloat(bw);
@@ -125,6 +165,7 @@ export function SettingsRadioSection({
try {
const update: RadioConfigUpdate = {
name,
lat: parsedLat,
lon: parsedLon,
tx_power: parsedTxPower,
@@ -150,8 +191,98 @@ export function SettingsRadioSection({
}
};
const handleSetPrivateKey = async () => {
if (!privateKey.trim()) {
setIdentityError('Private key is required');
return;
}
setIdentityError(null);
setIdentityBusy(true);
try {
await onSetPrivateKey(privateKey.trim());
setPrivateKey('');
toast.success('Private key set, rebooting...');
setIdentityRebooting(true);
await onReboot();
if (!pageMode) {
onClose();
}
} catch (err) {
setIdentityError(err instanceof Error ? err.message : 'Failed to set private key');
} finally {
setIdentityRebooting(false);
setIdentityBusy(false);
}
};
const handleSaveFloodSettings = async () => {
setFloodError(null);
setFloodBusy(true);
try {
const update: AppSettingsUpdate = {};
const hours = parseInt(advertIntervalHours, 10);
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
if (newAdvertInterval !== appSettings.advert_interval) {
update.advert_interval = newAdvertInterval;
}
if (floodScope !== appSettings.flood_scope) {
update.flood_scope = floodScope;
}
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings.max_radio_contacts) {
update.max_radio_contacts = newMaxRadioContacts;
}
if (Object.keys(update).length > 0) {
await onSaveAppSettings(update);
}
toast.success('Settings saved');
} catch (err) {
setFloodError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setFloodBusy(false);
}
};
const handleAdvertise = async () => {
setAdvertising(true);
try {
await onAdvertise();
} finally {
setAdvertising(false);
}
};
return (
<div className={className}>
{/* Connection display */}
<div className="space-y-2">
<Label>Connection</Label>
{health?.connection_info ? (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-status-connected" />
<code className="px-2 py-1 bg-muted rounded text-foreground text-sm">
{health.connection_info}
</code>
</div>
) : (
<div className="flex items-center gap-2 text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-status-disconnected" />
<span>Not connected</span>
</div>
)}
</div>
{/* Radio Name */}
<div className="space-y-2">
<Label htmlFor="name">Radio Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<Separator />
{/* Radio Config */}
<div className="space-y-2">
<Label htmlFor="preset">Preset</Label>
<select
@@ -285,6 +416,128 @@ export function SettingsRadioSection({
<Button onClick={handleSave} disabled={busy || rebooting} className="w-full">
{busy || rebooting ? 'Saving & Rebooting...' : 'Save Radio Config & Reboot'}
</Button>
<Separator />
{/* Keys */}
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
</div>
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
/>
<Button
onClick={handleSetPrivateKey}
disabled={identityBusy || identityRebooting || !privateKey.trim()}
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
variant="outline"
>
{identityBusy || identityRebooting
? 'Setting & Rebooting...'
: 'Set Private Key & Reboot'}
</Button>
</div>
{identityError && (
<div className="text-sm text-destructive" role="alert">
{identityError}
</div>
)}
<Separator />
{/* Flood & Advert Control */}
<div className="space-y-2">
<Label className="text-base">Flood & Advert Control</Label>
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="flood-scope">Flood Scope / Region</Label>
<Input
id="flood-scope"
value={floodScope}
onChange={(e) => setFloodScope(e.target.value)}
placeholder="#MyRegion"
/>
<p className="text-xs text-muted-foreground">
Tag outgoing flood messages with a region name (e.g. #MyRegion). Repeaters with this
region configured will prioritize your traffic. Leave empty to disable.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="max-contacts">Max Contacts on Radio</Label>
<Input
id="max-contacts"
type="number"
min="1"
max="1000"
value={maxRadioContacts}
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Favorite contacts load first, then recent non-repeater contacts until this limit is
reached (1-1000)
</p>
</div>
{floodError && (
<div className="text-sm text-destructive" role="alert">
{floodError}
</div>
)}
<Button onClick={handleSaveFloodSettings} disabled={floodBusy} className="w-full">
{floodBusy ? 'Saving...' : 'Save Settings'}
</Button>
<Separator />
{/* Send Advertisement */}
<div className="space-y-2">
<Label>Send Advertisement</Label>
<p className="text-xs text-muted-foreground">
Send a flood advertisement to announce your presence on the mesh network.
</p>
<Button
onClick={handleAdvertise}
disabled={advertising || !health?.radio_connected}
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
>
{advertising ? 'Sending...' : 'Send Advertisement'}
</Button>
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}
</div>
</div>
);
}
@@ -1,17 +1,15 @@
export type SettingsSection =
| 'radio'
| 'identity'
| 'connectivity'
| 'mqtt'
| 'local'
| 'database'
| 'bot'
| 'mqtt'
| 'statistics'
| 'about';
export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
'radio',
'identity',
'connectivity',
'local',
'database',
'bot',
'mqtt',
@@ -21,9 +19,8 @@ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
radio: '📻 Radio',
identity: '🪪 Identity',
connectivity: '📡 Connectivity',
database: '🗄️ Database & Interface',
local: '🖥️ Local Configuration',
database: '🗄️ Database & Messaging',
bot: '🤖 Bots',
mqtt: '📤 MQTT',
statistics: '📊 Statistics',
+5 -6
View File
@@ -120,12 +120,11 @@ vi.mock('../components/SettingsModal', () => ({
SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
<div data-testid="settings-modal-section">{desktopSection ?? 'none'}</div>
),
SETTINGS_SECTION_ORDER: ['radio', 'identity', 'connectivity', 'database', 'bot'],
SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'],
SETTINGS_SECTION_LABELS: {
radio: '📻 Radio',
identity: '🪪 Identity',
connectivity: '📡 Connectivity',
database: '🗄️ Database',
local: '🖥️ Local Configuration',
database: '🗄️ Database & Messaging',
bot: '🤖 Bot',
},
}));
@@ -274,10 +273,10 @@ describe('App favorite toggle flow', () => {
expect(screen.getByTestId('settings-modal-section')).toHaveTextContent('radio');
});
fireEvent.click(screen.getAllByRole('button', { name: /Identity/i })[0]);
fireEvent.click(screen.getAllByRole('button', { name: /Local Configuration/i })[0]);
await waitFor(() => {
expect(screen.getByTestId('settings-modal-section')).toHaveTextContent('identity');
expect(screen.getByTestId('settings-modal-section')).toHaveTextContent('local');
});
fireEvent.click(screen.getByRole('button', { name: 'Back to Chat' }));
+3 -4
View File
@@ -89,12 +89,11 @@ vi.mock('../components/NewMessageModal', () => ({
vi.mock('../components/SettingsModal', () => ({
SettingsModal: () => null,
SETTINGS_SECTION_ORDER: ['radio', 'identity', 'connectivity', 'database', 'bot'],
SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'],
SETTINGS_SECTION_LABELS: {
radio: 'Radio',
identity: 'Identity',
connectivity: 'Connectivity',
database: 'Database',
local: 'Local Configuration',
database: 'Database & Messaging',
bot: 'Bot',
},
}));
+27 -20
View File
@@ -149,9 +149,14 @@ function setMatchMedia(matches: boolean) {
});
}
function openConnectivitySection() {
const connectivityToggle = screen.getByRole('button', { name: /Connectivity/i });
fireEvent.click(connectivityToggle);
function openRadioSection() {
const radioToggle = screen.getByRole('button', { name: /Radio/i });
fireEvent.click(radioToggle);
}
function openLocalSection() {
const localToggle = screen.getByRole('button', { name: /Local Configuration/i });
fireEvent.click(localToggle);
}
function openMqttSection() {
@@ -200,10 +205,9 @@ describe('SettingsModal', () => {
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 radio tab', async () => {
renderModal();
openConnectivitySection();
openRadioSection();
expect(
screen.getByText(
@@ -214,13 +218,14 @@ describe('SettingsModal', () => {
it('saves changed max contacts value through onSaveAppSettings', async () => {
const { onSaveAppSettings } = renderModal();
openConnectivitySection();
openRadioSection();
const maxContactsInput = screen.getByLabelText('Max Contacts on Radio');
fireEvent.change(maxContactsInput, { target: { value: '250' } });
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
// Click the "Save Settings" button in the Flood & Advert Control section
const saveButtons = screen.getAllByRole('button', { name: 'Save Settings' });
fireEvent.click(saveButtons[0]);
await waitFor(() => {
expect(onSaveAppSettings).toHaveBeenCalledWith({ max_radio_contacts: 250 });
@@ -231,9 +236,11 @@ describe('SettingsModal', () => {
const { onSaveAppSettings } = renderModal({
appSettings: { ...baseSettings, max_radio_contacts: 200 },
});
openRadioSection();
openConnectivitySection();
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
// Click the "Save Settings" button in the Flood & Advert Control section
const saveButtons = screen.getAllByRole('button', { name: 'Save Settings' });
fireEvent.click(saveButtons[0]);
await waitFor(() => {
expect(onSaveAppSettings).not.toHaveBeenCalled();
@@ -247,22 +254,22 @@ describe('SettingsModal', () => {
});
expect(screen.getByText('No bots configured')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Connectivity/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Local Configuration/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];
const localToggle = screen.getAllByRole('button', { name: /Local Configuration/i })[0];
expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Public Key')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Local label text')).not.toBeInTheDocument();
fireEvent.click(identityToggle);
expect(screen.getByLabelText('Public Key')).toBeInTheDocument();
fireEvent.click(localToggle);
expect(screen.getByLabelText('Local label text')).toBeInTheDocument();
fireEvent.click(identityToggle);
expect(screen.queryByLabelText('Public Key')).not.toBeInTheDocument();
fireEvent.click(localToggle);
expect(screen.queryByLabelText('Local label text')).not.toBeInTheDocument();
});
it('clears stale errors when switching external desktop sections', async () => {
@@ -316,6 +323,7 @@ describe('SettingsModal', () => {
onSetPrivateKey,
onReboot,
});
openRadioSection();
fireEvent.click(screen.getByRole('button', { name: 'Save Radio Config & Reboot' }));
await waitFor(() => {
@@ -324,7 +332,6 @@ describe('SettingsModal', () => {
});
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) },
});
@@ -340,7 +347,7 @@ describe('SettingsModal', () => {
it('stores and clears reopen-last-conversation preference locally', () => {
window.location.hash = '#raw';
renderModal();
openDatabaseSection();
openLocalSection();
const checkbox = screen.getByLabelText('Reopen to last viewed channel/conversation');
expect(checkbox).not.toBeChecked();