From f9eb46f2ab3d1a7f3974514a92d60777202da32f Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Fri, 20 Feb 2026 17:03:13 -0800 Subject: [PATCH] Remember last used channel when selected --- frontend/src/components/SettingsModal.tsx | 40 +++++++ frontend/src/components/settingsConstants.ts | 2 +- frontend/src/hooks/useConversationRouter.ts | 109 +++++++++++++++--- frontend/src/test/appStartupHash.test.tsx | 65 +++++++++++ frontend/src/test/settingsModal.test.tsx | 30 +++++ frontend/src/utils/lastViewedConversation.ts | 103 +++++++++++++++++ .../specs/reopen-last-conversation.spec.ts | 80 +++++++++++++ 7 files changed, 409 insertions(+), 20 deletions(-) create mode 100644 frontend/src/utils/lastViewedConversation.ts create mode 100644 tests/e2e/specs/reopen-last-conversation.spec.ts diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e25f707..792bdd4 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -19,6 +19,11 @@ import { Separator } from './ui/separator'; import { toast } from './ui/sonner'; import { api } from '../api'; import { formatTime } from '../utils/messageParser'; +import { + captureLastViewedConversationFromHash, + getReopenLastConversationEnabled, + setReopenLastConversationEnabled, +} from '../utils/lastViewedConversation'; // Radio presets for common configurations interface RadioPreset { @@ -141,6 +146,9 @@ export function SettingsModal(props: SettingsModalProps) { const [retentionDays, setRetentionDays] = useState('14'); const [cleaning, setCleaning] = useState(false); const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false); + const [reopenLastConversation, setReopenLastConversation] = useState( + getReopenLastConversationEnabled + ); // Advertisement interval state const [advertInterval, setAdvertInterval] = useState('0'); @@ -222,6 +230,12 @@ export function SettingsModal(props: SettingsModalProps) { } }, [open, pageMode, onRefreshAppSettings]); + useEffect(() => { + if (open || pageMode) { + setReopenLastConversation(getReopenLastConversationEnabled()); + } + }, [open, pageMode]); + useEffect(() => { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; @@ -529,6 +543,14 @@ export function SettingsModal(props: SettingsModalProps) { } }; + const handleToggleReopenLastConversation = (enabled: boolean) => { + setReopenLastConversation(enabled); + setReopenLastConversationEnabled(enabled); + if (enabled) { + captureLastViewedConversationFromHash(); + } + }; + const handleSaveBotSettings = async () => { setBusySection('bot'); setSectionError(null); @@ -1044,6 +1066,24 @@ export function SettingsModal(props: SettingsModalProps) {

+ + +
+ + +

+ This applies only to this device/browser. It does not sync to server settings. +

+
+ {getSectionError('database') && (
{getSectionError('database')}
)} diff --git a/frontend/src/components/settingsConstants.ts b/frontend/src/components/settingsConstants.ts index 22931b8..d28475f 100644 --- a/frontend/src/components/settingsConstants.ts +++ b/frontend/src/components/settingsConstants.ts @@ -19,7 +19,7 @@ export const SETTINGS_SECTION_LABELS: Record = { radio: '📻 Radio', identity: '🪪 Identity', connectivity: '📡 Connectivity', - database: '🗄️ Database', + database: '🗄️ Database & Interfacr', bot: '🤖 Bot', statistics: '📊 Statistics', }; diff --git a/frontend/src/hooks/useConversationRouter.ts b/frontend/src/hooks/useConversationRouter.ts index 0b67223..de60314 100644 --- a/frontend/src/hooks/useConversationRouter.ts +++ b/frontend/src/hooks/useConversationRouter.ts @@ -5,6 +5,11 @@ import { resolveChannelFromHashToken, resolveContactFromHashToken, } from '../utils/urlHash'; +import { + getLastViewedConversation, + getReopenLastConversationEnabled, + saveLastViewedConversation, +} from '../utils/lastViewedConversation'; import { getContactDisplayName } from '../utils/pubkey'; import type { Channel, Contact, Conversation } from '../types'; @@ -30,6 +35,16 @@ export function useConversationRouter({ const [activeConversation, setActiveConversation] = useState(null); const activeConversationRef = useRef(null); + const getPublicChannelConversation = useCallback((): Conversation | null => { + const publicChannel = channels.find((c) => c.name === 'Public'); + if (!publicChannel) return null; + return { + type: 'channel', + id: publicChannel.key, + name: publicChannel.name, + }; + }, [channels]); + // Phase 1: Set initial conversation from URL hash or default to Public channel // Only needs channels (fast path) - doesn't wait for contacts useEffect(() => { @@ -73,17 +88,49 @@ export function useConversationRouter({ // Contact hash — wait for phase 2 if (hashConv?.type === 'contact') return; + // No hash: optionally restore last-viewed conversation if enabled on this device. + if (!hashConv && getReopenLastConversationEnabled()) { + const lastViewed = getLastViewedConversation(); + if (lastViewed?.type === 'raw') { + setActiveConversation(lastViewed); + hasSetDefaultConversation.current = true; + return; + } + if (lastViewed?.type === 'map') { + setActiveConversation(lastViewed); + hasSetDefaultConversation.current = true; + return; + } + if (lastViewed?.type === 'visualizer') { + setActiveConversation(lastViewed); + hasSetDefaultConversation.current = true; + return; + } + if (lastViewed?.type === 'channel') { + const channel = + channels.find((c) => c.key.toLowerCase() === lastViewed.id.toLowerCase()) || + resolveChannelFromHashToken(lastViewed.id, channels); + if (channel) { + setActiveConversation({ + type: 'channel', + id: channel.key, + name: channel.name, + }); + hasSetDefaultConversation.current = true; + return; + } + } + // Last-viewed contact resolution waits for contacts in phase 2. + if (lastViewed?.type === 'contact') return; + } + // No hash or unresolvable — default to Public - const publicChannel = channels.find((c) => c.name === 'Public'); - if (publicChannel) { - setActiveConversation({ - type: 'channel', - id: publicChannel.key, - name: publicChannel.name, - }); + const publicConversation = getPublicChannelConversation(); + if (publicConversation) { + setActiveConversation(publicConversation); hasSetDefaultConversation.current = true; } - }, [channels, activeConversation]); + }, [channels, activeConversation, getPublicChannelConversation]); // Phase 2: Resolve contact hash (only if phase 1 didn't set a conversation) useEffect(() => { @@ -105,25 +152,49 @@ export function useConversationRouter({ } // Contact hash didn't match — fall back to Public if channels loaded. - if (channels.length > 0) { - const publicChannel = channels.find((c) => c.name === 'Public'); - if (publicChannel) { - setActiveConversation({ - type: 'channel', - id: publicChannel.key, - name: publicChannel.name, - }); - hasSetDefaultConversation.current = true; - } + const publicConversation = getPublicChannelConversation(); + if (publicConversation) { + setActiveConversation(publicConversation); + hasSetDefaultConversation.current = true; + } + return; + } + + // No hash: optionally restore a last-viewed contact once contacts are loaded. + if (!hashConv && getReopenLastConversationEnabled()) { + const lastViewed = getLastViewedConversation(); + if (lastViewed?.type !== 'contact') return; + if (!contactsLoaded) return; + + const contact = contacts.find( + (item) => item.public_key.toLowerCase() === lastViewed.id.toLowerCase() + ); + if (contact) { + setActiveConversation({ + type: 'contact', + id: contact.public_key, + name: getContactDisplayName(contact.name, contact.public_key), + }); + hasSetDefaultConversation.current = true; + return; + } + + const publicConversation = getPublicChannelConversation(); + if (publicConversation) { + setActiveConversation(publicConversation); + hasSetDefaultConversation.current = true; } } - }, [contacts, channels, activeConversation, contactsLoaded]); + }, [contacts, channels, activeConversation, contactsLoaded, getPublicChannelConversation]); // Keep ref in sync and update URL hash useEffect(() => { activeConversationRef.current = activeConversation; if (activeConversation) { updateUrlHash(activeConversation); + if (getReopenLastConversationEnabled()) { + saveLastViewedConversation(activeConversation); + } } }, [activeConversation]); diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index 8752e54..5cb1e93 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -137,6 +137,10 @@ vi.mock('../components/ui/sonner', () => ({ })); import { App } from '../App'; +import { + LAST_VIEWED_CONVERSATION_KEY, + REOPEN_LAST_CONVERSATION_KEY, +} from '../utils/lastViewedConversation'; const publicChannel = { key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72', @@ -149,6 +153,7 @@ const publicChannel = { describe('App startup hash resolution', () => { beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); window.location.hash = `#contact/${'a'.repeat(64)}/Alice`; mocks.api.getRadioConfig.mockResolvedValue({ @@ -178,6 +183,7 @@ describe('App startup hash resolution', () => { afterEach(() => { window.location.hash = ''; + localStorage.clear(); }); it('falls back to Public when contact hash is unresolvable and contacts are empty', async () => { @@ -189,4 +195,63 @@ describe('App startup hash resolution', () => { } }); }); + + it('restores last viewed channel when hash is empty and reopen preference is enabled', async () => { + const chatChannel = { + key: '11111111111111111111111111111111', + name: 'Ops', + is_hashtag: false, + on_radio: false, + last_read_at: null, + }; + + window.location.hash = ''; + localStorage.setItem(REOPEN_LAST_CONVERSATION_KEY, '1'); + localStorage.setItem( + LAST_VIEWED_CONVERSATION_KEY, + JSON.stringify({ + type: 'channel', + id: chatChannel.key, + name: chatChannel.name, + }) + ); + mocks.api.getChannels.mockResolvedValue([publicChannel, chatChannel]); + + render(); + + await waitFor(() => { + for (const node of screen.getAllByTestId('active-conversation')) { + expect(node).toHaveTextContent(`channel:${chatChannel.key}:${chatChannel.name}`); + } + }); + }); + + it('uses Public channel when hash is empty and reopen preference is disabled', async () => { + const chatChannel = { + key: '11111111111111111111111111111111', + name: 'Ops', + is_hashtag: false, + on_radio: false, + last_read_at: null, + }; + + window.location.hash = ''; + localStorage.setItem( + LAST_VIEWED_CONVERSATION_KEY, + JSON.stringify({ + type: 'channel', + id: chatChannel.key, + name: chatChannel.name, + }) + ); + mocks.api.getChannels.mockResolvedValue([publicChannel, chatChannel]); + + render(); + + await waitFor(() => { + for (const node of screen.getAllByTestId('active-conversation')) { + expect(node).toHaveTextContent(`channel:${publicChannel.key}:Public`); + } + }); + }); }); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 2815201..915e060 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -11,6 +11,10 @@ import type { StatisticsResponse, } from '../types'; import type { SettingsSection } from '../components/SettingsModal'; +import { + LAST_VIEWED_CONVERSATION_KEY, + REOPEN_LAST_CONVERSATION_KEY, +} from '../utils/lastViewedConversation'; const baseConfig: RadioConfig = { public_key: 'aa'.repeat(32), @@ -128,9 +132,16 @@ function openConnectivitySection() { fireEvent.click(connectivityToggle); } +function openDatabaseSection() { + const databaseToggle = screen.getByRole('button', { name: /Database/i }); + fireEvent.click(databaseToggle); +} + describe('SettingsModal', () => { afterEach(() => { vi.restoreAllMocks(); + localStorage.clear(); + window.location.hash = ''; }); it('refreshes app settings when opened', async () => { @@ -291,6 +302,25 @@ describe('SettingsModal', () => { expect(onClose).not.toHaveBeenCalled(); }); + it('stores and clears reopen-last-conversation preference locally', () => { + window.location.hash = '#raw'; + renderModal(); + openDatabaseSection(); + + const checkbox = screen.getByLabelText('Reopen to last viewed channel/conversation'); + expect(checkbox).not.toBeChecked(); + + fireEvent.click(checkbox); + + expect(localStorage.getItem(REOPEN_LAST_CONVERSATION_KEY)).toBe('1'); + expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toContain('"type":"raw"'); + + fireEvent.click(checkbox); + + expect(localStorage.getItem(REOPEN_LAST_CONVERSATION_KEY)).toBeNull(); + expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toBeNull(); + }); + it('renders statistics section with fetched data', async () => { const mockStats: StatisticsResponse = { busiest_channels_24h: [ diff --git a/frontend/src/utils/lastViewedConversation.ts b/frontend/src/utils/lastViewedConversation.ts new file mode 100644 index 0000000..1bd7967 --- /dev/null +++ b/frontend/src/utils/lastViewedConversation.ts @@ -0,0 +1,103 @@ +import type { Conversation } from '../types'; +import { parseHashConversation } from './urlHash'; + +export const REOPEN_LAST_CONVERSATION_KEY = 'remoteterm-reopen-last-conversation'; +export const LAST_VIEWED_CONVERSATION_KEY = 'remoteterm-last-viewed-conversation'; + +const SUPPORTED_TYPES: Conversation['type'][] = ['contact', 'channel', 'raw', 'map', 'visualizer']; + +function isSupportedType(value: unknown): value is Conversation['type'] { + return typeof value === 'string' && SUPPORTED_TYPES.includes(value as Conversation['type']); +} + +export function getReopenLastConversationEnabled(): boolean { + try { + return localStorage.getItem(REOPEN_LAST_CONVERSATION_KEY) === '1'; + } catch { + return false; + } +} + +export function setReopenLastConversationEnabled(enabled: boolean): void { + try { + if (enabled) { + localStorage.setItem(REOPEN_LAST_CONVERSATION_KEY, '1'); + return; + } + + localStorage.removeItem(REOPEN_LAST_CONVERSATION_KEY); + localStorage.removeItem(LAST_VIEWED_CONVERSATION_KEY); + } catch { + // localStorage may be unavailable + } +} + +export function saveLastViewedConversation(conversation: Conversation): void { + try { + localStorage.setItem(LAST_VIEWED_CONVERSATION_KEY, JSON.stringify(conversation)); + } catch { + // localStorage may be unavailable + } +} + +export function getLastViewedConversation(): Conversation | null { + try { + const raw = localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY); + if (!raw) return null; + + const parsed = JSON.parse(raw) as Partial; + if ( + !isSupportedType(parsed.type) || + typeof parsed.id !== 'string' || + typeof parsed.name !== 'string' + ) { + return null; + } + + if (parsed.type !== 'map') { + return { + type: parsed.type, + id: parsed.id, + name: parsed.name, + }; + } + + return { + type: 'map', + id: parsed.id, + name: parsed.name, + ...(typeof parsed.mapFocusKey === 'string' && { mapFocusKey: parsed.mapFocusKey }), + }; + } catch { + return null; + } +} + +export function captureLastViewedConversationFromHash(): void { + const hashConversation = parseHashConversation(); + if (!hashConversation) return; + + if (hashConversation.type === 'raw') { + saveLastViewedConversation({ type: 'raw', id: 'raw', name: 'Raw Packet Feed' }); + return; + } + if (hashConversation.type === 'map') { + saveLastViewedConversation({ + type: 'map', + id: 'map', + name: 'Node Map', + ...(hashConversation.mapFocusKey && { mapFocusKey: hashConversation.mapFocusKey }), + }); + return; + } + if (hashConversation.type === 'visualizer') { + saveLastViewedConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' }); + return; + } + + saveLastViewedConversation({ + type: hashConversation.type, + id: hashConversation.name, + name: hashConversation.label || hashConversation.name, + }); +} diff --git a/tests/e2e/specs/reopen-last-conversation.spec.ts b/tests/e2e/specs/reopen-last-conversation.spec.ts new file mode 100644 index 0000000..091bc03 --- /dev/null +++ b/tests/e2e/specs/reopen-last-conversation.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from '@playwright/test'; +import { createChannel, deleteChannel } from '../helpers/api'; + +const REOPEN_LAST_CONVERSATION_KEY = 'remoteterm-reopen-last-conversation'; +const LAST_VIEWED_CONVERSATION_KEY = 'remoteterm-last-viewed-conversation'; + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +test.describe('Reopen last conversation (device-local)', () => { + let channelName = ''; + let channelKey = ''; + + test.beforeAll(async () => { + channelName = `#e2ereopen${Date.now().toString().slice(-6)}`; + const channel = await createChannel(channelName); + channelKey = channel.key; + }); + + test.afterAll(async () => { + try { + await deleteChannel(channelKey); + } catch { + // Best-effort cleanup + } + }); + + test('reopens last viewed conversation on startup when enabled', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText('Connected')).toBeVisible(); + + await page.getByText(channelName, { exact: true }).first().click(); + await expect( + page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(channelName)}`, 'i')) + ).toBeVisible(); + + await page.getByRole('button', { name: 'Settings' }).click(); + await page.getByRole('button', { name: /Database & Interfacr/i }).click(); + await page.getByLabel('Reopen to last viewed channel/conversation').check(); + await page.getByRole('button', { name: 'Back to Chat' }).click(); + + // Fresh launch path without hash should restore the saved conversation. + await page.goto('/'); + await expect( + page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(channelName)}`, 'i')) + ).toBeVisible(); + }); + + test('clears local storage and falls back to default when disabled', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText('Connected')).toBeVisible(); + + await page.getByText(channelName, { exact: true }).first().click(); + await expect( + page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(channelName)}`, 'i')) + ).toBeVisible(); + + await page.getByRole('button', { name: 'Settings' }).click(); + await page.getByRole('button', { name: /Database & Interfacr/i }).click(); + + const reopenToggle = page.getByLabel('Reopen to last viewed channel/conversation'); + await reopenToggle.check(); + await reopenToggle.uncheck(); + + const localState = await page.evaluate( + ([enabledKey, lastViewedKey]) => ({ + enabled: localStorage.getItem(enabledKey), + lastViewed: localStorage.getItem(lastViewedKey), + }), + [REOPEN_LAST_CONVERSATION_KEY, LAST_VIEWED_CONVERSATION_KEY] + ); + expect(localState.enabled).toBeNull(); + expect(localState.lastViewed).toBeNull(); + + await page.getByRole('button', { name: 'Back to Chat' }).click(); + await page.goto('/'); + await expect(page.getByPlaceholder(/message\s+Public/i)).toBeVisible(); + }); +});