import { useCallback, useEffect, useRef, useState } from 'react'; /** * Screen stack kept in the browser history: every screen change is a history entry holding the whole * stack, so the toolbar back button, the browser back button and the Android back button all go * through history.back() and behave the same. A reload restores the stack from history.state. */ export interface LoginPrefill { server: string; key: string; } export type Route = | { name: 'cards' } | { name: 'card'; id: string } | { name: 'edit'; id?: string } | { name: 'login'; prefill?: LoginPrefill }; export interface NavState { stack: Route[]; /** Login opened over the lock screen ("forgot PIN", login link); going back returns to the lock. */ lockedLogin?: { prefill?: LoginPrefill }; } const STATE_KEY = 'cardy'; function readState(state: unknown): NavState | undefined { const nav = (state as Record | null)?.[STATE_KEY] as NavState | undefined; return nav && Array.isArray(nav.stack) && nav.stack.length > 0 ? nav : undefined; } function hashFor(nav: NavState): string { if (nav.lockedLogin) return '#/login'; const top = nav.stack[nav.stack.length - 1]; switch (top?.name) { case 'card': return `#/card/${top.id}`; case 'edit': return top.id ? `#/edit/${top.id}` : '#/new'; case 'login': return '#/login'; default: return '#/cards'; } } // The key is in the prefill of login links: never keep it in the address bar or in history. function forHistory(nav: NavState): NavState { return { stack: nav.stack.map((r) => (r.name === 'login' ? { name: 'login' } : r)), ...(nav.lockedLogin ? { lockedLogin: {} } : {}), }; } /** Number of history entries this app pushed above the root screen. */ function depth(nav: NavState): number { return nav.stack.length - 1 + (nav.lockedLogin ? 1 : 0); } const write = (mode: 'push' | 'replace', nav: NavState) => history[mode === 'push' ? 'pushState' : 'replaceState']({ [STATE_KEY]: forHistory(nav) }, '', hashFor(nav)); /** * @param initial stack to start with; `restore` allows taking the stack from history.state (after a reload). */ export function useNavigation(initial: Route[], restore: boolean) { const [nav, setNav] = useState(() => (restore && readState(history.state)) || { stack: initial }); const navRef = useRef(nav); navRef.current = nav; /** Set while history.back()/go() is in flight: ignore further back requests until popstate. */ const pendingPop = useRef(false); /** Target of reset(): applied when the history.go() it started lands. */ const pendingReset = useRef(undefined); useEffect(() => { write('replace', navRef.current); const onPop = (event: PopStateEvent) => { pendingPop.current = false; const target = pendingReset.current; if (target) { pendingReset.current = undefined; write('replace', target); setNav(target); return; } // An entry without our state (e.g. the page before the app): fall back to the root screen. setNav(readState(event.state) ?? { stack: navRef.current.stack.slice(0, 1) }); }; window.addEventListener('popstate', onPop); return () => window.removeEventListener('popstate', onPop); }, []); const go = useCallback((next: NavState) => { write('push', next); setNav(next); }, []); const push = useCallback((route: Route) => go({ stack: [...navRef.current.stack, route] }), [go]); /** Returns false when already at the root (nothing to go back to inside the app). */ const back = useCallback((): boolean => { if (pendingPop.current) return true; if (depth(navRef.current) === 0) return false; pendingPop.current = true; history.back(); return true; }, []); const replaceTop = useCallback((route: Route) => { const next = { stack: [...navRef.current.stack.slice(0, -1), route] }; write('replace', next); setNav(next); }, []); /** Replaces the whole stack, unwinding the app's history entries first. */ const reset = useCallback((stack: Route[]) => { const next: NavState = { stack }; const entries = depth(navRef.current); if (entries === 0) { write('replace', next); setNav(next); return; } pendingReset.current = next; pendingPop.current = true; history.go(-entries); }, []); const openLockedLogin = useCallback((prefill?: LoginPrefill) => go({ ...navRef.current, lockedLogin: { prefill } }), [go]); return { nav, push, back, replaceTop, reset, openLockedLogin }; }