diff --git a/frontend/src/hooks/useAppShell.ts b/frontend/src/hooks/useAppShell.ts index 5ec871e..5bb7342 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,35 @@ 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) { + // 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); + }); + } 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..af2eaa1 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); }, []); @@ -230,16 +280,40 @@ 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) { - updateUrlHash(activeConversation); - } - if (activeConversation.type !== 'search') { - saveLastViewedConversation(activeConversation); + if (shouldPushHistoryRef.current) { + shouldPushHistoryRef.current = false; + pushUrlHash(activeConversation); + } else { + updateUrlHash(activeConversation); + } } } + if (activeConversation && activeConversation.type !== 'search') { + saveLastViewedConversation(activeConversation); + } }, [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); + 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 022f653..f6f1b2a 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -192,7 +192,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 c938b74..9096afd 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -134,6 +134,7 @@ vi.mock('../components/ui/sonner', () => ({ }, })); +import { act } from '@testing-library/react'; import { App } from '../App'; import { LAST_VIEWED_CONVERSATION_KEY, @@ -344,6 +345,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, @@ -377,6 +379,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 5f2563b..8808d1e 100644 --- a/frontend/src/utils/urlHash.ts +++ b/frontend/src/utils/urlHash.ts @@ -172,9 +172,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); + } +}