Files
cinny/src/app/hooks/usePwaInstallPrompt.ts
T
jaredandClaude Opus 5 bd1e61e8cd
CI / Build & Quality Checks (push) Successful in 1m59s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 1m59s
feat(pwa): offer to install — Chromium prompt, iOS "Add to Home Screen" hint (#116)
The client never handled beforeinstallprompt and showed no install hint, so
phone users only got the PWA if they knew to dig through Share → Add to
Home Screen (iOS never prompts; Chromium's mini-infobar is easy to miss).

- utils/pwaInstall.ts (pure, 4 tests): show from the second visit, never in
  Tauri or an installed PWA (display-mode standalone / navigator.standalone),
  30-day snooze after a dismissal; kind = real prompt when the browser
  handed us a deferred beforeinstallprompt, Share-sheet instructions on iOS
  Safari, nothing elsewhere (Firefox desktop has no install path).
- hooks/usePwaInstallPrompt.ts: captures beforeinstallprompt/appinstalled,
  counts one visit per browser session, waits 6s for the prompt event before
  deciding, then enqueues a sticky toast; tap → prompt(), X → snooze.
- ToastNotif gains onDismiss (fired by the X button only) so the snooze is
  recorded however the toast is closed. Mounted from ClientNonUIFeatures for
  signed-in users only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-17 13:31:29 -04:00

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