From 32b6f4203f64377e5e2b7c601338a1ccf9e414ac Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 13 Aug 2026 10:52:45 -0600 Subject: [PATCH 1/2] feat(rrc): add App toggle for all-room unread and sounds Default keeps badge+beep on every room message; turning it off is IRC-style DMs and @mentions only, with hub notices never alerting. --- docs/agents/reticulum.md | 2 +- docs/reticulum.md | 4 +- src/renderer/App.tsx | 3 +- src/renderer/components/AppPanel.test.tsx | 74 ++++++++++++++++ src/renderer/components/AppPanel.tsx | 26 ++++++ src/renderer/lib/appSettingsStorage.test.ts | 15 ++++ src/renderer/lib/appSettingsStorage.ts | 9 ++ src/renderer/lib/defaultAppSettings.ts | 5 ++ .../lib/rrcInactiveNotifications.test.ts | 32 ++++++- src/renderer/lib/rrcInactiveNotifications.ts | 21 +++-- src/renderer/lib/rrcMention.test.ts | 86 +++++++++++++++++++ src/renderer/lib/rrcMention.ts | 23 +++++ src/renderer/lib/rrcNotificationGate.test.ts | 2 +- src/renderer/lib/rrcNotificationGate.ts | 4 +- src/renderer/locales/cs/translation.json | 4 +- src/renderer/locales/de/translation.json | 4 +- src/renderer/locales/en/translation.json | 2 + src/renderer/locales/es/translation.json | 4 +- src/renderer/locales/fr/translation.json | 4 +- src/renderer/locales/id/translation.json | 4 +- src/renderer/locales/it/translation.json | 4 +- src/renderer/locales/ja/translation.json | 4 +- src/renderer/locales/ko/translation.json | 4 +- src/renderer/locales/nl/translation.json | 4 +- src/renderer/locales/pl/translation.json | 4 +- src/renderer/locales/pt-BR/translation.json | 4 +- src/renderer/locales/ru/translation.json | 4 +- src/renderer/locales/tr/translation.json | 4 +- src/renderer/locales/uk/translation.json | 4 +- src/renderer/locales/zh/translation.json | 4 +- ...eticulumRuntime.rrc-alert.contract.test.ts | 18 ++++ src/renderer/runtime/useReticulumRuntime.ts | 15 +++- 32 files changed, 368 insertions(+), 33 deletions(-) create mode 100644 src/renderer/runtime/useReticulumRuntime.rrc-alert.contract.test.ts diff --git a/docs/agents/reticulum.md b/docs/agents/reticulum.md index a2c86c653..d38b58d36 100644 --- a/docs/agents/reticulum.md +++ b/docs/agents/reticulum.md @@ -18,7 +18,7 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Re - **RNode flasher timeouts:** `RNODE_COMMAND_TIMEOUT_MS` (30 s serial), `RNODE_BT_PAIRING_TIMEOUT_MS` (90 s BLE pairing), `ESP32_FLASH_STALL_TIMEOUT_MS` / `NRF52_DFU_STALL_TIMEOUT_MS` (60 s no-progress → `ESP32_FLASH_STALLED` / `NRF52_DFU_STALLED`); humanized via `flasherErrorHumanize.ts` - **Peer aliases / History vs Contacts:** LXMF/Nomad announce names overlay path-table peers; SQLite `reticulum_destinations.last_heard` = History, `is_contact` = Contacts (Save as contact only — inbound/outbound LXMF does **not** auto-add Contacts; sidecar `/contacts` wire rows are History hints unless SQLite `is_contact=1`); default avatars via vendored LXMFace (`lib/reticulum/lxmface.ts`); renderer refresh + `reticulumContactToNodeRecordPreservingLabel` refuse hash-prefix wipes of Chat/`nodeStore` labels; ingest stamps History via `persistReticulumHistoryFromPayload` + `stampHistoryPeer`; SQL upsert guard preserves real names over hash-prefix aliases; destination upsert requires exact 32-hex (lowercase) and omits `favorited` on icon-only patches so favorites/icons survive path/probe refresh - **RRC `/who`:** `RrcPanel` sends hub-global `/who ` with **no `K_ROOM`** so rrcd does not treat it as room chat; never `/who` synthetic `[hub]` / `@dm` rooms. Empty inbound `K_ROOM` → `[hub]` (`resolveRrcInboundChatRoom` / `RRC_HUB_STREAM_ROOM`), never the focused room. First `/who` NOTICE per named room may appear in the transcript (`consumeWhoTranscriptSlot` / `shouldShowRrcWhoTranscript`); later snapshots update the nicklist only. User-initiated Refresh / composer `/who` bypasses that slot. -- **Stores/lib:** `reticulumIdentityStore.ts` (session-global sidecar identity status shared by `useReticulumSidecarApi` — distinct from identity-scoped `identityStore`), `reticulumPeerStore.ts` (path-table `peers` + `history` + saved `contacts`; soft-TTL reads, forced `?refresh=1`, incremental `peers_updated` route-field patches, 50ms batching, name/appearance preservation, 30s/60s large-mesh poll), `reticulumDiscoveryMapStore.ts`, `reticulumRmapDiscovery.ts`, `reticulumDiscoveryMapLayout.ts`, `nomadNetworkStore.ts`, `rrcHubStore.ts` / `rrcSessionStore.ts` (RRC hubs + multi-hub sessions; hydrate/clear room history via `rrcRoomHistory.ts`; persist → SQLite `rrc_messages` via `rrcMessagePersist.ts` + `ipc/rrc-db-handlers.ts`; prefs in `rrcHubPrefs` / `rrcRoomPrefs` / `rrcRecentRooms`; notifications in `rrcInactiveNotifications` / `rrcMention`); **Remote (rnsh/rncp):** `rncpTransferStore.ts`, `rnshSessionStore.ts`, `reticulumInboundPolicyStore.ts`, `reticulumRemoteAddressStore.ts`, `rncpEnableRequestStore.ts` + lib `remoteSettingsStorage.ts`, `pushRncpListenerPolicy.ts`, `rncpInboundPolicyLists.ts`, `sendRncpRequestEnable.ts`, `rncpRequestEnableRateLimit.ts`, `applyRncpReceiveDestShare.ts` / `rncpReceiveDestSharePending.ts` (mark pending on request-enable; consume on ingest within TTL), `hooks/useRemotePathCapability.ts`, `components/remote/*`; WS events `rmap.discovery`, `lxmf_outbound_status`, `nomadnetwork.node`, `rrc.*`, `rnsh.*` / `rncp.*` in `useReticulumRuntime` (sidecar also emits `nomad.serving_start` / `nomad.serving_stop`; renderer polls serving status via HTTP, not those WS events) +- **Stores/lib:** `reticulumIdentityStore.ts` (session-global sidecar identity status shared by `useReticulumSidecarApi` — distinct from identity-scoped `identityStore`), `reticulumPeerStore.ts` (path-table `peers` + `history` + saved `contacts`; soft-TTL reads, forced `?refresh=1`, incremental `peers_updated` route-field patches, 50ms batching, name/appearance preservation, 30s/60s large-mesh poll), `reticulumDiscoveryMapStore.ts`, `reticulumRmapDiscovery.ts`, `reticulumDiscoveryMapLayout.ts`, `nomadNetworkStore.ts`, `rrcHubStore.ts` / `rrcSessionStore.ts` (RRC hubs + multi-hub sessions; hydrate/clear room history via `rrcRoomHistory.ts`; persist → SQLite `rrc_messages` via `rrcMessagePersist.ts` + `ipc/rrc-db-handlers.ts`; prefs in `rrcHubPrefs` / `rrcRoomPrefs` / `rrcRecentRooms`; notifications in `rrcInactiveNotifications` / `rrcMention` (`resolveRrcAlertType` + App `rrcUnreadAllRoomMessages`, default all-room, IRC mention/DM opt-out)); **Remote (rnsh/rncp):** `rncpTransferStore.ts`, `rnshSessionStore.ts`, `reticulumInboundPolicyStore.ts`, `reticulumRemoteAddressStore.ts`, `rncpEnableRequestStore.ts` + lib `remoteSettingsStorage.ts`, `pushRncpListenerPolicy.ts`, `rncpInboundPolicyLists.ts`, `sendRncpRequestEnable.ts`, `rncpRequestEnableRateLimit.ts`, `applyRncpReceiveDestShare.ts` / `rncpReceiveDestSharePending.ts` (mark pending on request-enable; consume on ingest within TTL), `hooks/useRemotePathCapability.ts`, `components/remote/*`; WS events `rmap.discovery`, `lxmf_outbound_status`, `nomadnetwork.node`, `rrc.*`, `rnsh.*` / `rncp.*` in `useReticulumRuntime` (sidecar also emits `nomad.serving_start` / `nomad.serving_stop`; renderer polls serving status via HTTP, not those WS events) - **LXMF outbound delivery:** sidecar `lxmf_delivery.rs` / `lxmf_outbound.rs` / `pn_cascade.rs` (Direct-first; after Direct exhausts **multi-PN cascade**: preferred remote → other enabled remotes hop-sorted → in **Auto** only, up to 3 heard-but-not-added Discovered PNs hop-sorted → local-prop last; intermediate WS `sending` + `delivery_method: "propagated"` or `"stored_locally"`; terminal `delivered` at remote PN vs `stored_locally` for local hosted PN). **Local-prop** is a full PN (in-process cascade deposit via `accept_stamped_propagated_blob`; host peer `/offer` sync; auto Chat drain after peer ingress + post-peer silent `/get`; explicit local Sync via `drain_local_inbox`) — not an outbox; clients need not Prefer you. Propagated **link establishment timeout** advances the cascade when other PNs remain (avoids Prefer-hash timeout storms). Sync vs deposit: `PROPAGATION_SYNC_OUTBOUND_BUSY` / `PN_DEPOSIT_DEFER_ADVANCE_AFTER`. Renderer `applyReticulumOutboundDeliveryStatus.ts` (WS `lxmf_outbound_status` → Zustand + SQLite `delivery_status` + `delivery_method`; early-status buffer; hash/status allowlist), `reticulumOutboundFailureBridge.ts` (`shouldApplyLinkDeliveryTimeoutFailureBridge` skips the link-timeout Failed bridge when cascade capacity remains — remote **or** enabled local-prop; also skips `propagated` / `stored_locally` rows so cascade is not killed), `markStaleReticulumOutbound.ts`. Optimistic pending rows use `reticulum-pending-*`; send-path rekey passes `replaces_message_hash` on SQLite upsert to delete the prior pending hash. Remote PN Completes UI: **Stored at propagation node** (`ReticulumMessageStatusBadge` PN + green check); local-prop Completes: deposited on your hosted node (PN + amber house; peer sync may still propagate). Mode Off has no cascade capacity, so the link-timeout bridge fails the row. **Paper exception:** `createReticulumPaperMessage` / paper create Completes immediately (`delivery_method: paper`, `ReticulumMessageStatusBadge` **Paper**) via `lxmf_message` — no `lxmf_outbound_status`; shared `reticulumMessageTransport` / `reticulumPaperErrors` keep IPC allowlists and i18n codes aligned. - **DM path reachability:** `useReticulumDmPathProbe.ts`, `reticulumDmPathReachability.ts`, `ReticulumDmPathReachabilityBadge.tsx` — Chat **Probe** matches Peer List (sidecar running check → `/probe` → toast → refresh); `applyProbeResult(forHash, …)` applies the settle without a second `/probe` and ignores stale completions after DM switch; manual reprobe forces Checking… even when passive hops look reachable; Peers virtualizes above 100 rows via `reticulumPeerListRows.ts`; peer refresh policy in `reticulumSidecarPeerRefreshEvents.ts` - **Inbound transport labels:** `received_via` resolves the path-table interface name against local interface config type, so a TCP hub display name still renders as TCP. diff --git a/docs/reticulum.md b/docs/reticulum.md index 73be3a64c..bf2691e37 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -31,7 +31,7 @@ After changing interfaces on a live network, **restart the stack** so RNS picks | Identity | Generate / import mnemonic; Ratspeak `.rsi` PIN backup; official raw identity file export/import; **identity vault** optional local `.rsi` store | | LXMF chat | DM-only text and reactions (outbound LXMF file/voice-note attach removed; attachment labels render; **cached raster images** display inline; use Remote rncp for peer files). **LXST live voice Call** is separate telephony (rsLXST), not an LXMF voice clip. | | Remote | **rnsh** multi-session shell + **rncp** send/receive/fetch under one tab (Shell / Transfer / Saved / Settings); Chat DM send-file convenience; path-speed gate (TCP/network); inbound Ask/allow-list; auto-reconnect / auto-retry; LXMF “request enable receive” prompt between mesh-client peers | -| RRC | Reticulum Relay Chat — discovered/manual/favourite hubs, up to **8** concurrent sessions, hub/room auto-join, rooms, nicklists, slash commands (`/list`, `/who`, `/join`, …), @mention unread badges (also badges the **Reticulum protocol pill** with LXMF Chat), toasts when the RRC tab is inactive, automatic reconnect with backoff | +| RRC | Reticulum Relay Chat — discovered/manual/favourite hubs, up to **8** concurrent sessions, hub/room auto-join, rooms, nicklists, slash commands (`/list`, `/who`, `/join`, …), unread badges + sounds for any new room message by default (also badges the **Reticulum protocol pill** with LXMF Chat; **App → Notifications** can switch to IRC-style DMs/@mentions only), toasts when the RRC tab is inactive, automatic reconnect with backoff | | Delivery | **Direct** when destination is in path table (outbound-initiated Direct replies need the sidecar **outbound Direct backchannel**). After Direct exhausts: **multi-PN cascade** — preferred remote → other enabled remotes (hop-sorted) → in **Auto** only, up to 3 heard-but-not-added **Discovered** PNs (hop-sorted, never persisted) → **local-prop last**. Remote PN Completes as `delivered` (**Stored at propagation node**, PN + green check); local-prop Completes as `stored_locally` (deposited on your hosted PN — PN + amber house; full PN parity with `lxmd`, including peer `/offer` sync — not an outbox). Propagation mode **Off** disables the cascade entirely. **Paper** for offline encrypted QR/`lxm://` handoff (no network — Completes immediately, no `lxmf_outbound_status`). Path/transport badges (RF/BLE/TCP/NET, multi, PN, Paper) are egress evidence — network UI stays **Sending** until `lxmf_outbound_status` (`delivered` / `stored_locally` / `failed`). Terminal `delivery_status` + `delivery_method` persist in SQLite. Hosted-PN Completes ≠ recipient opened Chat. Inbound `received_via` / TCP badges use local interface **config type**, not display name. | | Peers | RNS path table + messaged History + saved Contacts + Favorites (Peers tab sub-tabs); LXMFace avatars; probe; **LXST Call** and **LRGP Challenge** on rows; peer detail modal (Save as contact is manual) | | Games | LRGP Tic-Tac-Toe + Chess via sibling [lrgp-rs](https://github.com/ratspeak/lrgp-rs); Games tab + Challenge from Peers/Chat; opponent labels via `resolveReticulumRemoteHashLabel`; deep-link `lrgp:` / `lxm://game/`; delivery chips + resend-after-restart (`games_outbound.db`); Chess promotion picker + threefold/50-move claims; wire-compatible with Ratspeak ([parity checklist](reticulum-games-parity.md)) | @@ -354,7 +354,7 @@ IRC-style multi-pane client (`RrcPanel` + `rrcHubStore` / `rrcSessionStore`): - Chat virtualization pins to the bottom while reading live traffic; **Jump to latest** appears when scrolled up; leaving/re-entering RRC restores the prior scroll pin when possible (`RrcChatView` + TanStack Virtual). - Unintended link drops enter **reconnecting** (backoff 2–30 s), preserve desired rooms (including join keys), and rejoin after WELCOME. Explicit **Disconnect** / **Cancel** clears that hub (`will_reconnect: false`). - **Involuntary PART:** hub/self `PARTED` while the room is still desired queues a silent re-JOIN; UI banner uses neutral `rrc.moderation.hubParted` (not kick/ban wording). Member-fanout `PARTED` (another peer left) updates the nicklist only — must not be treated as self-leave. -- @mention unread badges and inactive-tab toasts; muted views use the shared Chat mute storage keyed as `rrc::`. +- Unread badges and inactive-tab sounds for any new room `msg`/`action` by default (also badges the **Reticulum protocol pill** with LXMF Chat). **App → Notifications → RRC unread for all room messages** (default on) couples badge + beep; off is IRC-style DMs + `@nick` only. Hub notices/`/who`/JOINED do not alert. Muted views use the shared Chat mute storage keyed as `rrc::`. - Sidecar modules: `rrc_codec`, `rrc_link`, `rrc_session`, `rrc_defaults`; REST under `/api/v1/rrc/*` (see [sidecar IPC](reticulum-sidecar-ipc.md)). - History: persist via `rrcMessagePersist.ts`; hydrate/clear via `rrcRoomHistory.ts` (UI load cap **500**/room; SQLite retention default **10,000**). diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5f5272981..5ef36a346 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -158,7 +158,7 @@ import { resolvePanelRebootHandler, } from './lib/appPanelHandlerSelection'; import { protocolRecord, selectByProtocol } from './lib/appProtocolSelect'; -import { getAppSettingsRaw } from './lib/appSettingsStorage'; +import { getAppSettingsRaw, isRrcUnreadAllRoomMessagesEnabled } from './lib/appSettingsStorage'; import { ADMIN_PANEL_INDEX, APP_PANEL_INDEX, @@ -2460,6 +2460,7 @@ function AppContent() { mutedViews: loadMutedViews('reticulum'), notifGloballyMuted: localStorage.getItem('mesh-client:notifMuted') === '1', localIdentityHash: rrcLocalIdentityHash, + notifyMode: isRrcUnreadAllRoomMessagesEnabled() ? 'all' : 'mentions', }); // Watching the active room: still ping on whisper / @nick (IRC highlight); stay silent on channel. if ( diff --git a/src/renderer/components/AppPanel.test.tsx b/src/renderer/components/AppPanel.test.tsx index 165f9334c..a89f6709e 100644 --- a/src/renderer/components/AppPanel.test.tsx +++ b/src/renderer/components/AppPanel.test.tsx @@ -200,6 +200,80 @@ describe('AppPanel: sound notification toggle', () => { }); }); +describe('AppPanel: RRC unread all room messages toggle', () => { + const defaultProps = { + nodeCount: 0, + messageCount: 0, + channels: [] as { index: number; name: string }[], + myNodeNum: null as number | null, + onLocationFilterChange: vi.fn(), + }; + + beforeEach(() => { + localStorage.removeItem('mesh-client:appSettings'); + }); + + it('shows the toggle only on the Reticulum protocol tab, checked by default', async () => { + const { unmount } = render( + + + , + ); + expect( + screen.queryByRole('checkbox', { name: /RRC unread for all room messages/i }), + ).toBeNull(); + unmount(); + + const { unmount: unmountMeshcore } = render( + + + , + ); + expect( + screen.queryByRole('checkbox', { name: /RRC unread for all room messages/i }), + ).toBeNull(); + unmountMeshcore(); + + render( + + + , + ); + const checkbox = await screen.findByRole('checkbox', { + name: /RRC unread for all room messages/i, + }); + expect(checkbox).toBeChecked(); + }); + + it('persists rrcUnreadAllRoomMessages false and remounts unchecked', async () => { + const { unmount } = render( + + + , + ); + const checkbox = await screen.findByRole('checkbox', { + name: /RRC unread for all room messages/i, + }); + act(() => { + fireEvent.click(checkbox); + }); + await waitFor(() => { + const raw = localStorage.getItem('mesh-client:appSettings'); + expect(raw).toContain('"rrcUnreadAllRoomMessages":false'); + }); + unmount(); + + render( + + + , + ); + expect( + await screen.findByRole('checkbox', { name: /RRC unread for all room messages/i }), + ).not.toBeChecked(); + }); +}); + describe('AppPanel: MeshCore path hash mode persist', () => { const defaultProps = { nodeCount: 0, diff --git a/src/renderer/components/AppPanel.tsx b/src/renderer/components/AppPanel.tsx index 2855d4846..3e081cd6e 100644 --- a/src/renderer/components/AppPanel.tsx +++ b/src/renderer/components/AppPanel.tsx @@ -175,6 +175,7 @@ interface AppSettings { use24HourTime: boolean; meshcoreOpenWireCompatEnabled: boolean; meshcorePathHashMode: 0 | 1 | 2; + rrcUnreadAllRoomMessages: boolean; } const DEFAULT_SETTINGS: AppSettings = { @@ -2135,6 +2136,31 @@ export default function AppPanel({ {t('appPanel.soundNotifications')} + {protocol === 'reticulum' && ( +
+
+ { + updateSetting('rrcUnreadAllRoomMessages', e.target.checked); + }} + aria-label={t('appPanel.rrcUnreadAllRoomMessages')} + className="accent-brand-green h-4 w-4 rounded" + /> + +
+

+ {t('appPanel.rrcUnreadAllRoomMessagesHint')} +

+
+ )} {/* Danger Zone — collapsible; same pattern as Appearance → Color scheme */} diff --git a/src/renderer/lib/appSettingsStorage.test.ts b/src/renderer/lib/appSettingsStorage.test.ts index 8d42cabea..a9e51770c 100644 --- a/src/renderer/lib/appSettingsStorage.test.ts +++ b/src/renderer/lib/appSettingsStorage.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { APP_SETTINGS_STORAGE_KEY, getAppSettingsRaw, + isRrcUnreadAllRoomMessagesEnabled, mergeAppSetting, mergeAppSettingsPartial, setAppSettingsRaw, @@ -57,6 +58,20 @@ describe('appSettingsStorage', () => { expect(parsed.other).toBe(1); }); + it('isRrcUnreadAllRoomMessagesEnabled defaults true and honors explicit false', () => { + expect(isRrcUnreadAllRoomMessagesEnabled()).toBe(true); + localStorage.setItem( + APP_SETTINGS_STORAGE_KEY, + JSON.stringify({ rrcUnreadAllRoomMessages: true }), + ); + expect(isRrcUnreadAllRoomMessagesEnabled()).toBe(true); + localStorage.setItem( + APP_SETTINGS_STORAGE_KEY, + JSON.stringify({ rrcUnreadAllRoomMessages: false }), + ); + expect(isRrcUnreadAllRoomMessagesEnabled()).toBe(false); + }); + it('setAppSettingsRaw replaces after migrating legacy', () => { localStorage.setItem(LEGACY_KEY, JSON.stringify({ old: 1 })); setAppSettingsRaw(JSON.stringify({ fresh: 2 })); diff --git a/src/renderer/lib/appSettingsStorage.ts b/src/renderer/lib/appSettingsStorage.ts index ba0f69fef..3d1332e2b 100644 --- a/src/renderer/lib/appSettingsStorage.ts +++ b/src/renderer/lib/appSettingsStorage.ts @@ -75,6 +75,15 @@ export function isShareLocationSendWaypointEnabled(): boolean { return parsed?.shareLocationSendWaypoint ?? DEFAULT_APP_SETTINGS_SHARED.shareLocationSendWaypoint; } +/** Whether RRC badges/sounds fire for every room msg/action (false = IRC-style mention/DM). */ +export function isRrcUnreadAllRoomMessagesEnabled(): boolean { + const parsed = parseStoredJson<{ rrcUnreadAllRoomMessages?: boolean }>( + getAppSettingsRaw(), + 'isRrcUnreadAllRoomMessagesEnabled', + ); + return parsed?.rrcUnreadAllRoomMessages ?? DEFAULT_APP_SETTINGS_SHARED.rrcUnreadAllRoomMessages; +} + /** Whether the Reticulum sidecar should start when the Reticulum connection panel mounts. */ export function isReticulumAutostartEnabled(): boolean { const parsed = parseStoredJson<{ reticulumAutostart?: boolean }>( diff --git a/src/renderer/lib/defaultAppSettings.ts b/src/renderer/lib/defaultAppSettings.ts index 5a7bdd75d..654cdd031 100644 --- a/src/renderer/lib/defaultAppSettings.ts +++ b/src/renderer/lib/defaultAppSettings.ts @@ -58,4 +58,9 @@ export const DEFAULT_APP_SETTINGS_SHARED = { meshcorePathHashMode: 0 as 0 | 1 | 2, /** Start Reticulum sidecar automatically when opening the Reticulum tab. */ reticulumAutostart: false, + /** + * RRC unread + sound: any new room msg/action (default). Off = IRC-style + * DMs + @mentions only. + */ + rrcUnreadAllRoomMessages: true, }; diff --git a/src/renderer/lib/rrcInactiveNotifications.test.ts b/src/renderer/lib/rrcInactiveNotifications.test.ts index 308edeaf5..388874b65 100644 --- a/src/renderer/lib/rrcInactiveNotifications.test.ts +++ b/src/renderer/lib/rrcInactiveNotifications.test.ts @@ -9,6 +9,7 @@ describe('resolveInactiveRrcNotificationType', () => { mutedViews: new Set(), notifGloballyMuted: false, localIdentityHash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + notifyMode: 'all' as const, }; it('returns null when globally muted', () => { @@ -21,7 +22,7 @@ describe('resolveInactiveRrcNotificationType', () => { ).toBeNull(); }); - it('prefers dm for whispers and @mentions', () => { + it('prefers dm for whispers and @mentions in all mode', () => { expect( resolveInactiveRrcNotificationType({ ...base, @@ -39,7 +40,7 @@ describe('resolveInactiveRrcNotificationType', () => { ).toBe('dm'); }); - it('returns channel for ordinary room traffic', () => { + it('returns channel for ordinary room traffic in all mode', () => { expect( resolveInactiveRrcNotificationType({ ...base, @@ -48,6 +49,33 @@ describe('resolveInactiveRrcNotificationType', () => { ).toBe('channel'); }); + it('drops plain room traffic in mentions mode; still alerts on mention/whisper', () => { + expect( + resolveInactiveRrcNotificationType({ + ...base, + notifyMode: 'mentions', + newMessages: [{ id: '1', room: '#lobby', kind: 'msg', body: 'hello', timestamp: 1 }], + }), + ).toBeNull(); + expect( + resolveInactiveRrcNotificationType({ + ...base, + notifyMode: 'mentions', + newMessages: [{ id: '1', room: '#lobby', kind: 'msg', body: 'hey @nv0n', timestamp: 1 }], + }), + ).toBe('dm'); + expect( + resolveInactiveRrcNotificationType({ + ...base, + notifyMode: 'mentions', + newMessages: [ + { id: '1', room: '#lobby', kind: 'msg', body: 'hello', timestamp: 1 }, + { id: '2', room: '[whispers]', kind: 'notice', body: 'psst', timestamp: 2 }, + ], + }), + ).toBe('dm'); + }); + it('skips muted rooms and self messages', () => { expect( resolveInactiveRrcNotificationType({ diff --git a/src/renderer/lib/rrcInactiveNotifications.ts b/src/renderer/lib/rrcInactiveNotifications.ts index 621c0a8e3..ea4ed0350 100644 --- a/src/renderer/lib/rrcInactiveNotifications.ts +++ b/src/renderer/lib/rrcInactiveNotifications.ts @@ -1,5 +1,5 @@ import type { ChatNotificationType } from '@/renderer/lib/chatNotifications'; -import { classifyRrcNotificationType, isRrcRoomMuted } from '@/renderer/lib/rrcMention'; +import { isRrcRoomMuted, resolveRrcAlertType, type RrcNotifyMode } from '@/renderer/lib/rrcMention'; import type { RrcChatMessage } from '@/shared/rrc-types'; export interface ResolveInactiveRrcNotificationTypeArgs { @@ -9,6 +9,8 @@ export interface ResolveInactiveRrcNotificationTypeArgs { mutedViews: ReadonlySet; notifGloballyMuted: boolean; localIdentityHash: string | null; + /** App toggle (or effective per-room mode): all chat lines vs IRC-style mention/DM. */ + notifyMode: RrcNotifyMode; } function isSelfRrcMessage( @@ -24,7 +26,7 @@ function isSelfRrcMessage( /** * Pick notification sound type for RRC traffic while the RRC panel is inactive or hidden. - * Priority: dm (whisper / @nick) over channel. + * Priority: dm (whisper / @nick) over channel. `notifyMode: 'mentions'` drops channel. */ export function resolveInactiveRrcNotificationType( args: ResolveInactiveRrcNotificationTypeArgs, @@ -34,11 +36,16 @@ export function resolveInactiveRrcNotificationType( let best: ChatNotificationType | null = null; for (const msg of args.newMessages) { if (isSelfRrcMessage(msg, args.localIdentityHash, args.nickname)) continue; - if (args.hubDestHash) { - const room = msg.room.trim() || '[hub]'; - if (isRrcRoomMuted(args.hubDestHash, room, args.mutedViews)) continue; - } - const type = classifyRrcNotificationType(msg, args.nickname); + const room = msg.room.trim() || '[hub]'; + const muted = args.hubDestHash + ? isRrcRoomMuted(args.hubDestHash, room, args.mutedViews) + : false; + const type = resolveRrcAlertType({ + msg, + nickname: args.nickname, + notifyMode: args.notifyMode, + muted, + }); if (!type) continue; if (type === 'dm') return 'dm'; best = best ?? type; diff --git a/src/renderer/lib/rrcMention.test.ts b/src/renderer/lib/rrcMention.test.ts index 2261062ca..6a7342c47 100644 --- a/src/renderer/lib/rrcMention.test.ts +++ b/src/renderer/lib/rrcMention.test.ts @@ -5,6 +5,7 @@ import { classifyRrcNotificationType, isRrcRoomMuted, isRrcWhisperRoom, + resolveRrcAlertType, rrcMuteViewKey, stripRrcMsgTargetAt, } from './rrcMention'; @@ -84,6 +85,91 @@ describe('classifyRrcNotificationType', () => { }); }); +describe('resolveRrcAlertType', () => { + const dmHash = 'aa'.repeat(16); + const whisper = { body: 'psst', room: '[whispers]', kind: 'notice' as const }; + const dstHash = { body: 'hi', room: '#lobby', kind: 'notice' as const, dst_hash: dmHash }; + const peerDm = { body: 'hi', room: `@${dmHash}`, kind: 'notice' as const }; + const mention = { body: 'hey @nv0n', room: '#lobby', kind: 'msg' as const }; + const mentionAction = { body: 'waves at @NV0N', room: '#lobby', kind: 'action' as const }; + const plain = { body: 'hello all', room: '#lobby', kind: 'msg' as const }; + const plainAction = { body: 'waves', room: '#lobby', kind: 'action' as const }; + const notice = { body: 'topic set', room: '#lobby', kind: 'notice' as const }; + const system = { body: '@nv0n', room: '#lobby', kind: 'system' as const }; + const error = { body: 'fail', room: '#lobby', kind: 'error' as const }; + + it.each(['all', 'mentions'] as const)('classifies DMs as dm in %s mode', (notifyMode) => { + expect(resolveRrcAlertType({ msg: whisper, nickname: 'nv0n', notifyMode, muted: false })).toBe( + 'dm', + ); + expect(resolveRrcAlertType({ msg: dstHash, nickname: 'nv0n', notifyMode, muted: false })).toBe( + 'dm', + ); + expect(resolveRrcAlertType({ msg: peerDm, nickname: 'nv0n', notifyMode, muted: false })).toBe( + 'dm', + ); + }); + + it.each(['all', 'mentions'] as const)('classifies @nick as dm in %s mode', (notifyMode) => { + expect(resolveRrcAlertType({ msg: mention, nickname: 'nv0n', notifyMode, muted: false })).toBe( + 'dm', + ); + expect( + resolveRrcAlertType({ msg: mentionAction, nickname: 'nv0n', notifyMode, muted: false }), + ).toBe('dm'); + }); + + it('returns channel for plain room msg/action only in all mode', () => { + expect( + resolveRrcAlertType({ msg: plain, nickname: 'nv0n', notifyMode: 'all', muted: false }), + ).toBe('channel'); + expect( + resolveRrcAlertType({ msg: plainAction, nickname: 'nv0n', notifyMode: 'all', muted: false }), + ).toBe('channel'); + expect( + resolveRrcAlertType({ msg: plain, nickname: 'nv0n', notifyMode: 'mentions', muted: false }), + ).toBeNull(); + expect( + resolveRrcAlertType({ + msg: plainAction, + nickname: 'nv0n', + notifyMode: 'mentions', + muted: false, + }), + ).toBeNull(); + }); + + it.each(['all', 'mentions'] as const)('drops notice/system/error in %s mode', (notifyMode) => { + expect( + resolveRrcAlertType({ msg: notice, nickname: 'nv0n', notifyMode, muted: false }), + ).toBeNull(); + expect( + resolveRrcAlertType({ msg: system, nickname: 'nv0n', notifyMode, muted: false }), + ).toBeNull(); + expect( + resolveRrcAlertType({ msg: error, nickname: 'nv0n', notifyMode, muted: false }), + ).toBeNull(); + }); + + it('returns null when muted even for @nick in all mode', () => { + expect( + resolveRrcAlertType({ msg: mention, nickname: 'nv0n', notifyMode: 'all', muted: true }), + ).toBeNull(); + }); + + it('does not false-match mentions with empty nickname; DMs still alert', () => { + expect( + resolveRrcAlertType({ msg: mention, nickname: '', notifyMode: 'all', muted: false }), + ).toBe('channel'); + expect( + resolveRrcAlertType({ msg: mention, nickname: ' ', notifyMode: 'mentions', muted: false }), + ).toBeNull(); + expect( + resolveRrcAlertType({ msg: whisper, nickname: '', notifyMode: 'mentions', muted: false }), + ).toBe('dm'); + }); +}); + describe('rrcMuteViewKey', () => { it('normalizes hub and preserves room spelling', () => { expect(rrcMuteViewKey('AABB', '#Lobby')).toBe('rrc:aabb:#Lobby'); diff --git a/src/renderer/lib/rrcMention.ts b/src/renderer/lib/rrcMention.ts index 17715210b..43b8420ea 100644 --- a/src/renderer/lib/rrcMention.ts +++ b/src/renderer/lib/rrcMention.ts @@ -98,6 +98,29 @@ export function classifyRrcNotificationType( return null; } +/** Global (or effective per-room) RRC notify level: all chat lines vs IRC-style mention/DM. */ +export type RrcNotifyMode = 'all' | 'mentions'; + +export interface ResolveRrcAlertTypeArgs { + msg: Pick; + nickname: string; + notifyMode: RrcNotifyMode; + muted: boolean; +} + +/** + * Shared badge + sound gate. Mute and IRC-style mention mode drop channel traffic; + * hub notices never alert. DMs / @nick stay `dm` in both modes. + */ +export function resolveRrcAlertType(args: ResolveRrcAlertTypeArgs): ChatNotificationType | null { + if (args.muted) return null; + const type = classifyRrcNotificationType(args.msg, args.nickname); + if (!type) return null; + if (type === 'dm') return 'dm'; + if (args.msg.kind !== 'msg' && args.msg.kind !== 'action') return null; + return args.notifyMode === 'all' ? 'channel' : null; +} + /** Mute storage key used by RrcPanel (`rrc:${hubHash}:${room}`). */ export function rrcMuteViewKey(hubHash: string, room: string): string { return `rrc:${hubHash.trim().toLowerCase()}:${room.trim()}`; diff --git a/src/renderer/lib/rrcNotificationGate.test.ts b/src/renderer/lib/rrcNotificationGate.test.ts index d04f17ff4..f69a75f08 100644 --- a/src/renderer/lib/rrcNotificationGate.test.ts +++ b/src/renderer/lib/rrcNotificationGate.test.ts @@ -22,7 +22,7 @@ describe('shouldPlayRrcNotification', () => { ).toBe(false); }); - it('plays channel when off panel, inactive window, or other room', () => { + it('plays channel when off panel, inactive window, or other room (toggle-on path)', () => { expect( shouldPlayRrcNotification({ onRrcPanel: false, diff --git a/src/renderer/lib/rrcNotificationGate.ts b/src/renderer/lib/rrcNotificationGate.ts index 93bdf3e5d..1c10f9aa4 100644 --- a/src/renderer/lib/rrcNotificationGate.ts +++ b/src/renderer/lib/rrcNotificationGate.ts @@ -11,7 +11,9 @@ 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, inactive window (hidden or unfocused), or other-room traffic: play - * channel or dm as classified. + * channel or dm as classified. IRC-style (`notifyMode: 'mentions'`) drops `channel` + * upstream in `resolveRrcAlertType`, so this gate only still suppresses channel + * beeps while watching the active room with all-room notify on. */ export function shouldPlayRrcNotification(args: ShouldPlayRrcNotificationArgs): boolean { if (!args.type) return false; diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 9e691544a..1d5acc66f 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -384,7 +384,9 @@ "themeSaveFailed": "Uložení kontrolního bodu barev se nezdařilo.", "themeRestoreFailed": "Obnovení kontrolního bodu barev se nezdařilo.", "themeResetFailed": "Obnovení barev se nezdařilo.", - "messageActionsBarBgVisible": "Zobrazit pozadí" + "messageActionsBarBgVisible": "Zobrazit pozadí", + "rrcUnreadAllRoomMessages": "RRC nepřečteno pro všechny zprávy z místnosti", + "rrcUnreadAllRoomMessagesHint": "Když je zapnuto, jakákoli nová zpráva RRC místnosti označí nepřečtené a může přehrát zvuk kanálu. Vypnout pro upozornění ve stylu IRC: pouze přímé zprávy a @zmínky." }, "aria": { "closeDialog": "Zavřít dialog", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 4538fbe19..7ec1ea987 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -384,7 +384,9 @@ "themeSaveFailed": "Farbprüfpunkt konnte nicht gespeichert werden.", "themeRestoreFailed": "Farbprüfpunkt konnte nicht wiederhergestellt werden.", "themeResetFailed": "Farben konnten nicht zurückgesetzt werden.", - "messageActionsBarBgVisible": "Zeige Hintergrund" + "messageActionsBarBgVisible": "Zeige Hintergrund", + "rrcUnreadAllRoomMessages": "RRC ungelesen für alle Raumnachrichten", + "rrcUnreadAllRoomMessagesHint": "Wenn eingeschaltet, markiert jede neue RRC-Raumnachricht ungelesen und kann einen Kanalton wiedergeben. Deaktivieren Sie für Warnungen im IRC-Stil: nur Direktnachrichten und @Erwähnungen." }, "aria": { "closeDialog": "Schließen Dialog", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 80dedd08c..d7c3d7f5f 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -274,6 +274,8 @@ "shareLocationSendWaypointHint": "When enabled, Meshtastic location shares send both a text message and a Waypoint packet so official apps show a map pin. Disable on busy meshes to send text only.", "notificationsSection": "Notifications", "soundNotifications": "Sound notifications", + "rrcUnreadAllRoomMessages": "RRC unread for all room messages", + "rrcUnreadAllRoomMessagesHint": "When on, any new RRC room message marks unread and can play a channel sound. Turn off for IRC-style alerts: only direct messages and @mentions.", "unknownError": "Unknown error", "resetDiagnosticsConfirm": "This will clear all routing anomalies, hop history, and packet stats. The engine will rebuild from new incoming packets. Continue?", "clearGpsDataConfirm": "This will remove stored GPS coordinates from all nodes. Nodes will remain but their positions will be blank until new data is received. Continue?", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index b363e54ef..a32d56c06 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -384,7 +384,9 @@ "themeSaveFailed": "Error al guardar el punto de verificación de color.", "themeRestoreFailed": "Error al restaurar el punto de verificación de color.", "themeResetFailed": "Error al restablecer los colores.", - "messageActionsBarBgVisible": "Mostrar fondo" + "messageActionsBarBgVisible": "Mostrar fondo", + "rrcUnreadAllRoomMessages": "RRC no leído para todos los mensajes de la habitación", + "rrcUnreadAllRoomMessagesHint": "Cuando está activado, cualquier mensaje nuevo de la sala RRC marca como no leído y puede reproducir un sonido de canal. Desactivar para alertas estilo IRC: solo mensajes directos y @menciones." }, "aria": { "closeDialog": "Cerrar cuadro de diálogo", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 942624086..e9627745a 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -384,7 +384,9 @@ "themeSaveFailed": "Échec de l'enregistrement du point de contrôle de couleur.", "themeRestoreFailed": "Échec de la restauration du point de contrôle des couleurs.", "themeResetFailed": "Impossible de réinitialiser les couleurs.", - "messageActionsBarBgVisible": "Afficher l'arrière-plan" + "messageActionsBarBgVisible": "Afficher l'arrière-plan", + "rrcUnreadAllRoomMessages": "RRC non lu pour tous les messages de la pièce", + "rrcUnreadAllRoomMessagesHint": "Lorsque cette option est activée, tout nouveau message de la salle RRC est non lu et peut lire un son de chaîne. Désactiver pour les alertes de type IRC : uniquement les messages directs et les @mentions." }, "aria": { "closeDialog": "Fermer la boîte de dialogue", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index f7e56eb59..4c40091b3 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -384,7 +384,9 @@ "themeSaveFailed": "Gagal menyimpan pos pemeriksaan warna.", "themeRestoreFailed": "Gagal memulihkan pos pemeriksaan warna.", "themeResetFailed": "Gagal mengatur ulang warna.", - "messageActionsBarBgVisible": "Tampilkan latar belakang" + "messageActionsBarBgVisible": "Tampilkan latar belakang", + "rrcUnreadAllRoomMessages": "RRC belum dibaca untuk semua pesan kamar", + "rrcUnreadAllRoomMessagesHint": "Saat aktif, setiap pesan ruang RRC baru menandai belum dibaca dan dapat memutar suara saluran. Nonaktifkan untuk peringatan bergaya IRC: hanya pesan langsung dan @mention." }, "aria": { "closeDialog": "Tutup dialog", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 99b41d952..acd1f6b7f 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -384,7 +384,9 @@ "themeSaveFailed": "Impossibile salvare il checkpoint del colore.", "themeRestoreFailed": "Impossibile ripristinare il checkpoint del colore.", "themeResetFailed": "Impossibile reimpostare i colori.", - "messageActionsBarBgVisible": "Mostra lo sfondo" + "messageActionsBarBgVisible": "Mostra lo sfondo", + "rrcUnreadAllRoomMessages": "RRC non letto per tutti i messaggi della stanza", + "rrcUnreadAllRoomMessagesHint": "Quando è acceso, qualsiasi nuovo messaggio della stanza RRC indica che non è stato letto ed è in grado di riprodurre un suono del canale. Disattiva per gli avvisi in stile IRC: solo messaggi diretti e @menzioni." }, "aria": { "closeDialog": "Chiudi finestra", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index a037872a8..925d311ec 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -384,7 +384,9 @@ "themeSaveFailed": "カラーチェックポイントの保存に失敗しました。", "themeRestoreFailed": "カラーチェックポイントの復元に失敗しました。", "themeResetFailed": "色をリセットできませんでした。", - "messageActionsBarBgVisible": "背景を表示" + "messageActionsBarBgVisible": "背景を表示", + "rrcUnreadAllRoomMessages": "すべてのルームメッセージのRRC未読", + "rrcUnreadAllRoomMessagesHint": "オンにすると、新しいRRCルームメッセージは未読になり、チャンネルサウンドを再生できます。IRCスタイルのアラートをオフにする:ダイレクトメッセージと@メンションのみ。" }, "aria": { "closeDialog": "ダイアログを閉じる", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index d81689f67..ed1f30105 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -384,7 +384,9 @@ "themeSaveFailed": "색상 체크포인트를 저장하지 못했습니다.", "themeRestoreFailed": "색상 체크포인트를 복원하지 못했습니다.", "themeResetFailed": "색상을 재설정하지 못했습니다.", - "messageActionsBarBgVisible": "배경 표시" + "messageActionsBarBgVisible": "배경 표시", + "rrcUnreadAllRoomMessages": "모든 객실 메시지에 대해 읽지 않은 RRC", + "rrcUnreadAllRoomMessagesHint": "켜져 있으면 새로운 RRC 룸 메시지가 읽지 않음으로 표시되어 채널 사운드를 재생할 수 있습니다. IRC 스타일 알림의 경우: 다이렉트 메시지 및 @ 멘션만 꺼주세요." }, "aria": { "closeDialog": "대화상자 닫기", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index c8d04de30..e3b80cbeb 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -384,7 +384,9 @@ "themeSaveFailed": "Kan kleurcontrolepunt niet opslaan.", "themeRestoreFailed": "Kan kleurcontrolepunt niet herstellen.", "themeResetFailed": "Kan kleuren niet resetten.", - "messageActionsBarBgVisible": "Achtergrond tonen" + "messageActionsBarBgVisible": "Achtergrond tonen", + "rrcUnreadAllRoomMessages": "RRC ongelezen voor alle kamerberichten", + "rrcUnreadAllRoomMessagesHint": "Wanneer ingeschakeld, markeert elk nieuw RRC-kamerbericht ongelezen en kan een kanaalgeluid worden afgespeeld. Schakel uit voor waarschuwingen in IRC-stijl: alleen directe berichten en @vermeldingen." }, "aria": { "closeDialog": "Dialoogvenster sluiten", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 41a3f8a64..6cc736439 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -384,7 +384,9 @@ "themeSaveFailed": "Nie udało się zapisać punktu kontrolnego koloru.", "themeRestoreFailed": "Nie udało się przywrócić punktu kontrolnego koloru.", "themeResetFailed": "Nie udało się zresetować kolorów.", - "messageActionsBarBgVisible": "Pokaż tło" + "messageActionsBarBgVisible": "Pokaż tło", + "rrcUnreadAllRoomMessages": "Nieprzeczytany RRC dla wszystkich wiadomości w pokoju", + "rrcUnreadAllRoomMessagesHint": "Gdy włączone, każdy nowy komunikat pokoju RRC oznacza nieprzeczytany i może odtwarzać dźwięk kanału. Wyłącz dla alertów w stylu IRC: tylko wiadomości bezpośrednie i @wzmianki." }, "aria": { "closeDialog": "Zamknij okno dialogowe", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index ad0665fae..f3aa7c579 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -384,7 +384,9 @@ "themeSaveFailed": "Falha ao salvar o ponto de verificação de cores.", "themeRestoreFailed": "Falha ao restaurar o ponto de verificação de cores.", "themeResetFailed": "Falha ao redefinir as cores.", - "messageActionsBarBgVisible": "Mostrar fundo" + "messageActionsBarBgVisible": "Mostrar fundo", + "rrcUnreadAllRoomMessages": "RRC não lido para todas as mensagens da sala", + "rrcUnreadAllRoomMessagesHint": "Quando ativada, qualquer nova mensagem da sala RRC marca como não lida e pode reproduzir um som de canal. Desativar para alertas no estilo IRC: apenas mensagens diretas e @menções." }, "aria": { "closeDialog": "Fechar janela", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 9d69f9adf..bae6b713d 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -384,7 +384,9 @@ "themeSaveFailed": "Не удалось сохранить цветовую контрольную точку.", "themeRestoreFailed": "Не удалось восстановить контрольную точку", "themeResetFailed": "Не удалось сбросить цвета.", - "messageActionsBarBgVisible": "Показать фон" + "messageActionsBarBgVisible": "Показать фон", + "rrcUnreadAllRoomMessages": "Непрочитанные RRC для всех сообщений комнаты", + "rrcUnreadAllRoomMessagesHint": "Когда включено, любое новое сообщение о помещении RRC помечается как непрочитанное и может воспроизводить звук канала. Отключите оповещения в стиле IRC: только прямые сообщения и @упоминания." }, "aria": { "closeDialog": "Закрыть диалог", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 9df5afc8f..a72d0d21d 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -384,7 +384,9 @@ "themeSaveFailed": "Renk kontrol noktası kaydedilemedi.", "themeRestoreFailed": "Renk kontrol noktası geri yüklenemedi.", "themeResetFailed": "Renkler sıfırlanamadı.", - "messageActionsBarBgVisible": "Arka planı göster" + "messageActionsBarBgVisible": "Arka planı göster", + "rrcUnreadAllRoomMessages": "Tüm oda mesajları için RRC okunmadı", + "rrcUnreadAllRoomMessagesHint": "Açık olduğunda, herhangi bir yeni RRC odası mesajı okunmadı olarak işaretlenir ve bir kanal sesi çalabilir. IRC tarzı uyarılar için kapatın: yalnızca doğrudan mesajlar ve @bahsetmeler." }, "aria": { "closeDialog": "İletişim kutusunu kapat", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index f6d89854b..c7a9e7a09 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -384,7 +384,9 @@ "themeSaveFailed": "Не вдалося зберегти контрольну точку кольору.", "themeRestoreFailed": "Не вдалося відновити кольорову контрольну точку.", "themeResetFailed": "Не вдалося скинути кольори.", - "messageActionsBarBgVisible": "Показувати тло" + "messageActionsBarBgVisible": "Показувати тло", + "rrcUnreadAllRoomMessages": "Непрочитані RRC для всіх повідомлень кімнати", + "rrcUnreadAllRoomMessagesHint": "Коли ввімкнено, будь-яке нове повідомлення про приміщення RRC позначається як непрочитане і може відтворювати звук каналу. Вимкніть для сповіщень у стилі IRC: лише прямі повідомлення та @згадки." }, "aria": { "closeDialog": "Закрити діалог", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 4b2c2c3cb..323e89346 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -384,7 +384,9 @@ "themeSaveFailed": "无法保存颜色检查点。", "themeRestoreFailed": "无法还原颜色检查点。", "themeResetFailed": "无法重置颜色。", - "messageActionsBarBgVisible": "显示背景" + "messageActionsBarBgVisible": "显示背景", + "rrcUnreadAllRoomMessages": "所有房间消息的RRC未读", + "rrcUnreadAllRoomMessagesHint": "开启时,任何新的RRC房间消息都会标记为未读,并且可以播放频道声音。关闭IRC样式警报:仅限直接消息和@提及。" }, "aria": { "closeDialog": "关闭对话框", diff --git a/src/renderer/runtime/useReticulumRuntime.rrc-alert.contract.test.ts b/src/renderer/runtime/useReticulumRuntime.rrc-alert.contract.test.ts new file mode 100644 index 000000000..f89bea644 --- /dev/null +++ b/src/renderer/runtime/useReticulumRuntime.rrc-alert.contract.test.ts @@ -0,0 +1,18 @@ +/** + * Source contract: RRC unread + sound ingest uses shared resolveRrcAlertType. + */ +import { describe, expect, it } from 'vitest'; + +import { loadRuntimeSource } from '../lib/sourceContractTestHelpers'; + +const SOURCE = loadRuntimeSource('useReticulumRuntime.ts'); + +describe('useReticulumRuntime RRC alert gating (source contract)', () => { + it('gates bumpUnread with resolveRrcAlertType and the live unread-all setting', () => { + expect(SOURCE).toContain('resolveRrcAlertType'); + expect(SOURCE).toContain('isRrcUnreadAllRoomMessagesEnabled'); + expect(SOURCE).toMatch( + /bumpUnread:\s*Boolean\(view\.hub\)\s*&&\s*resolveRrcAlertType\([\s\S]*?notifyMode:\s*isRrcUnreadAllRoomMessagesEnabled\(\)\s*\?\s*'all'\s*:\s*'mentions'/, + ); + }); +}); diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 99ce7f7dc..0e4b38cdc 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -6,7 +6,10 @@ import { applyRncpReceiveDestShareFromLxmf, rncpReceiveDestShareSavedToastMessage, } from '@/renderer/lib/applyRncpReceiveDestShare'; -import { isReticulumAutostartEnabled } from '@/renderer/lib/appSettingsStorage'; +import { + isReticulumAutostartEnabled, + isRrcUnreadAllRoomMessagesEnabled, +} from '@/renderer/lib/appSettingsStorage'; import { BatchedRingBufferAppender } from '@/renderer/lib/batchedRingBufferAppender'; import { requestChatOutboxDrain } from '@/renderer/lib/chatOutboxDrain'; import { loadMutedViews } from '@/renderer/lib/chatPanelProtocolStorage'; @@ -128,7 +131,7 @@ import { } from '@/renderer/lib/rncpLxmfControlSideEffectDedup'; import { consumeRncpReceiveDestSharePending } from '@/renderer/lib/rncpReceiveDestSharePending'; import { applyRrcDirectMessageRoom } from '@/renderer/lib/rrcDirectMessageRoute'; -import { isRrcRoomMuted } from '@/renderer/lib/rrcMention'; +import { isRrcRoomMuted, resolveRrcAlertType } from '@/renderer/lib/rrcMention'; import { resolveRrcInboundChatRoom, shouldDropEmptyRrcInbound, @@ -1244,7 +1247,13 @@ export function useReticulumRuntime(): ProtocolRuntime { }, { bumpUnread: - Boolean(view.hub) && !isRrcRoomMuted(view.hub!, room, loadMutedViews('reticulum')), + Boolean(view.hub) && + resolveRrcAlertType({ + msg: { body: p.body, room, kind, dst_hash: p.dst_hash }, + nickname: session.nickname, + notifyMode: isRrcUnreadAllRoomMessagesEnabled() ? 'all' : 'mentions', + muted: isRrcRoomMuted(view.hub!, room, loadMutedViews('reticulum')), + }) != null, hubDestHash, }, ); From c3d904d006b8b05e71a254f50424f85ed36cad70 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 13 Aug 2026 11:04:29 -0600 Subject: [PATCH 2/2] fix(rrc): harden unread toggle review findings Reject non-direct hub notices before mention classify, gate the App control on hasRrcPanel, treat non-boolean setting values as default, and cover ingest unread via sidecar events. --- src/renderer/components/AppPanel.tsx | 4 +- src/renderer/lib/appSettingsStorage.test.ts | 8 + src/renderer/lib/appSettingsStorage.ts | 6 +- src/renderer/lib/rrcMention.test.ts | 19 ++ src/renderer/lib/rrcMention.ts | 7 +- ...eticulumRuntime.rrc-alert.contract.test.ts | 164 +++++++++++++++++- 6 files changed, 199 insertions(+), 9 deletions(-) diff --git a/src/renderer/components/AppPanel.tsx b/src/renderer/components/AppPanel.tsx index 3e081cd6e..c0b125abf 100644 --- a/src/renderer/components/AppPanel.tsx +++ b/src/renderer/components/AppPanel.tsx @@ -303,7 +303,7 @@ export default function AppPanel({ }; }, [t]); - const { nodeStaleThresholdMs, nodeOfflineThresholdMs, hasReticulumInterfaceConfig } = + const { nodeStaleThresholdMs, nodeOfflineThresholdMs, hasReticulumInterfaceConfig, hasRrcPanel } = useRadioProvider(protocol); const isReticulumDmOnly = hasReticulumInterfaceConfig; @@ -2136,7 +2136,7 @@ export default function AppPanel({ {t('appPanel.soundNotifications')} - {protocol === 'reticulum' && ( + {hasRrcPanel && (
{ expect(isRrcUnreadAllRoomMessagesEnabled()).toBe(false); }); + it('ignores malformed rrcUnreadAllRoomMessages string values', () => { + localStorage.setItem( + APP_SETTINGS_STORAGE_KEY, + JSON.stringify({ rrcUnreadAllRoomMessages: 'false' }), + ); + expect(isRrcUnreadAllRoomMessagesEnabled()).toBe(true); + }); + it('setAppSettingsRaw replaces after migrating legacy', () => { localStorage.setItem(LEGACY_KEY, JSON.stringify({ old: 1 })); setAppSettingsRaw(JSON.stringify({ fresh: 2 })); diff --git a/src/renderer/lib/appSettingsStorage.ts b/src/renderer/lib/appSettingsStorage.ts index 3d1332e2b..f21b41c5b 100644 --- a/src/renderer/lib/appSettingsStorage.ts +++ b/src/renderer/lib/appSettingsStorage.ts @@ -77,11 +77,13 @@ export function isShareLocationSendWaypointEnabled(): boolean { /** Whether RRC badges/sounds fire for every room msg/action (false = IRC-style mention/DM). */ export function isRrcUnreadAllRoomMessagesEnabled(): boolean { - const parsed = parseStoredJson<{ rrcUnreadAllRoomMessages?: boolean }>( + const parsed = parseStoredJson<{ rrcUnreadAllRoomMessages?: unknown }>( getAppSettingsRaw(), 'isRrcUnreadAllRoomMessagesEnabled', ); - return parsed?.rrcUnreadAllRoomMessages ?? DEFAULT_APP_SETTINGS_SHARED.rrcUnreadAllRoomMessages; + return typeof parsed?.rrcUnreadAllRoomMessages === 'boolean' + ? parsed.rrcUnreadAllRoomMessages + : DEFAULT_APP_SETTINGS_SHARED.rrcUnreadAllRoomMessages; } /** Whether the Reticulum sidecar should start when the Reticulum connection panel mounts. */ diff --git a/src/renderer/lib/rrcMention.test.ts b/src/renderer/lib/rrcMention.test.ts index 6a7342c47..2c25002c8 100644 --- a/src/renderer/lib/rrcMention.test.ts +++ b/src/renderer/lib/rrcMention.test.ts @@ -151,6 +151,25 @@ describe('resolveRrcAlertType', () => { ).toBeNull(); }); + it('does not alert on @nick hub notices', () => { + const hubNotice = { + body: 'hey @nv0n topic changed', + room: '#lobby', + kind: 'notice' as const, + }; + expect( + resolveRrcAlertType({ msg: hubNotice, nickname: 'nv0n', notifyMode: 'all', muted: false }), + ).toBeNull(); + expect( + resolveRrcAlertType({ + msg: { ...hubNotice, room: '[hub]' }, + nickname: 'nv0n', + notifyMode: 'mentions', + muted: false, + }), + ).toBeNull(); + }); + it('returns null when muted even for @nick in all mode', () => { expect( resolveRrcAlertType({ msg: mention, nickname: 'nv0n', notifyMode: 'all', muted: true }), diff --git a/src/renderer/lib/rrcMention.ts b/src/renderer/lib/rrcMention.ts index 43b8420ea..04d5e21db 100644 --- a/src/renderer/lib/rrcMention.ts +++ b/src/renderer/lib/rrcMention.ts @@ -110,14 +110,17 @@ export interface ResolveRrcAlertTypeArgs { /** * Shared badge + sound gate. Mute and IRC-style mention mode drop channel traffic; - * hub notices never alert. DMs / @nick stay `dm` in both modes. + * non-direct hub notice/system/error never alert (even with @nick). Direct NOTICE + * whispers stay eligible. Room `msg`/`action` @nick stay `dm` in both modes. */ export function resolveRrcAlertType(args: ResolveRrcAlertTypeArgs): ChatNotificationType | null { if (args.muted) return null; + if (args.msg.kind !== 'msg' && args.msg.kind !== 'action' && !isRrcDirectMessage(args.msg)) { + return null; + } const type = classifyRrcNotificationType(args.msg, args.nickname); if (!type) return null; if (type === 'dm') return 'dm'; - if (args.msg.kind !== 'msg' && args.msg.kind !== 'action') return null; return args.notifyMode === 'all' ? 'channel' : null; } diff --git a/src/renderer/runtime/useReticulumRuntime.rrc-alert.contract.test.ts b/src/renderer/runtime/useReticulumRuntime.rrc-alert.contract.test.ts index f89bea644..b8afb7688 100644 --- a/src/renderer/runtime/useReticulumRuntime.rrc-alert.contract.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.rrc-alert.contract.test.ts @@ -1,12 +1,42 @@ +// @vitest-environment jsdom /** - * Source contract: RRC unread + sound ingest uses shared resolveRrcAlertType. + * Source contract + executable ingest tests for RRC unread alert gating. */ -import { describe, expect, it } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { loadRuntimeSource } from '../lib/sourceContractTestHelpers'; +import { APP_SETTINGS_STORAGE_KEY } from '@/renderer/lib/appSettingsStorage'; +import { resetReticulumManualStackStopSuppressForTests } from '@/renderer/lib/reticulum/reticulumManualStackStopSuppress'; +import { rrcMuteViewKey } from '@/renderer/lib/rrcMention'; +import { loadRuntimeSource } from '@/renderer/lib/sourceContractTestHelpers'; +import { useReticulumRuntime } from '@/renderer/runtime/useReticulumRuntime'; +import { useRrcSessionStore } from '@/renderer/stores/rrcSessionStore'; +import type { ReticulumSidecarEvent } from '@/shared/reticulum-types'; + +vi.mock('@/renderer/lib/reticulum/fetchRecentInboundLxmf', () => ({ + fetchRecentInboundLxmf: vi.fn().mockResolvedValue([]), + fetchRecentInboundLxmfDetailed: vi.fn().mockResolvedValue({ messages: [], ringLen: 0 }), +})); + +vi.mock('@/renderer/lib/reticulum/useReticulumNobleBleYieldWatcher', () => ({ + useReticulumNobleBleYieldWatcher: () => {}, +})); + +vi.mock('@/renderer/lib/reticulum/useReticulumPropagationAutoSync', () => ({ + useReticulumPropagationAutoSync: () => {}, +})); + +vi.mock('@/renderer/components/Toast', () => ({ + pushAppToast: vi.fn(), + useToast: () => ({ addToast: vi.fn() }), +})); const SOURCE = loadRuntimeSource('useReticulumRuntime.ts'); +const HUB = '28c7c1a68c735693aa8e6b8193ed44b2'; +const PEER = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const SELF = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + describe('useReticulumRuntime RRC alert gating (source contract)', () => { it('gates bumpUnread with resolveRrcAlertType and the live unread-all setting', () => { expect(SOURCE).toContain('resolveRrcAlertType'); @@ -16,3 +46,131 @@ describe('useReticulumRuntime RRC alert gating (source contract)', () => { ); }); }); + +describe('useReticulumRuntime RRC unread ingest (handleSidecarEvent)', () => { + let eventHandler: ((evt: ReticulumSidecarEvent) => void) | null = null; + + beforeEach(() => { + resetReticulumManualStackStopSuppressForTests(); + localStorage.removeItem(APP_SETTINGS_STORAGE_KEY); + localStorage.removeItem('mesh-client:mutedViews:reticulum'); + useRrcSessionStore.getState().clearSession(); + useRrcSessionStore.getState().setNickname('nv0n'); + useRrcSessionStore.getState().setLocalIdentityHash(SELF); + useRrcSessionStore.getState().applyStatus('active', HUB, 'Community'); + useRrcSessionStore.getState().roomJoined('#other'); + useRrcSessionStore.getState().setActiveRoom('#other'); + eventHandler = null; + vi.mocked(window.electronAPI.db.insertRrcMessage).mockReset(); + vi.mocked(window.electronAPI.db.insertRrcMessage).mockResolvedValue({ changes: 1 }); + vi.mocked(window.electronAPI.reticulum.onEvent).mockImplementation((cb) => { + eventHandler = cb; + return () => { + if (eventHandler === cb) eventHandler = null; + }; + }); + vi.mocked(window.electronAPI.reticulum.start).mockResolvedValue({ + running: true, + port: 19437, + pid: 1, + }); + vi.mocked(window.electronAPI.reticulum.stop).mockResolvedValue(undefined); + vi.mocked(window.electronAPI.reticulum.getStatus).mockResolvedValue({ + running: true, + port: 19437, + pid: 1, + healthy: true, + }); + }); + + afterEach(() => { + vi.mocked(window.electronAPI.reticulum.onEvent).mockReset(); + vi.mocked(window.electronAPI.reticulum.onEvent).mockReturnValue(() => {}); + useRrcSessionStore.getState().clearSession(); + }); + + async function connectAndGetOnEvent() { + const { result, unmount } = renderHook(() => useReticulumRuntime()); + await act(async () => { + await result.current.connect(); + }); + expect(eventHandler).toBeTruthy(); + return { onEvent: eventHandler!, unmount }; + } + + function sendRrcMessage( + onEvent: (evt: ReticulumSidecarEvent) => void, + payload: Record, + ): void { + act(() => { + onEvent({ + type: 'rrc.message', + payload: { + hub_dest_hash: HUB, + sender_hash: PEER, + timestamp: Date.now(), + ...payload, + }, + }); + }); + } + + function lobbyUnread(): number { + return useRrcSessionStore.getState().unreadByRoom.get('lobby') ?? 0; + } + + it('bumps unread for plain room traffic in all mode', async () => { + localStorage.setItem( + APP_SETTINGS_STORAGE_KEY, + JSON.stringify({ rrcUnreadAllRoomMessages: true }), + ); + const { onEvent, unmount } = await connectAndGetOnEvent(); + sendRrcMessage(onEvent, { id: 'all-1', room: '#lobby', kind: 'msg', body: 'hello all' }); + expect(lobbyUnread()).toBe(1); + unmount(); + }); + + it('skips plain room traffic in mentions mode and bumps @nick', async () => { + localStorage.setItem( + APP_SETTINGS_STORAGE_KEY, + JSON.stringify({ rrcUnreadAllRoomMessages: false }), + ); + const { onEvent, unmount } = await connectAndGetOnEvent(); + sendRrcMessage(onEvent, { id: 'mentions-1', room: '#lobby', kind: 'msg', body: 'hello all' }); + expect(lobbyUnread()).toBe(0); + sendRrcMessage(onEvent, { id: 'mentions-2', room: '#lobby', kind: 'msg', body: 'hey @nv0n' }); + expect(lobbyUnread()).toBe(1); + unmount(); + }); + + it('does not bump muted rooms even for @nick in all mode', async () => { + localStorage.setItem( + APP_SETTINGS_STORAGE_KEY, + JSON.stringify({ rrcUnreadAllRoomMessages: true }), + ); + localStorage.setItem( + 'mesh-client:mutedViews:reticulum', + JSON.stringify([rrcMuteViewKey(HUB, '#lobby')]), + ); + const { onEvent, unmount } = await connectAndGetOnEvent(); + sendRrcMessage(onEvent, { id: 'mute-1', room: '#lobby', kind: 'msg', body: 'hey @nv0n' }); + expect(lobbyUnread()).toBe(0); + unmount(); + }); + + it('does not bump hub notices that mention @nick', async () => { + localStorage.setItem( + APP_SETTINGS_STORAGE_KEY, + JSON.stringify({ rrcUnreadAllRoomMessages: true }), + ); + const { onEvent, unmount } = await connectAndGetOnEvent(); + sendRrcMessage(onEvent, { + id: 'notice-1', + room: '#lobby', + kind: 'notice', + body: 'hey @nv0n topic changed', + }); + expect(lobbyUnread()).toBe(0); + unmount(); + }); +});