The PWA already existed but nothing told a mobile visitor it could be installed - Chrome own default mini-infobar is easy to miss, and Safari has no browser-driven prompt at all. New pwaInstall.ts (self- mounting like toast.ts) captures beforeinstallprompt as early as possible (a top-level side effect of importing the module from Shell.ts, live before boot() even starts its async login check, since that event never replays for a listener that attached too late) and shows a banner with a real Installer button on Chrome/Edge/Android, or manual Share-then-home-screen instructions on iOS Safari where no such event exists at all. Mobile-only, suppressed once already running standalone, and a close click remembers not to ask again. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
108 lines
4.6 KiB
TypeScript
108 lines
4.6 KiB
TypeScript
// 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<void>;
|
|
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<void> {
|
|
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
|
|
? `<span class="pwaInstallText">Installe Watch Party : appuie sur <strong>Partager</strong> puis « Sur l'écran d'accueil ».</span>
|
|
<button type="button" class="pwaInstallClose" aria-label="Fermer">×</button>`
|
|
: `<span class="pwaInstallText">Installe Watch Party sur ton écran d'accueil pour y accéder plus vite.</span>
|
|
<button type="button" class="pwaInstallBtn">Installer</button>
|
|
<button type="button" class="pwaInstallClose" aria-label="Fermer">×</button>`;
|
|
|
|
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);
|
|
}
|