119 lines
4.2 KiB
TypeScript
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();
|
|
});
|