From b34b6a53710e4958fdec2a4209615231bbd39bf6 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 8 Aug 2026 17:04:03 -0600 Subject: [PATCH 1/4] fix(reticulum): persist last DM focus; clean diagnostics and Nomad sort Restore the last-focused Chat DM across remounts instead of autofocusing the busiest peer. Keep Reticulum interface rows out of the LoRa Node/Offense table, and sort Nomad Announces by column only. --- src/renderer/components/ChatPanel.test.tsx | 100 ++++++++++++++++++ src/renderer/components/ChatPanel.tsx | 34 +++--- .../components/DiagnosticsPanel.test.tsx | 38 +++++++ src/renderer/components/DiagnosticsPanel.tsx | 15 ++- .../lib/chatPanelProtocolStorage.test.ts | 15 +++ src/renderer/lib/chatPanelProtocolStorage.ts | 37 +++++++ src/renderer/lib/nomad/nomadNodeSort.test.ts | 4 +- src/renderer/lib/nomad/nomadNodeSort.ts | 8 +- 8 files changed, 226 insertions(+), 25 deletions(-) diff --git a/src/renderer/components/ChatPanel.test.tsx b/src/renderer/components/ChatPanel.test.tsx index efaa391e4..7599255b1 100644 --- a/src/renderer/components/ChatPanel.test.tsx +++ b/src/renderer/components/ChatPanel.test.tsx @@ -3795,6 +3795,106 @@ describe('ChatPanel reticulum dm-only chat', () => { }); }); + it('restores last-focused DM instead of the peer with the most history', async () => { + const lastFocusedId = 0x201; + const busierPeerId = 0x202; + localStorage.setItem( + 'mesh-client:openDmTabs:reticulum', + JSON.stringify([lastFocusedId, busierPeerId]), + ); + localStorage.setItem('mesh-client:activeDm:reticulum', String(lastFocusedId)); + const nodes = new Map([ + [ + lastFocusedId, + { + node_id: lastFocusedId, + reticulum_destination_hash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + long_name: 'Last Focused', + short_name: 'LF', + hw_model: 'Reticulum', + snr: 0, + battery: 0, + last_heard: Date.now(), + latitude: null, + longitude: null, + favorited: false, + source: 'rf', + }, + ], + [ + busierPeerId, + { + node_id: busierPeerId, + reticulum_destination_hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + long_name: 'Busier Peer', + short_name: 'BP', + hw_model: 'Reticulum', + snr: 0, + battery: 0, + last_heard: Date.now(), + latitude: null, + longitude: null, + favorited: false, + source: 'rf', + }, + ], + ]); + const messages: ChatMessage[] = [ + { + sender_id: lastFocusedId, + sender_name: 'Last Focused', + payload: 'one message', + channel: 0, + to: 1, + reticulum_sender_hash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + timestamp: Date.now() - 1000, + status: 'acked', + }, + { + sender_id: busierPeerId, + sender_name: 'Busier Peer', + payload: 'many one', + channel: 0, + to: 1, + reticulum_sender_hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + timestamp: Date.now() - 500, + status: 'acked', + }, + { + sender_id: busierPeerId, + sender_name: 'Busier Peer', + payload: 'many two', + channel: 0, + to: 1, + reticulum_sender_hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + timestamp: Date.now() - 400, + status: 'acked', + }, + { + sender_id: busierPeerId, + sender_name: 'Busier Peer', + payload: 'many three', + channel: 0, + to: 1, + reticulum_sender_hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + timestamp: Date.now() - 300, + status: 'acked', + }, + ]; + render( + + + , + ); + await waitFor(() => { + expect(screen.getByText('one message')).toBeInTheDocument(); + }); + expect(screen.queryByText('many one')).not.toBeInTheDocument(); + const lastFocusedBtn = screen.getAllByRole('button', { name: 'Last Focused' })[0]; + expect(lastFocusedBtn.className).toMatch(/text-white/); + expect(localStorage.getItem('mesh-client:activeDm:reticulum')).toBe(String(lastFocusedId)); + }); + it('promotes DM pills into the channel grid column with flex-wrap (no separate DM row)', () => { const peerIds = [0x101, 0x102, 0x103, 0x104, 0x105, 0x106]; localStorage.setItem('mesh-client:openDmTabs:reticulum', JSON.stringify(peerIds)); diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index e9e997672..a68c2401e 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -85,12 +85,14 @@ import { playMessageNotification } from '../lib/chatNotifications'; import { dismissedDmTabsStorageKey, lastReadStorageKey, + loadActiveDmInitial, loadMutedViews, loadOpenDmTabsInitial, loadPersistedLastReadInitial, loadStarred, notifyPersistedLastReadChanged, openDmTabsStorageKey, + saveActiveDm, saveMutedViews, saveStarred, type StarredMessage, @@ -738,7 +740,9 @@ function ChatPanel({ const [openDmTabs, setOpenDmTabs] = useState(() => loadOpenDmTabsInitial(protocol)); const openDmTabsRef = useRef(openDmTabs); openDmTabsRef.current = openDmTabs; - const [activeDmNode, setActiveDmNode] = useState(null); + const [activeDmNode, setActiveDmNode] = useState(() => + loadActiveDmInitial(protocol), + ); const [dmAddressInput, setDmAddressInput] = useState(''); const [dmAddressError, setDmAddressError] = useState(null); const [dismissedDmTabs, setDismissedDmTabs] = useState>(() => { @@ -767,6 +771,11 @@ function ChatPanel({ } }, [openDmTabs, protocol]); + // Persist last-focused DM so remount/autofocus does not jump to another open tab. + useEffect(() => { + saveActiveDm(protocol, activeDmNode); + }, [activeDmNode, protocol]); + useEffect(() => { try { localStorage.setItem(dismissedDmTabsStorageKey(protocol), JSON.stringify(dismissedDmTabs)); @@ -922,24 +931,21 @@ function ChatPanel({ }); }, [activeDmNode, isOwnNode, ownNodeIdSet, protocol]); - // Reticulum DM-only: auto-focus the conversation with the most history when none selected. + // Reticulum DM-only: when none selected, restore last-focused open tab (not + // "most message history" — that jumped users to an older/busier peer). useEffect(() => { if (!dmOnlyChat || activeDmNode != null || visibleDmTabs.length === 0) return; // Wait until own identity is known so we never autofocus a misattributed self tab. if (protocol === 'reticulum' && ownNodeIdSet.size === 0) return; - let bestTab = visibleDmTabs[0]; - let bestCount = inferredDmTabs.get(bestTab) ?? 0; - for (const [nodeNum, count] of inferredDmTabs) { - if (!visibleDmTabs.includes(nodeNum)) continue; - if (count > bestCount) { - bestCount = count; - bestTab = nodeNum; - } - } - if (protocol === 'reticulum' && isOwnNode(bestTab)) return; - setActiveDmNode(bestTab); + const stored = loadActiveDmInitial(protocol); + const preferred = + (stored != null && visibleDmTabs.includes(stored) ? stored : null) ?? + [...openDmTabsRef.current].reverse().find((id) => visibleDmTabs.includes(id)) ?? + visibleDmTabs[0]; + if (protocol === 'reticulum' && isOwnNode(preferred)) return; + setActiveDmNode(preferred); setViewMode('dm'); - }, [activeDmNode, dmOnlyChat, inferredDmTabs, isOwnNode, ownNodeIdSet, protocol, visibleDmTabs]); + }, [activeDmNode, dmOnlyChat, isOwnNode, ownNodeIdSet, protocol, visibleDmTabs]); const inferredDmTabSet = useMemo(() => new Set(inferredDmTabs.keys()), [inferredDmTabs]); diff --git a/src/renderer/components/DiagnosticsPanel.test.tsx b/src/renderer/components/DiagnosticsPanel.test.tsx index f3fc770f7..5f715f083 100644 --- a/src/renderer/components/DiagnosticsPanel.test.tsx +++ b/src/renderer/components/DiagnosticsPanel.test.tsx @@ -686,4 +686,42 @@ describe('DiagnosticsPanel reticulum scope', () => { expect(screen.getByText('Reticulum interface config')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Repair config' })).toBeInTheDocument(); }); + + it('does not list Reticulum TX-queue rows in the LoRa mesh Node/Offense table', () => { + diagnosticsStoreState.diagnosticRows = [ + { + kind: 'rf', + id: 'rf:0:reticulum/tx-queue-drops/RNode 41F4', + nodeId: 0, + condition: 'reticulum/tx-queue-drops', + cause: 'Interface "RNode 41F4" dropped 128 outbound packets (TX queue full)', + severity: 'error', + detectedAt: Date.now(), + causeI18n: { + key: 'diagnosticsPanel.reticulum.runtime.txQueueDropsBle', + params: { name: 'RNode 41F4', count: '128' }, + }, + reticulumInterfaceId: 'rnode-41f4', + reticulumRepairKind: 'edit', + }, + ]; + + render( + , + ); + + expect(screen.queryByText('Mesh diagnostics (1)')).not.toBeInTheDocument(); + expect(screen.queryByText('!00000000')).not.toBeInTheDocument(); + expect(screen.getAllByText(/RNode 41F4/).length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: /edit interface/i })).toBeInTheDocument(); + }); }); diff --git a/src/renderer/components/DiagnosticsPanel.tsx b/src/renderer/components/DiagnosticsPanel.tsx index 56887ad42..3a3b6eebb 100644 --- a/src/renderer/components/DiagnosticsPanel.tsx +++ b/src/renderer/components/DiagnosticsPanel.tsx @@ -51,6 +51,7 @@ import { getRecommendedAction, getRecommendedActionForRfCondition, } from '../lib/diagnostics/RemediationEngine'; +import { isReticulumDiagnosticRow } from '../lib/diagnostics/ReticulumDiagnosticEngine'; import { hasLocalStatsData } from '../lib/diagnostics/RFDiagnosticEngine'; import type { OurPosition } from '../lib/gpsSource'; import { startNetworkDiscovery } from '../lib/networkDiscovery'; @@ -193,7 +194,9 @@ export default function DiagnosticsPanel({ [t], ); const showMqttControls = capabilities?.hasMqttHybrid !== false; - const showLoRaMeshDiagnostics = capabilities?.hasHopCount !== false; + // LoRa Node/Offense tables are Meshtastic/MeshCore only. On Reticulum, native + // rows already render in ReticulumDiagnosticsSection — never as !00000000 peers. + const showLoRaMeshDiagnostics = protocol !== 'reticulum' && capabilities?.hasHopCount !== false; const showForeignLoraDiagnostics = capabilities?.hasDiagnosticsPanel !== false; const diagnosticRows = useDiagnosticsStore((s) => s.diagnosticRows); const diagnosticRowsRestoredAt = useDiagnosticsStore((s) => s.diagnosticRowsRestoredAt); @@ -484,14 +487,20 @@ export default function DiagnosticsPanel({ return order(a.severity) - order(b.severity); }); - const selfRows = anomalyList.filter((r) => r.nodeId === myNodeNum && !isForeignLoraRfRow(r)); + const selfRows = anomalyList.filter( + (r) => r.nodeId === myNodeNum && !isForeignLoraRfRow(r) && !isReticulumDiagnosticRow(r), + ); const foreignLoraListenerId = foreignLoraListenerNodeId > 0 ? foreignLoraListenerNodeId : myNodeNum; const otherCrossProtocolRows = anomalyList.filter( (r) => r.nodeId === foreignLoraListenerId && isForeignLoraRfRow(r) && !isMeshCoreInterferenceRow(r), ); - const meshRows = anomalyList.filter((r) => r.nodeId !== myNodeNum); + // Reticulum interface/stack rows belong in ReticulumDiagnosticsSection only — + // never as peer Node/Offense rows (avoids !00000000 self placeholders). + const meshRows = anomalyList.filter( + (r) => r.nodeId !== myNodeNum && !isReticulumDiagnosticRow(r), + ); const errorCount = visibleDiagnosticRows.filter( (r) => r.kind === 'routing' && r.severity === 'error', diff --git a/src/renderer/lib/chatPanelProtocolStorage.test.ts b/src/renderer/lib/chatPanelProtocolStorage.test.ts index c489ce176..e327075c7 100644 --- a/src/renderer/lib/chatPanelProtocolStorage.test.ts +++ b/src/renderer/lib/chatPanelProtocolStorage.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + activeDmStorageKey, clearDraft, clearFloodScopeOverride, clearPersistedRoomsLastRead, @@ -10,6 +11,7 @@ import { floodScopeOverridesStorageKey, getSanitizedMeshtasticChatLastRead, lastReadStorageKey, + loadActiveDmInitial, loadDraftsInitial, loadFloodScopeOverridesInitial, loadMutedViews, @@ -24,6 +26,7 @@ import { sanitizeMeshcoreRoomsLastRead, sanitizeMeshtasticChatLastRead, sanitizeReticulumChatLastRead, + saveActiveDm, saveDraft, saveFloodScopeOverride, saveMutedViews, @@ -55,6 +58,18 @@ describe('chatPanelProtocolStorage', () => { expect(localStorage.getItem(openDmTabsStorageKey('meshcore'))).toBeNull(); }); + it('persists and loads last-focused active DM per protocol', () => { + expect(loadActiveDmInitial('reticulum')).toBeNull(); + saveActiveDm('reticulum', 0xdeadbeef); + expect(localStorage.getItem(activeDmStorageKey('reticulum'))).toBe(String(0xdeadbeef >>> 0)); + expect(loadActiveDmInitial('reticulum')).toBe(0xdeadbeef >>> 0); + expect(loadActiveDmInitial('meshcore')).toBeNull(); + + saveActiveDm('reticulum', null); + expect(localStorage.getItem(activeDmStorageKey('reticulum'))).toBeNull(); + expect(loadActiveDmInitial('reticulum')).toBeNull(); + }); + it('migrates legacy lastRead only into meshtastic key', () => { localStorage.setItem('mesh-client:lastRead', JSON.stringify({ 'ch:0': 1 })); const mt = loadPersistedLastReadInitial('meshtastic'); diff --git a/src/renderer/lib/chatPanelProtocolStorage.ts b/src/renderer/lib/chatPanelProtocolStorage.ts index 50808115e..aef1f37d5 100644 --- a/src/renderer/lib/chatPanelProtocolStorage.ts +++ b/src/renderer/lib/chatPanelProtocolStorage.ts @@ -29,6 +29,10 @@ export function openDmTabsStorageKey(protocol: MeshProtocol): string { return `mesh-client:openDmTabs:${protocol}`; } +export function activeDmStorageKey(protocol: MeshProtocol): string { + return `mesh-client:activeDm:${protocol}`; +} + export function lastReadStorageKey(protocol: MeshProtocol): string { return `mesh-client:lastRead:${protocol}`; } @@ -84,6 +88,39 @@ export function loadOpenDmTabsInitial(protocol: MeshProtocol): number[] { return []; } +/** + * Last-focused DM node id for this protocol (null if missing/invalid). + * Reticulum normalizes with `>>> 0` like open tabs. + */ +export function loadActiveDmInitial(protocol: MeshProtocol): number | null { + const key = activeDmStorageKey(protocol); + try { + const raw = localStorage.getItem(key); + if (raw == null || raw === '') return null; + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return null; + return protocol === 'reticulum' ? parsed >>> 0 : parsed; + } catch (e) { + console.debug('[chatPanelProtocolStorage] loadActiveDmInitial failed ' + errLikeToLogString(e)); + return null; + } +} + +/** Persist last-focused DM; pass null to clear. */ +export function saveActiveDm(protocol: MeshProtocol, nodeId: number | null): void { + const key = activeDmStorageKey(protocol); + try { + if (nodeId == null) { + localStorage.removeItem(key); + return; + } + const normalized = protocol === 'reticulum' ? nodeId >>> 0 : nodeId; + localStorage.setItem(key, String(normalized)); + } catch (e) { + console.debug('[chatPanelProtocolStorage] saveActiveDm failed ' + errLikeToLogString(e)); + } +} + export function draftsStorageKey(protocol: MeshProtocol): string { return `mesh-client:drafts:${protocol}`; } diff --git a/src/renderer/lib/nomad/nomadNodeSort.test.ts b/src/renderer/lib/nomad/nomadNodeSort.test.ts index 82299cfb2..027b9e8a0 100644 --- a/src/renderer/lib/nomad/nomadNodeSort.test.ts +++ b/src/renderer/lib/nomad/nomadNodeSort.test.ts @@ -53,13 +53,13 @@ describe('nomadNodeSort', () => { expect(sortPreparedNomadNodeRows([], 'lastSeen', 'desc')).toEqual([]); }); - it('keeps favorites ahead of any column sort', () => { + it('sorts announces by lastSeen without pinning favorites ahead', () => { const prepared = prepareNomadNodeRows([ node({ destination_hash: 'a1', display_name: 'Alpha', favorited: false, last_seen: 200 }), node({ destination_hash: 'z9', display_name: 'Zulu', favorited: true, last_seen: 100 }), ]); const sorted = sortPreparedNomadNodeRows(prepared, 'lastSeen', 'desc'); - expect(sorted.map((r) => r.node.display_name)).toEqual(['Zulu', 'Alpha']); + expect(sorted.map((r) => r.node.display_name)).toEqual(['Alpha', 'Zulu']); }); it('sorts lastSeen newest first (desc) and oldest first (asc)', () => { diff --git a/src/renderer/lib/nomad/nomadNodeSort.ts b/src/renderer/lib/nomad/nomadNodeSort.ts index 4b2051e0e..7a1643cc7 100644 --- a/src/renderer/lib/nomad/nomadNodeSort.ts +++ b/src/renderer/lib/nomad/nomadNodeSort.ts @@ -145,17 +145,13 @@ function comparePrepared( return a.hashLower.localeCompare(b.hashLower); } -/** Favorites first, then the active column. Mutates a copy only. */ +/** Sort by the active column only (favorites tab already filters to favorites). */ export function sortPreparedNomadNodeRows( rows: readonly PreparedNomadNodeRow[], sortKey: NomadNodeSortKey, sortDir: NomadNodeSortDir, ): PreparedNomadNodeRow[] { const next = [...rows]; - next.sort((a, b) => { - const favDelta = Number(b.favorited) - Number(a.favorited); - if (favDelta !== 0) return favDelta; - return comparePrepared(a, b, sortKey, sortDir); - }); + next.sort((a, b) => comparePrepared(a, b, sortKey, sortDir)); return next; } From ac926253396869c7b20d96b0bb5330e4086bbdfa Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 8 Aug 2026 17:43:59 -0600 Subject: [PATCH 2/4] feat(reticulum): expose per-interface RNode flow control Add a typed flow_control field for RF interfaces (RNode/RNode Multi/KISS, covering USB, ble://, and tcp:// ports) so bursts wait for the device CMD_READY ready-gate instead of overflowing the bounded TX queue and dropping frames. Default on for RF, omitted for other types. - Sidecar: flow_control on InterfaceRow/Add/Update, KNOWN_IFACE_CONFIG_KEYS, INI round-trip, RF add default, and a one-time bootstrap repair that enables the key when missing (preserving an explicit No). - Renderer: mirror types, mark it a known UI key (dedups Advanced), add/edit checkbox with i18n, and require a stack restart on change. - Docs and full test matrix (sidecar config/repair, known-keys, stack restart, extra-config dedup, panel add/edit/absent-for-TCP). --- docs/reticulum.md | 1 + .../src/stack/auto_path_policy.rs | 1 + reticulum-sidecar/src/stack/config.rs | 326 ++++++++++++++++++ reticulum-sidecar/src/stack/config_audit.rs | 1 + reticulum-sidecar/src/stack/live.rs | 3 + .../src/stack/local_rnode_primary.rs | 1 + reticulum-sidecar/src/stack/lxmf_outbound.rs | 1 + reticulum-sidecar/src/stack/mod.rs | 10 + reticulum-sidecar/src/stack/nomad_timeouts.rs | 1 + reticulum-sidecar/src/stack/path_failover.rs | 1 + reticulum-sidecar/src/stack/persistence.rs | 3 + reticulum-sidecar/src/stack/rf_profiles.rs | 1 + reticulum-sidecar/src/stack/types.rs | 7 + reticulum-sidecar/src/stack/via.rs | 9 + .../ReticulumInterfacesPanel.test.tsx | 152 ++++++++ .../reticulum/ReticulumInterfacesPanel.tsx | 44 +++ .../reticulumInterfaceExtraConfig.test.ts | 11 + .../reticulumInterfaceExtraConfig.ts | 1 + .../reticulumInterfaceStackRestart.test.ts | 9 + .../reticulumInterfaceStackRestart.ts | 3 +- .../lib/reticulum/reticulumSidecarReads.ts | 1 + .../useReticulumInterfaceSnapshot.ts | 2 + src/renderer/locales/cs/translation.json | 3 +- src/renderer/locales/de/translation.json | 3 +- src/renderer/locales/en/translation.json | 1 + src/renderer/locales/es/translation.json | 3 +- src/renderer/locales/fr/translation.json | 3 +- src/renderer/locales/id/translation.json | 3 +- src/renderer/locales/it/translation.json | 3 +- src/renderer/locales/ja/translation.json | 3 +- src/renderer/locales/ko/translation.json | 3 +- src/renderer/locales/nl/translation.json | 3 +- src/renderer/locales/pl/translation.json | 3 +- src/renderer/locales/pt-BR/translation.json | 3 +- src/renderer/locales/ru/translation.json | 3 +- src/renderer/locales/tr/translation.json | 3 +- src/renderer/locales/uk/translation.json | 3 +- src/renderer/locales/zh/translation.json | 3 +- 38 files changed, 619 insertions(+), 16 deletions(-) diff --git a/docs/reticulum.md b/docs/reticulum.md index 4d954e963..cc7d6812a 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -194,6 +194,7 @@ Config lives under `userData/reticulum/config/` (rnsd INI). The Connection tab s - **All:** display name; optional rnsd **mode** (`full`, `gateway`, `access_point`, `roaming`, `boundary`, `point_to_point` — shorthands `gw` / `ap` accepted). Defaults when omitted on add: TCP/UDP/I2P → `boundary`; RNode / RNode Multi → `access_point`; **Auto, BLE Peer, KISS, Pipe** leave mode unset (RNS default `full`). Hubs usually use **Boundary**; RNodes usually use **Access point**. On edit, clearing mode omits it from config (RNS `full`). - **IFAC (all types):** optional `network_name` and `passphrase` for private/authenticated network segments ([common interface options](https://reticulum.network/manual/interfaces.html#common-interface-options)). Shown on add and edit; passphrase uses a masked input with show/hide. +- **Flow control (RF only — RNode / RNode Multi / KISS, covering USB, `ble://`, and `tcp://` RNode ports):** typed `flow_control` checkbox. **Defaults on** for RF interfaces so bursts wait for the device `CMD_READY` ready-gate instead of overflowing the bounded TX queue (drops as `PACKET DROPPED: interface TX channel full`). Not shown for TCP/UDP/I2P/Auto/BLE Peer (key omitted). Existing RF interfaces missing the key are repaired to `Yes` once on sidecar bootstrap; an explicit `No` is preserved. Changing it requires a stack restart. No longer needs the Advanced editor. - **Advanced (edit only):** free-form `key = value` lines for other common options (e.g. `forward_interval`, `ifac_size`, I2P SAM host/port below). Keys that duplicate typed form fields are ignored. Unknown INI keys are preserved across enable/edit/repair via sidecar `extra_config` (no longer silently dropped). - **TCP client:** host, port (mesh hub — default port **4242**); IPv6 literals use brackets: `[2001:db8::1]:4242` - **I2P:** comma-separated peer hostnames (`.b32.i2p` addresses, e.g. `{52-base32-chars}.b32.i2p`); max **512** characters total; validated in UI and sidecar before write. The typed **Host** field is the hub **peers** list, not the SAM bridge. By default the stack talks to a **SAM application bridge** on **`127.0.0.1:7656`** on the machine running mesh-client (not HTTP/HTTPS I2PTunnel proxies on `4444`/`4445`). **Restart I2P after enabling SAM** so the bridge listens, then enable the interface and restart the Reticulum stack if it stays down. RMAP publish on I2P sets `connectable=yes` (inbound); hub `peers` are dialed as clients as well (Python RNS parity). diff --git a/reticulum-sidecar/src/stack/auto_path_policy.rs b/reticulum-sidecar/src/stack/auto_path_policy.rs index d2f153f2c..306f9db87 100644 --- a/reticulum-sidecar/src/stack/auto_path_policy.rs +++ b/reticulum-sidecar/src/stack/auto_path_policy.rs @@ -302,6 +302,7 @@ mod tests { reachable_on, network_name: None, passphrase: None, + flow_control: None, extra_config: HashMap::default(), } } diff --git a/reticulum-sidecar/src/stack/config.rs b/reticulum-sidecar/src/stack/config.rs index 5ef62cd73..e4d85dafc 100644 --- a/reticulum-sidecar/src/stack/config.rs +++ b/reticulum-sidecar/src/stack/config.rs @@ -60,6 +60,7 @@ const KNOWN_IFACE_CONFIG_KEYS: &[&str] = &[ "reachable_on", "network_name", "passphrase", + "flow_control", ]; fn is_known_iface_config_key(key: &str) -> bool { @@ -68,6 +69,23 @@ fn is_known_iface_config_key(key: &str) -> bool { .any(|k| k.eq_ignore_ascii_case(key)) } +/// RF interface types whose RNode driver honors the `flow_control` TX ready-gate. +/// Matches `SERIAL_PORT_IFACE_TYPES` (rnode / rnode_multi / kiss), covering USB, +/// `ble://`, and `tcp://` RNode ports. +fn iface_type_supports_flow_control(iface_type: &str) -> bool { + SERIAL_PORT_IFACE_TYPES.contains(&iface_type) +} + +/// Default `flow_control` when adding/repairing an interface with the key absent. +/// RF interfaces default on; all other types leave it unset. +pub(crate) fn default_flow_control_for_iface_type(iface_type: &str) -> Option { + if iface_type_supports_flow_control(iface_type) { + Some(true) + } else { + None + } +} + /// Normalize optional IFAC / free-text fields: whitespace-only → None. fn nonempty_opt_string(raw: Option<&str>) -> Option { raw.map(str::trim) @@ -445,6 +463,13 @@ fn interface_block_to_row(block: &IniBlock) -> Option { reachable_on: block.get("reachable_on").map(str::to_string), network_name: nonempty_opt_string(block.get("network_name")), passphrase: nonempty_opt_string(block.get("passphrase")), + // Only RF types honor flow control; non-RF blocks keep it unset so a + // stray key is not surfaced as a typed field. + flow_control: if iface_type_supports_flow_control(iface_type) { + block.get_bool("flow_control") + } else { + None + }, extra_config: { let mut extras = HashMap::new(); for key in &block.order { @@ -527,6 +552,13 @@ fn interface_row_to_block(row: &InterfaceRow) -> IniBlock { } } + // Flow control is RF-only; never emit it for TCP/UDP/I2P/Auto blocks. + if iface_type_supports_flow_control(&row.iface_type) { + if let Some(v) = row.flow_control { + block.set("flow_control", &bool_to_ini(v)); + } + } + // Preserve unknown keys; typed fields take priority on key collision. // Skip keys/values with line breaks so API/disk corruption cannot inject INI sections. for (key, value) in &row.extra_config { @@ -802,6 +834,10 @@ pub fn add_interface_to_config( reachable_on: req.reachable_on.clone(), network_name: nonempty_opt_string(req.network_name.as_deref()), passphrase: nonempty_opt_string(req.passphrase.as_deref()), + // RF interfaces default flow control on unless the request overrides it. + flow_control: req + .flow_control + .or_else(|| default_flow_control_for_iface_type(&req.iface_type)), extra_config: req.extra_config.clone(), }; @@ -905,6 +941,9 @@ pub fn update_interface_in_config( validate_ini_scalar("passphrase", passphrase)?; row.passphrase = nonempty_opt_string(Some(passphrase.as_str())); } + if patch.flow_control.is_some() { + row.flow_control = patch.flow_control; + } if let Some(ref extra) = patch.extra_config { validate_extra_config(extra)?; row.extra_config = extra.clone(); @@ -1013,6 +1052,9 @@ pub struct UpdateInterfacePatch { pub reachable_on: Option, pub network_name: Option, pub passphrase: Option, + /// RNode/KISS TX ready-gate toggle. `None` leaves the current value. + #[serde(default)] + pub flow_control: Option, /// When `Some`, replaces the interface's preserved unknown keys. /// When `None` (omitted), existing `extra_config` is kept. #[serde(default)] @@ -1041,6 +1083,35 @@ pub fn repair_rnode_radio_fields_in_config(config_dir: &Path) -> Result Result { + let content = read_config(config_dir)?; + let mut parsed = parse_config(&content)?; + let mut changed = false; + for block in &mut parsed.interfaces { + let Some(mut row) = interface_block_to_row(block) else { + continue; + }; + if !iface_type_supports_flow_control(&row.iface_type) { + continue; + } + // `interface_block_to_row` parses an existing (even explicit `No`) value; + // only a missing/unparseable key yields `None`, which we default on. + if row.flow_control.is_some() { + continue; + } + row.flow_control = Some(true); + *block = interface_row_to_block(&row); + changed = true; + } + if changed { + write_config(config_dir, &serialize_config(&parsed))?; + } + Ok(changed) +} + fn rnode_needs_preset_expansion(row: &InterfaceRow) -> bool { if row.iface_type != "rnode" { return false; @@ -2525,6 +2596,7 @@ target_port = 4242 reachable_on: None, network_name: None, passphrase: None, + flow_control: None, extra_config: { let mut m = HashMap::new(); m.insert("ok".into(), "1".into()); @@ -2872,4 +2944,258 @@ mode = Boundry assert!(disk.contains("mode = Boundry")); let _ = fs::remove_dir_all(&dir); } + + fn fresh_config_dir() -> PathBuf { + let dir = std::env::temp_dir().join(format!("mesh_reticulum_cfg_{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + write_config( + &dir, + r#"[reticulum] +enable_transport = No +[logging] +loglevel = 4 +[interfaces] +"#, + ) + .unwrap(); + dir + } + + #[test] + fn flow_control_key_is_known_config_key() { + assert!(is_known_iface_config_key("flow_control")); + } + + #[test] + fn add_rnode_defaults_flow_control_on() { + for serial in ["/dev/ttyUSB0", "ble://Heltec V3", "tcp://192.168.1.50:4242"] { + let dir = fresh_config_dir(); + let row = add_interface_to_config( + &dir, + &AddInterfaceRequest { + iface_type: "rnode".into(), + name: Some("RNode".into()), + serial_port: Some(serial.into()), + callsign: Some("N0CALL".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(row.flow_control, Some(true), "serial={serial}"); + let disk = read_config(&dir).unwrap(); + assert!( + disk.contains("flow_control = Yes"), + "serial={serial}\n{disk}" + ); + let _ = fs::remove_dir_all(&dir); + } + } + + #[test] + fn add_kiss_defaults_flow_control_on() { + let dir = fresh_config_dir(); + let row = add_interface_to_config( + &dir, + &AddInterfaceRequest { + iface_type: "kiss".into(), + name: Some("KISS TNC".into()), + serial_port: Some("/dev/ttyACM0".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(row.flow_control, Some(true)); + let disk = read_config(&dir).unwrap(); + assert!(disk.contains("flow_control = Yes"), "{disk}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn add_tcp_omits_flow_control() { + let dir = fresh_config_dir(); + let row = add_interface_to_config( + &dir, + &AddInterfaceRequest { + iface_type: "tcp".into(), + name: Some("Hub".into()), + host: Some("example.org".into()), + port: Some(4242), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(row.flow_control, None); + let disk = read_config(&dir).unwrap(); + assert!(!disk.contains("flow_control"), "{disk}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn add_rnode_explicit_false_not_overwritten() { + let dir = fresh_config_dir(); + let row = add_interface_to_config( + &dir, + &AddInterfaceRequest { + iface_type: "rnode".into(), + name: Some("RNode".into()), + serial_port: Some("/dev/ttyUSB0".into()), + callsign: Some("N0CALL".into()), + flow_control: Some(false), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(row.flow_control, Some(false)); + let disk = read_config(&dir).unwrap(); + assert!(disk.contains("flow_control = No"), "{disk}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn flow_control_round_trips_and_stays_typed() { + for (value, expected_ini) in [(true, "flow_control = Yes"), (false, "flow_control = No")] { + let dir = fresh_config_dir(); + let added = add_interface_to_config( + &dir, + &AddInterfaceRequest { + iface_type: "rnode".into(), + name: Some("RNode".into()), + serial_port: Some("/dev/ttyUSB0".into()), + callsign: Some("N0CALL".into()), + flow_control: Some(value), + ..Default::default() + }, + ) + .unwrap(); + // Update an unrelated field to force a read→modify→write cycle. + update_interface_in_config( + &dir, + &added.id, + &UpdateInterfacePatch { + enabled: Some(false), + ..Default::default() + }, + ) + .unwrap(); + let disk = read_config(&dir).unwrap(); + assert!(disk.contains(expected_ini), "{disk}"); + let rows = interfaces_from_config_dir(&dir).unwrap(); + let row = rows.iter().find(|r| r.id == added.id).unwrap(); + assert_eq!(row.flow_control, Some(value)); + // Typed promotion: never left dangling in extra_config. + assert!(!row.extra_config.contains_key("flow_control")); + let _ = fs::remove_dir_all(&dir); + } + } + + #[test] + fn update_toggles_flow_control_off() { + let dir = fresh_config_dir(); + let added = add_interface_to_config( + &dir, + &AddInterfaceRequest { + iface_type: "rnode".into(), + name: Some("RNode".into()), + serial_port: Some("/dev/ttyUSB0".into()), + callsign: Some("N0CALL".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(added.flow_control, Some(true)); + let updated = update_interface_in_config( + &dir, + &added.id, + &UpdateInterfacePatch { + flow_control: Some(false), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(updated.flow_control, Some(false)); + let disk = read_config(&dir).unwrap(); + assert!(disk.contains("flow_control = No"), "{disk}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn flow_control_parsed_as_typed_not_extra_config() { + let content = r#" +[interfaces] +[[LoRa]] +type = RNodeInterface +enabled = Yes +port = /dev/ttyUSB0 +flow_control = Yes +"#; + let rows = interfaces_from_parsed(&parse_config(content).unwrap()); + let rnode = rows.iter().find(|r| r.iface_type == "rnode").unwrap(); + assert_eq!(rnode.flow_control, Some(true)); + assert!(!rnode.extra_config.contains_key("flow_control")); + } + + #[test] + fn repair_flow_control_enables_missing_rf_key() { + let dir = std::env::temp_dir().join(format!("mesh_reticulum_cfg_{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + write_config( + &dir, + r#"[interfaces] +[[LoRa]] +type = RNodeInterface +enabled = Yes +port = /dev/ttyUSB0 +"#, + ) + .unwrap(); + assert!(repair_flow_control_defaults_in_config(&dir).unwrap()); + let disk = read_config(&dir).unwrap(); + assert!(disk.contains("flow_control = Yes"), "{disk}"); + // Idempotent: second pass makes no change. + assert!(!repair_flow_control_defaults_in_config(&dir).unwrap()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn repair_flow_control_preserves_explicit_no() { + let dir = std::env::temp_dir().join(format!("mesh_reticulum_cfg_{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + write_config( + &dir, + r#"[interfaces] +[[LoRa]] +type = RNodeInterface +enabled = Yes +port = /dev/ttyUSB0 +flow_control = No +"#, + ) + .unwrap(); + assert!(!repair_flow_control_defaults_in_config(&dir).unwrap()); + let rows = interfaces_from_config_dir(&dir).unwrap(); + assert_eq!(rows[0].flow_control, Some(false)); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn repair_flow_control_ignores_non_rf_blocks() { + let dir = std::env::temp_dir().join(format!("mesh_reticulum_cfg_{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + write_config( + &dir, + r#"[interfaces] +[[Hub]] +type = TCPClientInterface +interface_enabled = Yes +name = Hub +target_host = example.org +target_port = 4242 +"#, + ) + .unwrap(); + assert!(!repair_flow_control_defaults_in_config(&dir).unwrap()); + let disk = read_config(&dir).unwrap(); + assert!(!disk.contains("flow_control"), "{disk}"); + let _ = fs::remove_dir_all(&dir); + } } diff --git a/reticulum-sidecar/src/stack/config_audit.rs b/reticulum-sidecar/src/stack/config_audit.rs index 69fea192e..33d3e26d0 100644 --- a/reticulum-sidecar/src/stack/config_audit.rs +++ b/reticulum-sidecar/src/stack/config_audit.rs @@ -658,6 +658,7 @@ target_port = 4242 reachable_on: None, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::new(), }); let settings = StackSettings { diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index cf2f6141a..a199300a8 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -2486,6 +2486,7 @@ impl LiveBridge { reachable_on: None, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::new(), }) .collect(); @@ -3864,6 +3865,7 @@ impl LiveBridge { reachable_on: None, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::new(), }) .collect(); @@ -6188,6 +6190,7 @@ mod nomad_private_first_failover_tests { reachable_on, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::default(), } } diff --git a/reticulum-sidecar/src/stack/local_rnode_primary.rs b/reticulum-sidecar/src/stack/local_rnode_primary.rs index 687d898b1..f56dd6c31 100644 --- a/reticulum-sidecar/src/stack/local_rnode_primary.rs +++ b/reticulum-sidecar/src/stack/local_rnode_primary.rs @@ -313,6 +313,7 @@ mod tests { reachable_on: None, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::new(), } } diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index 4bdd2a7ee..2c7cc7181 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -1886,6 +1886,7 @@ mod tests { reachable_on, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::default(), } } diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index b541109c7..a06daf3c1 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -227,6 +227,16 @@ impl StackHandle { tracing::warn!("failed to repair RNode radio fields in config: {e}"); } + match config::repair_flow_control_defaults_in_config(&config_dir) { + Ok(true) => { + tracing::info!("enabled flow_control default on RF interfaces missing the key"); + } + Ok(false) => {} + Err(e) => { + tracing::warn!("failed to apply flow_control defaults in config: {e}"); + } + } + let mut persisted = PersistedState::load(&config_dir, &storage_dir); persisted.ensure_defaults(); if let Ok(ifaces) = config::interfaces_from_config_dir(&config_dir) { diff --git a/reticulum-sidecar/src/stack/nomad_timeouts.rs b/reticulum-sidecar/src/stack/nomad_timeouts.rs index fe8461d2e..5bd2ff079 100644 --- a/reticulum-sidecar/src/stack/nomad_timeouts.rs +++ b/reticulum-sidecar/src/stack/nomad_timeouts.rs @@ -163,6 +163,7 @@ mod tests { reachable_on: None, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::new(), } } diff --git a/reticulum-sidecar/src/stack/path_failover.rs b/reticulum-sidecar/src/stack/path_failover.rs index b4c1d4986..7c85fb105 100644 --- a/reticulum-sidecar/src/stack/path_failover.rs +++ b/reticulum-sidecar/src/stack/path_failover.rs @@ -303,6 +303,7 @@ mod tests { reachable_on, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::default(), } } diff --git a/reticulum-sidecar/src/stack/persistence.rs b/reticulum-sidecar/src/stack/persistence.rs index cde98c7b0..898be622e 100644 --- a/reticulum-sidecar/src/stack/persistence.rs +++ b/reticulum-sidecar/src/stack/persistence.rs @@ -343,6 +343,9 @@ impl PersistedState { reachable_on: req.reachable_on, network_name: req.network_name, passphrase: req.passphrase, + flow_control: req + .flow_control + .or_else(|| super::config::default_flow_control_for_iface_type(&req.iface_type)), extra_config: req.extra_config, }; self.interfaces.push(row.clone()); diff --git a/reticulum-sidecar/src/stack/rf_profiles.rs b/reticulum-sidecar/src/stack/rf_profiles.rs index ea003dc31..3e54753da 100644 --- a/reticulum-sidecar/src/stack/rf_profiles.rs +++ b/reticulum-sidecar/src/stack/rf_profiles.rs @@ -212,6 +212,7 @@ mod tests { reachable_on: None, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::new(), }; assert!(!row_params_match_preset(&row)); diff --git a/reticulum-sidecar/src/stack/types.rs b/reticulum-sidecar/src/stack/types.rs index 4a7618302..aac6ea82a 100644 --- a/reticulum-sidecar/src/stack/types.rs +++ b/reticulum-sidecar/src/stack/types.rs @@ -55,6 +55,10 @@ pub struct InterfaceRow { /// IFAC authentication passphrase (common interface option). #[serde(default, skip_serializing_if = "Option::is_none")] pub passphrase: Option, + /// RNode/KISS TX ready-gate (`CMD_READY`). Defaults on for RF interfaces so + /// bursts do not overflow the bounded TX queue. Only meaningful for RF types. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flow_control: Option, /// Unknown INI keys preserved across CRUD so typed writes do not drop them. #[serde(default)] pub extra_config: HashMap, @@ -254,6 +258,9 @@ pub struct AddInterfaceRequest { pub network_name: Option, #[serde(default)] pub passphrase: Option, + /// RNode/KISS TX ready-gate. When omitted, RF interfaces default to `true`. + #[serde(default)] + pub flow_control: Option, #[serde(default)] pub extra_config: HashMap, } diff --git a/reticulum-sidecar/src/stack/via.rs b/reticulum-sidecar/src/stack/via.rs index ea55ea4c1..97bd13ee4 100644 --- a/reticulum-sidecar/src/stack/via.rs +++ b/reticulum-sidecar/src/stack/via.rs @@ -295,6 +295,7 @@ mod tests { reachable_on: None, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::new(), } } @@ -420,6 +421,7 @@ mod tests { reachable_on: None, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::new(), }]; assert_eq!(resolve_stub_sent_via(&ifaces), "rf"); @@ -457,6 +459,7 @@ mod tests { reachable_on: None, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::new(), }]; let live = vec![InterfaceRow { @@ -488,6 +491,7 @@ mod tests { reachable_on: None, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::new(), }]; let merged = merge_live_interfaces_with_config(&config, live); @@ -532,6 +536,7 @@ mod tests { reachable_on: None, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::new(), }, InterfaceRow { @@ -563,6 +568,7 @@ mod tests { reachable_on: None, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::new(), }, ]; @@ -605,6 +611,7 @@ mod tests { reachable_on: None, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::new(), }]; let merged = merge_live_interfaces_with_config(&config, live); @@ -647,6 +654,7 @@ mod tests { reachable_on: None, network_name: Some("ttp_internal".into()), passphrase: Some("resistance202606".into()), + flow_control: None, extra_config: extra.clone(), }]; let live = vec![InterfaceRow { @@ -678,6 +686,7 @@ mod tests { reachable_on: None, network_name: None, passphrase: None, + flow_control: None, extra_config: std::collections::HashMap::new(), }]; let merged = merge_live_interfaces_with_config(&config, live); diff --git a/src/renderer/components/reticulum/ReticulumInterfacesPanel.test.tsx b/src/renderer/components/reticulum/ReticulumInterfacesPanel.test.tsx index ac77e6f14..d174fedab 100644 --- a/src/renderer/components/reticulum/ReticulumInterfacesPanel.test.tsx +++ b/src/renderer/components/reticulum/ReticulumInterfacesPanel.test.tsx @@ -689,6 +689,7 @@ describe('ReticulumInterfacesPanel', () => { expect(window.electronAPI.reticulum.proxyPost).toHaveBeenCalledWith('/api/v1/interfaces', { type: 'rnode', serial_port: 'tcp://192.168.1.10', + flow_control: true, preset: 'rnode_us', frequency: 914875000, bandwidth: 125000, @@ -1989,4 +1990,155 @@ describe('ReticulumInterfacesPanel', () => { ); }); }); + + describe('flow control', () => { + it('shows a checked flow-control checkbox and posts flow_control: true when adding an RNode', async () => { + const user = userEvent.setup(); + const proxyPost = vi.fn().mockResolvedValue({ ok: true }); + window.electronAPI.reticulum.proxyPost = proxyPost; + + render(); + + await user.selectOptions( + screen.getByLabelText('connectionPanel.reticulumInterfaces.type'), + 'rnode', + ); + const flowControl = screen.getByRole('checkbox', { + name: 'connectionPanel.reticulumInterfaces.flowControl', + }); + expect(flowControl).toBeChecked(); + await user.type( + screen.getByLabelText('connectionPanel.reticulumInterfaces.callsign'), + 'NV0N', + ); + await user.click( + screen.getByRole('button', { name: 'connectionPanel.reticulumInterfaces.add' }), + ); + + await waitFor(() => { + expect(proxyPost).toHaveBeenCalledWith( + '/api/v1/interfaces', + expect.objectContaining({ type: 'rnode', flow_control: true }), + ); + }); + }); + + it('does not show a flow-control checkbox for TCP add', () => { + render(); + expect( + screen.queryByRole('checkbox', { + name: 'connectionPanel.reticulumInterfaces.flowControl', + }), + ).not.toBeInTheDocument(); + }); + + it.each([ + { serial_port: '/dev/ttyUSB0', label: 'serial RNode' }, + { serial_port: 'ble://AA:BB:CC:DD:EE:FF', label: 'BLE RNode' }, + { serial_port: 'tcp://192.168.1.50', label: 'Wi-Fi RNode' }, + ])('shows the flow-control checkbox when editing a $label', async ({ serial_port }) => { + const user = userEvent.setup(); + render( + , + ); + + await user.click( + screen.getByRole('button', { name: 'connectionPanel.reticulumInterfaces.edit' }), + ); + expect( + screen.getByRole('checkbox', { + name: 'connectionPanel.reticulumInterfaces.flowControl', + }), + ).toBeChecked(); + }); + + it('reflects flow_control: false from the row and posts flow_control: false when saving', async () => { + const user = userEvent.setup(); + const proxyPut = vi.fn().mockResolvedValue({ ok: true }); + window.electronAPI.reticulum.proxyPut = proxyPut; + + render( + , + ); + + await user.click( + screen.getByRole('button', { name: 'connectionPanel.reticulumInterfaces.edit' }), + ); + const flowControl = screen.getByRole('checkbox', { + name: 'connectionPanel.reticulumInterfaces.flowControl', + }); + expect(flowControl).toBeChecked(); + await user.click(flowControl); + await user.click( + screen.getByRole('button', { name: 'connectionPanel.reticulumInterfaces.saveEdit' }), + ); + + await waitFor(() => { + expect(proxyPut).toHaveBeenCalledWith( + '/api/v1/interfaces/rnode-1', + expect.objectContaining({ flow_control: false }), + ); + }); + const patch = proxyPut.mock.calls[0][1] as Record; + // Typed field only — never leaked into the Advanced extra_config bag. + expect((patch.extra_config as Record).flow_control).toBeUndefined(); + }); + + it('does not show a flow-control checkbox when editing a TCP hub', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click( + screen.getByRole('button', { name: 'connectionPanel.reticulumInterfaces.edit' }), + ); + expect( + screen.queryByRole('checkbox', { + name: 'connectionPanel.reticulumInterfaces.flowControl', + }), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/renderer/components/reticulum/ReticulumInterfacesPanel.tsx b/src/renderer/components/reticulum/ReticulumInterfacesPanel.tsx index 955c15bb9..e2ce62bd0 100644 --- a/src/renderer/components/reticulum/ReticulumInterfacesPanel.tsx +++ b/src/renderer/components/reticulum/ReticulumInterfacesPanel.tsx @@ -215,6 +215,8 @@ export function ReticulumInterfacesPanel({ }); const [selectedPreset, setSelectedPreset] = useState('rnode_us'); const [addRfFields, setAddRfFields] = useState(defaultAddRnodeRfFields); + // RF interfaces default flow control on (TX ready-gate) to avoid BLE buffer bursts. + const [addFlowControl, setAddFlowControl] = useState(true); const [auditByInterfaceId, setAuditByInterfaceId] = useState< Map >(() => new Map()); @@ -430,6 +432,7 @@ export function ReticulumInterfacesPanel({ } else { body.serial_port = serialPort.trim(); } + body.flow_control = addFlowControl; } if (ifaceType === 'ble_peer') { const seeds = seedAddresses @@ -524,6 +527,9 @@ export function ReticulumInterfacesPanel({ setRnodeDeviceName(''); setIfaceCallsign(''); } + if (ifaceType === 'rnode' || ifaceType === 'rnode_multi' || ifaceType === 'kiss') { + setAddFlowControl(true); + } } catch (e) { // catch-no-log-ok: interface add failure shown via interfaceError setInterfaceError( @@ -954,6 +960,8 @@ export function ReticulumInterfacesPanel({ rnodeWifiHost={rnodeWifiHost} rnodeWifiPort={rnodeWifiPort} seedAddresses={seedAddresses} + addFlowControl={addFlowControl} + onAddFlowControlChange={setAddFlowControl} onIfaceTypeChange={handleIfaceTypeChange} onIfaceModeChange={setIfaceMode} onIfaceHostChange={setIfaceHost} @@ -1185,6 +1193,7 @@ function buildInterfaceEditPatch(draft: { mode: string; networkName: string; passphrase: string; + flowControl: boolean; extraConfig: Record; rf: RnodeRfFieldValues; }): Record | null { @@ -1201,6 +1210,7 @@ function buildInterfaceEditPatch(draft: { } if (draft.type === 'rnode' || draft.type === 'rnode_multi' || draft.type === 'kiss') { body.serial_port = draft.serialPort.trim() || null; + body.flow_control = draft.flowControl; } if (draft.type === 'ble_peer') { body.seed_addresses = draft.seedAddresses @@ -1503,6 +1513,8 @@ function InterfaceEditPanel({ const [networkName, setNetworkName] = useState(iface.network_name ?? ''); const [passphrase, setPassphrase] = useState(iface.passphrase ?? ''); const [showPassphrase, setShowPassphrase] = useState(false); + // RF-only TX ready-gate; default on when the stored row omits the key. + const [flowControl, setFlowControl] = useState(() => iface.flow_control ?? true); const [advancedText, setAdvancedText] = useState(() => formatInterfaceExtraConfig(iface.extra_config ?? undefined), ); @@ -1665,6 +1677,18 @@ function InterfaceEditPanel({ setRfFields((prev) => ({ ...prev, ...patch })); }} /> + ) : null} {editRequiresCallsign ? ( @@ -1796,6 +1820,7 @@ function InterfaceEditPanel({ mode, networkName, passphrase, + flowControl, extraConfig: parsedExtra.extraConfig, rf: rfFields, }); @@ -1854,6 +1879,8 @@ function InterfacesSection({ rnodeWifiHost, rnodeWifiPort, seedAddresses, + addFlowControl, + onAddFlowControlChange, onIfaceTypeChange, onIfaceModeChange, onIfaceHostChange, @@ -1919,6 +1946,8 @@ function InterfacesSection({ rnodeWifiHost: string; rnodeWifiPort: string; seedAddresses: string; + addFlowControl: boolean; + onAddFlowControlChange: (v: boolean) => void; onIfaceTypeChange: (v: ReticulumIfaceUiType) => void; onIfaceModeChange: (v: string) => void; onIfaceHostChange: (v: string) => void; @@ -2337,6 +2366,21 @@ function InterfacesSection({ {t('connectionPanel.reticulumInterfaces.pickDevice')} ) : null} + {showSerial ? ( + + ) : null} { it('recognizes known typed keys case-insensitively', () => { expect(isKnownIfaceUiKey('network_name')).toBe(true); expect(isKnownIfaceUiKey('Passphrase')).toBe(true); + expect(isKnownIfaceUiKey('flow_control')).toBe(true); + expect(isKnownIfaceUiKey('Flow_Control')).toBe(true); expect(isKnownIfaceUiKey('forward_interval')).toBe(false); }); + it('drops flow_control from Advanced parse as a reserved typed key', () => { + const parsed = parseInterfaceExtraConfig(` +flow_control = No +forward_interval = 300 +`); + expect(parsed.extraConfig).toEqual({ forward_interval: '300' }); + expect(parsed.reservedKeys).toContain('flow_control'); + }); + it('formats and parses extra_config round-trip', () => { const text = formatInterfaceExtraConfig({ forward_interval: '300', diff --git a/src/renderer/lib/reticulum/reticulumInterfaceExtraConfig.ts b/src/renderer/lib/reticulum/reticulumInterfaceExtraConfig.ts index bfbd28b24..bad470698 100644 --- a/src/renderer/lib/reticulum/reticulumInterfaceExtraConfig.ts +++ b/src/renderer/lib/reticulum/reticulumInterfaceExtraConfig.ts @@ -35,6 +35,7 @@ export const KNOWN_IFACE_UI_KEYS: ReadonlySet = new Set([ 'reachable_on', 'network_name', 'passphrase', + 'flow_control', ]); export function isKnownIfaceUiKey(key: string): boolean { diff --git a/src/renderer/lib/reticulum/reticulumInterfaceStackRestart.test.ts b/src/renderer/lib/reticulum/reticulumInterfaceStackRestart.test.ts index 12933bc84..22414031e 100644 --- a/src/renderer/lib/reticulum/reticulumInterfaceStackRestart.test.ts +++ b/src/renderer/lib/reticulum/reticulumInterfaceStackRestart.test.ts @@ -34,4 +34,13 @@ describe('reticulumInterfaceChangeRequiresStackRestart', () => { ); expect(reticulumInterfaceChangeRequiresStackRestart(undefined, { name: 'new' })).toBe(false); }); + + it('requires restart when flow_control changes alone', () => { + expect(reticulumInterfaceChangeRequiresStackRestart(undefined, { flow_control: false })).toBe( + true, + ); + expect(reticulumInterfaceChangeRequiresStackRestart(undefined, { flow_control: true })).toBe( + true, + ); + }); }); diff --git a/src/renderer/lib/reticulum/reticulumInterfaceStackRestart.ts b/src/renderer/lib/reticulum/reticulumInterfaceStackRestart.ts index 55f510428..c9411503b 100644 --- a/src/renderer/lib/reticulum/reticulumInterfaceStackRestart.ts +++ b/src/renderer/lib/reticulum/reticulumInterfaceStackRestart.ts @@ -38,6 +38,7 @@ export function reticulumInterfaceChangeRequiresStackRestart( 'host' in patch || 'port' in patch || 'command' in patch || - 'mode' in patch + 'mode' in patch || + 'flow_control' in patch ); } diff --git a/src/renderer/lib/reticulum/reticulumSidecarReads.ts b/src/renderer/lib/reticulum/reticulumSidecarReads.ts index aecdc1c30..ff02a039e 100644 --- a/src/renderer/lib/reticulum/reticulumSidecarReads.ts +++ b/src/renderer/lib/reticulum/reticulumSidecarReads.ts @@ -99,6 +99,7 @@ export interface ReticulumSidecarInterfaceRow { reachable_on?: string | null; network_name?: string | null; passphrase?: string | null; + flow_control?: boolean | null; extra_config?: Record | null; } diff --git a/src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.ts b/src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.ts index 32861a839..ec685515e 100644 --- a/src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.ts +++ b/src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.ts @@ -56,6 +56,8 @@ export interface ReticulumInterfaceRow { network_name?: string | null; /** IFAC authentication passphrase. */ passphrase?: string | null; + /** RNode/KISS TX ready-gate. Only present for RF interface types. */ + flow_control?: boolean | null; /** Unknown INI keys preserved by the sidecar across CRUD. */ extra_config?: Record | null; } diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 4a57e0df4..04eb70a95 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -1199,7 +1199,8 @@ "backboneEnableGuidanceLead": "Povolit maximálně 1 až 3 páteřní brány", "backboneEnableGuidanceBody": "—2 je ideální místo pro redundanci bez zbytečného nafukování šířky pásma. Zaměřte se na 1 globální rozbočovač a 1 regionální bránu a povolte speciální rozhraní (jako je I2P nebo Yggdrasil) přesně podle potřeby. Lokálně připojené RNodes a místní LAN rozhraní se do tohoto limitu páteřní sítě nezapočítávají.", "defaultHubsPickerHint": "Začněte primární a globální páteří plus svým regionem. Zapněte nejvýše 1 až 3 páteřní brány (2 je optimum). Místní RNode a LAN rozhraní se nepočítají. Nové položky se přidávají vypnuté ke kontrole. Sync také vypne zbývající vyřazené oficiální testnet huby (Amsterdam).", - "rnodeTransportBleHint": "Ble vazby se mohou desynchronizovat s macOS (Forget + re-pair). Preferujte USB sériové nebo Wi-Fi (tcp://), pokud je rádio v blízkosti." + "rnodeTransportBleHint": "Ble vazby se mohou desynchronizovat s macOS (Forget + re-pair). Preferujte USB sériové nebo Wi-Fi (tcp://), pokud je rádio v blízkosti.", + "flowControl": "Řízení toku (TX ready-gate)" }, "reticulumPeers": { "hops": "Hops", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 23843fba3..765ed3efa 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -1196,7 +1196,8 @@ "backboneEnableGuidanceLead": "Aktivieren Sie höchstens 1 bis 3 Backbone-Gateways", "backboneEnableGuidanceBody": "—2 ist optimal für Redundanz ohne unnötigen Bandbreitenverbrauch. Streben Sie 1 globalen Hub und 1 regionales Gateway an, und aktivieren Sie Spezialschnittstellen (wie I2P oder Yggdrasil) nur bei Bedarf. Lokal verbundene RNodes und lokale LAN-Schnittstellen zählen nicht zu diesem Backbone-Limit.", "defaultHubsPickerHint": "Beginnen Sie mit Primär & Global plus Ihrer Region. Aktivieren Sie höchstens 1 bis 3 Backbone-Gateways (2 ist optimal). Lokale RNodes und LAN-Schnittstellen zählen nicht. Neue Einträge werden deaktiviert hinzugefügt, damit Sie sie prüfen können. Sync deaktiviert außerdem verbleibende außer Betrieb genommene offizielle Testnet-Hubs (Amsterdam).", - "rnodeTransportBleHint": "BLE-Bindungen können mit macOS desynchronisiert werden (Forget + Re-Pair). Bevorzugen Sie USB seriell oder Wi-Fi (tcp://), wenn das Radio in der Nähe ist." + "rnodeTransportBleHint": "BLE-Bindungen können mit macOS desynchronisiert werden (Forget + Re-Pair). Bevorzugen Sie USB seriell oder Wi-Fi (tcp://), wenn das Radio in der Nähe ist.", + "flowControl": "Flusskontrolle (TX Ready-Gate)" }, "reticulumPeers": { "hops": "Hops", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 4ab2a2bd9..fec2e7238 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -1183,6 +1183,7 @@ "passphraseAria": "IFAC passphrase", "showPassphrase": "Show passphrase", "hidePassphrase": "Hide passphrase", + "flowControl": "Flow control (TX ready-gate)", "advanced": "Advanced", "advancedHint": "Additional key=value pairs for this interface, one per line. Known fields (host, mode, network name, …) stay in the form above.", "advancedAria": "Advanced interface key-value options", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 3790adbf5..754211852 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -1198,7 +1198,8 @@ "backboneEnableGuidanceLead": "Habilite de 1 a 3 puertas de enlace troncales como máximo", "backboneEnableGuidanceBody": "—2 es el punto óptimo para la redundancia sin una saturación innecesaria del ancho de banda. Apunte a 1 centro global y 1 puerta de enlace regional, y habilite interfaces especializadas (como I2P o Yggdrasil) estrictamente según sea necesario. Los RNodes conectados localmente y las interfaces LAN locales no cuentan para este límite de red troncal.", "defaultHubsPickerHint": "Empiece con Primario y global más su región. Active como máximo 1 a 3 puertas de enlace de backbone (2 es el punto óptimo). Los RNodes locales y las interfaces LAN no cuentan. Las entradas nuevas se añaden desactivadas para que pueda revisarlas. Sync también desactiva hubs de testnet oficiales fuera de servicio restantes (Ámsterdam).", - "rnodeTransportBleHint": "Los enlaces ble pueden desincronizarse con macOS (olvidar + volver a emparejar). Prefiera USB serie o Wi-Fi (tcp://) cuando la radio esté cerca." + "rnodeTransportBleHint": "Los enlaces ble pueden desincronizarse con macOS (olvidar + volver a emparejar). Prefiera USB serie o Wi-Fi (tcp://) cuando la radio esté cerca.", + "flowControl": "Control de flujo (puerta lista para TX)" }, "reticulumPeers": { "hops": "Hops", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 724d4606c..e93eb2c98 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -1197,7 +1197,8 @@ "backboneEnableGuidanceLead": "Activer 1 à 3 passerelles dorsales au maximum", "backboneEnableGuidanceBody": "—2 est l’optimum pour la redondance sans gaspiller la bande passante. Visez 1 hub mondial et 1 passerelle régionale, et activez les interfaces spécialisées (comme I2P ou Yggdrasil) seulement si besoin. Les RNodes connectés localement et les interfaces LAN locales ne comptent pas dans cette limite de backbone.", "defaultHubsPickerHint": "Commencez par Primaire et mondial plus votre région. Activez au plus 1 à 3 passerelles backbone (2 est l’optimum). Les RNodes locaux et les interfaces LAN ne comptent pas. Les nouvelles entrées sont ajoutées désactivées pour révision. Sync désactive aussi les hubs de testnet officiels hors service restants (Amsterdam).", - "rnodeTransportBleHint": "Les liaisons ble peuvent se désynchroniser avec macOS (Forget + re-pair). Préférez USB série ou Wi-Fi (tcp ://) lorsque la radio est à proximité." + "rnodeTransportBleHint": "Les liaisons ble peuvent se désynchroniser avec macOS (Forget + re-pair). Préférez USB série ou Wi-Fi (tcp ://) lorsque la radio est à proximité.", + "flowControl": "Contrôle de flux (porte prête TX)" }, "reticulumPeers": { "hops": "Hops", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 67a9fca85..93ff22a8f 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -1198,7 +1198,8 @@ "backboneEnableGuidanceLead": "Aktifkan 1 hingga 3 gateway backbone paling banyak", "backboneEnableGuidanceBody": "—2 adalah titik ideal untuk redundansi tanpa pemborosan bandwidth. Bidik 1 hub global dan 1 gateway regional, dan aktifkan antarmuka khusus (seperti I2P atau Yggdrasil) hanya jika diperlukan. RNode yang terhubung lokal dan antarmuka LAN lokal tidak dihitung dalam batas backbone ini.", "defaultHubsPickerHint": "Mulai dengan Utama & Global plus wilayah Anda. Aktifkan paling banyak 1 hingga 3 gateway backbone (2 adalah titik ideal). RNode lokal dan antarmuka LAN tidak dihitung. Entri baru ditambahkan dalam keadaan nonaktif agar bisa ditinjau. Sync juga menonaktifkan sisa hub testnet resmi yang sudah dinonaktifkan (Amsterdam).", - "rnodeTransportBleHint": "Ikatan BLE dapat desinkronisasi dengan macOS (Forget + re-pair). Lebih suka USB serial atau Wi-Fi (tcp://) saat radio ada di dekatnya." + "rnodeTransportBleHint": "Ikatan BLE dapat desinkronisasi dengan macOS (Forget + re-pair). Lebih suka USB serial atau Wi-Fi (tcp://) saat radio ada di dekatnya.", + "flowControl": "Kontrol aliran (gerbang siap TX)" }, "reticulumPeers": { "hops": "Hops", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index f4efb10df..d4af7a304 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -1196,7 +1196,8 @@ "backboneEnableGuidanceLead": "Abilita da 1 a 3 gateway backbone al massimo", "backboneEnableGuidanceBody": "—2 è l’optimum per la ridondanza senza spreco di banda. Punta a 1 hub globale e 1 gateway regionale, e abilita interfacce speciali (come I2P o Yggdrasil) solo se necessario. Gli RNode collegati in locale e le interfacce LAN locali non contano verso questo limite di dorsale.", "defaultHubsPickerHint": "Inizia con Primaria e globale più la tua regione. Abilita al massimo da 1 a 3 gateway di dorsale (2 è l’optimum). Gli RNode locali e le interfacce LAN non contano. Le nuove voci vengono aggiunte disabilitate per la revisione. Sync disabilita anche gli hub testnet ufficiali fuori servizio rimanenti (Amsterdam).", - "rnodeTransportBleHint": "I legami BLE possono essere desincronizzati con macOS (Forget + re-pair). Preferire USB seriale o Wi-Fi (tcp://) quando la radio è nelle vicinanze." + "rnodeTransportBleHint": "I legami BLE possono essere desincronizzati con macOS (Forget + re-pair). Preferire USB seriale o Wi-Fi (tcp://) quando la radio è nelle vicinanze.", + "flowControl": "Controllo del flusso (TX ready-gate)" }, "reticulumPeers": { "hops": "Hops", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 6576e7e94..92944ed8c 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -1198,7 +1198,8 @@ "backboneEnableGuidanceLead": "最大1〜3つのバックボーンゲートウェイを有効にする", "backboneEnableGuidanceBody": "—冗長性を保ちつつ不要な帯域を増やさない最適は 2 です。グローバルハブ 1 つと地域ゲートウェイ 1 つを目安にし、I2P や Yggdrasil などの特殊インターフェースは必要なときだけ有効にしてください。ローカル接続の RNode とローカル LAN インターフェースはこのバックボーン上限に数えません。", "defaultHubsPickerHint": "プライマリおよびグローバルに加え、自分の地域から始めてください。バックボーンゲートウェイは最大 1〜3(最適は 2)まで有効にします。ローカル RNode と LAN インターフェースは数えません。新規エントリは確認しやすいよう無効で追加されます。Sync は残っている廃止済み公式テストネットハブ(アムステルダム)も無効化します。", - "rnodeTransportBleHint": "BLEボンドはmacOSと同期ずれすることがあります(忘れる+再ペアリング)。ラジオが近くにある場合は、USBシリアルまたはWi-Fi(tcp://)を優先してください。" + "rnodeTransportBleHint": "BLEボンドはmacOSと同期ずれすることがあります(忘れる+再ペアリング)。ラジオが近くにある場合は、USBシリアルまたはWi-Fi(tcp://)を優先してください。", + "flowControl": "フロー制御(TXレディゲート)" }, "reticulumPeers": { "hops": "Hops", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index aecb71630..d6c35e47f 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -1198,7 +1198,8 @@ "backboneEnableGuidanceLead": "최대 1 ~ 3개의 백본 게이트웨이 활성화", "backboneEnableGuidanceBody": "-2는 불필요한 대역폭 부풀림이 없는 이중화를 위한 스윗 스팟입니다. 글로벌 허브 1개와 지역 게이트웨이 1개를 목표로 하고 필요에 따라 특수 인터페이스 (예: I2P 또는 Yggdrasil) 를 엄격하게 활성화합니다. 로컬로 연결된 RNode 및 로컬 LAN 인터페이스는 이 백본 제한에 포함되지 않습니다.", "defaultHubsPickerHint": "기본 및 글로벌 백본에 지역을 더해 시작하세요. 백본 게이트웨이는 최대 1~3개(이상적은 2개)만 사용하세요. 로컬 RNode와 LAN 인터페이스는 포함되지 않습니다. 새 항목은 검토할 수 있도록 비활성으로 추가됩니다. Sync는 남은 폐기된 공식 테스트넷 허브(암스테르담)도 비활성화합니다.", - "rnodeTransportBleHint": "BLE 본드는 macOS와 비동기화할 수 있습니다 (Forget + re-pair). 라디오가 근처에 있을 때는 USB 직렬 또는 Wi-Fi (tcp://) 를 선호합니다." + "rnodeTransportBleHint": "BLE 본드는 macOS와 비동기화할 수 있습니다 (Forget + re-pair). 라디오가 근처에 있을 때는 USB 직렬 또는 Wi-Fi (tcp://) 를 선호합니다.", + "flowControl": "흐름 제어(TX Ready-Gate)" }, "reticulumPeers": { "hops": "Hops", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index e64b174ab..f90433d50 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -1197,7 +1197,8 @@ "backboneEnableGuidanceLead": "Maximaal 1 tot 3 backbone-gateways inschakelen", "backboneEnableGuidanceBody": "—2 is ideaal voor redundantie zonder onnodige bandbreedte. Streef naar 1 globale hub en 1 regionale gateway, en schakel speciale interfaces (zoals I2P of Yggdrasil) alleen in als nodig. Lokaal verbonden RNodes en lokale LAN-interfaces tellen niet mee voor deze backbone-limiet.", "defaultHubsPickerHint": "Begin met Primair & globaal plus uw regio. Schakel hoogstens 1 tot 3 backbone-gateways in (2 is ideaal). Lokale RNodes en LAN-interfaces tellen niet mee. Nieuwe items worden uitgeschakeld toegevoegd zodat u ze kunt beoordelen. Sync schakelt ook resterende buiten gebruik gestelde officiële testnet-hubs uit (Amsterdam).", - "rnodeTransportBleHint": "BLE-bindingen kunnen desynchroniseren met macOS (vergeten + opnieuw koppelen). Geef de voorkeur aan USB-serieel of Wi-Fi (tcp://) wanneer de radio in de buurt is." + "rnodeTransportBleHint": "BLE-bindingen kunnen desynchroniseren met macOS (vergeten + opnieuw koppelen). Geef de voorkeur aan USB-serieel of Wi-Fi (tcp://) wanneer de radio in de buurt is.", + "flowControl": "Stromingsregeling (TX ready-gate)" }, "reticulumPeers": { "hops": "Hops", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 2c7fe147c..b101d7044 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -1200,7 +1200,8 @@ "backboneEnableGuidanceLead": "Włącz maksymalnie 1 do 3 bramek szkieletowych", "backboneEnableGuidanceBody": "—2 to optimum dla nadmiarowości bez zbędnego zużycia pasma. Celuj w 1 globalny hub i 1 regionalną bramę, a specjalne interfejsy (jak I2P lub Yggdrasil) włączaj tylko gdy trzeba. Lokalnie podłączone RNode i lokalne interfejsy LAN nie wliczają się do tego limitu magistrali.", "defaultHubsPickerHint": "Zacznij od Podstawowej i globalnej magistrali oraz swojego regionu. Włącz co najwyżej 1–3 bramy magistrali (2 to optimum). Lokalne RNode i interfejsy LAN się nie liczą. Nowe wpisy są dodawane wyłączone, by je przejrzeć. Sync wyłącza też pozostałe wycofane oficjalne huby testnetu (Amsterdam).", - "rnodeTransportBleHint": "Wiązania BLE mogą być desynchronizowane z macOS (Forget + repair). Preferuj port szeregowy USB lub Wi-Fi (tcp://), gdy radio znajduje się w pobliżu." + "rnodeTransportBleHint": "Wiązania BLE mogą być desynchronizowane z macOS (Forget + repair). Preferuj port szeregowy USB lub Wi-Fi (tcp://), gdy radio znajduje się w pobliżu.", + "flowControl": "Kontrola przepływu (bramka gotowa TX)" }, "reticulumPeers": { "hops": "Hops", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 5b77f8814..8c4efc5ba 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -1198,7 +1198,8 @@ "backboneEnableGuidanceLead": "Habilite de 1 a 3 gateways de backbone no máximo", "backboneEnableGuidanceBody": "—2 é o ponto ideal para redundância sem inchaço desnecessário da largura de banda. Aponte para 1 hub global e 1 gateway regional e habilite interfaces especializadas (como I2P ou Yggdrasil) estritamente conforme necessário. Os RNodes conectados localmente e as interfaces de LAN locais não contam para esse limite de backbone.", "defaultHubsPickerHint": "Comece com Primário e global mais sua região. Ative no máximo 1 a 3 gateways de backbone (2 é o ponto ideal). RNodes locais e interfaces LAN não contam. Novas entradas são adicionadas desativadas para revisão. Sync também desativa hubs de testnet oficiais desativados restantes (Amsterdã).", - "rnodeTransportBleHint": "As ligações BLE podem dessincronizar com o macOS (Forget + re-pair). Prefira USB serial ou Wi-Fi (tcp://) quando o rádio estiver próximo." + "rnodeTransportBleHint": "As ligações BLE podem dessincronizar com o macOS (Forget + re-pair). Prefira USB serial ou Wi-Fi (tcp://) quando o rádio estiver próximo.", + "flowControl": "Controle de fluxo (portão pronto para TX)" }, "reticulumPeers": { "hops": "Hops", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index da4472ccc..4cfc55747 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -1200,7 +1200,8 @@ "backboneEnableGuidanceLead": "Включить не более 1-3 магистральных шлюзов", "backboneEnableGuidanceBody": "—2 — оптимум для избыточности без лишней нагрузки на канал. Цель: 1 глобальный хаб и 1 региональный шлюз; специальные интерфейсы (I2P или Yggdrasil) включайте только по необходимости. Локально подключённые RNode и локальные LAN-интерфейсы не входят в этот лимит магистрали.", "defaultHubsPickerHint": "Начните с первичной и глобальной магистрали плюс ваш регион. Включайте не более 1–3 магистральных шлюзов (2 — оптимум). Локальные RNode и LAN-интерфейсы не учитываются. Новые записи добавляются выключенными для проверки. Sync также отключает оставшиеся выведенные из эксплуатации официальные тестовые хабы (Амстердам).", - "rnodeTransportBleHint": "Связи BLE могут рассинхронизироваться с macOS (Forget + re-pair). Предпочитайте последовательный USB или Wi-Fi (tcp://), когда радиостанция находится поблизости." + "rnodeTransportBleHint": "Связи BLE могут рассинхронизироваться с macOS (Forget + re-pair). Предпочитайте последовательный USB или Wi-Fi (tcp://), когда радиостанция находится поблизости.", + "flowControl": "Управление потоком (шлюз готовности к передаче)" }, "reticulumPeers": { "hops": "Hops", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 10d990d9d..ce572affe 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -1198,7 +1198,8 @@ "backboneEnableGuidanceLead": "En fazla 1 ila 3 omurga ağ geçidini etkinleştirin", "backboneEnableGuidanceBody": "—2 gereksiz bant genişliği şişmesi olmadan fazlalık için en uygun noktadır. 1 küresel merkez ve 1 bölgesel ağ geçidini hedefleyin ve kesinlikle gerektiğinde özel arayüzleri (I2P veya Yggdrasil gibi) etkinleştirin. Yerel olarak bağlı RNode'lar ve yerel lan arayüzleri bu omurga sınırına dahil edilmez.", "defaultHubsPickerHint": "Birincil ve küresel omurga ile bölgenizden başlayın. En fazla 1–3 omurga ağ geçidi etkinleştirin (2 idealdir). Yerel RNode ve LAN arayüzleri sayılmaz. Yeni girdiler inceleme için kapalı eklenir. Sync ayrıca kalan kullanım dışı resmi testnet hub’larını da kapatır (Amsterdam).", - "rnodeTransportBleHint": "BLE bağları macOS ile senkronize olmayabilir (Forget + re-pair). Radyo yakındayken USB seri veya Wi-Fi (tcp://) tercih edin." + "rnodeTransportBleHint": "BLE bağları macOS ile senkronize olmayabilir (Forget + re-pair). Radyo yakındayken USB seri veya Wi-Fi (tcp://) tercih edin.", + "flowControl": "Akış kontrolü (TX hazır kapısı)" }, "reticulumPeers": { "hops": "Hops", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index d3e8a7afd..b21cc7946 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -1200,7 +1200,8 @@ "backboneEnableGuidanceLead": "Увімкнути щонайбільше від 1 до 3 магістральних шлюзів", "backboneEnableGuidanceBody": "—2 - це солодка пляма для надлишковості без зайвого роздування пропускної здатності. Створіть 1 глобальний хаб та 1 регіональний шлюз, а також увімкніть спеціалізовані інтерфейси (такі як I2P або Yggdrasil) строго за потреби. Місцеві з'єднані RNodes та локальні інтерфейси локальної мережі не враховуються до цієї межі магістралі.", "defaultHubsPickerHint": "Почніть з первинної та глобальної магістралі плюс ваш регіон. Увімкніть не більше 1–3 магістральних шлюзів (2 — оптимум). Локальні RNode та LAN-інтерфейси не враховуються. Нові записи додаються вимкненими для перевірки. Sync також вимикає залишкові виведені з експлуатації офіційні тестові хаби (Амстердам).", - "rnodeTransportBleHint": "Зв'язки BLE можуть розсинхронізуватися з macOS (Forget + re-pair). Якщо радіоприймач поруч, віддайте перевагу USB serial або Wi-Fi (tcp://)." + "rnodeTransportBleHint": "Зв'язки BLE можуть розсинхронізуватися з macOS (Forget + re-pair). Якщо радіоприймач поруч, віддайте перевагу USB serial або Wi-Fi (tcp://).", + "flowControl": "Контроль потоку (TX ready-gate)" }, "reticulumPeers": { "hops": "Hops", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 4700ec000..24f1e5bdc 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -1198,7 +1198,8 @@ "backboneEnableGuidanceLead": "最多启用1至3个骨干网关", "backboneEnableGuidanceBody": "—2是冗余的最佳选择,不会造成不必要的带宽膨胀。瞄准1个全球集线器和1个区域网关,并严格根据需要启用专业接口(如I2P或Yggdrasil )。本地连接的RNode和本地LAN接口不计入此骨干限制。", "defaultHubsPickerHint": "从“主要和全球骨干网”加上您的区域开始。最多启用 1 到 3 个骨干网关(2 个较理想)。本地 RNode 和局域网接口不计入。新条目默认禁用,便于您审阅后再启用。同步还会禁用剩余已停用的官方测试网枢纽(阿姆斯特丹)。", - "rnodeTransportBleHint": "BLE绑定可以与macOS取消同步(忘记+重新配对)。当收音机在附近时,首选USB串行或Wi-Fi ( tcp://)。" + "rnodeTransportBleHint": "BLE绑定可以与macOS取消同步(忘记+重新配对)。当收音机在附近时,首选USB串行或Wi-Fi ( tcp://)。", + "flowControl": "流量控制(TX 就绪门)" }, "reticulumPeers": { "hops": "Hops", From 8344e1c8437074040140ad51c887e9e61a5055c0 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 8 Aug 2026 18:08:00 -0600 Subject: [PATCH 3/4] fix(chat): badge dock/tray for messages while window is unfocused Read-marking and background-notification gates keyed only on document.hidden, so a visible-but-unfocused Electron window (uncovered, second display, Stage Manager) auto-advanced the last-read watermark for the open conversation. Unread stayed 0 and no OS dock/tray badge appeared. Add isAppWindowInactive() (document.hidden || !document.hasFocus()) and use it at the user-attention gates in ChatPanel, RoomsPanel, App, RRC, and RrcChatView. Regaining window focus re-runs the near-bottom read check so clicking the dock badge clears unread and follows to newest when pinned. Tray unread math and the set-tray-unread pipeline are unchanged. --- src/renderer/App.tsx | 13 ++- src/renderer/components/ChatPanel.test.tsx | 99 ++++++++++++++++++++ src/renderer/components/ChatPanel.tsx | 26 ++++- src/renderer/components/RoomsPanel.tsx | 5 +- src/renderer/components/rrc/RrcChatView.tsx | 3 +- src/renderer/lib/appWindowActivity.test.ts | 91 ++++++++++++++++++ src/renderer/lib/appWindowActivity.ts | 64 +++++++++++++ src/renderer/lib/rrcNotificationGate.test.ts | 14 +-- src/renderer/lib/rrcNotificationGate.ts | 7 +- src/renderer/vitest.setup.ts | 9 ++ vitest.config.mts | 1 + 11 files changed, 311 insertions(+), 21 deletions(-) create mode 100644 src/renderer/lib/appWindowActivity.test.ts create mode 100644 src/renderer/lib/appWindowActivity.ts diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 680014a31..8b60ce399 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -18,6 +18,7 @@ import { import { useTranslation } from 'react-i18next'; import { MESHCORE_ROOM_MESSAGE_CHANNEL } from '@/renderer/hooks/meshcore/meshcoreHookPreamble'; +import { isAppWindowInactive } from '@/renderer/lib/appWindowActivity'; import { resolveInactiveChatNotificationType } from '@/renderer/lib/chatInactiveNotifications'; import { clearPersistedLastReadForProtocol, @@ -2365,7 +2366,9 @@ function AppContent() { return; } const isActiveAndChatOpen = - protocolRef.current === 'meshtastic' && activePanelIndexRef.current === 1 && !document.hidden; + protocolRef.current === 'meshtastic' && + activePanelIndexRef.current === 1 && + !isAppWindowInactive(); if (count > prevMeshtasticMsgCountRef.current && !isActiveAndChatOpen) { const newMsgs = meshtasticMsgsRef.current.slice(prevMeshtasticMsgCountRef.current); const mutedRaw = localStorage.getItem('mesh-client:mutedViews:meshtastic'); @@ -2398,7 +2401,7 @@ function AppContent() { selectByProtocol(capabilitiesByProtocol, protocolRef.current) .prefersDeviceOwnerLongNameInHeader && activePanelIndexRef.current === 1 && - !document.hidden; + !isAppWindowInactive(); if (count > prevMeshcoreMsgCountRef.current && !isActiveAndChatOpen) { const newMsgs = meshcoreMsgsRef.current.slice(prevMeshcoreMsgCountRef.current); const type = resolveInactiveChatNotificationType({ @@ -2425,7 +2428,9 @@ function AppContent() { return; } const isActiveAndChatOpen = - protocolRef.current === 'reticulum' && activePanelIndexRef.current === 1 && !document.hidden; + protocolRef.current === 'reticulum' && + activePanelIndexRef.current === 1 && + !isAppWindowInactive(); if (count > prevReticulumMsgCountRef.current && !isActiveAndChatOpen) { const newMsgs = reticulumMsgsRef.current.slice(prevReticulumMsgCountRef.current); const ownNodes = reticulumOwnNodeIdSetRef.current; @@ -2483,7 +2488,7 @@ function AppContent() { type && shouldPlayRrcNotification({ onRrcPanel, - documentHidden: document.hidden, + windowInactive: isAppWindowInactive(), forOtherRoom, type, }) diff --git a/src/renderer/components/ChatPanel.test.tsx b/src/renderer/components/ChatPanel.test.tsx index 7599255b1..aa5967699 100644 --- a/src/renderer/components/ChatPanel.test.tsx +++ b/src/renderer/components/ChatPanel.test.tsx @@ -2713,6 +2713,105 @@ describe('ChatPanel unread watermarks', () => { expect(stored[`dm:${peerId}`]).toBe(secondTs); }); }); + + it('holds unread on the open DM while the window is visible but unfocused, then clears on refocus', async () => { + const user = userEvent.setup(); + const ts = Date.now(); + const selfId = 0x12345678; + const peerId = 2; + // Seed the open DM as already read so any advance is attributable to the new inbound. + localStorage.setItem('mesh-client:lastRead:meshcore', JSON.stringify({ [`dm:${peerId}`]: ts })); + const readStored = () => + JSON.parse(localStorage.getItem('mesh-client:lastRead:meshcore') ?? '{}') as Record< + string, + number + >; + const nodes = new Map([ + [ + peerId, + { + node_id: peerId, + long_name: 'Alice', + short_name: 'Alice', + hw_model: '', + snr: 0, + battery: 0, + last_heard: ts, + latitude: null, + longitude: null, + }, + ], + ]); + const firstMsg = { + sender_id: peerId, + sender_name: 'Alice', + payload: 'first', + channel: -1, + timestamp: ts, + status: 'acked' as const, + to: selfId, + }; + const { rerender } = render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Alice' })); + await waitFor(() => { + expect(screen.getByText('first')).toBeInTheDocument(); + }); + + // Window is visible but not focused (e.g. user switched to another app). + const hasFocusSpy = vi.spyOn(document, 'hasFocus').mockReturnValue(false); + + const secondTs = ts + 5000; + const withSecond = [ + firstMsg, + { + sender_id: peerId, + sender_name: 'Alice', + payload: 'second', + channel: -1, + timestamp: secondTs, + status: 'acked' as const, + to: selfId, + }, + ]; + rerender( + + + , + ); + + // Give the inbound mark-read effect (rAF) a chance to run and confirm it stayed read-gated. + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(readStored()[`dm:${peerId}`]).toBe(ts); + + // Refocusing (clicking the dock/taskbar badge) clears unread on the open conversation. + hasFocusSpy.mockReturnValue(true); + fireEvent(window, new Event('focus')); + + await waitFor(() => { + expect(readStored()[`dm:${peerId}`]).toBe(secondTs); + }); + + hasFocusSpy.mockRestore(); + }); }); describe('ChatPanel compose emoji picker', () => { diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index a68c2401e..827b4e6e2 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -37,6 +37,7 @@ import { } from 'react'; import { useTranslation } from 'react-i18next'; +import { isAppWindowInactive } from '@/renderer/lib/appWindowActivity'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { formatDisplayTime } from '@/renderer/lib/formatDisplayTime'; import { formatShortRelativeAgo } from '@/renderer/lib/formatShortRelativeAgo'; @@ -1177,7 +1178,7 @@ function ChatPanel({ const peer = resolveDmPeer(msg); const msgViewKey = peer != null ? `dm:${peer}` : `ch:${msg.channel}`; if (mutedViews.has(msgViewKey)) return false; - return isActive && msgViewKey !== viewKey && !document.hidden; + return isActive && msgViewKey !== viewKey && !isAppWindowInactive(); }); const type = pickAudibleNotificationType( gated, @@ -1215,7 +1216,7 @@ function ChatPanel({ const applyNearBottomReadState = useCallback( (distFromBottom: number) => { - if (document.hidden) return; + if (isAppWindowInactive()) return; if (distFromBottom < 50) { markCurrentViewRead(); setUnreadDividerTimestamp(0); // hide divider once user has read to bottom @@ -1229,7 +1230,7 @@ function ChatPanel({ const prevLen = prevUnreadSourceLengthRef.current; const newLen = unreadSourceMessages.length; prevUnreadSourceLengthRef.current = newLen; - if (!isActive || document.hidden || newLen <= prevLen) return; + if (!isActive || isAppWindowInactive() || newLen <= prevLen) return; const newMsgs = unreadSourceMessages.slice(prevLen); const hasInboundForView = newMsgs.some((msg) => { @@ -1285,10 +1286,27 @@ function ChatPanel({ }); }, [updateScrollButtonVisibility]); + // Regaining window focus (e.g. clicking the dock/taskbar icon on a new-message + // badge) should clear unread and follow to newest when pinned near bottom — + // mirrors the pinned/scrolled-to-bottom read logic that is skipped while unfocused. + useEffect(() => { + if (!isActive) return; + const onFocus = () => { + requestAnimationFrame(() => { + const dist = updateScrollButtonVisibility(); + if (dist !== undefined) applyNearBottomReadState(dist); + }); + }; + window.addEventListener('focus', onFocus); + return () => { + window.removeEventListener('focus', onFocus); + }; + }, [applyNearBottomReadState, isActive, updateScrollButtonVisibility]); + // Refresh scroll button + mark-read when message list changes; scrollToEnd when app-pinned // (followOnAppend uses the tighter VIRTUALIZER_SCROLL_END_THRESHOLD). useEffect(() => { - if (!isActive || document.hidden) return; + if (!isActive || isAppWindowInactive()) return; if (isPinnedToBottomRef.current) { messageVirtualizerRef.current.scrollToEnd(); } diff --git a/src/renderer/components/RoomsPanel.tsx b/src/renderer/components/RoomsPanel.tsx index 6239986f0..1334854a9 100644 --- a/src/renderer/components/RoomsPanel.tsx +++ b/src/renderer/components/RoomsPanel.tsx @@ -29,6 +29,7 @@ import { useTranslation } from 'react-i18next'; import { useMeshcoreRoomAuth } from '@/renderer/hooks/useMeshcoreRoomAuth'; import { useMeshcoreRoomLoginQueueRevision } from '@/renderer/hooks/useMeshcoreRoomLoginQueueRevision'; import { useMeshcoreRoomSessionRevision } from '@/renderer/hooks/useMeshcoreRoomSessionRevision'; +import { isAppWindowInactive } from '@/renderer/lib/appWindowActivity'; import { loadMutedViews, loadPersistedRoomsLastRead, @@ -566,7 +567,7 @@ export default function RoomsPanel({ const applyNearBottomReadState = useCallback( (distFromBottom: number) => { - if (!isActive || document.hidden) return; + if (!isActive || isAppWindowInactive()) return; if (distFromBottom < 50) { markSelectedRoomRead(); setUnreadDividerTimestamp(0); @@ -627,7 +628,7 @@ export default function RoomsPanel({ }, [updateScrollButtonVisibility]); useEffect(() => { - if (!isActive || document.hidden || selectedRoomId == null) return; + if (!isActive || isAppWindowInactive() || selectedRoomId == null) return; // An explicit row-key jump (Starred → Go to message) or its room-switch guard // owns scroll for this transition — skip pinned-bottom follow so we do not race // scrollToEnd ahead of scrollToRowKey (effect order: this runs before both). diff --git a/src/renderer/components/rrc/RrcChatView.tsx b/src/renderer/components/rrc/RrcChatView.tsx index 24dcf0096..120a8dddd 100644 --- a/src/renderer/components/rrc/RrcChatView.tsx +++ b/src/renderer/components/rrc/RrcChatView.tsx @@ -14,6 +14,7 @@ import { import { useTranslation } from 'react-i18next'; import MentionAutocomplete from '@/renderer/components/MentionAutocomplete'; +import { isAppWindowInactive } from '@/renderer/lib/appWindowActivity'; import { isSafeChatUrl } from '@/renderer/lib/chatMentionSegments'; import { CHAT_SCROLL_END_THRESHOLD, @@ -332,7 +333,7 @@ export function RrcChatView({ // Follow new messages when pinned (Rooms/Chat contract). useEffect(() => { - if (!isActive || document.hidden || !activeRoom) return; + if (!isActive || isAppWindowInactive() || !activeRoom) return; if (isPinnedToBottomRef.current) { messageVirtualizerRef.current.scrollToEnd(); } diff --git a/src/renderer/lib/appWindowActivity.test.ts b/src/renderer/lib/appWindowActivity.test.ts new file mode 100644 index 000000000..eda7b5394 --- /dev/null +++ b/src/renderer/lib/appWindowActivity.test.ts @@ -0,0 +1,91 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { isAppWindowInactive, useAppWindowActivity } from './appWindowActivity'; + +function setVisibility(hidden: boolean): void { + Object.defineProperty(document, 'hidden', { value: hidden, configurable: true }); +} + +function setFocus(focused: boolean): void { + vi.spyOn(document, 'hasFocus').mockReturnValue(focused); +} + +afterEach(() => { + vi.restoreAllMocks(); + setVisibility(false); +}); + +describe('isAppWindowInactive', () => { + it('is active only when visible and focused', () => { + setVisibility(false); + setFocus(true); + expect(isAppWindowInactive()).toBe(false); + }); + + it('is inactive when hidden even if focused', () => { + setVisibility(true); + setFocus(true); + expect(isAppWindowInactive()).toBe(true); + }); + + it('is inactive when visible but unfocused', () => { + setVisibility(false); + setFocus(false); + expect(isAppWindowInactive()).toBe(true); + }); + + it('is inactive when hidden and unfocused', () => { + setVisibility(true); + setFocus(false); + expect(isAppWindowInactive()).toBe(true); + }); +}); + +describe('useAppWindowActivity', () => { + it('reports the initial visible + focused state', () => { + setVisibility(false); + setFocus(true); + const { result } = renderHook(() => useAppWindowActivity()); + expect(result.current).toEqual({ inactive: false, hidden: false, focused: true }); + }); + + it('updates to inactive on window blur while still visible', () => { + setVisibility(false); + setFocus(true); + const { result } = renderHook(() => useAppWindowActivity()); + expect(result.current.inactive).toBe(false); + + setFocus(false); + act(() => { + window.dispatchEvent(new Event('blur')); + }); + expect(result.current).toEqual({ inactive: true, hidden: false, focused: false }); + }); + + it('clears inactive on window focus', () => { + setVisibility(false); + setFocus(false); + const { result } = renderHook(() => useAppWindowActivity()); + expect(result.current.inactive).toBe(true); + + setFocus(true); + act(() => { + window.dispatchEvent(new Event('focus')); + }); + expect(result.current.inactive).toBe(false); + }); + + it('reacts to visibilitychange', () => { + setVisibility(false); + setFocus(true); + const { result } = renderHook(() => useAppWindowActivity()); + expect(result.current.hidden).toBe(false); + + setVisibility(true); + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + expect(result.current).toEqual({ inactive: true, hidden: true, focused: true }); + }); +}); diff --git a/src/renderer/lib/appWindowActivity.ts b/src/renderer/lib/appWindowActivity.ts new file mode 100644 index 000000000..9dd642731 --- /dev/null +++ b/src/renderer/lib/appWindowActivity.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; + +/** + * "User isn't actively looking at the app" signal for read-marking and background + * notification gates. Combines Page Visibility (`document.hidden`) with window focus + * (`document.hasFocus()`): a visible-but-unfocused Electron window (uncovered window, + * second display, Stage Manager, spaces quirk) counts as inactive so open-conversation + * traffic still bumps unread and produces an OS dock/tray badge. + * + * Use this for user-attention gates only. Keep bare `document.hidden` for + * resource/polling paths (heartbeat, topology refresh) that should keep running + * whenever the window is merely visible. + */ +export function isAppWindowInactive(): boolean { + return document.hidden || !document.hasFocus(); +} + +export interface AppWindowActivity { + inactive: boolean; + hidden: boolean; + focused: boolean; +} + +function readActivity(): AppWindowActivity { + const hidden = document.hidden; + const focused = document.hasFocus(); + return { inactive: hidden || !focused, hidden, focused }; +} + +/** + * React hook mirroring {@link isAppWindowInactive}. Re-renders on `window` + * `focus`/`blur` and `document` `visibilitychange`. + */ +export function useAppWindowActivity(): AppWindowActivity { + const [activity, setActivity] = useState(readActivity); + + useEffect(() => { + const update = () => { + setActivity((prev) => { + const next = readActivity(); + if ( + prev.inactive === next.inactive && + prev.hidden === next.hidden && + prev.focused === next.focused + ) { + return prev; + } + return next; + }); + }; + + update(); + window.addEventListener('focus', update); + window.addEventListener('blur', update); + document.addEventListener('visibilitychange', update); + return () => { + window.removeEventListener('focus', update); + window.removeEventListener('blur', update); + document.removeEventListener('visibilitychange', update); + }; + }, []); + + return activity; +} diff --git a/src/renderer/lib/rrcNotificationGate.test.ts b/src/renderer/lib/rrcNotificationGate.test.ts index 8b8bb131f..d04f17ff4 100644 --- a/src/renderer/lib/rrcNotificationGate.test.ts +++ b/src/renderer/lib/rrcNotificationGate.test.ts @@ -7,7 +7,7 @@ describe('shouldPlayRrcNotification', () => { expect( shouldPlayRrcNotification({ onRrcPanel: true, - documentHidden: false, + windowInactive: false, forOtherRoom: false, type: 'dm', }), @@ -15,18 +15,18 @@ describe('shouldPlayRrcNotification', () => { expect( shouldPlayRrcNotification({ onRrcPanel: true, - documentHidden: false, + windowInactive: false, forOtherRoom: false, type: 'channel', }), ).toBe(false); }); - it('plays channel when off panel, hidden, or other room', () => { + it('plays channel when off panel, inactive window, or other room', () => { expect( shouldPlayRrcNotification({ onRrcPanel: false, - documentHidden: false, + windowInactive: false, forOtherRoom: false, type: 'channel', }), @@ -34,7 +34,7 @@ describe('shouldPlayRrcNotification', () => { expect( shouldPlayRrcNotification({ onRrcPanel: true, - documentHidden: true, + windowInactive: true, forOtherRoom: false, type: 'channel', }), @@ -42,7 +42,7 @@ describe('shouldPlayRrcNotification', () => { expect( shouldPlayRrcNotification({ onRrcPanel: true, - documentHidden: false, + windowInactive: false, forOtherRoom: true, type: 'channel', }), @@ -53,7 +53,7 @@ describe('shouldPlayRrcNotification', () => { expect( shouldPlayRrcNotification({ onRrcPanel: false, - documentHidden: false, + windowInactive: false, forOtherRoom: false, type: null, }), diff --git a/src/renderer/lib/rrcNotificationGate.ts b/src/renderer/lib/rrcNotificationGate.ts index 398a07622..93bdf3e5d 100644 --- a/src/renderer/lib/rrcNotificationGate.ts +++ b/src/renderer/lib/rrcNotificationGate.ts @@ -2,7 +2,7 @@ import type { ChatNotificationType } from '@/renderer/lib/chatNotifications'; export interface ShouldPlayRrcNotificationArgs { onRrcPanel: boolean; - documentHidden: boolean; + windowInactive: boolean; forOtherRoom: boolean; type: ChatNotificationType | null; } @@ -10,11 +10,12 @@ export interface ShouldPlayRrcNotificationArgs { /** * Whether to play an RRC notification sound. * While watching the active room on the RRC panel: only DM (whisper / @nick). - * Off panel, hidden window, or other-room traffic: play channel or dm as classified. + * Off panel, inactive window (hidden or unfocused), or other-room traffic: play + * channel or dm as classified. */ export function shouldPlayRrcNotification(args: ShouldPlayRrcNotificationArgs): boolean { if (!args.type) return false; - if (args.onRrcPanel && !args.documentHidden && !args.forOtherRoom) { + if (args.onRrcPanel && !args.windowInactive && !args.forOtherRoom) { return args.type === 'dm'; } return true; diff --git a/src/renderer/vitest.setup.ts b/src/renderer/vitest.setup.ts index c3aaa1cfc..7e563a247 100644 --- a/src/renderer/vitest.setup.ts +++ b/src/renderer/vitest.setup.ts @@ -104,6 +104,15 @@ vi.stubGlobal('localStorage', { key: (i: number) => Object.keys(_localStorageStore)[i] ?? null, }); +// jsdom's document.hasFocus() defaults to false; the app treats an unfocused window as +// inactive (appWindowActivity). Default to focused so mark-read/notification tests match a +// normal foreground window; tests simulate blur with vi.spyOn(document, 'hasFocus'). +Object.defineProperty(document, 'hasFocus', { + configurable: true, + writable: true, + value: () => true, +}); + // jsdom doesn't implement scroll APIs window.HTMLElement.prototype.scrollIntoView = vi.fn(); window.HTMLElement.prototype.scrollTo = vi.fn(); diff --git a/vitest.config.mts b/vitest.config.mts index 626de2486..a3082a8cb 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -68,6 +68,7 @@ const RENDERER_LOGIC_EXCLUDE = [ 'src/renderer/hooks/meshcore/meshcoreHookPreamble.reconcile.test.ts', 'src/renderer/hooks/meshcore/meshcoreHookPreamble.resolvePubKey.test.ts', 'src/renderer/lib/appSettingsStorage.test.ts', + 'src/renderer/lib/appWindowActivity.test.ts', 'src/renderer/lib/bleReconnectHelper.test.ts', 'src/renderer/lib/chatNotifications.test.ts', 'src/renderer/lib/reticulumVoiceCallTones.test.ts', From cdb7bdabd411afd9dad8b5e5c5b7edd2bdd669c4 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 8 Aug 2026 18:23:11 -0600 Subject: [PATCH 4/4] fix(chat): address PR review for focus gates and LoRa diagnostics Gate LoRa anomaly tables on capabilities.protocol/hasHopCount, wire Rooms near-bottom read sync through useAppWindowActivity, and add platform plus inactive-window regression coverage for Chat, Rooms, and RRC. --- src/renderer/components/ChatPanel.test.tsx | 186 ++++++------- .../components/DiagnosticsPanel.test.tsx | 87 ++++++- src/renderer/components/DiagnosticsPanel.tsx | 245 +++++++++--------- src/renderer/components/RoomsPanel.test.tsx | 94 +++++++ src/renderer/components/RoomsPanel.tsx | 10 +- .../components/rrc/RrcChatView.test.tsx | 52 ++++ src/renderer/lib/appWindowActivity.test.ts | 21 +- 7 files changed, 478 insertions(+), 217 deletions(-) diff --git a/src/renderer/components/ChatPanel.test.tsx b/src/renderer/components/ChatPanel.test.tsx index aa5967699..b68cbe6f9 100644 --- a/src/renderer/components/ChatPanel.test.tsx +++ b/src/renderer/components/ChatPanel.test.tsx @@ -2714,104 +2714,112 @@ describe('ChatPanel unread watermarks', () => { }); }); - it('holds unread on the open DM while the window is visible but unfocused, then clears on refocus', async () => { - const user = userEvent.setup(); - const ts = Date.now(); - const selfId = 0x12345678; - const peerId = 2; - // Seed the open DM as already read so any advance is attributable to the new inbound. - localStorage.setItem('mesh-client:lastRead:meshcore', JSON.stringify({ [`dm:${peerId}`]: ts })); - const readStored = () => - JSON.parse(localStorage.getItem('mesh-client:lastRead:meshcore') ?? '{}') as Record< - string, - number - >; - const nodes = new Map([ - [ - peerId, - { - node_id: peerId, - long_name: 'Alice', - short_name: 'Alice', - hw_model: '', - snr: 0, - battery: 0, - last_heard: ts, - latitude: null, - longitude: null, - }, - ], - ]); - const firstMsg = { - sender_id: peerId, - sender_name: 'Alice', - payload: 'first', - channel: -1, - timestamp: ts, - status: 'acked' as const, - to: selfId, - }; - const { rerender } = render( - - - , - ); - - await user.click(screen.getByRole('button', { name: 'Alice' })); - await waitFor(() => { - expect(screen.getByText('first')).toBeInTheDocument(); - }); - - // Window is visible but not focused (e.g. user switched to another app). - const hasFocusSpy = vi.spyOn(document, 'hasFocus').mockReturnValue(false); - - const secondTs = ts + 5000; - const withSecond = [ - firstMsg, - { + it.each(['linux', 'darwin', 'win32'] as const)( + 'holds unread on the open DM while the window is visible but unfocused, then clears on refocus (%s)', + async (platform) => { + vi.mocked(window.electronAPI.getPlatform).mockReturnValue(platform); + const user = userEvent.setup(); + const ts = Date.now(); + const selfId = 0x12345678; + const peerId = 2; + // Seed the open DM as already read so any advance is attributable to the new inbound. + localStorage.setItem( + 'mesh-client:lastRead:meshcore', + JSON.stringify({ [`dm:${peerId}`]: ts }), + ); + const readStored = () => + JSON.parse(localStorage.getItem('mesh-client:lastRead:meshcore') ?? '{}') as Record< + string, + number + >; + const nodes = new Map([ + [ + peerId, + { + node_id: peerId, + long_name: 'Alice', + short_name: 'Alice', + hw_model: '', + snr: 0, + battery: 0, + last_heard: ts, + latitude: null, + longitude: null, + }, + ], + ]); + const firstMsg = { sender_id: peerId, sender_name: 'Alice', - payload: 'second', + payload: 'first', channel: -1, - timestamp: secondTs, + timestamp: ts, status: 'acked' as const, to: selfId, - }, - ]; - rerender( - - - , - ); + }; + const { rerender } = render( + + + , + ); - // Give the inbound mark-read effect (rAF) a chance to run and confirm it stayed read-gated. - await new Promise((resolve) => setTimeout(resolve, 30)); - expect(readStored()[`dm:${peerId}`]).toBe(ts); + await user.click(screen.getByRole('button', { name: 'Alice' })); + await waitFor(() => { + expect(screen.getByText('first')).toBeInTheDocument(); + }); - // Refocusing (clicking the dock/taskbar badge) clears unread on the open conversation. - hasFocusSpy.mockReturnValue(true); - fireEvent(window, new Event('focus')); + // Window is visible but not focused (e.g. user switched to another app). + const hasFocusSpy = vi.spyOn(document, 'hasFocus').mockReturnValue(false); - await waitFor(() => { - expect(readStored()[`dm:${peerId}`]).toBe(secondTs); - }); + const secondTs = ts + 5000; + const withSecond = [ + firstMsg, + { + sender_id: peerId, + sender_name: 'Alice', + payload: 'second', + channel: -1, + timestamp: secondTs, + status: 'acked' as const, + to: selfId, + }, + ]; + rerender( + + + , + ); - hasFocusSpy.mockRestore(); - }); + // Give the inbound mark-read effect (rAF) a chance to run and confirm it stayed read-gated. + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(readStored()[`dm:${peerId}`]).toBe(ts); + + // Refocusing (clicking the dock/taskbar badge) clears unread on the open conversation. + hasFocusSpy.mockReturnValue(true); + fireEvent(window, new Event('focus')); + + await waitFor(() => { + expect(readStored()[`dm:${peerId}`]).toBe(secondTs); + }); + + hasFocusSpy.mockRestore(); + vi.mocked(window.electronAPI.getPlatform).mockReturnValue('linux'); + }, + ); }); describe('ChatPanel compose emoji picker', () => { diff --git a/src/renderer/components/DiagnosticsPanel.test.tsx b/src/renderer/components/DiagnosticsPanel.test.tsx index 5f715f083..15a85b768 100644 --- a/src/renderer/components/DiagnosticsPanel.test.tsx +++ b/src/renderer/components/DiagnosticsPanel.test.tsx @@ -647,8 +647,91 @@ describe('DiagnosticsPanel reticulum scope', () => { ); expect(screen.queryByText('Ghost hop from Meshtastic')).not.toBeInTheDocument(); - expect(screen.getByText(/no diagnostics detected/i)).toBeInTheDocument(); + expect(screen.queryByText(/no diagnostics detected/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/^Diagnostics \(/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/network health/i)).not.toBeInTheDocument(); + }); + + it('hides LoRa mesh diagnostics when hasHopCount is false', () => { + diagnosticsStoreState.diagnosticRows = [ + { + kind: 'routing', + id: 'routing:1', + nodeId: 2, + type: 'hop_goblin', + severity: 'error', + description: 'Should stay hidden without hop count', + detectedAt: Date.now(), + } satisfies RoutingDiagnosticRow, + ]; + + render( + , + ); + + expect(screen.queryByText(/network health/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/^Diagnostics \(/i)).not.toBeInTheDocument(); + expect(screen.queryByText('Should stay hidden without hop count')).not.toBeInTheDocument(); + }); + + it('derives LoRa mesh visibility from capabilities.protocol, not the tab prop alone', () => { + diagnosticsStoreState.diagnosticRows = [ + { + kind: 'routing', + id: 'routing:2', + nodeId: 2, + type: 'hop_goblin', + severity: 'error', + description: 'Mismatched prop must not show LoRa tables', + detectedAt: Date.now(), + } satisfies RoutingDiagnosticRow, + ]; + + render( + , + ); + expect(screen.queryByText(/network health/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/^Diagnostics \(/i)).not.toBeInTheDocument(); + expect(screen.queryByText('Mismatched prop must not show LoRa tables')).not.toBeInTheDocument(); + }); + + it('shows LoRa mesh diagnostics for non-Reticulum capabilities with hop count', () => { + diagnosticsStoreState.diagnosticRows = []; + + render( + , + ); + + expect(screen.getByText(/network health/i)).toBeInTheDocument(); + expect(screen.getByText(/no diagnostics detected/i)).toBeInTheDocument(); }); it('shows Reticulum config diagnostics rows on the Reticulum tab', () => { @@ -721,6 +804,8 @@ describe('DiagnosticsPanel reticulum scope', () => { expect(screen.queryByText('Mesh diagnostics (1)')).not.toBeInTheDocument(); expect(screen.queryByText('!00000000')).not.toBeInTheDocument(); + expect(screen.queryByText(/^Diagnostics \(/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/no diagnostics detected/i)).not.toBeInTheDocument(); expect(screen.getAllByText(/RNode 41F4/).length).toBeGreaterThan(0); expect(screen.getByRole('button', { name: /edit interface/i })).toBeInTheDocument(); }); diff --git a/src/renderer/components/DiagnosticsPanel.tsx b/src/renderer/components/DiagnosticsPanel.tsx index 3a3b6eebb..084a20102 100644 --- a/src/renderer/components/DiagnosticsPanel.tsx +++ b/src/renderer/components/DiagnosticsPanel.tsx @@ -194,9 +194,12 @@ export default function DiagnosticsPanel({ [t], ); const showMqttControls = capabilities?.hasMqttHybrid !== false; - // LoRa Node/Offense tables are Meshtastic/MeshCore only. On Reticulum, native - // rows already render in ReticulumDiagnosticsSection — never as !00000000 peers. - const showLoRaMeshDiagnostics = protocol !== 'reticulum' && capabilities?.hasHopCount !== false; + // LoRa Node/Offense tables are Meshtastic/MeshCore only. Derive from + // capabilities.protocol (not the tab prop alone) so a mismatched protocol + // prop cannot resurrect LoRa mesh tables on Reticulum. Native Reticulum rows + // render in ReticulumDiagnosticsSection — never as !00000000 peers. + const showLoRaMeshDiagnostics = + capabilities?.protocol !== 'reticulum' && capabilities?.hasHopCount !== false; const showForeignLoraDiagnostics = capabilities?.hasDiagnosticsPanel !== false; const diagnosticRows = useDiagnosticsStore((s) => s.diagnosticRows); const diagnosticRowsRestoredAt = useDiagnosticsStore((s) => s.diagnosticRowsRestoredAt); @@ -1390,127 +1393,129 @@ export default function DiagnosticsPanel({ )} - {/* Anomaly Table */} -
-
-

- {t('diagnosticsPanel.diagnosticsHeading', { count: visibleDiagnosticRows.length })} -

- { - setSearch(e.target.value); - }} - placeholder={t('diagnosticsPanel.searchAnomalies')} - aria-label={t('diagnosticsPanel.searchAnomalies')} - className="bg-secondary-dark/80 focus:border-brand-green/50 w-48 rounded-lg border border-gray-600/50 px-3 py-1.5 text-sm text-gray-200 focus:outline-none" - /> -
- - {anomalyList.length === 0 ? ( -
- {visibleDiagnosticRows.length === 0 - ? t('diagnosticsPanel.noDiagnosticsHealthy') - : t('diagnosticsPanel.noAnomaliesMatchSearch')} + {/* Anomaly Table — LoRa mesh only; Reticulum-only rows live in ReticulumDiagnosticsSection */} + {showLoRaMeshDiagnostics && ( +
+
+

+ {t('diagnosticsPanel.diagnosticsHeading', { count: visibleDiagnosticRows.length })} +

+ { + setSearch(e.target.value); + }} + placeholder={t('diagnosticsPanel.searchAnomalies')} + aria-label={t('diagnosticsPanel.searchAnomalies')} + className="bg-secondary-dark/80 focus:border-brand-green/50 w-48 rounded-lg border border-gray-600/50 px-3 py-1.5 text-sm text-gray-200 focus:outline-none" + />
- ) : ( -
- {selfRows.length > 0 && ( -
-

- {t('diagnosticsPanel.connectedNodeYouHeading', { count: selfRows.length })} -

-
- - - - - - - - - - - - - {renderTableBody(selfRows)} - -
{t('diagnosticsPanel.tableNode')}{t('diagnosticsPanel.tableOffense')} - {t('diagnosticsPanel.tableHops')} - - {t('diagnosticsPanel.tableDetected')} - {t('diagnosticsPanel.tableSuggestedFix')} - {t('diagnosticsPanel.tableAction')} -
+ + {anomalyList.length === 0 ? ( +
+ {visibleDiagnosticRows.length === 0 + ? t('diagnosticsPanel.noDiagnosticsHealthy') + : t('diagnosticsPanel.noAnomaliesMatchSearch')} +
+ ) : ( +
+ {selfRows.length > 0 && ( +
+

+ {t('diagnosticsPanel.connectedNodeYouHeading', { count: selfRows.length })} +

+
+ + + + + + + + + + + + + {renderTableBody(selfRows)} + +
{t('diagnosticsPanel.tableNode')}{t('diagnosticsPanel.tableOffense')} + {t('diagnosticsPanel.tableHops')} + + {t('diagnosticsPanel.tableDetected')} + {t('diagnosticsPanel.tableSuggestedFix')} + {t('diagnosticsPanel.tableAction')} +
+
-
- )} - {otherCrossProtocolRows.length > 0 && ( -
-

- {t('diagnosticsPanel.otherCrossProtocolHeading', { - count: otherCrossProtocolRows.length, - })} -

-
- - - - - - - - - - - - - {renderTableBody(otherCrossProtocolRows)} - -
{t('diagnosticsPanel.tableNode')}{t('diagnosticsPanel.tableOffense')} - {t('diagnosticsPanel.tableHops')} - - {t('diagnosticsPanel.tableDetected')} - {t('diagnosticsPanel.tableSuggestedFix')} - {t('diagnosticsPanel.tableAction')} -
+ )} + {otherCrossProtocolRows.length > 0 && ( +
+

+ {t('diagnosticsPanel.otherCrossProtocolHeading', { + count: otherCrossProtocolRows.length, + })} +

+
+ + + + + + + + + + + + + {renderTableBody(otherCrossProtocolRows)} + +
{t('diagnosticsPanel.tableNode')}{t('diagnosticsPanel.tableOffense')} + {t('diagnosticsPanel.tableHops')} + + {t('diagnosticsPanel.tableDetected')} + {t('diagnosticsPanel.tableSuggestedFix')} + {t('diagnosticsPanel.tableAction')} +
+
-
- )} - {meshRows.length > 0 && ( -
-

- {t('diagnosticsPanel.meshDiagnosticsHeading', { count: meshRows.length })} -

-
- - - - - - - - - - - - - {renderTableBody(meshRows)} - -
{t('diagnosticsPanel.tableNode')}{t('diagnosticsPanel.tableOffense')} - {t('diagnosticsPanel.tableHops')} - - {t('diagnosticsPanel.tableDetected')} - {t('diagnosticsPanel.tableSuggestedFix')} - {t('diagnosticsPanel.tableAction')} -
+ )} + {meshRows.length > 0 && ( +
+

+ {t('diagnosticsPanel.meshDiagnosticsHeading', { count: meshRows.length })} +

+
+ + + + + + + + + + + + + {renderTableBody(meshRows)} + +
{t('diagnosticsPanel.tableNode')}{t('diagnosticsPanel.tableOffense')} + {t('diagnosticsPanel.tableHops')} + + {t('diagnosticsPanel.tableDetected')} + {t('diagnosticsPanel.tableSuggestedFix')} + {t('diagnosticsPanel.tableAction')} +
+
-
- )} -
- )} -
+ )} +
+ )} +
+ )}
); } diff --git a/src/renderer/components/RoomsPanel.test.tsx b/src/renderer/components/RoomsPanel.test.tsx index 53b2130d0..ccf09f5f8 100644 --- a/src/renderer/components/RoomsPanel.test.tsx +++ b/src/renderer/components/RoomsPanel.test.tsx @@ -1300,4 +1300,98 @@ describe('RoomsPanel', () => { expect(screen.getByText('CR')).toBeInTheDocument(); expect(screen.getByLabelText('Collapse Room')).toBeInTheDocument(); }); + + it('holds room unread while visible but unfocused, then advances watermark on refocus', async () => { + meshcoreClearAllRoomSessions(); + const room = makeRoom(0x1040, 'Focus Room'); + const nodes = new Map([[room.node_id, room]]); + meshcoreApplyRoomSession(room.node_id, { + guestPassword: 'hello', + adminPassword: '', + role: 'readwrite', + }); + const firstTs = 1000; + const secondTs = 5000; + savePersistedRoomsLastRead(mergeRoomLastReadWatermark({}, room.node_id, firstTs)); + const readStored = () => + JSON.parse(localStorage.getItem('mesh-client:roomsLastRead:meshcore') ?? '{}') as Record< + string, + number + >; + const firstMsg = buildMeshcoreRoomIncomingMessage({ + rawText: 'first', + roomServerId: room.node_id, + authorId: 0x200, + authorName: 'Alice', + timestamp: firstTs, + receivedVia: 'rf', + }); + const distSpy = vi.spyOn(chatScrollUtils, 'getDistFromChatBottom').mockReturnValue(0); + const hasFocusSpy = vi.spyOn(document, 'hasFocus').mockReturnValue(true); + + try { + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(screen.getByText('first')).toBeInTheDocument(); + }); + + hasFocusSpy.mockReturnValue(false); + fireEvent(window, new Event('blur')); + + const secondMsg = buildMeshcoreRoomIncomingMessage({ + rawText: 'second', + roomServerId: room.node_id, + authorId: 0x201, + authorName: 'Bob', + timestamp: secondTs, + receivedVia: 'rf', + }); + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByText('second')).toBeInTheDocument(); + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(readStored()[String(room.node_id)]).toBe(firstTs); + + hasFocusSpy.mockReturnValue(true); + fireEvent(window, new Event('focus')); + + await waitFor(() => { + expect(readStored()[String(room.node_id)]).toBe(secondTs); + }); + } finally { + distSpy.mockRestore(); + hasFocusSpy.mockRestore(); + } + }); }); diff --git a/src/renderer/components/RoomsPanel.tsx b/src/renderer/components/RoomsPanel.tsx index 1334854a9..4f290dee9 100644 --- a/src/renderer/components/RoomsPanel.tsx +++ b/src/renderer/components/RoomsPanel.tsx @@ -29,7 +29,7 @@ import { useTranslation } from 'react-i18next'; import { useMeshcoreRoomAuth } from '@/renderer/hooks/useMeshcoreRoomAuth'; import { useMeshcoreRoomLoginQueueRevision } from '@/renderer/hooks/useMeshcoreRoomLoginQueueRevision'; import { useMeshcoreRoomSessionRevision } from '@/renderer/hooks/useMeshcoreRoomSessionRevision'; -import { isAppWindowInactive } from '@/renderer/lib/appWindowActivity'; +import { useAppWindowActivity } from '@/renderer/lib/appWindowActivity'; import { loadMutedViews, loadPersistedRoomsLastRead, @@ -264,6 +264,7 @@ export default function RoomsPanel({ alwaysShowMessageActions = false, }: Props) { const { t } = useTranslation(); + const { inactive: appWindowInactive } = useAppWindowActivity(); const parentIconTrigger = useParentIconTrigger(); const { ensureRoomAuth, RemoteAuthModal } = useMeshcoreRoomAuth(); const [selectedRoomId, setSelectedRoomId] = useState( @@ -567,13 +568,13 @@ export default function RoomsPanel({ const applyNearBottomReadState = useCallback( (distFromBottom: number) => { - if (!isActive || isAppWindowInactive()) return; + if (!isActive || appWindowInactive) return; if (distFromBottom < 50) { markSelectedRoomRead(); setUnreadDividerTimestamp(0); } }, - [isActive, markSelectedRoomRead], + [appWindowInactive, isActive, markSelectedRoomRead], ); const handleStreamScroll = useCallback(() => { @@ -628,7 +629,7 @@ export default function RoomsPanel({ }, [updateScrollButtonVisibility]); useEffect(() => { - if (!isActive || isAppWindowInactive() || selectedRoomId == null) return; + if (!isActive || appWindowInactive || selectedRoomId == null) return; // An explicit row-key jump (Starred → Go to message) or its room-switch guard // owns scroll for this transition — skip pinned-bottom follow so we do not race // scrollToEnd ahead of scrollToRowKey (effect order: this runs before both). @@ -642,6 +643,7 @@ export default function RoomsPanel({ }); }, [ applyNearBottomReadState, + appWindowInactive, isActive, roomPosts.length, scrollToBottom, diff --git a/src/renderer/components/rrc/RrcChatView.test.tsx b/src/renderer/components/rrc/RrcChatView.test.tsx index bbbccc692..5ffd8bad7 100644 --- a/src/renderer/components/rrc/RrcChatView.test.tsx +++ b/src/renderer/components/rrc/RrcChatView.test.tsx @@ -361,6 +361,58 @@ describe('RrcChatView stick-to-bottom', () => { }); }); + it('does not follow appends while the window is visible but unfocused', async () => { + const hasFocusSpy = vi.spyOn(document, 'hasFocus').mockReturnValue(true); + try { + const { rerender } = render( + , + ); + await waitFor(() => { + expect(mockScrollToEnd).toHaveBeenCalled(); + }); + mockScrollToEnd.mockClear(); + + hasFocusSpy.mockReturnValue(false); + rerender( + , + ); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(mockScrollToEnd).not.toHaveBeenCalled(); + } finally { + hasFocusSpy.mockRestore(); + } + }); + + it('follows appends when focused and pinned', async () => { + const hasFocusSpy = vi.spyOn(document, 'hasFocus').mockReturnValue(true); + try { + const { rerender } = render( + , + ); + await waitFor(() => { + expect(mockScrollToEnd).toHaveBeenCalled(); + }); + mockScrollToEnd.mockClear(); + + rerender( + , + ); + await waitFor(() => { + expect(mockScrollToEnd).toHaveBeenCalled(); + }); + } finally { + hasFocusSpy.mockRestore(); + } + }); + it('does not follow appends when scrolled up and shows Jump to Latest', async () => { const user = userEvent.setup(); const { rerender } = render( diff --git a/src/renderer/lib/appWindowActivity.test.ts b/src/renderer/lib/appWindowActivity.test.ts index eda7b5394..bae0194e4 100644 --- a/src/renderer/lib/appWindowActivity.test.ts +++ b/src/renderer/lib/appWindowActivity.test.ts @@ -1,8 +1,10 @@ import { act, renderHook } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { isAppWindowInactive, useAppWindowActivity } from './appWindowActivity'; +const PLATFORMS = ['linux', 'darwin', 'win32'] as const; + function setVisibility(hidden: boolean): void { Object.defineProperty(document, 'hidden', { value: hidden, configurable: true }); } @@ -11,12 +13,21 @@ function setFocus(focused: boolean): void { vi.spyOn(document, 'hasFocus').mockReturnValue(focused); } +beforeEach(() => { + vi.mocked(window.electronAPI.getPlatform).mockReturnValue('linux'); +}); + afterEach(() => { vi.restoreAllMocks(); setVisibility(false); + vi.mocked(window.electronAPI.getPlatform).mockReturnValue('linux'); }); -describe('isAppWindowInactive', () => { +describe.each(PLATFORMS)('isAppWindowInactive (%s)', (platform) => { + beforeEach(() => { + vi.mocked(window.electronAPI.getPlatform).mockReturnValue(platform); + }); + it('is active only when visible and focused', () => { setVisibility(false); setFocus(true); @@ -42,7 +53,11 @@ describe('isAppWindowInactive', () => { }); }); -describe('useAppWindowActivity', () => { +describe.each(PLATFORMS)('useAppWindowActivity (%s)', (platform) => { + beforeEach(() => { + vi.mocked(window.electronAPI.getPlatform).mockReturnValue(platform); + }); + it('reports the initial visible + focused state', () => { setVisibility(false); setFocus(true);