106 lines
3.3 KiB
TypeScript
106 lines
3.3 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useI18n } from '../i18n/I18nProvider';
|
|
import {
|
|
OFFER_EVENT,
|
|
canInstall,
|
|
dismissInstall,
|
|
dismissed,
|
|
isInstalled,
|
|
isIosSafari,
|
|
promptInstall,
|
|
subscribe,
|
|
} from '../pwa/install';
|
|
|
|
// The install offer, and the only dialog the app opens without being asked. It waits
|
|
// for the browser to say the app is installable, and then for the visitor to have
|
|
// stayed long enough to want it — a prompt on the way in is a prompt nobody reads.
|
|
// Markup and classes are the auth dialog's (see AuthModal.tsx); there is nothing new
|
|
// to draw here.
|
|
//
|
|
// Long enough to have opened a photo and worked on it, short enough that the offer
|
|
// still lands in the visit that earned it.
|
|
const DELAY_MS = 120_000;
|
|
|
|
export function InstallPrompt() {
|
|
const { t } = useI18n();
|
|
// 'manual' is Safari: no install event exists there, so the only thing to show is
|
|
// where the instruction lives.
|
|
const [face, setFace] = useState<'native' | 'manual' | null>(null);
|
|
|
|
useEffect(() => {
|
|
// An installed app has nothing to offer, and a refusal is final — the visitor
|
|
// said no to this dialog, not to this session.
|
|
if (isInstalled() || dismissed()) return;
|
|
|
|
const manual = isIosSafari();
|
|
let waited = false;
|
|
let asked = false;
|
|
let shown = false;
|
|
|
|
// Both ways in have to agree: installability (or Safari, which never fires the
|
|
// event) and either the delay running out or something in the app asking for the
|
|
// offer — a Library button, say.
|
|
const maybeShow = () => {
|
|
if (shown || (!waited && !asked)) return;
|
|
if (manual) {
|
|
shown = true;
|
|
setFace('manual');
|
|
} else if (canInstall()) {
|
|
shown = true;
|
|
setFace('native');
|
|
}
|
|
};
|
|
const onOffer = () => {
|
|
asked = true;
|
|
maybeShow();
|
|
};
|
|
|
|
window.addEventListener(OFFER_EVENT, onOffer);
|
|
const unsubscribe = subscribe(maybeShow);
|
|
const timer = window.setTimeout(() => {
|
|
waited = true;
|
|
maybeShow();
|
|
}, DELAY_MS);
|
|
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
window.removeEventListener(OFFER_EVENT, onOffer);
|
|
unsubscribe();
|
|
};
|
|
}, []);
|
|
|
|
if (!face) return null;
|
|
const close = () => {
|
|
dismissInstall();
|
|
setFace(null);
|
|
};
|
|
|
|
return (
|
|
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && close()}>
|
|
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="install-title">
|
|
<h2 id="install-title">{t('install.title')}</h2>
|
|
<p className="hint">{t('install.body')}</p>
|
|
{face === 'manual' ? <p className="hint">{t('install.ios')}</p> : null}
|
|
{face === 'native' ? (
|
|
<button
|
|
type="button"
|
|
className="btn primary"
|
|
data-key="install-yes"
|
|
// `promptInstall` records the browser's answer itself: accepted means the
|
|
// app is installed, waved away means the offer was spent and refused.
|
|
onClick={() => {
|
|
void promptInstall();
|
|
setFace(null);
|
|
}}
|
|
>
|
|
{t('install.yes')}
|
|
</button>
|
|
) : null}
|
|
<button type="button" className="btn ghost" data-key="install-no" onClick={close}>
|
|
{t('install.no')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|