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; 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(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]); }