diff --git a/AGENTS.md b/AGENTS.md index 739e69802..098dc16e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,7 +146,7 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **Sidecar:** `reticulum-sidecar/` (AGPL Rust binary `mesh-client-reticulum`; path deps `rsReticulum`/`rsLXMF`/`rsNomad`); dev: `pnpm run reticulum:sidecar:dev` - **IPC:** `reticulum:*` main handlers — `start` / `stop` / `getStatus` / `syncInterfaceIssueScope`, `proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete`, **`factoryReset`** (blocked on generic proxy), config file read/import dialog, `showNomadContentSourceDialog`, `setNomadContentSource`, Remote `rncpSend` / `rncpFetch` / `setRncpListener` / `showRncpOpenFileDialog` / `showRncpSaveDirectoryDialog` / `revealInFolder`. Also `media:ensureCameraAccess`, `gps:exportGpx`, `db:setReticulumDestinationVerified`, Remote DB `db:listReticulumRemoteAddresses` / upsert / delete and `db:listReticulumInboundPolicy` / upsert / delete (`src/main/ipc/reticulum-db-handlers.ts`), `mesh-client:openUrl` / `electronAPI.deepLink.onOpenUrl`. Renderer uses `electronAPI.reticulum` proxy (no direct localhost). `ReticulumStackPanel` + `useReticulumInterfaceSnapshot` sync enabled interface names after hydrate so TCP/TX issue banners clear when hubs are disabled; `reticulumSidecarIssueTracker` keeps that enabled set sticky while reading sidecar logs. -- **Panels:** `ReticulumStackPanel` (Connection — stack lifecycle, interfaces, issue banner), `ReticulumNetworkPanel` (Network — identity **slots** + QR share/ingest, stack/announce settings, propagation rename/delete, config import), `ReticulumMapPanel` (Map — RMAP v4 discovery), `ReticulumRmapDiscoveryControls` / `ReticulumRmapConnectionStatus` (RMAP publish), `ReticulumAdminPanel` (Admin — RNode flasher, factory reset), `ReticulumPeerListPanel` (Peers — path request + probe + verified badge), `NomadNetworkPanel` (Nomad — browse + **My Pages** watched-folder static host via `NomadPageServerPanel`/rsNomad; `nomad_serving_enabled` + `nomad_serving_content_source` restore hosting after live stack start; lazy-mount keep-alive, dual-axis page scroll; fit-width default and open-width toggle), `ReticulumRemotePanel` (Remote — rnsh multi-session shell + rncp send/receive/fetch; Saved addresses + inbound policy; Chat DM send-file via `ChatDmRncpControl`), `RrcPanel` (RRC — multi-hub relay chat) +- **Panels:** `ReticulumStackPanel` (Connection — stack lifecycle, interfaces, issue banner), `ReticulumNetworkPanel` (Network — identity **slots** + QR share/ingest, stack/announce settings, propagation rename/delete, config import), `ReticulumMapPanel` (Map — RMAP v4 discovery), `ReticulumRmapDiscoveryControls` / `ReticulumRmapConnectionStatus` (RMAP publish: Network enable-all eligible interfaces; Connection **X of Y** status), `ReticulumAdminPanel` (Admin — RNode flasher, factory reset), `ReticulumPeerListPanel` (Peers — path request + probe + verified badge), `NomadNetworkPanel` (Nomad — browse + **My Pages** watched-folder static host via `NomadPageServerPanel`/rsNomad; `nomad_serving_enabled` + `nomad_serving_content_source` restore hosting after live stack start; lazy-mount keep-alive, dual-axis page scroll; fit-width default and open-width toggle), `ReticulumRemotePanel` (Remote — rnsh multi-session shell + rncp send/receive/fetch; Saved addresses + inbound policy; Chat DM send-file via `ChatDmRncpControl`), `RrcPanel` (RRC — multi-hub relay chat) - **Deep links / QR:** OS scheme is **`lxm://`** (not `mesh-client://`); `MeshClientDeepLinkHost`, `meshClientDeepLink.ts`, `QrIngestControl` / `QrCodeImage`. External contact imports confirm before upsert. - **Decommissioned hubs:** `src/shared/reticulumDecommissionedHubs.ts` (Dublin / Amsterdam / BetweenTheBorders) — stack-start auto-disable + **Add default hubs** disables matching enabled TCP rows; keep TS↔Rust synced via `pnpm run check:reticulum-decommissioned-hubs`. Current presets: US-East / I2P / Yggdrasil / Ratspeak / RMAP World - **Propagation sync:** `reticulumPropagationStore` / `reticulumPropagationSync.ts` — Complete on HaveAll, Establishing stall (~45s) + hard ceiling (~180s), auto-sync interval from last success with failure cooldown, error keys for identity / non-PN / peering stamp; stamps `lastPropagationSyncAttemptAt` / `activePropagationSyncAttemptAt` for WS correlation — `refreshFromSidecar` must **not** clear the active attempt while `sync.active` @@ -165,7 +165,7 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **Self label / header:** `reticulumSelfNodeLabel.ts` (`resolveReticulumSelfHeaderLabel` — Network display name in app header) - **Nomad errors:** `lib/nomad/nomadPageErrorHumanize.ts` (sidecar error codes → i18n); LinkClient Nomad overlay in `reticulum-sidecar/patches/` - **Gating:** `hasReticulumDiscoveryMap` (Map tab); `hasReticulumRemotePanel` / `hasRncpTransfer` (Remote tab + Chat DM rncp); `hasRrcPanel` (RRC tab); `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities` -- **rnsh/rncp:** sidecar `stack/{rnsh_session,rncp_transfer,path_speed,link_task}.rs` + HTTP `/api/v1/rnsh/*`, `/api/v1/rncp/*`, `/api/v1/remote/*`; typed `electronAPI.reticulum.rnsh|rncp|remote`; picker-gated send/fetch paths in `reticulum-remote-paths.ts`; LXMF enable-request sentinel `mesh-client:request-rncp-receive:v1` (`rncpRequestEnable.ts`); peer reply `mesh-client:rncp-receive-dest:v1:` autofills via `applyRncpReceiveDestShare` only after `markRncpReceiveDestSharePending` (from `sendRncpRequestEnable`) within TTL; inbound listener config persists (`rncp_listener_*` in `mesh_client_stack.json`) and restores on live stack start +- **rnsh/rncp:** sidecar `stack/{rnsh_session,rncp_transfer,path_speed,link_task}.rs` + HTTP `/api/v1/rnsh/*`, `/api/v1/rncp/*`, `/api/v1/remote/*`; typed `electronAPI.reticulum.rnsh|rncp|remote`; picker-gated send/fetch paths in `reticulum-remote-paths.ts`; LXMF enable-request sentinel `mesh-client:request-rncp-receive:v1` (`rncpRequestEnable.ts`); peer reply `mesh-client:rncp-receive-dest:v1:` autofills via `applyRncpReceiveDestShare` (prefer pending from `markRncpReceiveDestSharePending` / `sendRncpRequestEnable`; still apply without pending for older peers); enable-request modal + dest-share side effects deduped by LXMF `message_hash` (`rncpLxmfControlSideEffectDedup`) so catch-up cannot re-fire; already-listening auto-share is once per peer per request-enable cooldown; inbound listener config persists (`rncp_listener_*` in `mesh_client_stack.json`) and restores on live stack start - **Runtime:** `useReticulumRuntime`, `lib/sessions/reticulumSession.ts`, `lib/ingest/reticulumIngest.ts`; connect starts sidecar, not `ConnectionDriver` RF. Sidecar RRC: `rrc_codec` / `rrc_link` / `rrc_session` / `api/rrc.rs` - **Diagnostics:** `ReticulumDiagnosticEngine.ts` (Reticulum-native rows; no LoRa hop-goblin semantics) — includes `reticulum/sidecar-unhealthy` (60s grace), `reticulum/propagation-sync-stuck`, `reticulum/propagation-sync-failing` (1h TTL) - **No Noble/MQTT** for Reticulum's own connections (sidecar owns BLE RNode via `btleplug`); gate UI with `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities`. On macOS/Windows, connecting a Reticulum BLE RNode may still **suspend/yield Noble** so it does not contend with the sidecar's BLE scan — see **Multi-protocol BLE** below. diff --git a/docs/reticulum.md b/docs/reticulum.md index 58497a59b..0a685ca45 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -94,7 +94,13 @@ The **Map** tab shows **local** RMAP v4 discovery data — interfaces your stack | **Map tab (local)** | rsReticulum `DiscoveryStore` via `GET /api/v1/rmap/discovered`; refreshed on a timer and via WebSocket `rmap.discovery` | | **Global map (external)** | [rmap.world](https://rmap.world/) — link in Map tab and Network → RMAP controls | -**Publish (appear on maps):** Network → **RMAP v4 discovery** or per-interface RMAP toggles on Connection. Requires App → GPS coordinates for map markers. LoRa-only stacks need an enabled TCP hub (for example `rmap.world:4242`) so discovery announces reach the wider network — see config audit `rmap_no_tcp_hub`. +**Publish (appear on maps):** Network → **RMAP v4 discovery** or per-interface **RMAP** toggles on Connection. Requires App → GPS coordinates for map markers. LoRa-only stacks need an enabled TCP hub (for example `rmap.world:4242`) so discovery announces reach the wider network — see config audit `rmap_no_tcp_hub`. + +**Eligible publish interfaces:** enabled RNode / RNode Multi / KISS (with serial port), BLE peer, I2P, UDP, and pipe. **Not eligible:** Auto, outbound TCP client hubs (including community presets), and system-managed shared-instance rows. + +**Network → Publish on RMAP v4:** enables `discoverable` on **all** eligible enabled interfaces (plus LoRa/BLE transport + `rmap.world` hub when needed). The checkbox is checked only when every eligible interface is publishing; a partial set is indeterminate — check again to sync the rest. Uncheck clears discoverable on all eligible rows. + +**Connection status:** shows **publishing X of Y** (eligible interfaces only; TCP hubs do not count toward Y). Amber when `0 < X < Y`, brand green when `X === Y`, gray when not publishing. **Consume (Map tab):** Sidecar bootstrap migrations in rnsd config: `discover_interfaces = Yes` so the stack listens for discovery announces; when `announce_interval_sec` is absent, writes **3600** (explicit **0** is preserved). Markers show GPS when coordinates were included in the announce; interfaces without coords appear in the sidebar list only. **Reachable** badges join discovery rows with the RNS path table (Peers tab) by matching `transport_id` against peer `destination_hash` or `via_hash`. @@ -112,7 +118,7 @@ Reticulum destination age prune is enabled by default at **30 days** and affects **Config audit kinds:** `rmap_missing_coordinates`, `rmap_no_tcp_hub`, `rmap_transport_disabled`, `rmap_i2p_not_connectable`. -**Implementation:** `ReticulumMapPanel.tsx`, `reticulumDiscoveryMapStore.ts`, `reticulumDiscoveryMapLayout.ts`, `reticulumRmapDiscovery.ts`, `useReticulumRuntime.ts` (WS `rmap.discovery`). +**Implementation:** `ReticulumMapPanel.tsx`, `reticulumDiscoveryMapStore.ts`, `reticulumDiscoveryMapLayout.ts`, `reticulumRmapDiscovery.ts` (capable gate, Network all-eligible checked state, X-of-Y tone), `ReticulumRmapDiscoveryControls` / `ReticulumRmapConnectionStatus`, `useReticulumRuntime.ts` (WS `rmap.discovery`). **Related panels:** **Topology** = logical hops (no geography); **Peers** = path table; **Map** = geographic discovery + reachability. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 620c1deef..1f1bdaebc 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1090,7 +1090,8 @@ Unrecognized codes pass through unchanged. 7. **Stub sidecar** — dev builds without `rns-stack` return an empty discovered list. 8. **Filter empty** — interface-type filter pills may exclude all rows; try **All**. 9. **Refresh errors** — transient sidecar errors show inline `refreshFailed` without clearing last-good markers. -10. **No publish-capable interface** — Auto and outbound TCP client types cannot publish RMAP discovery. +10. **No publish-capable interface** — Auto and outbound TCP client types cannot publish RMAP discovery. Eligible types are RNode / RNode Multi / KISS (with serial), BLE peer, I2P, UDP, and pipe. +11. **Partial publishing (amber X of Y)** — Connection shows **publishing X of Y** in amber when some but not all eligible interfaces have `discoverable=yes`. TCP hubs never count toward Y. Use Network → **Publish on RMAP v4** (check again while indeterminate) or per-interface **RMAP** toggles on Connection to sync the rest. ### Reticulum BLE RNode blocks Meshtastic/MeshCore Noble BLE @@ -1322,7 +1323,7 @@ See [reticulum.md — RNode over Wi-Fi](reticulum.md#rnode-over-wi-fi). **Chat DM note**: the destination field is the peer's **`rncp.receive`** hash, not their LXMF/Chat hash. Prefer **Request enable** (mesh-client peers share the receive hash after they accept) or paste from their Remote → **My rncp receive destination**. -**Request enable / 422**: `sendRncpRequestEnable` must POST LXMF with a `text` field (not `content`) — wrong key → HTTP **422**. After you send request-enable, the peer's `mesh-client:rncp-receive-dest:v1:` reply is applied only if a pending mark exists (`rncpReceiveDestSharePending`, TTL); shares without a prior request-enable from this session are ignored. +**Request enable / 422**: `sendRncpRequestEnable` must POST LXMF with a `text` field (not `content`) — wrong key → HTTP **422**. After you send request-enable, the peer's `mesh-client:rncp-receive-dest:v1:` reply is applied when present (prefer a pending mark from `rncpReceiveDestSharePending` in this session; shares without that mark are still applied so older peers / pasted hashes autofill). Inbound enable-request modal enqueue and dest-share apply are deduped by LXMF `message_hash` so periodic catch-up / WS duplicates do not re-open the modal or re-toast; when the peer is already listening, dest is auto-shared at most once per peer per outbound request-enable cooldown. **Sleep / wake**: Suspend clears in-memory rnsh sessions and rncp transfers; reopen Remote (or wait for resume reconnect) after wake. See [Sleep, wake, and long-running sessions](#sleep-wake-and-long-running-sessions). diff --git a/src/renderer/components/ReticulumRmapConnectionStatus.test.tsx b/src/renderer/components/ReticulumRmapConnectionStatus.test.tsx index a8af83995..18e0d11a8 100644 --- a/src/renderer/components/ReticulumRmapConnectionStatus.test.tsx +++ b/src/renderer/components/ReticulumRmapConnectionStatus.test.tsx @@ -2,7 +2,9 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; import type { ReticulumInterfaceRow } from '@/renderer/lib/reticulum/useReticulumInterfaceSnapshot'; import { ReticulumRmapConnectionStatus } from './ReticulumRmapConnectionStatus'; @@ -25,7 +27,7 @@ function iface( } describe('ReticulumRmapConnectionStatus', () => { - it('shows not publishing and opens Network settings', async () => { + it('shows not publishing in gray and opens Network settings', async () => { const user = userEvent.setup(); const onOpen = vi.fn(); render( @@ -35,7 +37,10 @@ describe('ReticulumRmapConnectionStatus', () => { onOpenRmapSettings={onOpen} />, ); - expect(screen.getByText('connectionPanel.reticulumRmap.notPublishing:{}')).toBeInTheDocument(); + const status = screen.getByText('connectionPanel.reticulumRmap.notPublishing:{}'); + expect(status).toBeInTheDocument(); + expect(status).toHaveClass('text-gray-400'); + expect(status).not.toHaveClass('text-amber-300'); await user.click( screen.getByRole('button', { name: 'connectionPanel.reticulumRmap.openSettingsAria:{}' }), ); @@ -45,21 +50,61 @@ describe('ReticulumRmapConnectionStatus', () => { ).toHaveAttribute('href', 'https://rmap.world/'); }); - it('shows publishing count and needs-sync warning', () => { + it('shows amber X of Y when partially publishing', async () => { render( , ); + const status = screen.getByRole('status'); + expect(status).toHaveTextContent( + 'connectionPanel.reticulumRmap.publishingOf:{"current":1,"total":2}', + ); expect( - screen.getByText('connectionPanel.reticulumRmap.publishing:{"count":1}'), - ).toBeInTheDocument(); + screen.getByText('connectionPanel.reticulumRmap.publishingOf:{"current":1,"total":2}'), + ).toHaveClass('text-amber-300'); + expect(screen.queryByText(/needsSync/)).not.toBeInTheDocument(); + hydrateAxeThemeColors(status); + expect(await axe(status)).toHaveNoViolations(); + }); + + it('shows green X of Y when fully publishing', async () => { + render( + , + ); + const status = screen.getByRole('status'); + expect( + screen.getByText('connectionPanel.reticulumRmap.publishingOf:{"current":3,"total":3}'), + ).toHaveClass('text-brand-green'); + expect( + screen.getByText('connectionPanel.reticulumRmap.publishingOf:{"current":3,"total":3}'), + ).not.toHaveClass('text-amber-300'); + hydrateAxeThemeColors(status); + expect(await axe(status)).toHaveNoViolations(); + }); + + it('excludes tcp hubs from Y and shows noPublishTargets when none eligible', () => { + render( + , + ); + expect(screen.getByText('connectionPanel.reticulumRmap.notPublishing:{}')).toBeInTheDocument(); expect( - screen.getByText('connectionPanel.reticulumRmap.needsSync:{"count":1}'), + screen.getByText('connectionPanel.reticulumRmap.noPublishTargets:{}'), ).toBeInTheDocument(); }); }); diff --git a/src/renderer/components/ReticulumRmapConnectionStatus.tsx b/src/renderer/components/ReticulumRmapConnectionStatus.tsx index a81964c18..5ce8f30cd 100644 --- a/src/renderer/components/ReticulumRmapConnectionStatus.tsx +++ b/src/renderer/components/ReticulumRmapConnectionStatus.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { RMAP_GLOBAL_MAP_URL, + rmapPublishCoverageTone, summarizeRmapPublishStatus, } from '@/renderer/lib/reticulum/reticulumRmapDiscovery'; import type { ReticulumInterfaceRow } from '@/renderer/lib/reticulum/useReticulumInterfaceSnapshot'; @@ -21,20 +22,25 @@ export function ReticulumRmapConnectionStatus({ }: ReticulumRmapConnectionStatusProps) { const { t } = useTranslation(); const summary = useMemo(() => summarizeRmapPublishStatus(interfaces), [interfaces]); + const tone = useMemo(() => rmapPublishCoverageTone(summary), [summary]); if (!sidecarApiReady) { return null; } + const statusClass = + tone === 'full' ? 'text-brand-green' : tone === 'partial' ? 'text-amber-300' : 'text-gray-400'; + return (
- - {summary.publishing - ? t('connectionPanel.reticulumRmap.publishing', { - count: summary.discoverableCount, - }) - : t('connectionPanel.reticulumRmap.notPublishing')} + + {tone === 'off' + ? t('connectionPanel.reticulumRmap.notPublishing') + : t('connectionPanel.reticulumRmap.publishingOf', { + current: summary.discoverableCount, + total: summary.publishTargetCount, + })} {onOpenRmapSettings ? (
- {summary.needsSyncCount > 0 ? ( -

- {t('connectionPanel.reticulumRmap.needsSync', { count: summary.needsSyncCount })} -

- ) : null} - {summary.publishing && summary.publishTargetCount === 0 ? ( + {summary.publishTargetCount === 0 ? (

{t('connectionPanel.reticulumRmap.noPublishTargets')}

) : null}
diff --git a/src/renderer/components/ReticulumRmapDiscoveryControls.test.tsx b/src/renderer/components/ReticulumRmapDiscoveryControls.test.tsx index 273dc97b2..cded9183c 100644 --- a/src/renderer/components/ReticulumRmapDiscoveryControls.test.tsx +++ b/src/renderer/components/ReticulumRmapDiscoveryControls.test.tsx @@ -109,6 +109,122 @@ describe('ReticulumRmapDiscoveryControls', () => { expect(await screen.findByText('reticulumRmapDiscovery.restartTitle')).toBeInTheDocument(); }); + it('enables RMAP on every eligible interface when Network publish is checked', async () => { + localStorage.setItem( + GPS_SETTINGS_STORAGE_KEY, + JSON.stringify({ staticLat: 40.01, staticLon: -105.02 }), + ); + window.electronAPI.reticulum.proxyGet = vi.fn().mockImplementation((path: string) => { + if (path === '/api/v1/interfaces') { + return Promise.resolve({ + interfaces: [ + { + id: 'rnode-1', + name: 'LoRa', + type: 'rnode', + enabled: true, + status: 'up', + serial_port: '/dev/ttyUSB0', + discoverable: false, + }, + { + id: 'ble-1', + name: 'BLE', + type: 'ble_peer', + enabled: true, + status: 'up', + discoverable: false, + }, + { + id: 'i2p-1', + name: 'I2P', + type: 'i2p', + enabled: true, + status: 'up', + discoverable: false, + }, + { + id: 'hub-1', + name: 'RMAP World', + type: 'tcp', + enabled: true, + status: 'up', + host: 'rmap.world', + port: 4242, + discoverable: false, + }, + ], + }); + } + if (path === '/api/v1/stack/settings') { + return Promise.resolve({ + enable_transport: true, + share_instance: true, + loglevel: 4, + }); + } + return Promise.resolve({}); + }); + const user = userEvent.setup(); + renderControls(); + await screen.findByLabelText('reticulumRmapDiscovery.publishToggle'); + await user.click(screen.getByLabelText('reticulumRmapDiscovery.publishToggle')); + + await waitFor(() => { + expect(window.electronAPI.reticulum.proxyPut).toHaveBeenCalledWith( + '/api/v1/interfaces/rnode-1', + expect.objectContaining({ discoverable: true }), + ); + }); + expect(window.electronAPI.reticulum.proxyPut).toHaveBeenCalledWith( + '/api/v1/interfaces/ble-1', + expect.objectContaining({ discoverable: true }), + ); + expect(window.electronAPI.reticulum.proxyPut).toHaveBeenCalledWith( + '/api/v1/interfaces/i2p-1', + expect.objectContaining({ discoverable: true, connectable: true }), + ); + expect(window.electronAPI.reticulum.proxyPut).not.toHaveBeenCalledWith( + '/api/v1/interfaces/hub-1', + expect.anything(), + ); + }); + + it('shows indeterminate Network checkbox when only some eligible interfaces publish', async () => { + window.electronAPI.reticulum.proxyGet = vi.fn().mockImplementation((path: string) => { + if (path === '/api/v1/interfaces') { + return Promise.resolve({ + interfaces: [ + { + id: 'rnode-1', + name: 'LoRa', + type: 'rnode', + enabled: true, + status: 'up', + serial_port: '/dev/ttyUSB0', + discoverable: true, + }, + { + id: 'ble-1', + name: 'BLE', + type: 'ble_peer', + enabled: true, + status: 'up', + discoverable: false, + }, + ], + }); + } + return Promise.resolve({}); + }); + renderControls(); + const checkbox = await screen.findByLabelText('reticulumRmapDiscovery.publishToggle'); + await waitFor(() => { + expect(checkbox).not.toBeChecked(); + expect(checkbox).toHaveProperty('indeterminate', true); + }); + }); + it('has no serious axe violations on GPS warning state', async () => { const { container } = renderControls(); await screen.findByText('reticulumRmapDiscovery.gpsMissingWarning'); diff --git a/src/renderer/components/ReticulumRmapDiscoveryControls.tsx b/src/renderer/components/ReticulumRmapDiscoveryControls.tsx index 6d32f4743..3aa1e7128 100644 --- a/src/renderer/components/ReticulumRmapDiscoveryControls.tsx +++ b/src/renderer/components/ReticulumRmapDiscoveryControls.tsx @@ -1,6 +1,6 @@ /* eslint-disable react-hooks/set-state-in-effect */ import { ExternalLink } from 'lucide-react-motion'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { getAppSettingsRaw } from '@/renderer/lib/appSettingsStorage'; @@ -12,6 +12,7 @@ import { clampRmapAnnounceIntervalMin, disableReticulumRmapDiscovery, persistRmapUiPrefs, + readRmapPublishPartial, readRmapPublishState, resolveRmapCoordinates, ReticulumRmapGpsRequiredError, @@ -46,6 +47,8 @@ export function ReticulumRmapDiscoveryControls({ const { addToast } = useToast(); const [interfaces, setInterfaces] = useState([]); const [publishOn, setPublishOn] = useState(false); + const [publishPartial, setPublishPartial] = useState(false); + const publishCheckboxRef = useRef(null); const [announceIntervalMin, setAnnounceIntervalMin] = useState( RMAP_ANNOUNCE_INTERVAL_DEFAULT_MIN, ); @@ -85,6 +88,7 @@ export function ReticulumRmapDiscoveryControls({ const rows = body.interfaces ?? []; setInterfaces(rows); setPublishOn(readRmapPublishState(rows)); + setPublishPartial(readRmapPublishPartial(rows)); } catch (e) { console.debug('[ReticulumRmapDiscoveryControls] refresh ' + errLikeToLogString(e)); } @@ -98,6 +102,13 @@ export function ReticulumRmapDiscoveryControls({ void refreshInterfaces(); }, [refreshInterfaces]); + useEffect(() => { + const el = publishCheckboxRef.current; + if (el) { + el.indeterminate = publishPartial; + } + }, [publishPartial]); + useEffect(() => { setCoords(resolveRmapCoordinates()); }, [publishOn, interfaces.length]); @@ -215,6 +226,7 @@ export function ReticulumRmapDiscoveryControls({