diff --git a/docs/reticulum.md b/docs/reticulum.md index f48c23fb9..855f1ddc2 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 delivery is a direct NOTICE with `K_DST` and no `K_ROOM` / room JOIN — this requires the hub to advertise **`CAP_DIRECT_NOTICE`** (`capabilities.direct_notice`). When the hub does not advertise that capability, `/msg` and plain replies in a DM tab show `rrc.directNoticeUnsupported` and do not send. 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/main/index.ipc-security.test.ts b/src/main/index.ipc-security.test.ts index c55aefc09..8cb96878c 100644 --- a/src/main/index.ipc-security.test.ts +++ b/src/main/index.ipc-security.test.ts @@ -117,7 +117,11 @@ describe('meshtastic:tcp-write byte validation (source contract)', () => { const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-connect'"); expect(handlerIdx).toBeGreaterThan(-1); const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 1200); - expect(handlerBody).toContain('meshtasticTcpSocket.destroy()'); + // Null the active ref before destroy so the superseded close does not emit + // meshtastic:tcp-disconnected against a healthy replacement (#792). + expect(handlerBody).toMatch( + /const prev = meshtasticTcpSocket;\s*meshtasticTcpSocket = null;\s*prev\.destroy\(\)/, + ); }); it('emits meshtastic:tcp-disconnected only for the active socket (PR #792)', () => { @@ -138,6 +142,15 @@ describe('meshtastic:tcp-write byte validation (source contract)', () => { expect(guardIdx).toBeGreaterThan(-1); expect(emitIdx).toBeGreaterThan(guardIdx); }); + + it('nulls meshtasticTcpSocket before destroy on disconnect (PR #792)', () => { + const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-disconnect'"); + expect(handlerIdx).toBeGreaterThan(-1); + const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 400); + expect(handlerBody).toMatch( + /const prev = meshtasticTcpSocket;\s*meshtasticTcpSocket = null;\s*prev\.destroy\(\)/, + ); + }); }); // ─── meshcore:tcp-write byte element validation ────────────────────── @@ -339,7 +352,7 @@ describe('meshcore:tcp-connect hostname validation (source contract)', () => { it('normalizes bracketed IPv6 before net.Socket.connect', () => { const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-connect'"); expect(handlerIdx).toBeGreaterThan(-1); - const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 800); + const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 1400); expect(handlerBody).toContain('formatHostForSocket('); }); @@ -358,6 +371,22 @@ describe('meshcore:tcp-connect hostname validation (source contract)', () => { expect(guardIdx).toBeGreaterThan(-1); expect(emitIdx).toBeGreaterThan(guardIdx); }); + + it('nulls meshcoreTcpSocket before destroy on connect-replace and disconnect (PR #792)', () => { + const connectIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-connect'"); + expect(connectIdx).toBeGreaterThan(-1); + const connectBody = INDEX_SOURCE.slice(connectIdx, connectIdx + 1200); + expect(connectBody).toMatch( + /const prev = meshcoreTcpSocket;\s*meshcoreTcpSocket = null;\s*prev\.destroy\(\)/, + ); + + const disconnectIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-disconnect'"); + expect(disconnectIdx).toBeGreaterThan(-1); + const disconnectBody = INDEX_SOURCE.slice(disconnectIdx, disconnectIdx + 400); + expect(disconnectBody).toMatch( + /const prev = meshcoreTcpSocket;\s*meshcoreTcpSocket = null;\s*prev\.destroy\(\)/, + ); + }); }); // ─── meshtastic:tcp-connect hostname validation ────────────────────── @@ -373,7 +402,7 @@ describe('meshtastic:tcp-connect hostname validation (source contract)', () => { it('normalizes bracketed IPv6 before net.Socket.connect', () => { const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-connect'"); expect(handlerIdx).toBeGreaterThan(-1); - const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 800); + const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 1400); expect(handlerBody).toContain('formatHostForSocket('); }); diff --git a/src/main/index.ts b/src/main/index.ts index bce33a6c6..2fac05e68 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6093,8 +6093,11 @@ ipcMain.handle('meshcore:tcp-connect', (event, host: string, port: number) => { return; } if (meshcoreTcpSocket) { - meshcoreTcpSocket.destroy(); + // Null before destroy so the superseded socket's 'close' does not emit + // meshcore:tcp-disconnected (renderer reconnect is driven by that event — #792). + const prev = meshcoreTcpSocket; meshcoreTcpSocket = null; + prev.destroy(); } const socketHost = formatHostForSocket(host); const socket = new net.Socket(); @@ -6102,8 +6105,8 @@ ipcMain.handle('meshcore:tcp-connect', (event, host: string, port: number) => { const connectTimeout = setTimeout(() => { if (settled) return; settled = true; - socket.destroy(); if (meshcoreTcpSocket === socket) meshcoreTcpSocket = null; + socket.destroy(); reject(new Error('meshcore:tcp-connect: connection timeout')); }, MESHCORE_TCP_CONNECT_TIMEOUT_MS); socket.connect(p, socketHost, () => { @@ -6179,8 +6182,10 @@ ipcMain.handle('meshcore:tcp-disconnect', (event) => { assertIpcSender(event, 'meshcore:tcp-disconnect'); if (meshcoreTcpSocket) { console.debug('[IPC] meshcore:tcp-disconnect'); - meshcoreTcpSocket.destroy(); + // Null before destroy so this teardown close is not reported as a live link drop. + const prev = meshcoreTcpSocket; meshcoreTcpSocket = null; + prev.destroy(); } }); @@ -6206,8 +6211,11 @@ ipcMain.handle('meshtastic:tcp-connect', (event, host: string, port: number) => return; } if (meshtasticTcpSocket) { - meshtasticTcpSocket.destroy(); + // Null before destroy so the superseded socket's 'close' does not emit + // meshtastic:tcp-disconnected (renderer reconnect is driven by that event — #792). + const prev = meshtasticTcpSocket; meshtasticTcpSocket = null; + prev.destroy(); } const socketHost = formatHostForSocket(host); const socket = new net.Socket(); @@ -6215,8 +6223,8 @@ ipcMain.handle('meshtastic:tcp-connect', (event, host: string, port: number) => const connectTimeout = setTimeout(() => { if (settled) return; settled = true; - socket.destroy(); if (meshtasticTcpSocket === socket) meshtasticTcpSocket = null; + socket.destroy(); reject(new Error('meshtastic:tcp-connect: connection timeout')); }, MESHTASTIC_TCP_CONNECT_TIMEOUT_MS); socket.connect(p, socketHost, () => { @@ -6292,8 +6300,10 @@ ipcMain.handle('meshtastic:tcp-disconnect', (event) => { assertIpcSender(event, 'meshtastic:tcp-disconnect'); if (meshtasticTcpSocket) { console.debug('[IPC] meshtastic:tcp-disconnect'); - meshtasticTcpSocket.destroy(); + // Null before destroy so this teardown close is not reported as a live link drop. + const prev = meshtasticTcpSocket; meshtasticTcpSocket = null; + prev.destroy(); } }); diff --git a/src/renderer/App.peer-detail-error-boundary.test.ts b/src/renderer/App.peer-detail-error-boundary.test.ts new file mode 100644 index 000000000..647679d39 --- /dev/null +++ b/src/renderer/App.peer-detail-error-boundary.test.ts @@ -0,0 +1,20 @@ +/** + * Source contract: Reticulum peer-detail modal must be isolated so a React #185 / + * render failure cannot take down the App shell; resetKeys recover on peer switch. + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const TEST_DIR = import.meta.dirname ?? __dirname; +const SOURCE = readFileSync(join(TEST_DIR, 'App.tsx'), 'utf-8'); + +describe('App ReticulumPeerDetailModal ErrorBoundary (regression)', () => { + it('wraps ReticulumPeerDetailModal in ReticulumPeerDetailErrorBoundary with Suspense fallback', () => { + expect(SOURCE).toContain('ReticulumPeerDetailErrorBoundary'); + expect(SOURCE).toMatch( + /hasReticulumPeerDetailModal && selectedPeerHash !== null && \(\s*\}[\s\S]*?ReticulumPeerDetailModal/, + ); + }); +}); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 12a0c0048..e269e3385 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -86,6 +86,7 @@ import { ProtocolSwitcher } from './components/ProtocolSwitcher'; import { RncpEnableRequestModal } from './components/remote/RncpEnableRequestModal'; import RemoteAdminErrorNotifier from './components/RemoteAdminErrorNotifier'; import { ReticulumVoiceOverlay } from './components/reticulum/ReticulumVoiceOverlay'; +import { ReticulumPeerDetailErrorBoundary } from './components/ReticulumPeerDetailErrorBoundary'; import { ReticulumStackAutostartCoordinator } from './components/ReticulumStackAutostartCoordinator'; import Sidebar from './components/Sidebar'; import { LinkIcon } from './components/SignalBars'; @@ -4575,7 +4576,13 @@ function AppContent() { )} {capabilities.hasReticulumPeerDetailModal && selectedPeerHash !== null && ( - }> + { + setSelectedPeerHash(null); + }} + suspenseFallback={} + > { @@ -4583,7 +4590,7 @@ function AppContent() { }} onSendMessage={handleMessageNode} /> - + )} ); diff --git a/src/renderer/components/ErrorBoundary.tsx b/src/renderer/components/ErrorBoundary.tsx index f29749f80..1c0ab39d0 100644 --- a/src/renderer/components/ErrorBoundary.tsx +++ b/src/renderer/components/ErrorBoundary.tsx @@ -4,8 +4,19 @@ import { Component } from 'react'; import { errLikeToLogString } from '../lib/errLikeToLogString'; import i18n from '../lib/i18n'; +export interface ErrorBoundaryFallbackProps { + error: Error | null; + resetError: () => void; +} + interface Props { children: ReactNode; + /** When any value changes after an error, clear the error state (re-mount children). */ + resetKeys?: readonly unknown[]; + /** Optional custom fallback; default is the centered Try again UI. */ + fallback?: (props: ErrorBoundaryFallbackProps) => ReactNode; + /** Called when the default Try again button (or fallback `resetError`) recovers. */ + onReset?: () => void; } interface State { @@ -13,6 +24,19 @@ interface State { error: Error | null; } +function resetKeysChanged( + prev: readonly unknown[] | undefined, + next: readonly unknown[] | undefined, +): boolean { + if (prev === next) return false; + if (!prev || !next) return prev !== next; + if (prev.length !== next.length) return true; + for (let i = 0; i < prev.length; i++) { + if (!Object.is(prev[i], next[i])) return true; + } + return false; +} + export default class ErrorBoundary extends Component { constructor(props: Props) { super(props); @@ -32,8 +56,25 @@ export default class ErrorBoundary extends Component { ); } + componentDidUpdate(prevProps: Props): void { + if (this.state.hasError && resetKeysChanged(prevProps.resetKeys, this.props.resetKeys)) { + this.resetError(); + } + } + + private resetError = (): void => { + this.setState({ hasError: false, error: null }); + this.props.onReset?.(); + }; + render() { if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback({ + error: this.state.error, + resetError: this.resetError, + }); + } return (
{i18n.t('errorBoundary.title')}
@@ -47,9 +88,7 @@ export default class ErrorBoundary extends Component {
+ {}} + 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/ReticulumPeerDetailModal.test.tsx b/src/renderer/components/ReticulumPeerDetailModal.test.tsx index 18b768909..49ff24718 100644 --- a/src/renderer/components/ReticulumPeerDetailModal.test.tsx +++ b/src/renderer/components/ReticulumPeerDetailModal.test.tsx @@ -426,3 +426,133 @@ describe('ReticulumPeerDetailModal — Network route hydrate', () => { }); }); }); + +/** Contact/history + live path-table route mismatch — bare getPeer selectors throw React #185. */ +function seedContactLiveRouteMismatch(hash: string): void { + useReticulumPeerStore.setState({ + peers: new Map([ + [ + hash, + { + destination_hash: hash, + hops: 2, + interface: 'RMAP World', + path_hash: 'bb'.repeat(16), + via_hash: 'bb'.repeat(16), + last_seen: 1_700_000_000, + }, + ], + ]), + contacts: new Map([ + [ + hash, + { + destination_hash: hash, + display_name: 'Saved Contact', + last_heard: 100, + is_contact: true, + hops: null, + interface: null, + }, + ], + ]), + history: new Map(), + peerAppearanceByHash: new Map(), + lastRefreshAt: null, + }); +} + +function seedHistoryLiveRouteMismatch(hash: string): void { + useReticulumPeerStore.setState({ + peers: new Map([ + [ + hash, + { + destination_hash: hash, + display_name: 'History Peer', + hops: 3, + interface: 'TCP Hub', + path_hash: 'cc'.repeat(16), + via_hash: 'cc'.repeat(16), + last_seen: 1_700_000_100, + }, + ], + ]), + contacts: new Map(), + history: new Map([ + [ + hash, + { + destination_hash: hash, + display_name: 'History Peer', + last_heard: 200, + is_contact: false, + hops: null, + interface: null, + }, + ], + ]), + peerAppearanceByHash: new Map(), + lastRefreshAt: null, + }); +} + +describe('ReticulumPeerDetailModal — getPeer selector stability (React #185)', () => { + beforeEach(() => { + addToast.mockClear(); + refreshReticulumPeerRouteFromPathsMock.mockClear(); + refreshReticulumPeerRouteFromPathsMock.mockResolvedValue({ ok: false, paths: [] }); + refreshReticulumPeersFromSidecarMock.mockClear(); + refreshReticulumPeersFromSidecarMock.mockResolvedValue(undefined); + requestReticulumPeerPathMock.mockReset(); + probeReticulumPeerMock.mockReset(); + vi.mocked(window.electronAPI.db.getReticulumIdentityActivity).mockResolvedValue([]); + vi.mocked(window.electronAPI.db.getReticulumDestinations).mockResolvedValue([]); + vi.mocked(window.electronAPI.db.upsertReticulumDestination).mockResolvedValue(undefined); + }); + + it('mounts when contact and live path-table route fields differ', async () => { + seedContactLiveRouteMismatch(PEER_HASH); + expect(useReticulumPeerStore.getState().getPeer(PEER_HASH)).not.toBe( + useReticulumPeerStore.getState().getPeer(PEER_HASH), + ); + + render( + , + ); + + expect( + await screen.findByRole('button', { name: 'peerDetailModal.copyHash' }), + ).toBeInTheDocument(); + expect(screen.getByText('Saved Contact')).toBeInTheDocument(); + }); + + it('Save as contact keeps the modal mounted when refresh leaves a route mismatch', async () => { + const user = userEvent.setup(); + seedHistoryLiveRouteMismatch(PEER_HASH); + + refreshReticulumPeersFromSidecarMock.mockImplementation(() => { + seedContactLiveRouteMismatch(PEER_HASH); + return Promise.resolve(undefined); + }); + + render( + , + ); + + expect(screen.getByRole('button', { name: 'peerDetailModal.saveContact' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'peerDetailModal.saveContact' })); + + await waitFor(() => { + expect(refreshReticulumPeersFromSidecarMock).toHaveBeenCalled(); + }); + + // After refresh the peer is a contact — Save is gone, but the modal must still be up. + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'peerDetailModal.saveContact' })).toBeNull(); + }); + expect(screen.getByRole('button', { name: 'peerDetailModal.copyHash' })).toBeInTheDocument(); + expect(screen.getByText('Saved Contact')).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/ReticulumPeerDetailModal.tsx b/src/renderer/components/ReticulumPeerDetailModal.tsx index 21710dd1a..fa91da019 100644 --- a/src/renderer/components/ReticulumPeerDetailModal.tsx +++ b/src/renderer/components/ReticulumPeerDetailModal.tsx @@ -2,6 +2,7 @@ import { Copy, MessageCircle, Star, X } from 'lucide-react-motion'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useReticulumPeer } from '@/renderer/hooks/useReticulumPeer'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { formatRelativeOrIsoDate } from '@/renderer/lib/formatRelativeOrIsoDate'; import { getIdentityIdForProtocol } from '@/renderer/lib/identityByProtocol'; @@ -61,7 +62,7 @@ export default function ReticulumPeerDetailModal({ const { t } = useTranslation(); const { addToast } = useToast(); const dialogRef = useRef(null); - const peer = useReticulumPeerStore((s) => s.getPeer(peerHash)); + const peer = useReticulumPeer(peerHash); const isContact = useReticulumPeerStore((s) => s.isContact(peerHash)); const toggleFavorite = useReticulumPeerStore((s) => s.toggleFavorite); const setCustomDisplayName = useReticulumPeerStore((s) => s.setCustomDisplayName); diff --git a/src/renderer/components/RrcPanel.test.tsx b/src/renderer/components/RrcPanel.test.tsx index 7ea42099e..e3e5d0cef 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, loadRrcOpenDms, upsertRrcOpenDm } 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,105 @@ 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(); + expect(loadRrcOpenDms(hubA)).toEqual([]); + const key = useRrcSessionStore.getState().roomMessageKey(`@${peerHash}`, hubA); + expect(useRrcSessionStore.getState().messages.get(key ?? '')?.[0]?.body).toBe('saved'); + }); + + it('restores open DMs from localStorage when the hub becomes active', async () => { + const peerHash = 'dddddddddddddddddddddddddddddddd'; + upsertRrcOpenDm(hubA, { identity_hash: peerHash, nickname: 'Alice' }); + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hubA, 'Hub A'); + store.setCapabilities({ direct_notice: true }); + store.roomJoined('#general'); + + render(); + + await waitFor(() => { + expect(useRrcSessionStore.getState().rooms.has(`@${peerHash}`)).toBe(true); + }); + expect(screen.getByRole('button', { name: 'Open room Alice' })).toBeInTheDocument(); + // Restore must not steal focus from the active channel. + expect(useRrcSessionStore.getState().activeRoom).toBe('#general'); + }); + + it('does not hub-JOIN an @hash DM room name', async () => { + const user = userEvent.setup(); + const peerHash = 'dddddddddddddddddddddddddddddddd'; + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hubA, 'Hub A'); + store.setCapabilities({ direct_notice: true }); + store.openDm({ identity_hash: peerHash, nickname: 'Alice' }, hubA, { focus: false }); + store.setActiveRoom('#general'); + vi.mocked(window.electronAPI.reticulum.rrc.join).mockClear(); + + render(); + + const composer = screen.getByRole('textbox', { name: /Message or \/command/i }); + await user.clear(composer); + await user.type(composer, `/join @${peerHash}`); + await user.click(screen.getByRole('button', { name: 'Send' })); + + await waitFor(() => { + expect(useRrcSessionStore.getState().activeRoom).toBe(`@${peerHash}`); + }); + 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 dd1079251..369e1773b 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, persist: false }); + void hydrateRrcRoomMessages(hub, rrcDmRoomKey(dm.identity_hash)); + } + void migrateLegacyWhispersForHub(hub); + }, [hubDestHash, status, openDm]); + const recentRooms = useMemo(() => { if (!hubDestHash) return []; void recentRoomsEpoch; @@ -215,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', }); @@ -238,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); @@ -257,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); @@ -293,11 +309,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 +349,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 +372,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 +416,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 +540,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 +583,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 +596,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 +616,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( @@ -632,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 @@ -663,7 +706,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 +749,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 +790,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 +806,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 +816,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 +874,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 +993,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: setPrefsEpoch((n) => n + 1); }} autoJoin={autoJoinRooms} - whisperRoomLabel={whisperRoomLabel} + dmRoomLabels={dmRoomLabels} /> )} @@ -1012,7 +1046,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: )} - {activeRoom && !activeRoom.startsWith('[') && ( + {activeRoom && (!activeRoom.startsWith('[') || isRrcDmRoom(activeRoom)) && (