/** * Web Push client helpers. * * Flow: read the VAPID public key from the BFF → register the service worker * → request notification permission → subscribe via PushManager → send the * subscription to the BFF (which stores it and pushes to it on each * notification). Disabling unsubscribes locally and on the server. * * All BFF calls ride the auth cookie (credentials: "include"), matching the * other lib clients. */ const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080"; /** True when the browser can do Web Push at all. */ export function pushSupported(): boolean { return ( typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window ); } /** iPhone/iPad/iPod (incl. iPadOS, which reports as "Macintosh" + touch). */ export function isIOS(): boolean { if (typeof navigator === "undefined") return false; const ua = navigator.userAgent; return ( /iPad|iPhone|iPod/.test(ua) || (navigator.maxTouchPoints > 1 && /Macintosh/.test(ua)) ); } /** Running as an installed (Home Screen) app, not a browser tab. */ export function isStandalone(): boolean { if (typeof window === "undefined") return false; return ( // iOS Safari's non-standard flag… (navigator as Navigator & { standalone?: boolean }).standalone === true || // …and the standard display-mode for everyone else. window.matchMedia("(display-mode: standalone)").matches ); } /** * iOS only allows Web Push when the site has been added to the Home Screen * (iOS 16.4+). In a plain Safari tab the push APIs don't exist, so this is * the case where we should guide the user to "Add to Home Screen" rather * than show a dead "unsupported". */ export function needsIOSInstall(): boolean { return isIOS() && !isStandalone() && !pushSupported(); } /** The browser's current notification permission, or "unsupported". */ export function pushPermission(): NotificationPermission | "unsupported" { if (!pushSupported()) return "unsupported"; return Notification.permission; } type VapidKey = { public_key: string; enabled: boolean }; async function getVapidKey(): Promise { const res = await fetch(`${BFF_BASE}/api/v1/push/key`, { credentials: "include", }); if (!res.ok) throw new Error("Could not load push configuration"); const body = (await res.json()) as { data?: VapidKey }; return body.data ?? { public_key: "", enabled: false }; } async function registration(): Promise { if (!pushSupported()) return null; return (await navigator.serviceWorker.getRegistration()) ?? null; } /** Whether push is currently enabled (permission granted + a live sub). */ export async function isPushEnabled(): Promise { if (!pushSupported() || Notification.permission !== "granted") return false; const reg = await registration(); if (!reg) return false; return !!(await reg.pushManager.getSubscription()); } /** Whether the server has push configured (VAPID keys present). */ export async function pushConfigured(): Promise { try { return (await getVapidKey()).enabled; } catch { return false; } } /** * Enable push: requests permission, subscribes, and registers the * subscription with the BFF. Throws a user-facing message on failure. */ export async function enablePush(): Promise { if (!pushSupported()) { throw new Error("This browser doesn't support push notifications."); } const { public_key, enabled } = await getVapidKey(); if (!enabled || !public_key) { throw new Error("Push notifications aren't available right now."); } const permission = await Notification.requestPermission(); if (permission !== "granted") { throw new Error("Notification permission was not granted."); } // The service worker is registered app-wide by ServiceWorkerRegistrar (with // the same `?bff=` query, so there's a single registration/scope). Reuse it; // only register here as a fallback if it somehow isn't present yet. const reg = (await navigator.serviceWorker.getRegistration()) ?? (await navigator.serviceWorker.register( `/sw.js?bff=${encodeURIComponent(BFF_BASE)}`, )); await navigator.serviceWorker.ready; let sub = await reg.pushManager.getSubscription(); // A subscription minted under a previous VAPID key still looks alive // locally, but every send fails with a credential mismatch — drop it and // subscribe fresh under the current key. if (sub && !matchesServerKey(sub, public_key)) { await sub.unsubscribe().catch(() => { /* stale anyway — subscribe() below replaces it */ }); sub = null; } if (!sub) { sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(public_key), }); } const json = sub.toJSON() as { endpoint?: string; keys?: { p256dh?: string; auth?: string }; }; const res = await fetch(`${BFF_BASE}/api/v1/push/subscribe`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint: json.endpoint, keys: json.keys }), }); if (!res.ok) throw new Error("Could not save your subscription."); } /** * Whether an existing subscription was created with the server's current * VAPID public key. Browsers that don't expose applicationServerKey (older * Safari) return null — treat that as a match rather than churn the sub. */ function matchesServerKey(sub: PushSubscription, publicKey: string): boolean { const current = sub.options?.applicationServerKey; if (!current) return true; const a = new Uint8Array(current); const b = urlBase64ToUint8Array(publicKey); return a.length === b.length && a.every((v, i) => v === b[i]); } /** Disable push: unsubscribe locally and remove the subscription server-side. */ export async function disablePush(): Promise { const reg = await registration(); const sub = reg ? await reg.pushManager.getSubscription() : null; if (!sub) return; const endpoint = sub.endpoint; try { await sub.unsubscribe(); } catch { /* ignore */ } await fetch(`${BFF_BASE}/api/v1/push/unsubscribe`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint }), }).catch(() => { /* best-effort */ }); } /** VAPID public keys are base64url; PushManager wants a Uint8Array. */ function urlBase64ToUint8Array(base64: string): Uint8Array { const padding = "=".repeat((4 - (base64.length % 4)) % 4); const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/"); const raw = atob(normalized); // Back with a concrete ArrayBuffer so the type is Uint8Array, // which PushManager.subscribe's applicationServerKey requires. const out = new Uint8Array(new ArrayBuffer(raw.length)); for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; }