import { useEffect } from 'react'; import { useToastStore, toast } from '../store/useToastStore'; /** * Chrome fires `beforeinstallprompt` when the app qualifies for installation. * Not in lib.dom yet, so the shape is declared here. */ interface BeforeInstallPromptEvent extends Event { prompt: () => Promise; userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; } const SNOOZE_KEY = 'muzick.install-prompt.snoozed-at'; const SNOOZE_MS = 30 * 24 * 60 * 60 * 1000; // Long enough that the suggestion lands after the app has proved useful, not // while the first page is still painting. const DELAY_MS = 20_000; function snoozed(): boolean { try { const at = Number(localStorage.getItem(SNOOZE_KEY)); return Number.isFinite(at) && at > 0 && Date.now() - at < SNOOZE_MS; } catch { return false; // Private mode or blocked storage — just ask. } } function snooze() { try { localStorage.setItem(SNOOZE_KEY, String(Date.now())); } catch { /* ignore */ } } /** * Offers "Add to Home screen" as a toast, once the browser says the app is * installable. Declining snoozes the suggestion for a month; installing * silences it for good. */ export function useInstallPrompt() { useEffect(() => { // Already installed — the standalone display mode is the reliable signal. if (window.matchMedia?.('(display-mode: standalone)').matches) return; if (snoozed()) return; let deferred: BeforeInstallPromptEvent | null = null; let timer: ReturnType | undefined; let unsubscribe: (() => void) | undefined; const offer = () => { if (!deferred) return; const event = deferred; let accepted = false; const id = toast.info('Muzick runs better from your home screen.', { ttl: 0, action: { label: 'Install', onClick: () => { accepted = true; unsubscribe?.(); // A prompt can only be shown once per event; drop it either way. deferred = null; void event.prompt().then(() => event.userChoice).then((choice) => { if (choice.outcome === 'dismissed') snooze(); }).catch(() => snooze()); }, }, }); // The toast's own close button calls dismiss() directly, so the only way // to notice a decline is to watch the toast leave the store. unsubscribe = useToastStore.subscribe((state) => { if (accepted) return; if (!state.toasts.some((t) => t.id === id)) { snooze(); unsubscribe?.(); } }); }; const onBeforeInstallPrompt = (e: Event) => { // Suppress Chrome's own mini-infobar so only our toast asks. e.preventDefault(); deferred = e as BeforeInstallPromptEvent; timer = setTimeout(offer, DELAY_MS); }; const onInstalled = () => { deferred = null; if (timer) clearTimeout(timer); snooze(); }; window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt); window.addEventListener('appinstalled', onInstalled); return () => { window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt); window.removeEventListener('appinstalled', onInstalled); if (timer) clearTimeout(timer); unsubscribe?.(); }; }, []); }