From 76f3a59d831bc1788ed4314e8694f1865938d50a Mon Sep 17 00:00:00 2001 From: Martin Fournier Date: Mon, 11 May 2026 01:18:53 -0400 Subject: [PATCH 1/4] Hook browser back/forward to nav state Push history on user-initiated navigation so 'Back' moves between views instead of leaving the app. - urlHash: add pushUrlHash/pushSettingsHash (pushState variants) - useConversationRouter: pushState on nav; popstate resolves hash to conversation without re-pushing; switch over if-chain - useAppShell: pushState on settings open; popstate syncs visibility; close via history.back() when we own the entry - Tests: popstate coverage in appStartupHash + useAppShell --- frontend/src/hooks/useAppShell.ts | 43 ++++++++++-- frontend/src/hooks/useConversationRouter.ts | 78 ++++++++++++++++++++- frontend/src/test/appFavorites.test.tsx | 2 + frontend/src/test/appStartupHash.test.tsx | 78 +++++++++++++++++++++ frontend/src/test/useAppShell.test.ts | 36 +++++++++- frontend/src/utils/urlHash.ts | 16 +++++ 6 files changed, 246 insertions(+), 7 deletions(-) diff --git a/frontend/src/hooks/useAppShell.ts b/frontend/src/hooks/useAppShell.ts index 5ec871e..c56bf63 100644 --- a/frontend/src/hooks/useAppShell.ts +++ b/frontend/src/hooks/useAppShell.ts @@ -3,7 +3,7 @@ import { startTransition, useCallback, useEffect, useRef, useState } from 'react import { getLocalLabel, type LocalLabel } from '../utils/localLabel'; import { getSavedDistanceUnit, type DistanceUnit } from '../utils/distanceUnits'; import type { SettingsSection } from '../components/settings/settingsConstants'; -import { parseHashSettingsSection, updateSettingsHash } from '../utils/urlHash'; +import { parseHashSettingsSection, updateSettingsHash, pushSettingsHash } from '../utils/urlHash'; interface UseAppShellResult { showNewMessage: boolean; @@ -39,19 +39,31 @@ export function useAppShell(): UseAppShellResult { const [localLabel, setLocalLabel] = useState(getLocalLabel); const [distanceUnit, setDistanceUnit] = useState(getSavedDistanceUnit); const previousHashRef = useRef(''); + const isOpeningSettingsRef = useRef(false); + const pushedSettingsEntryRef = useRef(false); useEffect(() => { if (showSettings) { - updateSettingsHash(settingsSection); + if (isOpeningSettingsRef.current) { + pushSettingsHash(settingsSection); + isOpeningSettingsRef.current = false; + } else { + updateSettingsHash(settingsSection); + } } }, [settingsSection, showSettings]); const handleCloseSettingsView = useCallback(() => { - if (typeof window !== 'undefined' && parseHashSettingsSection() !== null) { - window.history.replaceState(null, '', previousHashRef.current || window.location.pathname); - } startTransition(() => setShowSettings(false)); setSidebarOpen(false); + if (typeof window !== 'undefined') { + if (pushedSettingsEntryRef.current) { + pushedSettingsEntryRef.current = false; + window.history.back(); + } else if (parseHashSettingsSection() !== null) { + window.history.replaceState(null, '', previousHashRef.current || window.location.pathname); + } + } }, []); const handleToggleSettingsView = useCallback(() => { @@ -64,12 +76,33 @@ export function useAppShell(): UseAppShellResult { previousHashRef.current = parseHashSettingsSection() === null ? window.location.hash : previousHashRef.current; } + isOpeningSettingsRef.current = true; + pushedSettingsEntryRef.current = true; startTransition(() => { setShowSettings(true); }); setSidebarOpen(false); }, [handleCloseSettingsView, showSettings]); + // Respond to browser back/forward navigating into or out of settings + useEffect(() => { + const handlePopstate = () => { + const section = parseHashSettingsSection(); + if (section !== null) { + pushedSettingsEntryRef.current = true; + startTransition(() => { + setShowSettings(true); + setSettingsSection(section); + }); + } else { + startTransition(() => setShowSettings(false)); + } + }; + + window.addEventListener('popstate', handlePopstate); + return () => window.removeEventListener('popstate', handlePopstate); + }, []); + const handleOpenNewMessage = useCallback(() => { setShowNewMessage(true); setSidebarOpen(false); diff --git a/frontend/src/hooks/useConversationRouter.ts b/frontend/src/hooks/useConversationRouter.ts index d0869a4..5b145cb 100644 --- a/frontend/src/hooks/useConversationRouter.ts +++ b/frontend/src/hooks/useConversationRouter.ts @@ -3,6 +3,7 @@ import { parseHashConversation, parseHashSettingsSection, updateUrlHash, + pushUrlHash, resolveChannelFromHashToken, resolveContactFromHashToken, } from '../utils/urlHash'; @@ -16,6 +17,43 @@ import { getContactDisplayName } from '../utils/pubkey'; import { toast } from '../components/ui/sonner'; import type { Channel, Contact, Conversation } from '../types'; +function resolveConversationFromHash( + channels: Channel[], + contacts: Contact[] +): Conversation | null { + const hashConv = parseHashConversation(); + if (!hashConv) return null; + + switch (hashConv.type) { + case 'raw': + return { type: 'raw', id: 'raw', name: 'Raw Packet Feed' }; + case 'map': + return { type: 'map', id: 'map', name: 'Node Map', mapFocusKey: hashConv.mapFocusKey }; + case 'visualizer': + return { type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' }; + case 'search': + return { type: 'search', id: 'search', name: 'Message Search' }; + case 'trace': + return { type: 'trace', id: 'trace', name: 'Trace' }; + case 'channel': { + const channel = resolveChannelFromHashToken(hashConv.name, channels); + return channel ? { type: 'channel', id: channel.key, name: channel.name } : null; + } + case 'contact': { + const contact = resolveContactFromHashToken(hashConv.name, contacts); + return contact + ? { + type: 'contact', + id: contact.public_key, + name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert), + } + : null; + } + default: + return null; + } +} + interface UseConversationRouterArgs { channels: Channel[]; contacts: Contact[]; @@ -42,9 +80,21 @@ export function useConversationRouter({ ? window.location.hash.length > 0 && parseHashSettingsSection() === null : false ); + const shouldPushHistoryRef = useRef(false); + const isHandlingPopstateRef = useRef(false); + const channelsRef = useRef(channels); + const contactsRef = useRef(contacts); + + useEffect(() => { + channelsRef.current = channels; + }, [channels]); + useEffect(() => { + contactsRef.current = contacts; + }, [contacts]); const setActiveConversation = useCallback((conv: Conversation | null) => { hashSyncEnabledRef.current = true; + shouldPushHistoryRef.current = true; setActiveConversationState(conv); }, []); @@ -232,7 +282,15 @@ export function useConversationRouter({ activeConversationRef.current = activeConversation; if (activeConversation) { if (hashSyncEnabledRef.current && !suspendHashSync) { - updateUrlHash(activeConversation); + if (isHandlingPopstateRef.current) { + // URL is already correct from the browser's popstate — no update needed + isHandlingPopstateRef.current = false; + } else if (shouldPushHistoryRef.current) { + shouldPushHistoryRef.current = false; + pushUrlHash(activeConversation); + } else { + updateUrlHash(activeConversation); + } } if (activeConversation.type !== 'search') { saveLastViewedConversation(activeConversation); @@ -240,6 +298,24 @@ export function useConversationRouter({ } }, [activeConversation, suspendHashSync]); + // Respond to browser back/forward by updating the active conversation + useEffect(() => { + const handlePopstate = () => { + // Settings hash transitions are handled by useAppShell + if (parseHashSettingsSection() !== null) return; + + const conv = resolveConversationFromHash(channelsRef.current, contactsRef.current); + if (conv) { + hashSyncEnabledRef.current = true; + isHandlingPopstateRef.current = true; + setActiveConversationState(conv); + } + }; + + window.addEventListener('popstate', handlePopstate); + return () => window.removeEventListener('popstate', handlePopstate); + }, []); + // If a delete action left us without an active conversation, recover to Public useEffect(() => { if (!pendingDeleteFallbackRef.current) return; diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index 22dbeed..a6c97b2 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -191,7 +191,9 @@ vi.mock('../utils/urlHash', () => ({ parseHashConversation: () => null, parseHashSettingsSection: () => null, updateUrlHash: vi.fn(), + pushUrlHash: vi.fn(), updateSettingsHash: vi.fn(), + pushSettingsHash: vi.fn(), getSettingsHash: (section: string) => `#settings/${section}`, getMapFocusHash: () => '#map', })); diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index 90013c3..dd9244c 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -133,6 +133,7 @@ vi.mock('../components/ui/sonner', () => ({ }, })); +import { act } from '@testing-library/react'; import { App } from '../App'; import { LAST_VIEWED_CONVERSATION_KEY, @@ -376,6 +377,83 @@ describe('App startup hash resolution', () => { expect(window.location.hash).toBe(''); }); + describe('Browser back/forward navigation', () => { + it('navigates to a channel conversation when popstate fires', async () => { + const opsChannel = { + key: 'CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + name: 'Ops', + is_hashtag: false, + on_radio: false, + last_read_at: null, + favorite: false, + }; + + window.location.hash = `#channel/${opsChannel.key}/Ops`; + mocks.api.getChannels.mockResolvedValue([publicChannel, opsChannel]); + + render(); + + await waitFor(() => { + for (const node of screen.getAllByTestId('active-conversation')) { + expect(node).toHaveTextContent(`channel:${opsChannel.key}:Ops`); + } + }); + + act(() => { + window.location.hash = `#channel/${publicChannel.key}/Public`; + window.dispatchEvent(new PopStateEvent('popstate', { state: null })); + }); + + await waitFor(() => { + for (const node of screen.getAllByTestId('active-conversation')) { + expect(node).toHaveTextContent(`channel:${publicChannel.key}:Public`); + } + }); + }); + + it('navigates to a contact conversation when popstate fires with a contact hash', async () => { + const aliceContact = { + public_key: 'b'.repeat(64), + name: 'Alice', + type: 1, + flags: 0, + direct_path: null, + direct_path_len: -1, + last_advert: null, + lat: null, + lon: null, + last_seen: null, + on_radio: false, + favorite: false, + last_contacted: null, + last_read_at: null, + first_seen: null, + }; + + window.location.hash = ''; + mocks.api.getContacts.mockResolvedValue([aliceContact]); + + render(); + + await waitFor(() => { + for (const node of screen.getAllByTestId('active-conversation')) { + expect(node).toHaveTextContent(`channel:${publicChannel.key}:Public`); + } + }); + + act(() => { + window.location.hash = `#contact/${aliceContact.public_key}/Alice`; + window.dispatchEvent(new PopStateEvent('popstate', { state: null })); + }); + + await waitFor(() => { + for (const node of screen.getAllByTestId('active-conversation')) { + expect(node).toHaveTextContent(`contact:${aliceContact.public_key}:Alice`); + } + }); + }); + }); + it('stays on radio settings section even when radio is disconnected', async () => { window.location.hash = '#settings/radio'; mocks.api.getRadioConfig.mockRejectedValue(new Error('radio offline')); diff --git a/frontend/src/test/useAppShell.test.ts b/frontend/src/test/useAppShell.test.ts index 3b9202a..c1181b7 100644 --- a/frontend/src/test/useAppShell.test.ts +++ b/frontend/src/test/useAppShell.test.ts @@ -90,7 +90,41 @@ describe('useAppShell', () => { result.current.handleCloseSettingsView(); }); - expect(window.location.hash).toBe('#channel/test/Public'); + await waitFor(() => { + expect(window.location.hash).toBe('#channel/test/Public'); + }); + }); + + it('pushes a new history entry when opening settings', async () => { + const { result } = renderHook(() => useAppShell()); + const lengthBefore = window.history.length; + + act(() => { + result.current.handleToggleSettingsView(); + }); + + await waitFor(() => { + expect(window.location.hash).toBe('#settings/radio'); + }); + + expect(window.history.length).toBe(lengthBefore + 1); + }); + + it('closes settings when popstate fires with a non-settings hash', async () => { + window.location.hash = '#settings/radio'; + + const { result } = renderHook(() => useAppShell()); + + expect(result.current.showSettings).toBe(true); + + act(() => { + window.location.hash = '#channel/abc/Public'; + window.dispatchEvent(new PopStateEvent('popstate', { state: null })); + }); + + await waitFor(() => { + expect(result.current.showSettings).toBe(false); + }); }); it('toggles the cracker shell without affecting sidebar state', () => { diff --git a/frontend/src/utils/urlHash.ts b/frontend/src/utils/urlHash.ts index c2b6f8d..97c9a2f 100644 --- a/frontend/src/utils/urlHash.ts +++ b/frontend/src/utils/urlHash.ts @@ -171,9 +171,25 @@ export function updateUrlHash(conv: Conversation | null): void { } } +// Update URL hash and add a new browser history entry +export function pushUrlHash(conv: Conversation | null): void { + const newHash = getConversationHash(conv); + if (newHash !== window.location.hash) { + window.history.pushState(null, '', newHash || window.location.pathname); + } +} + export function updateSettingsHash(section: SettingsSection): void { const newHash = getSettingsHash(section); if (newHash !== window.location.hash) { window.history.replaceState(null, '', newHash); } } + +// Push a settings hash as a new browser history entry +export function pushSettingsHash(section: SettingsSection): void { + const newHash = getSettingsHash(section); + if (newHash !== window.location.hash) { + window.history.pushState(null, '', newHash); + } +} From ecdcbd85d4de818f0caaaa884e1a31672d276140 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 13 May 2026 17:05:59 -0700 Subject: [PATCH 2/4] Don't push history when we've gotten here via popstate --- frontend/src/hooks/useAppShell.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/useAppShell.ts b/frontend/src/hooks/useAppShell.ts index c56bf63..5bb7342 100644 --- a/frontend/src/hooks/useAppShell.ts +++ b/frontend/src/hooks/useAppShell.ts @@ -89,7 +89,9 @@ export function useAppShell(): UseAppShellResult { const handlePopstate = () => { const section = parseHashSettingsSection(); if (section !== null) { - pushedSettingsEntryRef.current = true; + // Don't set pushedSettingsEntryRef here — the user arrived via + // back/forward, not by opening settings. Closing settings should + // replaceState, not history.back(), to avoid popping an unrelated entry. startTransition(() => { setShowSettings(true); setSettingsSection(section); From dc02015c4357c2cf12be660bf2bd38597b2f0bb9 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 13 May 2026 17:16:04 -0700 Subject: [PATCH 3/4] Drop back to empty state on deleted contact or bad hash in history --- frontend/src/hooks/useConversationRouter.ts | 24 ++++++++++----------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/frontend/src/hooks/useConversationRouter.ts b/frontend/src/hooks/useConversationRouter.ts index 5b145cb..af2eaa1 100644 --- a/frontend/src/hooks/useConversationRouter.ts +++ b/frontend/src/hooks/useConversationRouter.ts @@ -280,21 +280,21 @@ export function useConversationRouter({ // Keep ref in sync and update URL hash useEffect(() => { activeConversationRef.current = activeConversation; - if (activeConversation) { + if (isHandlingPopstateRef.current) { + // URL is already correct from the browser's popstate — no update needed + isHandlingPopstateRef.current = false; + } else if (activeConversation) { if (hashSyncEnabledRef.current && !suspendHashSync) { - if (isHandlingPopstateRef.current) { - // URL is already correct from the browser's popstate — no update needed - isHandlingPopstateRef.current = false; - } else if (shouldPushHistoryRef.current) { + if (shouldPushHistoryRef.current) { shouldPushHistoryRef.current = false; pushUrlHash(activeConversation); } else { updateUrlHash(activeConversation); } } - if (activeConversation.type !== 'search') { - saveLastViewedConversation(activeConversation); - } + } + if (activeConversation && activeConversation.type !== 'search') { + saveLastViewedConversation(activeConversation); } }, [activeConversation, suspendHashSync]); @@ -305,11 +305,9 @@ export function useConversationRouter({ if (parseHashSettingsSection() !== null) return; const conv = resolveConversationFromHash(channelsRef.current, contactsRef.current); - if (conv) { - hashSyncEnabledRef.current = true; - isHandlingPopstateRef.current = true; - setActiveConversationState(conv); - } + hashSyncEnabledRef.current = true; + isHandlingPopstateRef.current = true; + setActiveConversationState(conv); }; window.addEventListener('popstate', handlePopstate); From 83da01fe455c8fe79b72bd001de39016b3895984 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 13 May 2026 17:20:58 -0700 Subject: [PATCH 4/4] Test fixtures matching contact shape --- frontend/src/test/appStartupHash.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index dd9244c..bc71594 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -344,6 +344,7 @@ describe('App startup hash resolution', () => { flags: 0, direct_path: null, direct_path_len: -1, + direct_path_hash_mode: 0, last_advert: null, lat: null, lon: null,