(feat) chain app nav to browser history state. Closes #250.

Hook browser back/forward to nav state
This commit is contained in:
Jack Kingsman
2026-05-15 22:49:20 -07:00
committed by GitHub
6 changed files with 251 additions and 11 deletions
+2
View File
@@ -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',
}));
+79
View File
@@ -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(<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'));
+35 -1
View File
@@ -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', () => {