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);
+ }
+}