Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions frontend/src/hooks/useAppShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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);
Expand Down
84 changes: 79 additions & 5 deletions frontend/src/hooks/useConversationRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
parseHashConversation,
parseHashSettingsSection,
updateUrlHash,
pushUrlHash,
resolveChannelFromHashToken,
resolveContactFromHashToken,
} from '../utils/urlHash';
Expand All @@ -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[];
Expand All @@ -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);
}, []);

Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/test/appFavorites.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}));
Expand Down
79 changes: 79 additions & 0 deletions frontend/src/test/appStartupHash.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ vi.mock('../components/ui/sonner', () => ({
},
}));

import { act } from '@testing-library/react';
import { App } from '../App';
import {
LAST_VIEWED_CONVERSATION_KEY,
Expand Down Expand Up @@ -343,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,
Expand Down Expand Up @@ -376,6 +378,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(<App />);

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(<App />);

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'));
Expand Down
36 changes: 35 additions & 1 deletion frontend/src/test/useAppShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/utils/urlHash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading