134 lines
4.5 KiB
TypeScript
134 lines
4.5 KiB
TypeScript
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<string, unknown> | 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<NavState>(() => (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<NavState | undefined>(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 };
|
|
}
|