mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-21 09:02:57 +02:00
Reorganize the settings panes a bit
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user