Suck less at settings UI (help me I'm not a designer)

This commit is contained in:
Jack Kingsman
2026-04-16 23:49:52 -07:00
parent 303becf4b8
commit 6f00e857c2
7 changed files with 460 additions and 427 deletions
+3 -1
View File
@@ -453,7 +453,9 @@ Do not rely on old class-only layout assumptions.
Key conventions documented in the reference:
- **Text sizes** use `rem`-based Tailwind values so they scale with the user's font-size slider. Do not use hard-locked `px` values (e.g., `text-[10px]`). The canonical sizes are `text-[0.625rem]` (10px), `text-[0.6875rem]` (11px), `text-[0.8125rem]` (13px), plus standard Tailwind `text-xs`/`text-sm`/`text-base`/`text-lg`/`text-xl`.
- **Section labels** use `text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium`.
- **Group titles** (sub-section headings within settings tabs) use `<h3 className="text-base font-semibold tracking-tight">`. These separate major groups like "Connection", "Identity", "MQTT Broker". When a group contains named sub-items (e.g. "Contact Management" → "Blocked Contacts", "Bulk Delete"), use `<h4 className="text-sm font-semibold">` for the children and nest them inside the parent group's `div` instead of separating with `<Separator />`.
- **Helper / description text** uses `text-[0.8125rem] text-muted-foreground` (13px). This is for explanatory paragraphs under inputs or sections — not for metadata, timestamps, or alert text which stay at `text-xs`.
- **Metadata labels** use `text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium` for compact category tags like "Push-enabled conversations" or "Registered Devices".
- **Buttons** use the shadcn `<Button>` component. Semantic color overrides (danger, warning, success) use `variant="outline"` with `className="border-{color}/50 text-{color} hover:bg-{color}/10"`.
- **Badges/tags** use `text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded` with `bg-muted` (neutral) or `bg-primary/10` (active).
- **Clickable text** (copy-to-clipboard, navigational links) uses `role="button" tabIndex={0}` with `cursor-pointer hover:text-primary transition-colors`.
@@ -185,7 +185,7 @@ export function SettingsDatabaseSection({
<div className={className}>
{/* ── Database Overview ── */}
<div className="space-y-3">
<Label className="text-base">Database Overview</Label>
<h3 className="text-base font-semibold tracking-tight">Database Overview</h3>
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm">Database size</span>
@@ -212,11 +212,11 @@ export function SettingsDatabaseSection({
{/* ── Storage Cleanup ── */}
<div className="space-y-4">
<Label className="text-base">Storage Cleanup</Label>
<h3 className="text-base font-semibold tracking-tight">Storage Cleanup</h3>
<div className="rounded-md border border-border p-3 space-y-2">
<Label className="text-sm">Delete Undecrypted Packets</Label>
<p className="text-xs text-muted-foreground">
<h3 className="text-sm font-semibold">Delete Undecrypted Packets</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Permanently deletes stored raw packets that have not yet been decrypted. These are
retained in case you later obtain the correct key once deleted, these messages can
never be recovered.
@@ -248,8 +248,8 @@ export function SettingsDatabaseSection({
</div>
<div className="rounded-md border border-border p-3 space-y-2">
<Label className="text-sm">Purge Archival Raw Packets</Label>
<p className="text-xs text-muted-foreground">
<h3 className="text-sm font-semibold">Purge Archival Raw Packets</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Deletes the raw packet bytes behind messages that are already decrypted and visible in
chat. This frees space but removes packet-analysis availability for those messages. It
does not affect displayed messages or future decryption.
@@ -269,7 +269,7 @@ export function SettingsDatabaseSection({
{/* ── DM Decryption ── */}
<div className="space-y-3">
<Label className="text-base">DM Decryption</Label>
<h3 className="text-base font-semibold tracking-tight">DM Decryption</h3>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
@@ -286,7 +286,7 @@ export function SettingsDatabaseSection({
/>
<span className="text-sm">Auto-decrypt historical DMs when new contact advertises</span>
</label>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
When enabled, the server will automatically try to decrypt stored DM packets when a new
contact sends an advertisement. This may cause brief delays on large packet backlogs.
</p>
@@ -296,8 +296,8 @@ export function SettingsDatabaseSection({
{/* ── Tracked Repeater Telemetry ── */}
<div className="space-y-3">
<Label className="text-base">Tracked Repeater Telemetry</Label>
<p className="text-xs text-muted-foreground">
<h3 className="text-base font-semibold tracking-tight">Tracked Repeater Telemetry</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Repeaters opted into automatic telemetry collection are polled on a scheduled interval. To
limit mesh traffic, the app caps telemetry at 24 checks per day across all tracked
repeaters so fewer tracked repeaters allows shorter intervals, and more tracked
@@ -427,145 +427,143 @@ export function SettingsDatabaseSection({
<Separator />
{/* ── Contact Management ── */}
<div className="space-y-2">
<Label className="text-base">Contact Management</Label>
</div>
<div className="space-y-5">
<h3 className="text-base font-semibold tracking-tight">Contact Management</h3>
{/* Block discovery of new node types */}
<div className="space-y-3">
<Label>Block Discovery of New Node Types</Label>
<p className="text-xs text-muted-foreground">
Checked types will be ignored when heard via advertisement. Existing contacts of these
types are still updated. This does not affect contacts added manually or via DM.
</p>
<div className="space-y-1.5">
{(
[
[1, 'Block clients'],
[2, 'Block repeaters'],
[3, 'Block room servers'],
[4, 'Block sensors'],
] as const
).map(([typeCode, label]) => {
const checked = discoveryBlockedTypes.includes(typeCode);
return (
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={() => {
const prev = discoveryBlockedTypes;
const next = checked ? prev.filter((t) => t !== typeCode) : [...prev, typeCode];
setDiscoveryBlockedTypes(next);
void persistAppSettings({ discovery_blocked_types: next }, () =>
setDiscoveryBlockedTypes(prev)
);
}}
className="rounded border-input"
/>
{label}
</label>
);
})}
</div>
{discoveryBlockedTypes.length > 0 && (
<p className="text-xs text-warning">
New{' '}
{discoveryBlockedTypes
.map((t) =>
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
)
.join(', ')}{' '}
heard via advertisement will not be added to your contact list.
{/* Block discovery of new node types */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">Block Discovery of New Node Types</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Checked types will be ignored when heard via advertisement. Existing contacts of these
types are still updated. This does not affect contacts added manually or via DM.
</p>
)}
</div>
<Separator />
{/* Blocked contacts list */}
<div className="space-y-3">
<Label>Blocked Contacts</Label>
<p className="text-xs text-muted-foreground">
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI
MQTT forwarding and bot responses are not affected. Messages are still stored and will
reappear if unblocked.
</p>
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No blocked contacts. Block contacts from their info pane, viewed by clicking their
avatar in any channel, or their name within the top status bar with the conversation
open.
</p>
) : (
<div className="space-y-2">
{blockedKeys.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
<div className="mt-1 space-y-1">
{blockedKeys.map((key) => (
<div key={key} className="flex items-center justify-between gap-2">
<span className="text-xs font-mono truncate flex-1">{key}</span>
{onToggleBlockedKey && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedKey(key)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
{blockedNames.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
<div className="mt-1 space-y-1">
{blockedNames.map((name) => (
<div key={name} className="flex items-center justify-between gap-2">
<span className="text-sm truncate flex-1">{name}</span>
{onToggleBlockedName && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedName(name)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
<div className="space-y-1.5">
{(
[
[1, 'Block clients'],
[2, 'Block repeaters'],
[3, 'Block room servers'],
[4, 'Block sensors'],
] as const
).map(([typeCode, label]) => {
const checked = discoveryBlockedTypes.includes(typeCode);
return (
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={() => {
const prev = discoveryBlockedTypes;
const next = checked
? prev.filter((t) => t !== typeCode)
: [...prev, typeCode];
setDiscoveryBlockedTypes(next);
void persistAppSettings({ discovery_blocked_types: next }, () =>
setDiscoveryBlockedTypes(prev)
);
}}
className="rounded border-input"
/>
{label}
</label>
);
})}
</div>
)}
</div>
{discoveryBlockedTypes.length > 0 && (
<p className="text-xs text-warning">
New{' '}
{discoveryBlockedTypes
.map((t) =>
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
)
.join(', ')}{' '}
heard via advertisement will not be added to your contact list.
</p>
)}
</div>
<Separator />
{/* Blocked contacts list */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">Blocked Contacts</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI
MQTT forwarding and bot responses are not affected. Messages are still stored and will
reappear if unblocked.
</p>
{/* Bulk delete */}
<div className="space-y-3">
<Label>Bulk Delete Contacts</Label>
<p className="text-xs text-muted-foreground">
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
nodes. Message history will be preserved.
</p>
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
Open Bulk Delete
</Button>
<BulkDeleteContactsModal
open={bulkDeleteOpen}
onClose={() => setBulkDeleteOpen(false)}
contacts={contacts}
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
/>
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No blocked contacts. Block contacts from their info pane, viewed by clicking their
avatar in any channel, or their name within the top status bar with the conversation
open.
</p>
) : (
<div className="space-y-2">
{blockedKeys.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
<div className="mt-1 space-y-1">
{blockedKeys.map((key) => (
<div key={key} className="flex items-center justify-between gap-2">
<span className="text-xs font-mono truncate flex-1">{key}</span>
{onToggleBlockedKey && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedKey(key)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
{blockedNames.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
<div className="mt-1 space-y-1">
{blockedNames.map((name) => (
<div key={name} className="flex items-center justify-between gap-2">
<span className="text-sm truncate flex-1">{name}</span>
{onToggleBlockedName && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedName(name)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Bulk delete */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">Bulk Delete Contacts</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
nodes. Message history will be preserved.
</p>
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
Open Bulk Delete
</Button>
<BulkDeleteContactsModal
open={bulkDeleteOpen}
onClose={() => setBulkDeleteOpen(false)}
contacts={contacts}
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
/>
</div>
</div>
</div>
);
@@ -729,7 +729,7 @@ function MqttPrivateConfigEditor({
}) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Forward mesh data to your own MQTT broker for home automation, logging, or alerting.
</p>
@@ -1000,7 +1000,7 @@ function MqttHaConfigEditor({
<div className="space-y-3">
<div className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
<div className="space-y-1">
<h3 className="text-base font-semibold text-foreground">Home Assistant MQTT Discovery</h3>
<h3 className="text-base font-semibold tracking-tight">Home Assistant MQTT Discovery</h3>
<p className="text-sm text-muted-foreground">
Publish discovery configs and MeshCore state to your MQTT broker so Home Assistant
creates native devices, sensors, GPS trackers, and message events automatically.
@@ -1010,27 +1010,27 @@ function MqttHaConfigEditor({
<div className="grid gap-2 md:grid-cols-3">
<div className="rounded-md border border-border/70 bg-background/80 p-3">
<div className="text-sm font-medium text-foreground">1. Same broker</div>
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-[0.8125rem] text-muted-foreground">
Home Assistant&apos;s built-in MQTT integration must point at the same broker
configured below.
</p>
</div>
<div className="rounded-md border border-border/70 bg-background/80 p-3">
<div className="text-sm font-medium text-foreground">2. Pick what to expose</div>
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-[0.8125rem] text-muted-foreground">
Choose repeaters for telemetry sensors and contacts for GPS tracker entities.
</p>
</div>
<div className="rounded-md border border-border/70 bg-background/80 p-3">
<div className="text-sm font-medium text-foreground">3. Automate in HA</div>
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-[0.8125rem] text-muted-foreground">
Radio health and message events publish continuously; repeater and contact data update
when new data is heard or collected.
</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Uses{' '}
<span
role="link"
@@ -1246,7 +1246,7 @@ function MqttHaConfigEditor({
<Separator />
<p className="text-sm font-semibold text-foreground">MQTT Broker</p>
<h3 className="text-base font-semibold tracking-tight">MQTT Broker</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
@@ -1337,8 +1337,8 @@ function MqttHaConfigEditor({
<Separator />
<div className="space-y-2">
<p className="text-sm font-semibold text-foreground">GPS Tracked Contacts</p>
<p className="text-xs text-muted-foreground">
<h3 className="text-base font-semibold tracking-tight">GPS Tracked Contacts</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Each selected contact becomes a <code className="text-[0.6875rem]">device_tracker</code>{' '}
in HA, updated whenever an advertisement with GPS coordinates is heard. Useful for
tracking mobile nodes on an HA map dashboard.
@@ -1366,7 +1366,7 @@ function MqttHaConfigEditor({
)}
{contactOptions.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No contacts available.</p>
<p className="text-[0.8125rem] text-muted-foreground italic">No contacts available.</p>
) : (
<>
<Input
@@ -1378,7 +1378,7 @@ function MqttHaConfigEditor({
/>
<div className="max-h-48 overflow-y-auto space-y-1 rounded border border-border p-2">
{filteredContacts.length === 0 ? (
<p className="text-xs text-muted-foreground italic py-1">
<p className="text-[0.8125rem] text-muted-foreground italic py-1">
No contacts match &ldquo;{contactSearch}&rdquo;
</p>
) : (
@@ -1408,8 +1408,8 @@ function MqttHaConfigEditor({
<Separator />
<div className="space-y-2">
<p className="text-sm font-semibold text-foreground">Telemetry Tracked Repeaters</p>
<p className="text-xs text-muted-foreground">
<h3 className="text-base font-semibold tracking-tight">Telemetry Tracked Repeaters</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Each selected repeater becomes an HA device with sensors for battery voltage, RSSI, SNR,
noise floor, packet counts, and uptime. Data updates whenever telemetry is collected
(auto-collect runs every ~8 hours, or on manual dashboard fetch). Only repeaters already
@@ -1417,13 +1417,13 @@ function MqttHaConfigEditor({
repeater and opting in at the bottom of the page).
</p>
{trackedRepeaters.length === 0 ? (
<div className="rounded-md border border-muted bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<div className="rounded-md border border-muted bg-muted/30 px-3 py-2 text-[0.8125rem] text-muted-foreground">
No repeaters are being auto-tracked for telemetry. Add repeaters to the auto-telemetry
tracking list in the Radio section first, then return here to select which ones to
expose to HA.
</div>
) : repeaterOptions.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
<p className="text-[0.8125rem] text-muted-foreground italic">
Auto-tracked repeaters not found in contact list.
</p>
) : (
@@ -1449,8 +1449,8 @@ function MqttHaConfigEditor({
<Separator />
<div className="space-y-2">
<p className="text-sm font-semibold text-foreground">Message Events</p>
<p className="text-xs text-muted-foreground">
<h3 className="text-base font-semibold tracking-tight">Message Events</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Matching messages fire an{' '}
<code className="text-[0.6875rem]">{`event.meshcore_${localRadioNodeId}_messages`}</code>{' '}
entity in HA with sender, text, channel, and direction attributes. Use HA automations to
@@ -1473,7 +1473,7 @@ function MqttCommunityConfigEditor({
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Advanced community MQTT editor. Use this for manual meshcoretomqtt-compatible setups or for
modifying a saved preset after creation. Only raw RF packets are shared &mdash; never
decrypted messages.
@@ -1536,7 +1536,7 @@ function MqttCommunityConfigEditor({
</div>
</div>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
LetsMesh uses <code>token</code> auth. MeshRank uses <code>none</code>.
</p>
@@ -1551,7 +1551,9 @@ function MqttCommunityConfigEditor({
value={(config.token_audience as string | undefined) ?? ''}
onChange={(e) => onChange({ ...config, token_audience: e.target.value })}
/>
<p className="text-xs text-muted-foreground">Defaults to the broker host when blank</p>
<p className="text-[0.8125rem] text-muted-foreground">
Defaults to the broker host when blank
</p>
</div>
<div className="space-y-2">
<Label htmlFor="fanout-comm-email">Owner Email (optional)</Label>
@@ -1562,7 +1564,7 @@ function MqttCommunityConfigEditor({
value={(config.email as string) || ''}
onChange={(e) => onChange({ ...config, email: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Used to claim your node on the community aggregator
</p>
</div>
@@ -1626,7 +1628,7 @@ function MqttCommunityConfigEditor({
onChange={(e) => onChange({ ...config, iata: e.target.value.toUpperCase() })}
className="w-32"
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Your nearest airport&apos;s IATA code (required)
</p>
</div>
@@ -1640,7 +1642,7 @@ function MqttCommunityConfigEditor({
value={(config.topic_template as string | undefined) ?? ''}
onChange={(e) => onChange({ ...config, topic_template: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Use <code>{'{IATA}'}</code> and <code>{'{PUBLIC_KEY}'}</code>. Default:{' '}
<code>{DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}</code>
</p>
@@ -1658,7 +1660,7 @@ function MeshRankConfigEditor({
}) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Pre-filled MeshRank setup. This saves as a regular Community MQTT integration once created,
but only asks for the MeshRank packet topic you were given.
</p>
@@ -1685,7 +1687,7 @@ function MeshRankConfigEditor({
})
}
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Paste the full topic template from your MeshRank config, for example{' '}
<code>meshrank/uplink/B435F6D5F7896B74C6B995FE221C2C1F/{'{PUBLIC_KEY}'}/packets</code>.
</p>
@@ -1705,7 +1707,7 @@ function LetsMeshConfigEditor({
}) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Pre-filled LetsMesh setup. This saves as a regular Community MQTT integration once created,
but only asks for the values LetsMesh expects from you.
</p>
@@ -1786,7 +1788,7 @@ function BotConfigEditor({
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Define a <code className="bg-muted px-1 rounded">bot()</code> function that receives
message data and optionally returns a reply.
</p>
@@ -1810,7 +1812,7 @@ function BotConfigEditor({
<BotCodeEditor value={code} onChange={(c) => onChange({ ...config, code: c })} />
</Suspense>
<div className="text-xs text-muted-foreground space-y-1">
<div className="text-[0.8125rem] text-muted-foreground space-y-1">
<p>
<strong>Available:</strong> Standard Python libraries and any modules installed in the
server environment.
@@ -1858,7 +1860,7 @@ function MapUploadConfigEditor({
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Automatically upload heard repeater and room server advertisements to{' '}
<a
href="https://map.meshcore.io"
@@ -1889,7 +1891,7 @@ function MapUploadConfigEditor({
/>
<div>
<span className="text-sm font-medium">Dry Run (log only, no uploads)</span>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
When enabled, upload payloads are logged at INFO level but not sent. Disable once you
have confirmed the logged output looks correct.
</p>
@@ -1907,7 +1909,7 @@ function MapUploadConfigEditor({
value={(config.api_url as string) || ''}
onChange={(e) => onChange({ ...config, api_url: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Leave blank to use the default <code>map.meshcore.io</code> endpoint.
</p>
</div>
@@ -1923,7 +1925,7 @@ function MapUploadConfigEditor({
/>
<div>
<span className="text-sm font-medium">Enable Geofence</span>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Only upload nodes whose location falls within the configured radius of your radio&apos;s
own position. Helps exclude nodes with false or spoofed coordinates. Uses the
latitude/longitude set in Radio Settings.
@@ -1941,7 +1943,7 @@ function MapUploadConfigEditor({
</div>
)}
{radioLatLonConfigured && (
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Using radio position{' '}
<code>
{radioLat?.toFixed(5)}, {radioLon?.toFixed(5)}
@@ -1965,7 +1967,7 @@ function MapUploadConfigEditor({
})
}
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Nodes further than this distance from your radio&apos;s position will not be uploaded.
</p>
</div>
@@ -2120,7 +2122,7 @@ function PillsSearchList({
)}
{items.length === 0 ? (
<p className="text-xs text-muted-foreground italic">{emptyItemsMessage}</p>
<p className="text-[0.8125rem] text-muted-foreground italic">{emptyItemsMessage}</p>
) : (
<>
<Input
@@ -2132,7 +2134,7 @@ function PillsSearchList({
/>
<div className="max-h-48 overflow-y-auto space-y-1 rounded border border-border p-2">
{filtered.length === 0 ? (
<p className="text-xs text-muted-foreground italic py-1">
<p className="text-[0.8125rem] text-muted-foreground italic py-1">
No {label.toLowerCase()} match &ldquo;{search}&rdquo;
</p>
) : (
@@ -2282,7 +2284,7 @@ function ScopeSelector({
return (
<div className="space-y-3">
<Label>Message Scope</Label>
<h3 className="text-base font-semibold tracking-tight">Message Scope</h3>
{showRawPackets && (
<label className="flex items-center gap-3 cursor-pointer">
@@ -2319,7 +2321,7 @@ function ScopeSelector({
{isListMode && (
<>
<p className="text-xs text-muted-foreground">{listHint}</p>
<p className="text-[0.8125rem] text-muted-foreground">{listHint}</p>
{channels.length > 0 && (
<PillsSearchList
@@ -2387,7 +2389,7 @@ function AppriseConfigEditor({
}) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Send push notifications via{' '}
<a
href="https://github.com/caronc/apprise"
@@ -2421,7 +2423,7 @@ function AppriseConfigEditor({
onChange={(e) => onChange({ ...config, urls: e.target.value })}
rows={4}
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
One URL per line. All URLs receive every matched notification. For Matrix room version 12
(servername-less room IDs), append <code>?hsreq=no</code> to the URL.
</p>
@@ -2436,7 +2438,7 @@ function AppriseConfigEditor({
/>
<div>
<span className="text-sm">Preserve identity on Discord</span>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
When enabled, Discord webhooks will use their configured name/avatar instead of
overriding with MeshCore sender info.
</p>
@@ -2492,7 +2494,7 @@ function WebhookConfigEditor({
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Send message data as JSON to an HTTP endpoint when messages are received.
</p>
@@ -2526,8 +2528,8 @@ function WebhookConfigEditor({
<Separator />
<div className="space-y-3">
<Label>HMAC Signing</Label>
<p className="text-xs text-muted-foreground">
<h3 className="text-base font-semibold tracking-tight">HMAC Signing</h3>
<p className="text-[0.8125rem] text-muted-foreground">
When a secret is set, each request includes an HMAC-SHA256 signature of the JSON body in
the specified header (e.g. <code className="bg-muted px-1 rounded">sha256=ab12cd...</code>
).
@@ -2590,7 +2592,7 @@ function SqsConfigEditor({
}) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Send matched mesh events to an Amazon SQS queue for durable processing by workers, Lambdas,
or downstream automation.
</p>
@@ -2631,15 +2633,17 @@ function SqsConfigEditor({
value={(config.endpoint_url as string) || ''}
onChange={(e) => onChange({ ...config, endpoint_url: e.target.value })}
/>
<p className="text-xs text-muted-foreground">Useful for LocalStack or custom endpoints</p>
<p className="text-[0.8125rem] text-muted-foreground">
Useful for LocalStack or custom endpoints
</p>
</div>
</div>
<Separator />
<div className="space-y-2">
<Label>Static Credentials (optional)</Label>
<p className="text-xs text-muted-foreground">
<h3 className="text-base font-semibold tracking-tight">Static Credentials (optional)</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Leave blank to use the server&apos;s normal AWS credential chain.
</p>
</div>
@@ -5,6 +5,7 @@ import { usePush } from '../../contexts/PushSubscriptionContext';
import type { Channel, Contact } from '../../types';
import { getContactDisplayName } from '../../utils/pubkey';
import { Button } from '../ui/button';
import { Checkbox } from '../ui/checkbox';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Separator } from '../ui/separator';
@@ -92,8 +93,8 @@ function PushDeviceManagement({
if (!isSupported) {
return (
<div className="space-y-3">
<Label>Web Push Notifications</Label>
<p className="text-sm text-muted-foreground">
<h3 className="text-base font-semibold tracking-tight">Web Push Notifications</h3>
<p className="text-[0.8125rem] text-muted-foreground">
{window.isSecureContext
? 'Push notifications are not supported by this browser.'
: 'Web Push requires HTTPS. Access RemoteTerm over HTTPS (self-signed certificates work) to enable push notifications.'}
@@ -105,13 +106,13 @@ function PushDeviceManagement({
return (
<div className="space-y-4">
<div className="space-y-1">
<Label>Web Push Notifications</Label>
<p className="text-sm text-muted-foreground">
<h3 className="text-base font-semibold tracking-tight">Web Push Notifications</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Receive notifications even when the browser is closed. Use the bell icon in any
conversation header to enable push for that contact or channel, or subscribe this browser
to receive notifications for all push-enabled conversations.
</p>
<p className="text-sm text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
The set of channels or DMs that trigger push notifications are global per-install (i.e.
all devices that register for Web Push will have the same set of channels/DMs that trigger
notifications). Subscribing or unsubscribing a particular browser only controls whether
@@ -265,12 +266,12 @@ export function SettingsLocalSection({
return (
<div className={className}>
<p className="text-sm text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
These settings apply only to this device/browser.
</p>
<div className="space-y-1">
<Label>Color Scheme</Label>
<h3 className="text-base font-semibold tracking-tight">Color Scheme</h3>
<ThemeSelector />
<ThemePreview className="mt-6" />
</div>
@@ -278,7 +279,7 @@ export function SettingsLocalSection({
<Separator />
<div className="space-y-3">
<Label>Local Label</Label>
<h3 className="text-base font-semibold tracking-tight">Local Label</h3>
<div className="flex items-center gap-2">
<Input
value={localLabelText}
@@ -305,7 +306,7 @@ export function SettingsLocalSection({
className="w-10 h-9 rounded border border-input cursor-pointer bg-transparent p-0.5"
/>
</div>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Display a colored banner at the top of the page to identify this instance.
</p>
</div>
@@ -330,7 +331,7 @@ export function SettingsLocalSection({
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Controls how distances are shown throughout the app.
</p>
</div>
@@ -338,86 +339,107 @@ export function SettingsLocalSection({
<Separator />
<div className="space-y-3">
<Label>UI Tweaks</Label>
<h3 className="text-base font-semibold tracking-tight">UI Tweaks</h3>
<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 className="space-y-2">
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="reopen-last"
checked={reopenLastConversation}
onCheckedChange={(checked) => handleToggleReopenLastConversation(checked === true)}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="reopen-last">Reopen Last Conversation</Label>
<p className="text-[0.8125rem] text-muted-foreground">
Automatically reopen to the last-open channel or contact when the app loads to the
bare URL.
</p>
</div>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={autoFocusInput}
onChange={(e) => {
const v = e.target.checked;
setAutoFocusInput(v);
setAutoFocusInputEnabled(v);
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Auto-focus input on conversation load (desktop only)</span>
</label>
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="auto-focus-input"
checked={autoFocusInput}
onCheckedChange={(checked) => {
const v = checked === true;
setAutoFocusInput(v);
setAutoFocusInputEnabled(v);
}}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="auto-focus-input">Auto-Focus Message Input</Label>
<p className="text-[0.8125rem] text-muted-foreground">
Place the cursor in the message input when switching conversations. Desktop only.
</p>
</div>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={batteryPercent}
onChange={(e) => {
const v = e.target.checked;
setBatteryPercent(v);
saveBatteryPercent(v);
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Show battery percentage in status bar</span>
</label>
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="battery-percent"
checked={batteryPercent}
onCheckedChange={(checked) => {
const v = checked === true;
setBatteryPercent(v);
saveBatteryPercent(v);
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
}}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="battery-percent">Show Battery Percentage</Label>
<p className="text-[0.8125rem] text-muted-foreground">
Display the radio&apos;s battery percentage in the status bar. Data updates every 60
seconds and may take up to a minute to appear after connecting.
</p>
</div>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={batteryVoltage}
onChange={(e) => {
const v = e.target.checked;
setBatteryVoltage(v);
saveBatteryVoltage(v);
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Show battery voltage in status bar</span>
</label>
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="battery-voltage"
checked={batteryVoltage}
onCheckedChange={(checked) => {
const v = checked === true;
setBatteryVoltage(v);
saveBatteryVoltage(v);
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
}}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="battery-voltage">Show Battery Voltage</Label>
<p className="text-[0.8125rem] text-muted-foreground">
Display the radio&apos;s battery voltage in the status bar (in mV). Data updates
every 60 seconds and may take up to a minute to appear after connecting.
</p>
</div>
</div>
{(batteryPercent || batteryVoltage) && (
<p className="text-xs text-muted-foreground ml-7">
Battery data updates every 60 seconds and may take up to a minute to appear after
connecting.
</p>
)}
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={statusDotPulse}
onChange={(e) => {
const v = e.target.checked;
setStatusDotPulse(v);
saveStatusDotPulse(v);
window.dispatchEvent(new Event(STATUS_DOT_PULSE_CHANGE_EVENT));
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">
Glitter status dot as packets arrive (blue = channel, purple = DM, cyan = advert, dark
green = other)
</span>
</label>
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="status-dot-pulse"
checked={statusDotPulse}
onCheckedChange={(checked) => {
const v = checked === true;
setStatusDotPulse(v);
saveStatusDotPulse(v);
window.dispatchEvent(new Event(STATUS_DOT_PULSE_CHANGE_EVENT));
}}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="status-dot-pulse">Status Dot Glitters</Label>
<p className="text-[0.8125rem] text-muted-foreground">
Flash the connection status dot in color as packets arrive: blue for channel, purple
for DM, cyan for advert, dark green for other.
</p>
</div>
</div>
</div>
<div className="space-y-3">
<Label htmlFor="font-scale-input">Relative Font Size</Label>
@@ -490,7 +512,7 @@ export function SettingsLocalSection({
Reset
</button>
</div>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Scales the app&apos;s typography for this browser only. The slider moves in 5% steps;
the number field accepts any value from 25% to 400%.
</p>
@@ -603,15 +625,15 @@ function ThemePreview({ className }: { className?: string }) {
desc="Sheet / dialog title"
/>
<PreviewTextRow
classes="text-base font-semibold"
label="text-base font-semibold"
desc="Section title"
classes="text-base font-semibold tracking-tight"
label="text-base font-semibold tracking-tight"
desc="Section / group title"
/>
<PreviewTextRow classes="text-sm" label="text-sm" desc="Body text, form labels" />
<PreviewTextRow
classes="text-xs text-muted-foreground"
label="text-xs text-muted-foreground"
desc="Helper text"
classes="text-[0.8125rem] text-muted-foreground"
label="text-[0.8125rem] text-muted-foreground"
desc="Helper / description text"
/>
<PreviewTextRow
classes="text-[0.6875rem] text-muted-foreground"
@@ -620,7 +642,7 @@ function ThemePreview({ className }: { className?: string }) {
/>
<div>
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Section Label
Metadata Label
</p>
<p className="text-[0.625rem] text-muted-foreground/60 mt-0.5">
text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium
@@ -392,7 +392,7 @@ export function SettingsRadioSection({
<div className={className}>
{/* ── Connection ── */}
<div className="space-y-3">
<Label className="text-base">Connection</Label>
<h3 className="text-base font-semibold tracking-tight">Connection</h3>
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
@@ -423,7 +423,7 @@ export function SettingsRadioSection({
>
{connectionBusy ? `${connectionActionLabel}...` : connectionActionLabel}
</Button>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Disconnect pauses automatic reconnect attempts so another device can use the radio.
</p>
</div>
@@ -432,7 +432,7 @@ export function SettingsRadioSection({
{/* ── Identity ── */}
<div className="space-y-2">
<Label className="text-base">Identity</Label>
<h3 className="text-base font-semibold tracking-tight">Identity</h3>
</div>
<div className="space-y-2">
@@ -477,7 +477,7 @@ export function SettingsRadioSection({
{/* ── Radio Parameters ── */}
<div className="space-y-2">
<Label className="text-base">Radio Parameters</Label>
<h3 className="text-base font-semibold tracking-tight">Radio Parameters</h3>
</div>
<div className="space-y-2">
@@ -590,7 +590,7 @@ export function SettingsRadioSection({
{/* ── Location ── */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base">Location</Label>
<h3 className="text-base font-semibold tracking-tight">Location</h3>
<Button
type="button"
variant="outline"
@@ -645,7 +645,7 @@ export function SettingsRadioSection({
<option value="off">Off</option>
<option value="current">Include Node Location</option>
</select>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Companion-radio firmware does not distinguish between saved coordinates and live GPS
here. When enabled, adverts include the node&apos;s current location state. That may be
the last coordinates you set from RemoteTerm or live GPS coordinates if the node itself
@@ -674,7 +674,7 @@ export function SettingsRadioSection({
{rebooting ? 'Rebooting...' : 'Save & Reboot'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Some settings may require a reboot to take effect on some radios.
</p>
@@ -682,7 +682,7 @@ export function SettingsRadioSection({
{/* ── Messaging ── */}
<div className="space-y-2">
<Label className="text-base">Messaging</Label>
<h3 className="text-base font-semibold tracking-tight">Messaging</h3>
</div>
<div className="space-y-2">
@@ -695,7 +695,7 @@ export function SettingsRadioSection({
/>
<div className="space-y-1">
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
When enabled, the radio sends one extra direct ACK transmission before the normal ACK
for received direct messages. This is a firmware-level receive behavior, not a
RemoteTerm retry setting.
@@ -714,7 +714,7 @@ export function SettingsRadioSection({
/>
<div className="space-y-1">
<Label htmlFor="auto-resend-channel">Auto-Resend Unheard Channel Messages</Label>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
When enabled, outgoing channel messages that receive no echo within 2 seconds are
automatically resent once (byte-perfect, within the 30-second dedup window). Repeaters
that already heard the original will ignore the duplicate. This functionality will NOT
@@ -732,7 +732,7 @@ export function SettingsRadioSection({
onChange={(e) => setFloodScope(e.target.value)}
placeholder="MyRegion"
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Tag outgoing flood messages with a region name (e.g. MyRegion). Repeaters configured for
that region can forward the traffic, while repeaters configured to deny other regions may
drop it. Leave empty to disable.
@@ -749,7 +749,7 @@ export function SettingsRadioSection({
value={maxRadioContacts}
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Configured radio contact capacity. Favorites reload first, then background maintenance
refills to about 80% of this value and offloads once occupancy reaches about 95%.
</p>
@@ -775,134 +775,137 @@ export function SettingsRadioSection({
<Separator />
{/* ── Advertising & Discovery ── */}
<div className="space-y-2">
<Label className="text-base">Advertising &amp; Discovery</Label>
</div>
<div className="space-y-5">
<h3 className="text-base font-semibold tracking-tight">Advertising &amp; Discovery</h3>
<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 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-[0.8125rem] text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
</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>Send Advertisement</Label>
<p className="text-xs text-muted-foreground">
Flood adverts propagate through repeaters. Zero-hop adverts are local-only and use less
airtime.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Button
onClick={() => handleAdvertise('flood')}
disabled={advertisingMode !== null || !health?.radio_connected}
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
>
{advertisingMode === 'flood' ? 'Sending...' : 'Send Flood Advertisement'}
</Button>
<Button
onClick={() => handleAdvertise('zero_hop')}
disabled={advertisingMode !== null || !health?.radio_connected}
className="w-full"
>
{advertisingMode === 'zero_hop' ? 'Sending...' : 'Send Zero-Hop Advertisement'}
</Button>
</div>
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}
</div>
<div className="space-y-3">
<Label>Mesh Discovery</Label>
<p className="text-xs text-muted-foreground">
Discover nearby node types that currently respond to mesh discovery requests: repeaters
and sensors.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{[
{ target: 'repeaters', label: 'Discover Repeaters' },
{ target: 'sensors', label: 'Discover Sensors' },
{ target: 'all', label: 'Discover Both' },
].map(({ target, label }) => (
<div className="space-y-2">
<h4 className="text-sm font-semibold">Send Advertisement</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Flood adverts propagate through repeaters. Zero-hop adverts are local-only and use less
airtime.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Button
key={target}
type="button"
variant="outline"
onClick={() => handleDiscover(target as RadioDiscoveryTarget)}
disabled={meshDiscoveryLoadingTarget !== null || !health?.radio_connected}
onClick={() => handleAdvertise('flood')}
disabled={advertisingMode !== null || !health?.radio_connected}
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
>
{advertisingMode === 'flood' ? 'Sending...' : 'Send Flood Advertisement'}
</Button>
<Button
onClick={() => handleAdvertise('zero_hop')}
disabled={advertisingMode !== null || !health?.radio_connected}
className="w-full"
>
{meshDiscoveryLoadingTarget === target ? 'Listening...' : label}
{advertisingMode === 'zero_hop' ? 'Sending...' : 'Send Zero-Hop Advertisement'}
</Button>
))}
</div>
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}
{discoverError && (
<p className="text-sm text-destructive" role="alert">
{discoverError}
</p>
)}
{meshDiscovery && (
<div className="space-y-2 rounded-md border border-input bg-muted/20 p-3">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-medium">
Last sweep: {meshDiscovery.results.length} node
{meshDiscovery.results.length === 1 ? '' : 's'}
</p>
<p className="text-xs text-muted-foreground">
{meshDiscovery.duration_seconds.toFixed(0)}s listen window
</p>
</div>
{meshDiscovery.results.length === 0 ? (
<p className="text-sm text-muted-foreground">
No supported nodes responded during the last discovery sweep.
</p>
) : (
<div className="space-y-2">
{meshDiscovery.results.map((result) => (
<div
key={result.public_key}
className="rounded-md border border-input bg-background px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">
{result.name ?? <span className="capitalize">{result.node_type}</span>}
</span>
<span className="text-xs text-muted-foreground">
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
</span>
</div>
{result.name && (
<p className="text-xs capitalize text-muted-foreground">{result.node_type}</p>
)}
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
{result.public_key}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Heard here: {result.local_snr ?? 'n/a'} dB SNR / {result.local_rssi ?? 'n/a'}{' '}
dBm RSSI. Remote heard us: {result.remote_snr ?? 'n/a'} dB SNR.
</p>
</div>
))}
</div>
)}
</div>
)}
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}
</div>
<div className="space-y-3">
<h4 className="text-sm font-semibold">Mesh Discovery</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Discover nearby node types that currently respond to mesh discovery requests: repeaters
and sensors.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{[
{ target: 'repeaters', label: 'Discover Repeaters' },
{ target: 'sensors', label: 'Discover Sensors' },
{ target: 'all', label: 'Discover Both' },
].map(({ target, label }) => (
<Button
key={target}
type="button"
variant="outline"
onClick={() => handleDiscover(target as RadioDiscoveryTarget)}
disabled={meshDiscoveryLoadingTarget !== null || !health?.radio_connected}
className="w-full"
>
{meshDiscoveryLoadingTarget === target ? 'Listening...' : label}
</Button>
))}
</div>
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}
{discoverError && (
<p className="text-sm text-destructive" role="alert">
{discoverError}
</p>
)}
{meshDiscovery && (
<div className="space-y-2 rounded-md border border-input bg-muted/20 p-3">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-medium">
Last sweep: {meshDiscovery.results.length} node
{meshDiscovery.results.length === 1 ? '' : 's'}
</p>
<p className="text-xs text-muted-foreground">
{meshDiscovery.duration_seconds.toFixed(0)}s listen window
</p>
</div>
{meshDiscovery.results.length === 0 ? (
<p className="text-sm text-muted-foreground">
No supported nodes responded during the last discovery sweep.
</p>
) : (
<div className="space-y-2">
{meshDiscovery.results.map((result) => (
<div
key={result.public_key}
className="rounded-md border border-input bg-background px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">
{result.name ?? <span className="capitalize">{result.node_type}</span>}
</span>
<span className="text-xs text-muted-foreground">
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
</span>
</div>
{result.name && (
<p className="text-xs capitalize text-muted-foreground">
{result.node_type}
</p>
)}
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
{result.public_key}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Heard here: {result.local_snr ?? 'n/a'} dB SNR /{' '}
{result.local_rssi ?? 'n/a'} dBm RSSI. Remote heard us:{' '}
{result.remote_snr ?? 'n/a'} dB SNR.
</p>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
@@ -226,7 +226,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<div className="space-y-6">
{/* Network */}
<div>
<h4 className="text-sm font-medium mb-2">Network</h4>
<h3 className="text-base font-semibold tracking-tight mb-2">Network</h3>
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.contact_count}</div>
@@ -247,7 +247,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
{/* Messages */}
<div>
<h4 className="text-sm font-medium mb-2">Messages</h4>
<h3 className="text-base font-semibold tracking-tight mb-2">Messages</h3>
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.total_dms}</div>
@@ -268,7 +268,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
{/* Activity */}
<div>
<h4 className="text-sm font-medium mb-2">Activity</h4>
<h3 className="text-base font-semibold tracking-tight mb-2">Activity</h3>
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground">
@@ -305,7 +305,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
{/* Packets */}
<div>
<h4 className="text-sm font-medium mb-2">Packets</h4>
<h3 className="text-base font-semibold tracking-tight mb-2">Packets</h3>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Total stored</span>
@@ -327,7 +327,9 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Packets per Hour (72h)</h4>
<h3 className="text-base font-semibold tracking-tight mb-2">
Packets per Hour (72h)
</h3>
<PacketsPerHourChart buckets={stats.packets_per_hour_72h} />
</div>
</>
@@ -337,7 +339,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
{/* Path Hash Width */}
<div>
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
<h3 className="text-base font-semibold tracking-tight mb-2">Path Hash Width (24h)</h3>
<div className="mb-2 text-xs text-muted-foreground">
Parsed stored raw packets from the last 24 hours:{' '}
{stats.path_hash_width_24h.total_packets}
@@ -407,7 +409,9 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
<h3 className="text-base font-semibold tracking-tight mb-2">
Busiest Channels (24h)
</h3>
<ResponsiveContainer
width="100%"
height={stats.busiest_channels_24h.length * 28 + 8}
@@ -451,7 +455,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Noise Floor (24h)</h4>
<h3 className="text-base font-semibold tracking-tight mb-2">Noise Floor (24h)</h3>
{stats.noise_floor_24h.latest_noise_floor_dbm != null && (
<div className="mb-2 text-xs text-muted-foreground">
Latest reading: {stats.noise_floor_24h.latest_noise_floor_dbm} dBm
+1 -1
View File
@@ -566,7 +566,7 @@ describe('SettingsModal', () => {
renderModal();
openLocalSection();
const checkbox = screen.getByLabelText('Reopen to last viewed channel/conversation');
const checkbox = screen.getByLabelText('Reopen Last Conversation');
expect(checkbox).not.toBeChecked();
fireEvent.click(checkbox);