mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-17 06:46:02 +02:00
(feat) chain app nav to browser history state. Closes #250.
Hook browser back/forward to nav state
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
}));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user