/** * usePushSubscription — push notification subscription lifecycle. * * Returns { subscribe, unsubscribe, permission, isSubscribed, setEnabled }. * * Critical iOS constraint (D-08 / CLAUDE.md iOS table): * subscribe() MUST be called directly inside an onClick handler — NO await * before pushManager.subscribe(). iOS requires a synchronous user gesture * in the call stack. The pattern is: * * onClick={() => { * void subscribe(registration) // ← no await here; pushManager.subscribe starts sync * }} * * The VAPID key is pre-fetched (GET /api/push/vapid-public-key) and cached in * sessionStorage before the tap. On tap, only subscribe() + POST are awaited * inside the async subscribe() body — the pushManager.subscribe itself is the * synchronous first call, satisfying the iOS gesture requirement. * * Health-check on mount (D-10): if OS permission is still 'granted' but there * is no active subscription (expired/cleared) AND notifications were not * explicitly disabled, silently re-subscribe in the background without user action. * * localStorage key: notificationsEnabled — '1' after successful subscribe, * '0' after explicit user disable. Absence means never configured. * * setEnabled(true) — if permission 'granted': silently subscribe + set '1'. * if permission 'default': no-op; caller must use tap-gated subscribe(). * if permission 'denied': no-op. * setEnabled(false) — unsubscribe + set '0'. */ import { useState, useEffect } from 'react'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Convert a URL-safe base64 string to a Uint8Array. * Required to pass the VAPID public key to pushManager.subscribe() as * applicationServerKey (Web Push spec — key must be a BufferSource). */ function urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = atob(base64); const buffer = new ArrayBuffer(rawData.length); const outputArray = new Uint8Array(buffer); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } /** * Fetch the VAPID public key from the API. * Caches the result in sessionStorage so subsequent calls within the session * are instant — the tap handler can proceed without an extra round-trip. */ async function fetchVapidKey(): Promise { const cached = sessionStorage.getItem('vapidPublicKey'); if (cached) return cached; const res = await fetch('/api/push/vapid-public-key', { credentials: 'include', }); if (!res.ok) throw new Error(`Failed to fetch VAPID key: ${res.status}`); const data = (await res.json()) as { publicKey: string }; if (data.publicKey) { sessionStorage.setItem('vapidPublicKey', data.publicKey); } return data.publicKey; } // --------------------------------------------------------------------------- // localStorage guards (pattern from InstallPrompt.tsx lines 284–297) // --------------------------------------------------------------------------- function readNotificationsEnabled(): boolean { try { return localStorage.getItem('notificationsEnabled') === '1'; } catch { return false; } } /** * Returns true when the user has explicitly disabled notifications * (localStorage.notificationsEnabled === '0'). */ function readNotificationsDisabled(): boolean { try { return localStorage.getItem('notificationsEnabled') === '0'; } catch { return false; } } function persistNotificationsEnabled(value: boolean): void { try { if (value) { localStorage.setItem('notificationsEnabled', '1'); } else { localStorage.setItem('notificationsEnabled', '0'); } } catch { // Private mode / storage disabled — ignore } } // --------------------------------------------------------------------------- // Hook // --------------------------------------------------------------------------- export interface UsePushSubscriptionReturn { /** * Call this inside an onClick handler (no await before the call). * * CR-04: `vapidKey` must be pre-fetched before the tap (e.g. via prefetchVapidKey() * in a useEffect) and passed in here. This eliminates the network round-trip inside * the tap handler, preserving the iOS user-gesture requirement for pushManager.subscribe(). */ subscribe: (registration: ServiceWorkerRegistration, vapidKey: string) => Promise; unsubscribe: () => Promise; /** Current Notification.permission value ('default' | 'granted' | 'denied') */ permission: NotificationPermission; /** True when an active push subscription exists in pushManager. */ isSubscribed: boolean; /** * Master on/off toggle (D-09). * * setEnabled(true): * - permission 'granted': silently subscribes + persists '1'. * - permission 'default': no-op; caller must trigger tap-gated subscribe() for the OS dialog. * - permission 'denied': no-op; caller shows the permission-denied hint. * * setEnabled(false): * - Calls unsubscribe() (DELETE server + browser) + persists '0'. */ setEnabled: (on: boolean) => Promise; } export function usePushSubscription(): UsePushSubscriptionReturn { const [permission, setPermission] = useState( typeof Notification !== 'undefined' ? Notification.permission : 'default', ); const [isSubscribed, setIsSubscribed] = useState(false); // Health-check on mount (D-10): if OS permission is granted but no active // subscription exists (expired/cleared) AND user hasn't explicitly disabled, // silently re-subscribe in background. useEffect(() => { if (typeof Notification === 'undefined') return; if (Notification.permission !== 'granted') return; if (!navigator.serviceWorker) return; void (async () => { try { const registration = await navigator.serviceWorker.ready; const existingSub = await registration.pushManager.getSubscription(); if (existingSub) { // Re-confirm server-side record (handles 410-prune recovery — D-10). // The upsert on endpoint is idempotent; this is a no-op if the record exists. await fetch('/api/push/subscription', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(existingSub.toJSON()), }).catch(() => {}); // non-blocking — ignore network failures setIsSubscribed(true); return; } // No active subscription — only silently re-subscribe if the user // hasn't explicitly turned notifications off (D-10). if (readNotificationsDisabled()) return; const vapidKey = await fetchVapidKey(); const sub = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidKey), }); await fetch('/api/push/subscription', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(sub.toJSON()), }); persistNotificationsEnabled(true); setIsSubscribed(true); } catch { // Ignore health-check errors — non-blocking background task } })(); }, []); /** * subscribe — MUST be called inside an onClick handler. * * iOS user-gesture requirement: pushManager.subscribe() must be called * synchronously within a user gesture (tap) callback. This function starts * the subscription synchronously and awaits only the POST afterwards. * * CR-04: The VAPID key MUST be pre-fetched before the tap (e.g., via * prefetchVapidKey() in a useEffect) and passed in as `vapidKey`. Fetching * it here (inside the async function) would insert an await-fetch before * pushManager.subscribe() and break the iOS gesture gate on cache miss. */ const subscribe = async ( registration: ServiceWorkerRegistration, vapidKey: string, ): Promise => { // pushManager.subscribe — synchronous initiation; iOS gesture gate is here. // No await before this call: vapidKey is already resolved by the caller. // iOS allows the async resolution of PushSubscription after the gesture frame, // as long as subscribe() is the first async operation in the tap handler. const sub = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidKey), }); // Persist the subscription to the server const res = await fetch('/api/push/subscription', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(sub.toJSON()), }); if (!res.ok) { throw new Error(`Failed to persist subscription: ${res.status}`); } persistNotificationsEnabled(true); setPermission(Notification.permission); setIsSubscribed(true); }; /** * unsubscribe — retrieve the active subscription and cancel it. * Also removes the server-side row via DELETE /api/push/subscription. */ const unsubscribe = async (): Promise => { if (!navigator.serviceWorker) return; const registration = await navigator.serviceWorker.ready; const sub = await registration.pushManager.getSubscription(); if (sub) { await sub.unsubscribe(); } await fetch('/api/push/subscription', { method: 'DELETE', credentials: 'include', }); persistNotificationsEnabled(false); setPermission(Notification.permission); setIsSubscribed(false); }; /** * setEnabled — master on/off for the settings toggle (D-09). * * on=true + permission granted: silently subscribes (no OS dialog needed). * on=true + permission default: no-op (caller triggers tap-gated subscribe()). * on=true + permission denied: no-op (caller shows permission-denied hint). * on=false: unsubscribes (browser + server DELETE). */ const setEnabled = async (on: boolean): Promise => { if (!on) { await unsubscribe(); return; } // on=true path const currentPermission = typeof Notification !== 'undefined' ? Notification.permission : 'default'; if (currentPermission !== 'granted') { // 'default' → caller must use tap-gated subscribe(); 'denied' → no-op return; } // Permission is granted — silently subscribe without a tap gesture // (already have OS permission, no dialog required). if (!navigator.serviceWorker) return; try { const registration = await navigator.serviceWorker.ready; const existingSub = await registration.pushManager.getSubscription(); if (existingSub) { // Already subscribed — just flip the stored flag back on. persistNotificationsEnabled(true); setIsSubscribed(true); return; } const vapidKey = await fetchVapidKey(); const sub = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidKey), }); const res = await fetch('/api/push/subscription', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(sub.toJSON()), }); if (res.ok) { persistNotificationsEnabled(true); setIsSubscribed(true); } } catch { // Ignore — non-fatal; user can retry via toggle } }; return { subscribe, unsubscribe, permission, isSubscribed, setEnabled }; } /** * Prefetch the VAPID key and cache in sessionStorage. * Call this in a useEffect when the permission prompt mounts — ensures the key * is ready before the user taps "Enable Notifications". */ export async function prefetchVapidKey(): Promise { try { await fetchVapidKey(); } catch { // Non-fatal — the subscribe() call will retry if sessionStorage miss } } /** * Read whether the user has previously enabled notifications * (localStorage.notificationsEnabled === '1'). * Exported for use in PermissionDeniedBanner and SettingsSheet initial state. */ export { readNotificationsEnabled };