From b77660196b2c8d2eeeda92d423c27f2aac24813f Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 13 May 2026 16:52:32 -0700 Subject: [PATCH] Persist login status for room servers. Closes #244. --- frontend/src/components/RoomServerPanel.tsx | 115 ++++++++++++++---- .../settings/SettingsFanoutSection.tsx | 7 +- frontend/src/test/roomServerPanel.test.tsx | 3 +- 3 files changed, 97 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/RoomServerPanel.tsx b/frontend/src/components/RoomServerPanel.tsx index b5a0e40..859005b 100644 --- a/frontend/src/components/RoomServerPanel.tsx +++ b/frontend/src/components/RoomServerPanel.tsx @@ -61,38 +61,107 @@ function createInitialPaneStates(): RoomPaneStates { }; } +function createInitialPaneData(): RoomPaneData { + return { status: null, acl: null, lppTelemetry: null }; +} + +// --------------------------------------------------------------------------- +// In-memory LRU cache so room login state survives conversation switches +// --------------------------------------------------------------------------- + +interface RoomCacheEntry { + authenticated: boolean; + loginError: string | null; + lastLoginAttempt: ServerLoginAttemptState | null; + paneData: RoomPaneData; + paneStates: RoomPaneStates; + consoleHistory: ConsoleEntry[]; +} + +const MAX_CACHED_ROOMS = 8; +const roomCache = new Map(); + +function getCachedRoom(publicKey: string): RoomCacheEntry | null { + const cached = roomCache.get(publicKey); + if (!cached) return null; + // Touch for LRU + roomCache.delete(publicKey); + roomCache.set(publicKey, cached); + return { + ...cached, + paneData: { ...cached.paneData }, + paneStates: { + status: { ...cached.paneStates.status, loading: false }, + acl: { ...cached.paneStates.acl, loading: false }, + lppTelemetry: { ...cached.paneStates.lppTelemetry, loading: false }, + }, + consoleHistory: cached.consoleHistory.map((e) => ({ ...e })), + }; +} + +function setCachedRoom(publicKey: string, entry: RoomCacheEntry) { + roomCache.delete(publicKey); + roomCache.set(publicKey, { + ...entry, + paneData: { ...entry.paneData }, + paneStates: { + status: { ...entry.paneStates.status, loading: false }, + acl: { ...entry.paneStates.acl, loading: false }, + lppTelemetry: { ...entry.paneStates.lppTelemetry, loading: false }, + }, + consoleHistory: entry.consoleHistory.map((e) => ({ ...e })), + }); + if (roomCache.size > MAX_CACHED_ROOMS) { + const lruKey = roomCache.keys().next().value as string | undefined; + if (lruKey) roomCache.delete(lruKey); + } +} + +export function resetRoomCacheForTests() { + roomCache.clear(); +} + export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPanelProps) { const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } = useRememberedServerPassword('room', contact.public_key); + + const cached = useMemo(() => getCachedRoom(contact.public_key), [contact.public_key]); + const [loginLoading, setLoginLoading] = useState(false); - const [loginError, setLoginError] = useState(null); - const [authenticated, setAuthenticated] = useState(false); - const [lastLoginAttempt, setLastLoginAttempt] = useState(null); + const [loginError, setLoginError] = useState(cached?.loginError ?? null); + const [authenticated, setAuthenticated] = useState(cached?.authenticated ?? false); + const [lastLoginAttempt, setLastLoginAttempt] = useState( + cached?.lastLoginAttempt ?? null + ); const [advancedOpen, setAdvancedOpen] = useState(false); - const [paneData, setPaneData] = useState({ - status: null, - acl: null, - lppTelemetry: null, - }); - const [paneStates, setPaneStates] = useState(createInitialPaneStates); - const [consoleHistory, setConsoleHistory] = useState([]); + const [paneData, setPaneData] = useState(cached?.paneData ?? createInitialPaneData); + const [paneStates, setPaneStates] = useState( + cached?.paneStates ?? createInitialPaneStates + ); + const [consoleHistory, setConsoleHistory] = useState( + cached?.consoleHistory ?? [] + ); const [consoleLoading, setConsoleLoading] = useState(false); + // Persist to cache on every state change useEffect(() => { - setLoginLoading(false); - setLoginError(null); - setAuthenticated(false); - setLastLoginAttempt(null); - setAdvancedOpen(false); - setPaneData({ - status: null, - acl: null, - lppTelemetry: null, + setCachedRoom(contact.public_key, { + authenticated, + loginError, + lastLoginAttempt, + paneData, + paneStates, + consoleHistory, }); - setPaneStates(createInitialPaneStates()); - setConsoleHistory([]); - setConsoleLoading(false); - }, [contact.public_key]); + }, [ + contact.public_key, + authenticated, + loginError, + lastLoginAttempt, + paneData, + paneStates, + consoleHistory, + ]); useEffect(() => { onAuthenticatedChange?.(authenticated); diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 78e89a3..4141dd7 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -1545,6 +1545,9 @@ function MqttCommunityConfigEditor({ +

+ LetsMesh uses token auth. MeshRank uses none. +

@@ -1566,10 +1569,6 @@ function MqttCommunityConfigEditor({ )} -

- LetsMesh uses token auth. MeshRank uses none. -

- {authMode === 'token' && (
diff --git a/frontend/src/test/roomServerPanel.test.tsx b/frontend/src/test/roomServerPanel.test.tsx index ab3aaad..6d688ab 100644 --- a/frontend/src/test/roomServerPanel.test.tsx +++ b/frontend/src/test/roomServerPanel.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; -import { RoomServerPanel } from '../components/RoomServerPanel'; +import { RoomServerPanel, resetRoomCacheForTests } from '../components/RoomServerPanel'; import type { Contact } from '../types'; vi.mock('../api', () => ({ @@ -50,6 +50,7 @@ describe('RoomServerPanel', () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); + resetRoomCacheForTests(); }); it('keeps room controls available when login is not confirmed', async () => {