From a04b1f9f425a2ca84854ca519cf5203aabb1b4d6 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Tue, 4 Aug 2026 22:45:34 -0600 Subject: [PATCH 1/6] feat(rrc): IRC-style per-peer DMs instead of shared [whispers] Route direct NOTICE traffic into @hash query tabs labeled by nick, with local Leave and localStorage persistence across restart. --- docs/reticulum.md | 2 +- src/renderer/components/RrcPanel.test.tsx | 66 +++++--- src/renderer/components/RrcPanel.tsx | 157 +++++++++++------- .../components/rrc/RrcRoomSidebar.tsx | 15 +- src/renderer/components/rrc/RrcTopicBar.tsx | 2 +- src/renderer/lib/rrcDmRoom.test.ts | 93 +++++++++++ src/renderer/lib/rrcDmRoom.ts | 130 +++++++++++++++ src/renderer/lib/rrcLegacyWhispersMigrate.ts | 135 +++++++++++++++ src/renderer/lib/rrcMention.ts | 5 +- src/renderer/lib/rrcOpenDms.test.ts | 49 ++++++ src/renderer/lib/rrcOpenDms.ts | 88 ++++++++++ src/renderer/lib/rrcWhisperReply.test.ts | 138 +-------------- src/renderer/lib/rrcWhisperReply.ts | 79 +-------- .../runtime/useReticulumRuntime.rrc.test.ts | 10 +- src/renderer/runtime/useReticulumRuntime.ts | 51 +++--- src/renderer/stores/rrcSessionStore.test.ts | 90 +++++++--- src/renderer/stores/rrcSessionStore.ts | 122 ++++++++++---- 17 files changed, 849 insertions(+), 383 deletions(-) create mode 100644 src/renderer/lib/rrcDmRoom.test.ts create mode 100644 src/renderer/lib/rrcDmRoom.ts create mode 100644 src/renderer/lib/rrcLegacyWhispersMigrate.ts create mode 100644 src/renderer/lib/rrcOpenDms.test.ts create mode 100644 src/renderer/lib/rrcOpenDms.ts diff --git a/docs/reticulum.md b/docs/reticulum.md index f48c23fb9..50205dc6c 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -292,7 +292,7 @@ IRC-style multi-pane client (`RrcPanel` + `rrcHubStore` / `rrcSessionStore`): - Discover hubs from announces, connect by hash, or favourite hubs (Nomad-style). Soft cap **8** concurrent hub sessions. - Per-hub rooms, nicklists (`/who`), topics, slash commands (`/help`, `/join`, `/part`, `/list`, `/msg`, …). Hub and room **auto-join** prefs in localStorage. -- **`[whispers]`:** `/msg NICK text` opens the synthetic whispers room and pins the reply peer. Plain text in `[whispers]` sends a NOTICE to that peer (no `/msg` prefix). Inbound whispers update the reply target only when the user has not pinned a peer via `/msg` or a prior plain reply. With no target yet, send shows `rrc.whisperNoTarget`. +- **Per-peer DMs (`@`):** `/msg NICK text` opens an IRC-style query tab for that peer (wire: direct NOTICE + `K_DST`, no room JOIN). Plain text in that tab replies to the same peer. Sidebar/header show the nick. Leave closes that DM locally; open DMs persist in localStorage until left. Legacy `[whispers]` inbox is migrated best-effort into per-peer rooms. - 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. diff --git a/src/renderer/components/RrcPanel.test.tsx b/src/renderer/components/RrcPanel.test.tsx index 7ea42099e..aad2e0b7a 100644 --- a/src/renderer/components/RrcPanel.test.tsx +++ b/src/renderer/components/RrcPanel.test.tsx @@ -10,6 +10,7 @@ import { resetRrcHubDisconnectSuppressForTests, } from '@/renderer/lib/rrcHubDisconnectSuppress'; import { saveRrcHubAutoJoin } from '@/renderer/lib/rrcHubPrefs'; +import { clearRrcOpenDms } from '@/renderer/lib/rrcOpenDms'; import { resetRrcRoomHistoryForTests } from '@/renderer/lib/rrcRoomHistory'; import { useRrcHubStore } from '@/renderer/stores/rrcHubStore'; import { useRrcSessionStore } from '@/renderer/stores/rrcSessionStore'; @@ -38,6 +39,9 @@ describe('RrcPanel', () => { vi.mocked(window.electronAPI.db.deleteRrcMessagesByRoom).mockClear(); vi.mocked(window.electronAPI.db.deleteRrcMessagesByRoom).mockResolvedValue({ changes: 1 }); localStorage.removeItem('mesh-client:rrc:hubAutoJoin'); + clearRrcOpenDms(hubA); + clearRrcOpenDms(hubB); + vi.mocked(window.electronAPI.db.listRrcMessages).mockResolvedValue([]); }); it('renders amber hub chrome and select-hub prompt', async () => { @@ -215,7 +219,7 @@ describe('RrcPanel', () => { expect(window.electronAPI.reticulum.rrc.connect).not.toHaveBeenCalled(); }); - it('sends plain text in [whispers] as NOTICE to the last /msg peer', async () => { + it('opens a per-peer DM on /msg and replies with NOTICE to that peer', async () => { const user = userEvent.setup(); const peerHash = 'dddddddddddddddddddddddddddddddd'; const store = useRrcSessionStore.getState(); @@ -241,13 +245,10 @@ describe('RrcPanel', () => { dst_hash: peerHash, }); }); - expect(useRrcSessionStore.getState().activeRoom).toBe('[whispers]'); - expect(useRrcSessionStore.getState().sessionsByHub.get(hubA)?.lastWhisperPeer).toEqual({ - identity_hash: peerHash, - nickname: 'Alice', - }); + expect(useRrcSessionStore.getState().activeRoom).toBe(`@${peerHash}`); + expect(useRrcSessionStore.getState().rooms.has(`@${peerHash}`)).toBe(true); - const whisperKey = useRrcSessionStore.getState().roomMessageKey('[whispers]'); + const whisperKey = useRrcSessionStore.getState().roomMessageKey(`@${peerHash}`); const outbound = useRrcSessionStore .getState() .messages.get(whisperKey ?? '') @@ -256,10 +257,11 @@ describe('RrcPanel', () => { kind: 'msg', body: 'first whisper', dst_hash: peerHash, + room: `@${peerHash}`, }); expect(outbound?.body).not.toContain('→'); - // Sidebar + header show peer nick, not the synthetic [whispers] key. + // Sidebar + header show peer nick, not the @hash key. expect(screen.getByRole('button', { name: 'Open room Alice' })).toBeInTheDocument(); expect(screen.getByText(/· Alice/)).toBeInTheDocument(); @@ -279,7 +281,7 @@ describe('RrcPanel', () => { }); }); - it('keeps pinned /msg peer when an inbound whisper arrives from someone else', async () => { + it('keeps separate DM tabs when whispering two peers', async () => { const user = userEvent.setup(); const aliceHash = 'dddddddddddddddddddddddddddddddd'; const bobHash = 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; @@ -301,19 +303,16 @@ describe('RrcPanel', () => { await user.click(screen.getByRole('button', { name: 'Send' })); await waitFor(() => { - expect(useRrcSessionStore.getState().sessionsByHub.get(hubA)?.whisperReplyPinned).toBe(true); + expect(useRrcSessionStore.getState().activeRoom).toBe(`@${aliceHash}`); }); - // Inbound whisper from Bob must not steal the pinned reply target. - useRrcSessionStore - .getState() - .setLastWhisperPeer({ identity_hash: bobHash, nickname: 'Bob' }, hubA, { - onlyIfUnpinned: true, - }); - expect(useRrcSessionStore.getState().sessionsByHub.get(hubA)?.lastWhisperPeer).toEqual({ - identity_hash: aliceHash, - nickname: 'Alice', + // Inbound-style open of Bob's DM must not replace Alice's tab. + useRrcSessionStore.getState().openDm({ identity_hash: bobHash, nickname: 'Bob' }, hubA, { + focus: false, }); + expect(useRrcSessionStore.getState().rooms.has(`@${aliceHash}`)).toBe(true); + expect(useRrcSessionStore.getState().rooms.has(`@${bobHash}`)).toBe(true); + expect(useRrcSessionStore.getState().activeRoom).toBe(`@${aliceHash}`); vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); const whisperComposer = screen.getByRole('textbox', { name: /Reply to Alice/i }); @@ -331,6 +330,35 @@ describe('RrcPanel', () => { }); }); + it('leaves a DM locally without hub PART and keeps history', async () => { + const user = userEvent.setup(); + const peerHash = 'dddddddddddddddddddddddddddddddd'; + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hubA, 'Hub A'); + store.setCapabilities({ direct_notice: true }); + store.roomJoined('#general', [{ identity_hash: peerHash, nickname: 'Alice' }]); + store.openDm({ identity_hash: peerHash, nickname: 'Alice' }, hubA, { focus: true }); + store.addMessage({ + id: 'keep-me', + room: `@${peerHash}`, + kind: 'msg', + body: 'saved', + timestamp: 1, + dst_hash: peerHash, + }); + vi.mocked(window.electronAPI.reticulum.rrc.part).mockClear(); + + render(); + await user.click(screen.getByRole('button', { name: /Leave room/i })); + + await waitFor(() => { + expect(useRrcSessionStore.getState().rooms.has(`@${peerHash}`)).toBe(false); + }); + expect(window.electronAPI.reticulum.rrc.part).not.toHaveBeenCalled(); + const key = useRrcSessionStore.getState().roomMessageKey(`@${peerHash}`, hubA); + expect(useRrcSessionStore.getState().messages.get(key ?? '')?.[0]?.body).toBe('saved'); + }); + it('rejects plain text in [hub] with join-room prompt', async () => { const user = userEvent.setup(); const store = useRrcSessionStore.getState(); diff --git a/src/renderer/components/RrcPanel.tsx b/src/renderer/components/RrcPanel.tsx index dd1079251..ed9da22af 100644 --- a/src/renderer/components/RrcPanel.tsx +++ b/src/renderer/components/RrcPanel.tsx @@ -12,11 +12,19 @@ import { runRrcHubAutoConnectBatch } from '@/renderer/hooks/useRrcStartupAutoCon import { loadMutedViews, saveMutedViews } from '@/renderer/lib/chatPanelProtocolStorage'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { isReticulumSidecarRunning } from '@/renderer/lib/reticulum/reticulumSidecarReads'; +import { + isRrcDmRoom, + parseRrcDmRoomKey, + rrcDmDisplayLabel, + rrcDmRoomKey, +} from '@/renderer/lib/rrcDmRoom'; import { formatRrcErrorMessage } from '@/renderer/lib/rrcErrorHumanize'; import { setRrcHubDisconnectSuppressed } from '@/renderer/lib/rrcHubDisconnectSuppress'; import { isRrcHubAutoJoin, toggleRrcHubAutoJoin } from '@/renderer/lib/rrcHubPrefs'; import { isRrcHubLinked } from '@/renderer/lib/rrcHubSession'; +import { migrateLegacyWhispersForHub } from '@/renderer/lib/rrcLegacyWhispersMigrate'; import { buildRrcWhisperCompleteMembers } from '@/renderer/lib/rrcNickComplete'; +import { loadRrcOpenDms } from '@/renderer/lib/rrcOpenDms'; import { loadRrcRecentRooms, pushRrcRecentRoom } from '@/renderer/lib/rrcRecentRooms'; import { clearRrcRoomHistory, hydrateRrcRoomMessages } from '@/renderer/lib/rrcRoomHistory'; import { dedupeRrcMembers, rrcIdentityHashesMatch } from '@/renderer/lib/rrcRoomMembers'; @@ -32,16 +40,11 @@ import { resolveRrcMsgTarget, RRC_HELP_I18N_KEYS, } from '@/renderer/lib/rrcSlashCommands'; -import { - resolveRrcWhisperReplyTarget, - rrcWhisperDisplayLabel, -} from '@/renderer/lib/rrcWhisperReply'; import { useRrcHubStore } from '@/renderer/stores/rrcHubStore'; import { MAX_RRC_HUB_SESSIONS, RRC_HUB_STREAM_ROOM, RRC_NICKNAME_STORAGE_KEY, - RRC_WHISPERS_ROOM, useRrcSessionStore, } from '@/renderer/stores/rrcSessionStore'; import type { RrcHubInfo, RrcRoomMember } from '@/shared/rrc-types'; @@ -104,10 +107,6 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: const sessionsByHub = useRrcSessionStore((s) => s.sessionsByHub); const showTimestamps = useRrcSessionStore((s) => s.showTimestamps); const capabilities = useRrcSessionStore((s) => s.capabilities); - const lastWhisperPeer = useRrcSessionStore((s) => { - const hub = s.focusedHubHash; - return hub ? (s.sessionsByHub.get(hub)?.lastWhisperPeer ?? null) : null; - }); const setNickname = useRrcSessionStore((s) => s.setNickname); const setFocusedHub = useRrcSessionStore((s) => s.setFocusedHub); const setActiveRoom = useRrcSessionStore((s) => s.setActiveRoom); @@ -120,7 +119,8 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: const localIdentityHash = useRrcSessionStore((s) => s.localIdentityHash); const setDisconnectIntent = useRrcSessionStore((s) => s.setDisconnectIntent); const setModerationBanner = useRrcSessionStore((s) => s.setModerationBanner); - const setLastWhisperPeer = useRrcSessionStore((s) => s.setLastWhisperPeer); + const openDm = useRrcSessionStore((s) => s.openDm); + const closeDm = useRrcSessionStore((s) => s.closeDm); const setError = useRrcSessionStore((s) => s.setError); const clearHubSession = useRrcSessionStore((s) => s.clearHubSession); @@ -163,6 +163,17 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: void hydrateRrcRoomMessages(hubDestHash, activeRoom); }, [hubDestHash, activeRoom]); + // Restore open DMs + migrate legacy [whispers] after hub is live. + useEffect(() => { + if (!hubDestHash || status !== 'active') return; + const hub = hubDestHash; + for (const dm of loadRrcOpenDms(hub)) { + openDm(dm, hub, { focus: false }); + void hydrateRrcRoomMessages(hub, rrcDmRoomKey(dm.identity_hash)); + } + void migrateLegacyWhispersForHub(hub); + }, [hubDestHash, status, openDm]); + const recentRooms = useMemo(() => { if (!hubDestHash) return []; void recentRoomsEpoch; @@ -293,11 +304,26 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: keys.add(rrcRoomMatchKey(name)); } }; - ensureSynthetic(RRC_WHISPERS_ROOM); + // Per-peer DMs live in `rooms` via openDm; only ensure hub stream synthetically. ensureSynthetic(RRC_HUB_STREAM_ROOM); return list; }, [rooms, unreadByRoom, activeRoom]); + const dmRoomLabels = useMemo(() => { + const map = new Map(); + for (const room of rooms.values()) { + if (!isRrcDmRoom(room.name)) continue; + const hash = parseRrcDmRoomKey(room.name); + if (!hash) continue; + const nick = room.members?.[0]?.nickname ?? null; + map.set( + rrcRoomMatchKey(room.name), + rrcDmDisplayLabel({ identity_hash: hash, nickname: nick }), + ); + } + return map; + }, [rooms]); + const unreadForHub = useRrcSessionStore((s) => s.unreadForHub); const joinedKeys = useMemo( @@ -318,28 +344,18 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: const activeRoomInfo = activeRoom ? rooms.get(activeRoom) : undefined; const muteKey = hubDestHash && activeRoom ? `rrc:${hubDestHash}:${activeRoom}` : null; const isMuted = muteKey ? mutedViews.has(muteKey) : false; - const whisperComposerPlaceholder = useMemo(() => { - if (activeRoom !== RRC_WHISPERS_ROOM) return undefined; - const peer = resolveRrcWhisperReplyTarget({ - lastWhisperPeer, - messages: activeMessages, - localIdentityHash, - }); - if (!peer) return undefined; - const name = peer.nickname || peer.identity_hash.slice(0, 8); - return t('rrc.whisperReplyPlaceholder', { name }); - }, [activeMessages, activeRoom, lastWhisperPeer, localIdentityHash, t]); + const activeDmPeerHash = activeRoom ? parseRrcDmRoomKey(activeRoom) : null; + const activeDmLabel = activeDmPeerHash + ? (dmRoomLabels.get(rrcRoomMatchKey(activeRoom!)) ?? + rrcDmDisplayLabel({ identity_hash: activeDmPeerHash, nickname: null })) + : null; - const whisperRoomLabel = useMemo(() => { - const peer = resolveRrcWhisperReplyTarget({ - lastWhisperPeer, - messages: activeMessages, - localIdentityHash, - }); - return rrcWhisperDisplayLabel(peer); - }, [activeMessages, lastWhisperPeer, localIdentityHash]); + const whisperComposerPlaceholder = useMemo(() => { + if (!activeDmLabel) return undefined; + return t('rrc.whisperReplyPlaceholder', { name: activeDmLabel }); + }, [activeDmLabel, t]); - const activeRoomHeaderLabel = activeRoom === RRC_WHISPERS_ROOM ? whisperRoomLabel : activeRoom; + const activeRoomHeaderLabel = activeDmLabel ?? activeRoom; const connected = status === 'active' || status === 'awaiting_welcome' || status === 'reconnecting'; const connectInFlight = status === 'connecting' || status === 'awaiting_welcome'; @@ -351,7 +367,10 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: status === 'active'; const cancelSessionLabel = connectInFlight || status === 'reconnecting'; const showNicklist = - Boolean(activeRoom) && activeRoom !== RRC_HUB_STREAM_ROOM && !activeRoom?.startsWith('['); + Boolean(activeRoom) && + activeRoom !== RRC_HUB_STREAM_ROOM && + !activeRoom?.startsWith('[') && + !isRrcDmRoom(activeRoom); const nicklistMembers = useMemo(() => { let members = dedupeRrcMembers([...(activeRoomInfo?.members ?? [])]); @@ -392,14 +411,23 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: }, [activeRoomInfo?.members, localIdentityHash, nickname]); const chatCompleteMembers = useMemo(() => { - if (activeRoom !== RRC_WHISPERS_ROOM) return nicklistMembers; + if (!isRrcDmRoom(activeRoom)) return nicklistMembers; + const peerHash = parseRrcDmRoomKey(activeRoom); + const peerNick = activeRoomInfo?.members?.[0]?.nickname ?? null; return buildRrcWhisperCompleteMembers({ - lastWhisperPeer, + lastWhisperPeer: peerHash ? { identity_hash: peerHash, nickname: peerNick } : null, messages: activeMessages, localIdentityHash, selfNickname: nickname, }); - }, [activeRoom, activeMessages, lastWhisperPeer, localIdentityHash, nickname, nicklistMembers]); + }, [ + activeRoom, + activeRoomInfo?.members, + activeMessages, + localIdentityHash, + nickname, + nicklistMembers, + ]); const displayError = lastError ? formatRrcErrorMessage(lastError, t) : null; @@ -507,6 +535,11 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: joined: [...rooms.keys()].map((name) => ({ name })), }); if (!room) return; + // Never hub-JOIN synthetic streams or per-peer DMs. + if (room.startsWith('[') || isRrcDmRoom(room)) { + if (isRrcDmRoom(room)) setActiveRoom(room); + return; + } // Already in this channel (possibly under `#name` vs `name`) — focus + refresh roster. const existingKey = [...rooms.keys()].find((k) => rrcRoomsMatch(k, room)); if (existingKey) { @@ -545,6 +578,11 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: if (!hubDestHash) return; const raw = (room ?? activeRoom)?.trim(); if (!raw || raw.startsWith('[')) return; + // Client-local leave for per-peer DMs (no hub PART). + if (isRrcDmRoom(raw)) { + closeDm(raw, hubDestHash); + return; + } // Wire PART must use the same spelling as JOIN (rrcd treats #general ≠ general). const joinedKey = [...rooms.keys()].find((k) => rrcRoomsMatch(k, raw)); const target = @@ -553,7 +591,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: listed: listedRooms, joined: [...rooms.values()], }); - if (!target) return; + if (!target || isRrcDmRoom(target)) return; markPartIntent(target); setActionBusy(true); try { @@ -573,7 +611,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: setActionBusy(false); } }, - [activeRoom, hubDestHash, listedRooms, markPartIntent, rooms, setError, t], + [activeRoom, closeDm, hubDestHash, listedRooms, markPartIntent, rooms, setError, t], ); const appendSystemLines = useCallback( @@ -663,7 +701,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: useRrcSessionStore.getState().setError(t('rrc.sendFailed')); return; } - if (!activeRoom || activeRoom.startsWith('[')) { + if (!activeRoom || activeRoom.startsWith('[') || isRrcDmRoom(activeRoom)) { useRrcSessionStore.getState().setError(t('rrc.joinRoomPrompt')); return; } @@ -706,18 +744,18 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: useRrcSessionStore.getState().setError(res.error ?? t('rrc.sendFailed')); return; } - setLastWhisperPeer( + const dmRoom = rrcDmRoomKey(resolved.identity_hash); + openDm( { identity_hash: resolved.identity_hash, nickname: resolved.nickname ?? null, }, - undefined, - { pin: true }, + hubDestHash, + { focus: true }, ); - if (activeRoom !== RRC_WHISPERS_ROOM) setActiveRoom(RRC_WHISPERS_ROOM); addMessage({ id: `whisper-out-${Date.now()}`, - room: RRC_WHISPERS_ROOM, + room: dmRoom, kind: 'msg', body: parsed.text, nickname: nickname || null, @@ -747,7 +785,10 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: } const res = await window.electronAPI.reticulum.rrc.send({ hub_dest_hash: hubDestHash, - room: activeRoom && !activeRoom.startsWith('[') ? activeRoom : undefined, + room: + activeRoom && !activeRoom.startsWith('[') && !isRrcDmRoom(activeRoom) + ? activeRoom + : undefined, body: parsed.body, type: 'msg', }); @@ -760,7 +801,8 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: return; } - if (activeRoom === RRC_WHISPERS_ROOM) { + const activeDmHash = activeRoom ? parseRrcDmRoomKey(activeRoom) : null; + if (activeRoom && activeDmHash) { if (status !== 'active' || !hubDestHash) { useRrcSessionStore.getState().setError(t('rrc.sendFailed')); return; @@ -769,35 +811,25 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: useRrcSessionStore.getState().setError(t('rrc.directNoticeUnsupported')); return; } - const peer = resolveRrcWhisperReplyTarget({ - lastWhisperPeer, - messages: messagesForActiveRoom(), - localIdentityHash, - }); - if (!peer) { - useRrcSessionStore.getState().setError(t('rrc.whisperNoTarget')); - return; - } const res = await window.electronAPI.reticulum.rrc.send({ hub_dest_hash: hubDestHash, body: parsed.body, type: 'notice', - dst_hash: peer.identity_hash, + dst_hash: activeDmHash, }); if (!res.ok) { useRrcSessionStore.getState().setError(res.error ?? t('rrc.sendFailed')); return; } - setLastWhisperPeer(peer, undefined, { pin: true }); addMessage({ id: `whisper-out-${Date.now()}`, - room: RRC_WHISPERS_ROOM, + room: activeRoom, kind: 'msg', body: parsed.body, nickname: nickname || null, sender_hash: localIdentityHash, timestamp: Date.now(), - dst_hash: peer.identity_hash, + dst_hash: activeDmHash, }); setDraft(''); return; @@ -837,14 +869,11 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: handlePart, hubDestHash, joinRoom, - lastWhisperPeer, localIdentityHash, - messagesForActiveRoom, nickname, + openDm, rooms, sendHubCommand, - setActiveRoom, - setLastWhisperPeer, setNickname, status, t, @@ -959,7 +988,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: setPrefsEpoch((n) => n + 1); }} autoJoin={autoJoinRooms} - whisperRoomLabel={whisperRoomLabel} + dmRoomLabels={dmRoomLabels} /> )} @@ -1012,7 +1041,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: )} - {activeRoom && !activeRoom.startsWith('[') && ( + {activeRoom && (!activeRoom.startsWith('[') || isRrcDmRoom(activeRoom)) && ( + {}} + suspenseFallback={
Loading…
} + > + +
+ + ); + } + const user = userEvent.setup(); + render(); + expect(screen.getByRole('alert')).toHaveTextContent('peer detail boom'); + await user.click(screen.getByRole('button', { name: 'Switch peer' })); + await waitFor(() => { + expect(screen.getByText('Peer safepeer')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/renderer/components/ReticulumPeerDetailErrorBoundary.tsx b/src/renderer/components/ReticulumPeerDetailErrorBoundary.tsx new file mode 100644 index 000000000..4720feff2 --- /dev/null +++ b/src/renderer/components/ReticulumPeerDetailErrorBoundary.tsx @@ -0,0 +1,58 @@ +/** + * Isolates ReticulumPeerDetailModal so a render failure cannot take down App. + * Resets when `peerHash` changes; Close clears selection via `onClose`. + */ +import { type ReactNode, Suspense } from 'react'; + +import ErrorBoundary from '@/renderer/components/ErrorBoundary'; +import i18n from '@/renderer/lib/i18n'; + +interface Props { + peerHash: string; + onClose: () => void; + children: ReactNode; + suspenseFallback: ReactNode; +} + +export function ReticulumPeerDetailErrorBoundary({ + peerHash, + onClose, + children, + suspenseFallback, +}: Props) { + return ( + ( +
+
+
+ {i18n.t('errorBoundary.title')} +
+

+ {error?.message || i18n.t('errorBoundary.unexpectedError')} +

+ +
+
+ )} + > + {children} +
+ ); +} diff --git a/src/renderer/components/RrcPanel.test.tsx b/src/renderer/components/RrcPanel.test.tsx index 32cd260ae..e3e5d0cef 100644 --- a/src/renderer/components/RrcPanel.test.tsx +++ b/src/renderer/components/RrcPanel.test.tsx @@ -401,6 +401,34 @@ describe('RrcPanel', () => { expect(window.electronAPI.reticulum.rrc.join).not.toHaveBeenCalled(); }); + it('does not issue /who for an open DM room', async () => { + const peerHash = 'dddddddddddddddddddddddddddddddd'; + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hubA, 'Hub A'); + store.setCapabilities({ direct_notice: true }); + store.roomJoined('#general'); + store.openDm({ identity_hash: peerHash, nickname: 'Alice' }, hubA, { focus: true }); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); + + render(); + + await waitFor(() => { + expect(useRrcSessionStore.getState().rooms.has(`@${peerHash}`)).toBe(true); + }); + // Allow the joined-room /who effect to run. + await new Promise((r) => setTimeout(r, 30)); + const whoCalls = vi.mocked(window.electronAPI.reticulum.rrc.send).mock.calls.filter((args) => { + const body = (args[0] as { body?: string } | undefined)?.body; + return typeof body === 'string' && body.startsWith('/who'); + }); + expect(whoCalls.every((args) => !(args[0] as { room?: string }).room?.startsWith('@'))).toBe( + true, + ); + expect(whoCalls.some((args) => (args[0] as { room?: string }).room === `@${peerHash}`)).toBe( + false, + ); + }); + it('rejects plain text in [hub] with join-room prompt', async () => { const user = userEvent.setup(); const store = useRrcSessionStore.getState(); diff --git a/src/renderer/components/RrcPanel.tsx b/src/renderer/components/RrcPanel.tsx index ed9da22af..369e1773b 100644 --- a/src/renderer/components/RrcPanel.tsx +++ b/src/renderer/components/RrcPanel.tsx @@ -168,7 +168,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: if (!hubDestHash || status !== 'active') return; const hub = hubDestHash; for (const dm of loadRrcOpenDms(hub)) { - openDm(dm, hub, { focus: false }); + openDm(dm, hub, { focus: false, persist: false }); void hydrateRrcRoomMessages(hub, rrcDmRoomKey(dm.identity_hash)); } void migrateLegacyWhispersForHub(hub); @@ -226,9 +226,13 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: const sendHubCommand = useCallback( async (body: string) => { if (status !== 'active' || !hubDestHash) return; + const hubRoom = + activeRoom && !activeRoom.startsWith('[') && !isRrcDmRoom(activeRoom) + ? activeRoom + : undefined; await window.electronAPI.reticulum.rrc.send({ hub_dest_hash: hubDestHash, - room: activeRoom && !activeRoom.startsWith('[') ? activeRoom : undefined, + room: hubRoom, body, type: 'msg', }); @@ -249,7 +253,8 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: listed: listedRooms, joined: [...rooms.keys()].map((name) => ({ name })), }); - if (!room || room.startsWith('[')) return; + // Never /who synthetic streams or per-peer DMs (client-local only). + if (!room || room.startsWith('[') || isRrcDmRoom(room)) return; const reqKey = `${hubDestHash}::${rrcRoomMatchKey(room)}`; if (!force && whoRequestedRef.current.has(reqKey)) return; whoRequestedRef.current.add(reqKey); @@ -268,7 +273,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: if (status !== 'active' || !hubDestHash) return; const live = new Set(); for (const key of rooms.keys()) { - if (!key || key.startsWith('[')) continue; + if (!key || key.startsWith('[') || isRrcDmRoom(key)) continue; const reqKey = `${hubDestHash}::${rrcRoomMatchKey(key)}`; live.add(reqKey); requestRoomWho(key, false); @@ -670,7 +675,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: } // Update local nicklist entry for self immediately. const selfHash = useRrcSessionStore.getState().localIdentityHash; - if (selfHash && activeRoom && !activeRoom.startsWith('[')) { + if (selfHash && activeRoom && !activeRoom.startsWith('[') && !isRrcDmRoom(activeRoom)) { const members = activeRoomInfo?.members ?? []; const next = members.map((m) => m.identity_hash.toLowerCase() === selfHash @@ -1134,7 +1139,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: busy={actionBusy} onRefreshWho={() => { const room = activeRoom; - if (!room || room.startsWith('[')) return; + if (!room || room.startsWith('[') || isRrcDmRoom(room)) return; requestRoomWho(room, true); }} onNickClick={(member: RrcRoomMember) => { diff --git a/src/renderer/lib/rrcDirectMessageRoute.test.ts b/src/renderer/lib/rrcDirectMessageRoute.test.ts new file mode 100644 index 000000000..a99dd82f2 --- /dev/null +++ b/src/renderer/lib/rrcDirectMessageRoute.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { rrcDmRoomKey } from '@/renderer/lib/rrcDmRoom'; + +import { applyRrcDirectMessageRoom } from './rrcDirectMessageRoute'; + +const peerA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const selfHash = 'cccccccccccccccccccccccccccccccc'; +const hub = '28c7c1a68c735693aa8e6b8193ed44b2'; + +describe('applyRrcDirectMessageRoom', () => { + it('opens inbound DM with focus:false and returns @hash room', () => { + const openDm = vi.fn(); + const room = applyRrcDirectMessageRoom({ + dst_hash: selfHash, + sender_hash: peerA, + nickname: 'Alice', + localIdentityHash: selfHash, + hubDestHash: hub, + fallbackRoom: '[hub]', + openDm, + }); + expect(room).toBe(rrcDmRoomKey(peerA)); + expect(openDm).toHaveBeenCalledWith({ identity_hash: peerA, nickname: 'Alice' }, hub, { + focus: false, + }); + }); + + it('opens outbound echo DM on dst with focus:false', () => { + const openDm = vi.fn(); + const room = applyRrcDirectMessageRoom({ + dst_hash: peerA, + sender_hash: selfHash, + nickname: 'Me', + localIdentityHash: selfHash, + hubDestHash: hub, + fallbackRoom: '#lobby', + openDm, + }); + expect(room).toBe(rrcDmRoomKey(peerA)); + expect(openDm).toHaveBeenCalledWith({ identity_hash: peerA, nickname: null }, hub, { + focus: false, + }); + }); + + it('does not openDm when local identity is unavailable', () => { + const openDm = vi.fn(); + const room = applyRrcDirectMessageRoom({ + dst_hash: peerA, + sender_hash: selfHash, + nickname: 'Me', + localIdentityHash: null, + hubDestHash: hub, + fallbackRoom: '[hub]', + openDm, + }); + expect(room).toBe('[hub]'); + expect(openDm).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/lib/rrcDirectMessageRoute.ts b/src/renderer/lib/rrcDirectMessageRoute.ts new file mode 100644 index 000000000..7c21a2827 --- /dev/null +++ b/src/renderer/lib/rrcDirectMessageRoute.ts @@ -0,0 +1,42 @@ +/** + * Apply an inbound RRC direct NOTICE to the session (open per-peer DM, resolve room). + * Extracted from useReticulumRuntime for behavioral tests. + */ + +import { + resolveRrcDmPeerFromDirectMessage, + type RrcDmPeer, + rrcDmRoomKey, +} from '@/renderer/lib/rrcDmRoom'; + +export interface ApplyRrcDirectMessageOpts { + dst_hash: string | null | undefined; + sender_hash: string | null | undefined; + nickname: string | null | undefined; + localIdentityHash: string | null | undefined; + hubDestHash: string | undefined; + fallbackRoom: string; + openDm: (peer: RrcDmPeer, hubHash: string | undefined, opts: { focus: boolean }) => void; +} + +/** + * Resolve peer + open DM when possible. Returns the room key for the message. + * When local identity is unavailable, returns `fallbackRoom` and does not openDm + * (caller should not mis-route outbound echo as a self-DM). + */ +export function applyRrcDirectMessageRoom(opts: ApplyRrcDirectMessageOpts): string { + const peer = resolveRrcDmPeerFromDirectMessage( + { + dst_hash: typeof opts.dst_hash === 'string' ? opts.dst_hash : null, + sender_hash: typeof opts.sender_hash === 'string' ? opts.sender_hash : null, + nickname: typeof opts.nickname === 'string' ? opts.nickname : null, + }, + opts.localIdentityHash, + ); + if (peer) { + // Do not steal focus from an active room — unread badge surfaces new DMs. + opts.openDm(peer, opts.hubDestHash, { focus: false }); + return rrcDmRoomKey(peer.identity_hash); + } + return opts.fallbackRoom; +} diff --git a/src/renderer/lib/rrcDmRoom.test.ts b/src/renderer/lib/rrcDmRoom.test.ts index de4b9a64d..7b1e49808 100644 --- a/src/renderer/lib/rrcDmRoom.test.ts +++ b/src/renderer/lib/rrcDmRoom.test.ts @@ -78,6 +78,28 @@ describe('resolveRrcDmPeerFromDirectMessage', () => { ), ).toBeNull(); }); + + it('defers when local identity is unavailable (outbound echo before init)', () => { + // Self-sent echo would otherwise open a DM on the sender (self) if we trusted sender_hash. + expect( + resolveRrcDmPeerFromDirectMessage( + { dst_hash: peerA, sender_hash: selfHash, nickname: 'Me' }, + null, + ), + ).toBeNull(); + expect( + resolveRrcDmPeerFromDirectMessage( + { dst_hash: peerA, sender_hash: selfHash, nickname: 'Me' }, + undefined, + ), + ).toBeNull(); + expect( + resolveRrcDmPeerFromDirectMessage( + { dst_hash: selfHash, sender_hash: peerA, nickname: 'Zeva' }, + null, + ), + ).toBeNull(); + }); }); describe('splitLegacyWhispersMessages', () => { diff --git a/src/renderer/lib/rrcDmRoom.ts b/src/renderer/lib/rrcDmRoom.ts index cbf5751f5..a0c211fd6 100644 --- a/src/renderer/lib/rrcDmRoom.ts +++ b/src/renderer/lib/rrcDmRoom.ts @@ -69,7 +69,11 @@ export function resolveRrcDmPeerFromDirectMessage( const local = localIdentityHash?.trim().toLowerCase() || null; const sender = msg.sender_hash?.trim().toLowerCase() || null; - if (sender && isRrcWhisperPeerHash(sender) && (!local || sender !== local)) { + // Without local identity we cannot tell inbound (peer→self) from outbound echo + // (self→peer). Defer until identity init completes so we never open a DM on self. + if (!local) return null; + + if (sender && isRrcWhisperPeerHash(sender) && sender !== local) { return { identity_hash: sender, nickname: msg.nickname?.trim() ? msg.nickname.trim() : null, @@ -77,10 +81,14 @@ export function resolveRrcDmPeerFromDirectMessage( } // Outbound (sender is self or missing): peer is dst; nick usually unknown on echo. - return { - identity_hash: dst, - nickname: null, - }; + if (!sender || sender === local) { + return { + identity_hash: dst, + nickname: null, + }; + } + + return null; } /** diff --git a/src/renderer/lib/rrcLegacyWhispersMigrate.test.ts b/src/renderer/lib/rrcLegacyWhispersMigrate.test.ts index 2bd0dd84c..6e572562c 100644 --- a/src/renderer/lib/rrcLegacyWhispersMigrate.test.ts +++ b/src/renderer/lib/rrcLegacyWhispersMigrate.test.ts @@ -31,15 +31,17 @@ describe('migrateLegacyWhispersForHub', () => { expect(window.electronAPI.db.listRrcMessages).not.toHaveBeenCalled(); }); - it('marks migrated and returns when list fails', async () => { + it('leaves migration unmarked when list fails so a later run can retry', async () => { vi.mocked(window.electronAPI.db.listRrcMessages).mockRejectedValue(new Error('db down')); await migrateLegacyWhispersForHub(hubA); expect(console.warn).toHaveBeenCalled(); expect(loadRrcOpenDms(hubA)).toEqual([]); + // Must remain retryable — do not mark migrated on read failure. vi.mocked(window.electronAPI.db.listRrcMessages).mockClear(); + vi.mocked(window.electronAPI.db.listRrcMessages).mockResolvedValue([]); await migrateLegacyWhispersForHub(hubA); - expect(window.electronAPI.db.listRrcMessages).not.toHaveBeenCalled(); + expect(window.electronAPI.db.listRrcMessages).toHaveBeenCalled(); }); it('marks migrated when there are no legacy rows', async () => { @@ -189,5 +191,11 @@ describe('migrateLegacyWhispersForHub', () => { .messages.get(key ?? '') ?.map((m) => m.body), ).toEqual(['one', 'two']); + + // Insert failure must leave migration unmarked for retry. + vi.mocked(window.electronAPI.db.listRrcMessages).mockClear(); + vi.mocked(window.electronAPI.db.insertRrcMessage).mockResolvedValue({ changes: 1 }); + await migrateLegacyWhispersForHub(hubA); + expect(window.electronAPI.db.listRrcMessages).toHaveBeenCalled(); }); }); diff --git a/src/renderer/lib/rrcLegacyWhispersMigrate.ts b/src/renderer/lib/rrcLegacyWhispersMigrate.ts index 58e4a5bd4..e323aa58e 100644 --- a/src/renderer/lib/rrcLegacyWhispersMigrate.ts +++ b/src/renderer/lib/rrcLegacyWhispersMigrate.ts @@ -26,7 +26,7 @@ function hasMigrated(hubHash: string): boolean { try { return localStorage.getItem(migratedKey(hubHash)) === '1'; } catch { - // catch-no-log-ok + // catch-no-log-ok private mode / blocked storage — treat as not migrated so we retry return false; } } @@ -35,7 +35,7 @@ function markMigrated(hubHash: string): void { try { localStorage.setItem(migratedKey(hubHash), '1'); } catch { - // catch-no-log-ok + // catch-no-log-ok private mode / quota — next session may re-run; INSERT OR IGNORE is safe } } @@ -52,13 +52,14 @@ export function resetRrcLegacyWhispersMigrateForTests(hubHash?: string): void { for (const k of keys) localStorage.removeItem(k); } } catch { - // catch-no-log-ok + // catch-no-log-ok test cleanup best-effort when Storage is unavailable } } /** * Load legacy `[whispers]` history, split into per-peer DMs, open those DMs, and * mark migration done for the hub. Safe to call repeatedly (no-ops after mark). + * Completion is persisted only after the read and all re-persist writes succeed. */ export async function migrateLegacyWhispersForHub(hubHash: string): Promise { const hub = normalizeRrcHubHash(hubHash); @@ -73,7 +74,7 @@ export async function migrateLegacyWhispersForHub(hubHash: string): Promise { }); it('attemptMeshcoreReconnect treats setup AbortError as superseded reconnect', () => { + // Behavioral mount of attemptMeshcoreReconnect with mocked transport + fake timers is + // impractical for this monolithic runtime (AGENTS.md source-contract guidance). Keep + // source contracts for the setup-abort → deferred restart + stuck-UI clear paths. const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); expect(reconnectBody).toMatch( /isMeshcoreSetupAbortError\(err\)[\s\S]*?reconnect aborted \(setup superseded\)/, diff --git a/src/renderer/runtime/useReticulumRuntime.rrc.test.ts b/src/renderer/runtime/useReticulumRuntime.rrc.test.ts index 51cb5e7ae..7cf0eee3b 100644 --- a/src/renderer/runtime/useReticulumRuntime.rrc.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.rrc.test.ts @@ -39,20 +39,13 @@ describe('useReticulumRuntime RRC event routing (regression)', () => { expect(SOURCE).toMatch(/addMessage\([\s\S]*?\{ hubDestHash \}/); }); - it('routes direct NOTICE into per-peer @hash DMs via openDm', () => { - expect(SOURCE).toMatch(/resolveRrcDmPeerFromDirectMessage/); - expect(SOURCE).toMatch(/session\.openDm\(peer, hubDestHash, \{ focus: false \}\)/); - expect(SOURCE).toMatch(/rrcDmRoomKey\(peer\.identity_hash\)/); + it('routes direct NOTICE into per-peer @hash DMs via applyRrcDirectMessageRoom', () => { + expect(SOURCE).toContain('applyRrcDirectMessageRoom'); + expect(SOURCE).toMatch(/applyRrcDirectMessageRoom\(\{[\s\S]*?openDm:/); expect(SOURCE).not.toMatch(/RRC_WHISPERS_ROOM/); expect(SOURCE).not.toMatch(/setLastWhisperPeer/); }); - it('does not steal focus when opening an inbound DM tab', () => { - expect(SOURCE).toMatch( - /Do not steal focus from an active room[\s\S]{0,200}openDm\(peer, hubDestHash, \{ focus: false \}\)/, - ); - }); - it('never treats synthetic [hub] / @dm names as JOIN targets from join-info NOTICE', () => { expect(SOURCE).toContain('Never treat synthetic `[hub]` / `@dm` names as hub JOIN targets'); expect(SOURCE).toMatch(/!topic\.room\.startsWith\('\['\)/); diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index f2f49fcd3..2331308f2 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -113,7 +113,7 @@ import { tryReserveRncpLxmfControlHandled, } from '@/renderer/lib/rncpLxmfControlSideEffectDedup'; import { consumeRncpReceiveDestSharePending } from '@/renderer/lib/rncpReceiveDestSharePending'; -import { resolveRrcDmPeerFromDirectMessage, rrcDmRoomKey } from '@/renderer/lib/rrcDmRoom'; +import { applyRrcDirectMessageRoom } from '@/renderer/lib/rrcDirectMessageRoute'; import { isRrcRoomMuted } from '@/renderer/lib/rrcMention'; import { shouldDropEmptyRrcInbound } from '@/renderer/lib/rrcMessageDisplay'; import { @@ -1057,21 +1057,17 @@ export function useReticulumRuntime(): ProtocolRuntime { const isDirect = Boolean(p.dst_hash); let room: string; if (isDirect) { - const peer = resolveRrcDmPeerFromDirectMessage( - { - dst_hash: typeof p.dst_hash === 'string' ? p.dst_hash : null, - sender_hash: typeof p.sender_hash === 'string' ? p.sender_hash : null, - nickname: typeof p.nickname === 'string' ? p.nickname : null, + room = applyRrcDirectMessageRoom({ + dst_hash: typeof p.dst_hash === 'string' ? p.dst_hash : null, + sender_hash: typeof p.sender_hash === 'string' ? p.sender_hash : null, + nickname: typeof p.nickname === 'string' ? p.nickname : null, + localIdentityHash: session.localIdentityHash, + hubDestHash, + fallbackRoom: view.activeRoom ?? RRC_HUB_STREAM_ROOM, + openDm: (peer, hub, openOpts) => { + session.openDm(peer, hub, openOpts); }, - session.localIdentityHash, - ); - if (peer) { - // Do not steal focus from an active room — unread badge surfaces new DMs. - session.openDm(peer, hubDestHash, { focus: false }); - room = rrcDmRoomKey(peer.identity_hash); - } else { - room = view.activeRoom ?? RRC_HUB_STREAM_ROOM; - } + }); } else { room = typeof p.room === 'string' && p.room.trim() diff --git a/src/renderer/stores/rrcSessionStore.test.ts b/src/renderer/stores/rrcSessionStore.test.ts index bbb380adf..ba9116ca7 100644 --- a/src/renderer/stores/rrcSessionStore.test.ts +++ b/src/renderer/stores/rrcSessionStore.test.ts @@ -4,7 +4,7 @@ import { migrateLegacyWhispersForHub, resetRrcLegacyWhispersMigrateForTests, } from '@/renderer/lib/rrcLegacyWhispersMigrate'; -import { clearRrcOpenDms, loadRrcOpenDms } from '@/renderer/lib/rrcOpenDms'; +import { clearRrcOpenDms, loadRrcOpenDms, saveRrcOpenDms } from '@/renderer/lib/rrcOpenDms'; import { useRrcSessionStore } from './rrcSessionStore'; @@ -92,6 +92,28 @@ describe('rrcSessionStore', () => { expect(useRrcSessionStore.getState().sessionsByHub.get(hubB)?.rooms.has(`@${bob}`)).toBe(true); }); + it('restore with persist:false keeps newest-first open-DM storage order', () => { + const hubA = '28c7c1a68c735693aa8e6b8193ed44b2'; + const alice = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const bob = 'cccccccccccccccccccccccccccccccc'; + const carol = 'dddddddddddddddddddddddddddddddd'; + // Newest-first as upsertRrcOpenDm would store after bob then alice then carol opens. + saveRrcOpenDms(hubA, [ + { identity_hash: carol, nickname: 'Carol' }, + { identity_hash: bob, nickname: 'Bob' }, + { identity_hash: alice, nickname: 'Alice' }, + ]); + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hubA, 'Hub A'); + for (const dm of loadRrcOpenDms(hubA)) { + store.openDm(dm, hubA, { focus: false, persist: false }); + } + expect(loadRrcOpenDms(hubA).map((d) => d.identity_hash)).toEqual([carol, bob, alice]); + expect(useRrcSessionStore.getState().rooms.has(`@${carol}`)).toBe(true); + expect(useRrcSessionStore.getState().rooms.has(`@${bob}`)).toBe(true); + expect(useRrcSessionStore.getState().rooms.has(`@${alice}`)).toBe(true); + }); + it('appends messages and bumps unread for inactive rooms', () => { const store = useRrcSessionStore.getState(); store.applyStatus('active', '28c7c1a68c735693aa8e6b8193ed44b2', 'Community'); diff --git a/src/renderer/stores/rrcSessionStore.ts b/src/renderer/stores/rrcSessionStore.ts index 4c27178f0..b274f20c4 100644 --- a/src/renderer/stores/rrcSessionStore.ts +++ b/src/renderer/stores/rrcSessionStore.ts @@ -298,9 +298,14 @@ interface RrcSessionStoreState { setModerationBanner: (message: string | null, hubHash?: string) => void; /** * Open a client-local per-peer DM (`@hash`) — no hub JOIN. - * Persists to localStorage so the DM survives restart until `closeDm`. + * Persists to localStorage so the DM survives restart until `closeDm` + * (unless `persist: false` when restoring already-saved tabs). */ - openDm: (peer: RrcDmPeer, hubHash?: string, opts?: { focus?: boolean }) => void; + openDm: ( + peer: RrcDmPeer, + hubHash?: string, + opts?: { focus?: boolean; persist?: boolean }, + ) => void; /** * Close a client-local DM: remove from JOINED + open-DM prefs. * Keeps SQLite / in-memory message history unless Clear history is used. @@ -536,7 +541,10 @@ export const useRrcSessionStore = create((set, get) => ({ set((s) => { const hub = hubHash !== undefined ? normHub(hubHash) : s.focusedHubHash; if (!hub) return {}; - upsertRrcOpenDm(hub, { identity_hash: hash, nickname: nick }); + // Restoring from loadRrcOpenDms must not rewrite storage (preserves newest-first order). + if (opts?.persist !== false) { + upsertRrcOpenDm(hub, { identity_hash: hash, nickname: nick }); + } return mutateHubSession(s, hub, (session) => { const rooms = new Map(session.rooms); const existing = rooms.get(room);