144 lines
4.7 KiB
TypeScript
144 lines
4.7 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|||
|
|
import { useSetAtom } from 'jotai';
|
||
|
|
import { Icons } from 'folds';
|
||
|
|
import { isTauri } from './useTauri';
|
||
|
|
import { ua } from '../utils/user-agent';
|
||
|
|
import { dismissToastAtom, toastQueueAtom } from '../state/toast';
|
||
|
|
import { installHintKind, shouldShowInstallHint } from '../utils/pwaInstall';
|
||
|
|
|
||
|
|
const VISITS_KEY = 'pwa-install-visits';
|
||
|
|
const DISMISSED_KEY = 'pwa-install-dismissed-at';
|
||
|
|
const SESSION_COUNTED_KEY = 'pwa-install-visit-counted';
|
||
|
|
const TOAST_ID = 'pwa-install-hint';
|
||
|
|
// Give the browser a moment to fire beforeinstallprompt (Chromium does so
|
||
|
|
// shortly after load once the manifest checks pass) before deciding which hint.
|
||
|
|
const DECIDE_DELAY_MS = 6000;
|
||
|
|
|
||
|
|
type BeforeInstallPromptEvent = Event & {
|
||
|
|
prompt: () => Promise<void>;
|
||
|
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||
|
|
};
|
||
|
|
|
||
|
|
const readNumber = (key: string): number | null => {
|
||
|
|
try {
|
||
|
|
const raw = localStorage.getItem(key);
|
||
|
|
if (raw === null) return null;
|
||
|
|
const n = Number(raw);
|
||
|
|
return Number.isFinite(n) ? n : null;
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const write = (key: string, value: string): void => {
|
||
|
|
try {
|
||
|
|
localStorage.setItem(key, value);
|
||
|
|
} catch {
|
||
|
|
/* blocked storage — the hint just won't be remembered */
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/** One visit per browser session (tab lifetime), not per React mount. */
|
||
|
|
const countVisit = (): number => {
|
||
|
|
try {
|
||
|
|
if (sessionStorage.getItem(SESSION_COUNTED_KEY)) return readNumber(VISITS_KEY) ?? 1;
|
||
|
|
sessionStorage.setItem(SESSION_COUNTED_KEY, '1');
|
||
|
|
} catch {
|
||
|
|
return readNumber(VISITS_KEY) ?? 1;
|
||
|
|
}
|
||
|
|
const next = (readNumber(VISITS_KEY) ?? 0) + 1;
|
||
|
|
write(VISITS_KEY, String(next));
|
||
|
|
return next;
|
||
|
|
};
|
||
|
|
|
||
|
|
const isStandalone = (): boolean =>
|
||
|
|
window.matchMedia?.('(display-mode: standalone)').matches === true ||
|
||
|
|
(navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||
|
|
|
||
|
|
const isIosSafari = (): boolean => {
|
||
|
|
const { os, browser } = ua();
|
||
|
|
return os.name === 'iOS' && (browser.name ?? '').includes('Safari');
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* [Gitea #116] Offer to install the PWA: a real prompt on Chromium (deferred
|
||
|
|
* `beforeinstallprompt`), Share-sheet instructions on iOS Safari, nothing
|
||
|
|
* elsewhere. Shown as a sticky toast from the second visit, snoozed 30 days on
|
||
|
|
* dismiss, never in Tauri or an installed PWA. Mount once for signed-in users.
|
||
|
|
*/
|
||
|
|
export function usePwaInstallPrompt(): void {
|
||
|
|
const enqueueToast = useSetAtom(toastQueueAtom);
|
||
|
|
const dismissToast = useSetAtom(dismissToastAtom);
|
||
|
|
const deferredRef = useRef<BeforeInstallPromptEvent | null>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (isTauri() || isStandalone()) return undefined;
|
||
|
|
|
||
|
|
const onBeforeInstall = (evt: Event) => {
|
||
|
|
evt.preventDefault();
|
||
|
|
deferredRef.current = evt as BeforeInstallPromptEvent;
|
||
|
|
};
|
||
|
|
const onInstalled = () => {
|
||
|
|
deferredRef.current = null;
|
||
|
|
dismissToast(TOAST_ID);
|
||
|
|
write(DISMISSED_KEY, String(Date.now()));
|
||
|
|
};
|
||
|
|
window.addEventListener('beforeinstallprompt', onBeforeInstall);
|
||
|
|
window.addEventListener('appinstalled', onInstalled);
|
||
|
|
|
||
|
|
const visits = countVisit();
|
||
|
|
const timer = setTimeout(() => {
|
||
|
|
const show = shouldShowInstallHint({
|
||
|
|
isTauri: false,
|
||
|
|
isStandalone: isStandalone(),
|
||
|
|
visits,
|
||
|
|
dismissedAt: readNumber(DISMISSED_KEY),
|
||
|
|
now: Date.now(),
|
||
|
|
});
|
||
|
|
if (!show) return;
|
||
|
|
const kind = installHintKind(deferredRef.current !== null, isIosSafari());
|
||
|
|
if (kind === 'none') return;
|
||
|
|
|
||
|
|
const dismiss = () => {
|
||
|
|
write(DISMISSED_KEY, String(Date.now()));
|
||
|
|
dismissToast(TOAST_ID);
|
||
|
|
};
|
||
|
|
|
||
|
|
enqueueToast({
|
||
|
|
id: TOAST_ID,
|
||
|
|
iconSrc: Icons.Download,
|
||
|
|
displayName: 'Install Lotus Chat',
|
||
|
|
body:
|
||
|
|
kind === 'prompt'
|
||
|
|
? 'Add it to your home screen or desktop for faster access and its own window. Tap to install.'
|
||
|
|
: 'On iPhone/iPad: tap Share, then "Add to Home Screen". Tap to dismiss.',
|
||
|
|
roomName: '',
|
||
|
|
roomId: '',
|
||
|
|
sticky: true,
|
||
|
|
onDismiss: () => write(DISMISSED_KEY, String(Date.now())),
|
||
|
|
onClick: () => {
|
||
|
|
const deferred = deferredRef.current;
|
||
|
|
if (kind === 'prompt' && deferred) {
|
||
|
|
deferred
|
||
|
|
.prompt()
|
||
|
|
.then(() => deferred.userChoice)
|
||
|
|
.then(({ outcome }) => {
|
||
|
|
if (outcome === 'accepted') deferredRef.current = null;
|
||
|
|
})
|
||
|
|
.catch(() => undefined)
|
||
|
|
.finally(dismiss);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
dismiss();
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}, DECIDE_DELAY_MS);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
window.removeEventListener('beforeinstallprompt', onBeforeInstall);
|
||
|
|
window.removeEventListener('appinstalled', onInstalled);
|
||
|
|
};
|
||
|
|
}, [enqueueToast, dismissToast]);
|
||
|
|
}
|