Files
RecipesCam/docker/frontend/src/pwa/install.ts
T

119 lines
4.2 KiB
TypeScript

// Installability lives outside React. The browser fires `beforeinstallprompt` once,
// whenever it likes, and the event has to be held until a click spends it — so this
// is a module-level slot plus a subscriber list, and no store, hook or context.
//
// The state the visitor owns is the answer: a refusal, or an install. Remember either
// one, and the offer never comes back. A no is an answer, not a snooze.
type InstallEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
};
const DISMISSED = 'rc.install.dismissed.v1';
// The other answer: installed, and never asked again — including from a plain browser
// tab, which has no `standalone` to tell it so.
const INSTALLED = 'rc.install.installed.v1';
// The window event a control elsewhere in the app (the Library's own button, say)
// fires to pull the offer forward instead of waiting out the delay.
export const OFFER_EVENT = 'rc:install-offer';
let deferred: InstallEvent | null = null;
const listeners = new Set<() => void>();
const notify = () => {
for (const listener of listeners) listener();
};
// Registers `listener` for every change in installability and returns the
// unsubscribe — the shape `useEffect` already has.
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function canInstall(): boolean {
return deferred !== null && !dismissed() && !isInstalled();
}
// Spends the browser's prompt and reports the answer. Null means there was nothing
// to spend: the event never arrived, or this is a second call.
//
// Either answer is remembered, because either answer is the visitor's: yes is the app
// being installed, and the browser's own sheet being waved away is a no — the event is
// spent now, and it never fires again for this install.
export async function promptInstall(): Promise<'accepted' | 'dismissed' | null> {
const event = deferred;
if (!event) return null;
// Spent either way, so it is cleared before the await: a second prompt() on the
// same event rejects.
deferred = null;
notify();
await event.prompt();
const { outcome } = await event.userChoice;
if (outcome === 'accepted') markInstalled();
else dismissInstall();
return outcome;
}
// The installed app is its own window; a Safari tab that was added to the home
// screen keeps `standalone` on the navigator instead.
export function isStandalone(): boolean {
return (
window.matchMedia('(display-mode: standalone)').matches ||
(navigator as Navigator & { standalone?: boolean }).standalone === true
);
}
// Every browser on iOS is WebKit, but only Safari's own UI has "Add to Home Screen",
// and only there does the manual instruction make sense.
export function isIosSafari(): boolean {
const ua = navigator.userAgent;
const ios = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
return ios && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(ua);
}
export function dismissed(): boolean {
return localStorage.getItem(DISMISSED) === '1';
}
export function dismissInstall(): void {
localStorage.setItem(DISMISSED, '1');
notify();
}
// True in the installed window itself, and also in any browser tab that watched the
// install happen — that tab keeps running the site, and must not ask for the app it
// just installed.
export function isInstalled(): boolean {
return isStandalone() || localStorage.getItem(INSTALLED) === '1';
}
export function markInstalled(): void {
localStorage.setItem(INSTALLED, '1');
notify();
}
// Asks for the offer now.
export function offerInstall(): void {
window.dispatchEvent(new Event(OFFER_EVENT));
}
window.addEventListener('beforeinstallprompt', (event) => {
// Without this Chromium shows its own mini-infobar and the event is spent; the
// app's dialog is the one the visitor gets.
event.preventDefault();
deferred = event as InstallEvent;
notify();
});
// Fires once the app is installed, from our prompt or from the browser's own menu,
// and is the only signal that the offer is done.
window.addEventListener('appinstalled', () => {
deferred = null;
markInstalled();
});