import { useCallback, useEffect, useState } from "react"; import { useAuthToken } from "./use-root-data"; import { getApiBaseUrl } from "~/utils/api-config"; /** * Web Push subscription management. * * Flow: ask permission -> fetch VAPID public key -> subscribe via the service * worker's PushManager -> POST the subscription to the backend. Disabling * unsubscribes locally and tells the backend to drop the row. */ type PermissionState = NotificationPermission | "unsupported"; 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); // Back the array with a concrete ArrayBuffer so it satisfies BufferSource. const outputArray = new Uint8Array(new ArrayBuffer(rawData.length)); for (let i = 0; i < rawData.length; i++) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } function isPushSupported(): boolean { return ( typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window ); } export function usePushNotifications() { const token = useAuthToken(); const apiBase = getApiBaseUrl(); const [permission, setPermission] = useState("default"); const [isSubscribed, setIsSubscribed] = useState(false); const [busy, setBusy] = useState(false); const authHeaders = useCallback(() => { const headers: Record = { "Content-Type": "application/json" }; if (token) headers["Authorization"] = `Bearer ${token}`; return headers; }, [token]); // Reflect current permission + subscription state on mount. // // We *also* re-POST the sub to the backend on every mount. Reasons: // - iOS Safari silently expires push subs after a few days of inactivity; // the local `getSubscription()` still returns the token but the backend // row may have been 410'd and deleted. Re-POSTing recreates it. // - Multi-device: a sub registered on device A may not exist server-side // for device B. Idempotent upsert (`update_or_create` by endpoint) makes // this safe to do on every visit. useEffect(() => { if (!isPushSupported()) { setPermission("unsupported"); return; } setPermission(Notification.permission); navigator.serviceWorker.ready .then((reg) => reg.pushManager.getSubscription()) .then((sub) => { setIsSubscribed(!!sub); if (sub && token) { // Fire-and-forget refresh; failure is non-fatal. fetch(`${apiBase}/api/notif/v1/push/subscribe/`, { method: "POST", headers: authHeaders(), body: JSON.stringify(sub.toJSON()), }).catch(() => {}); } }) .catch(() => setIsSubscribed(false)); }, [apiBase, token, authHeaders]); const subscribe = useCallback(async (): Promise => { if (!isPushSupported() || !token) return false; setBusy(true); try { const perm = await Notification.requestPermission(); setPermission(perm); if (perm !== "granted") return false; // Get the VAPID public key from the backend. The backend's view returns // {"public_key": ...} but its global CamelCaseJSONRenderer rewrites that // to {"publicKey": ...} on the wire — so we read camelCase here. const keyRes = await fetch(`${apiBase}/api/notif/v1/push/vapid-public-key/`); const { publicKey } = await keyRes.json(); if (!publicKey) return false; const reg = await navigator.serviceWorker.ready; let sub = await reg.pushManager.getSubscription(); if (!sub) { sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey), }); } const res = await fetch(`${apiBase}/api/notif/v1/push/subscribe/`, { method: "POST", headers: authHeaders(), body: JSON.stringify(sub.toJSON()), }); const ok = res.ok; setIsSubscribed(ok); return ok; } catch (e) { console.error("Push subscribe failed:", e); return false; } finally { setBusy(false); } }, [apiBase, token, authHeaders]); const disable = useCallback(async (): Promise => { if (!isPushSupported()) return; setBusy(true); try { const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.getSubscription(); if (sub) { await fetch(`${apiBase}/api/notif/v1/push/unsubscribe/`, { method: "POST", headers: authHeaders(), body: JSON.stringify({ endpoint: sub.endpoint }), }).catch(() => {}); await sub.unsubscribe().catch(() => {}); } setIsSubscribed(false); } finally { setBusy(false); } }, [apiBase, authHeaders]); return { supported: permission !== "unsupported", permission, isSubscribed, busy, subscribe, disable, }; }