pwa and mobile client
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user