// Mobile-only "install this as an app" nudge - see docs/architecture.md. // Self-mounting like toast.ts (no per-route/per-party state to attach), // but the beforeinstallprompt listener below is attached the moment // this module is first imported - app.ts does that at the very top, // well before boot()'s async login check - rather than lazily on first // use like toast.ts: the browser only ever fires this event once per // page load, often before any UI exists to react to it, and there's no // way to ask for a replay if a listener attaches too late to catch it. import './pwaInstall.css'; /** Chrome/Edge/Android-WebView's own install-prompt event - not in * lib.dom.d.ts (Safari/Firefox never implement it at all). */ interface BeforeInstallPromptEvent extends Event { prompt(): Promise; userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; } const DISMISSED_KEY = 'watchparty:pwaInstallDismissed'; const MOBILE_QUERY = '(max-width: 720px)'; let deferredPrompt: BeforeInstallPromptEvent | null = null; let bannerEl: HTMLElement | null = null; window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); // suppress the browser's own mini-infobar - this banner replaces it deferredPrompt = e as BeforeInstallPromptEvent; renderIfEligible(); }); window.addEventListener('appinstalled', () => { deferredPrompt = null; removeBanner(); }); function isStandalone(): boolean { return window.matchMedia('(display-mode: standalone)').matches || (navigator as unknown as { standalone?: boolean }).standalone === true; // iOS Safari's own flag - no display-mode query there } /** No feature-detection exists for "is this iOS Safari" - beforeinstallprompt * simply never fires there, which is the actual fact this module cares * about, but by the time that's observable it's too late to show * install instructions instead. Checked only as a fallback once * deferredPrompt has had a real chance to arrive - see renderIfEligible. */ function isIOS(): boolean { return /iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPadOS 13+ reports as a Mac } function removeBanner(): void { bannerEl?.remove(); bannerEl = null; } function dismiss(): void { localStorage.setItem(DISMISSED_KEY, '1'); removeBanner(); } async function install(): Promise { if (!deferredPrompt) return; const prompt = deferredPrompt; deferredPrompt = null; // a captured prompt can only ever be used once await prompt.prompt(); const { outcome } = await prompt.userChoice; // 'dismissed' here means the user closed the browser's own native // picker without installing - not the same signal as our own close // button (dismiss() above), so this banner stays up rather than // remembering "never ask again" over a single not-right-now. if (outcome === 'accepted') removeBanner(); } function renderIfEligible(): void { if (bannerEl) return; if (isStandalone()) return; if (localStorage.getItem(DISMISSED_KEY)) return; if (!window.matchMedia(MOBILE_QUERY).matches) return; const ios = isIOS(); if (!deferredPrompt && !ios) return; // no install path this module actually knows how to help with bannerEl = document.createElement('div'); bannerEl.className = 'pwaInstallBanner'; bannerEl.innerHTML = ios ? `Installe Watch Party : appuie sur Partager puis « Sur l'écran d'accueil ». ` : `Installe Watch Party sur ton écran d'accueil pour y accéder plus vite. `; bannerEl.querySelector('.pwaInstallClose')!.addEventListener('click', dismiss); bannerEl.querySelector('.pwaInstallBtn')?.addEventListener('click', () => { install().catch(() => {}); }); document.body.insertBefore(bannerEl, document.body.firstChild); } /** * Called once by Shell.ts after a successful login - covers both the * case where beforeinstallprompt already fired before Shell existed to * react to it (deferredPrompt sitting ready, banner never actually * shown yet) and iOS, which has no event at all to trigger this from. * Also re-checked on resize: crossing the 720px breakpoint live (an * unfolding or rotated device) shouldn't need a reload to notice. */ export function initPwaInstallPrompt(): void { renderIfEligible(); window.addEventListener('resize', renderIfEligible); }