import { useCallback, useEffect, useState } from "react"; import { useAuthToken } from "./use-root-data"; import { getApiBaseUrl } from "~/utils/api-config"; import { toast } from "./use-toast"; /** * 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. * * Every awaited step is guarded by a timeout — several of them (notably * `pushManager.subscribe` and `serviceWorker.ready` on iOS Safari when the * PWA isn't home-screen-installed) can return a Promise that never resolves. * Without timeouts the toggle wedges as "..." forever. */ type PermissionState = NotificationPermission | "unsupported"; const STEP_TIMEOUT_MS = 15000; function withTimeout(p: Promise, ms: number, label: string): Promise { return new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error(`timeout: ${label}`)), ms); p.then( (v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); }, ); }); } function isIosSafari(): boolean { if (typeof navigator === "undefined") return false; const ua = navigator.userAgent || ""; const isIos = /iPad|iPhone|iPod/.test(ua) && !("MSStream" in window); return isIos; } function isStandalonePwa(): boolean { if (typeof window === "undefined") return false; const mm = window.matchMedia?.("(display-mode: standalone)").matches; // iOS-specific standalone flag on the Navigator object. const nav = navigator as Navigator & { standalone?: boolean }; return !!(mm || nav.standalone); } 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); withTimeout(navigator.serviceWorker.ready, STEP_TIMEOUT_MS, "serviceWorker.ready") .then((reg) => withTimeout(reg.pushManager.getSubscription(), STEP_TIMEOUT_MS, "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; // iOS Safari only allows Web Push from a home-screen-installed PWA. In a // regular Safari tab, `pushManager.subscribe` returns a Promise that // never resolves — indistinguishable from a hang. Bail out early with a // clear message rather than wedging the UI. if (isIosSafari() && !isStandalonePwa()) { toast({ title: "برای فعال‌سازی، ابتدا سایت را به صفحه اصلی اضافه کنید", description: "در Safari دکمه اشتراک‌گذاری را بزنید و «Add to Home Screen» را انتخاب کنید، سپس از آیکن روی صفحه اصلی وارد شوید و دوباره تلاش کنید.", }); return false; } setBusy(true); try { const perm = await withTimeout( Notification.requestPermission(), STEP_TIMEOUT_MS, "requestPermission", ); setPermission(perm); if (perm !== "granted") { if (perm === "denied") { toast({ title: "اعلان‌ها در مرورگر مسدود شده‌اند", description: "از تنظیمات مرورگر یا PWA اعلان‌ها را برای این سایت فعال کنید و صفحه را رفرش کنید.", variant: "destructive", }); } 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 withTimeout( fetch(`${apiBase}/api/notif/v1/push/vapid-public-key/`), STEP_TIMEOUT_MS, "vapid-key fetch", ); const { publicKey } = await keyRes.json(); if (!publicKey) return false; const reg = await withTimeout( navigator.serviceWorker.ready, STEP_TIMEOUT_MS, "serviceWorker.ready", ); let sub = await withTimeout( reg.pushManager.getSubscription(), STEP_TIMEOUT_MS, "getSubscription", ); if (!sub) { sub = await withTimeout( reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey), }), STEP_TIMEOUT_MS, "pushManager.subscribe", ); } const res = await withTimeout( fetch(`${apiBase}/api/notif/v1/push/subscribe/`, { method: "POST", headers: authHeaders(), body: JSON.stringify(sub.toJSON()), }), STEP_TIMEOUT_MS, "backend subscribe", ); const ok = res.ok; setIsSubscribed(ok); if (ok) { toast({ title: "اعلان‌ها فعال شد", description: "از این پس اعلان‌ها را دریافت می‌کنید.", }); } return ok; } catch (e) { console.error("Push subscribe failed:", e); // Include the step name / error text in the toast so field debugging // doesn't need browser console access — sellers can read out what // failed to whoever's helping them. const detail = e instanceof Error ? e.message : String(e); toast({ title: "فعال‌سازی اعلان با خطا مواجه شد", description: `${detail || "خطای ناشناخته"}. لطفاً چند لحظه بعد دوباره تلاش کنید.`, variant: "destructive", }); return false; } finally { setBusy(false); } }, [apiBase, token, authHeaders]); const disable = useCallback(async (): Promise => { if (!isPushSupported()) return; setBusy(true); try { const reg = await withTimeout( navigator.serviceWorker.ready, STEP_TIMEOUT_MS, "serviceWorker.ready", ); const sub = await withTimeout( reg.pushManager.getSubscription(), STEP_TIMEOUT_MS, "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); } catch (e) { console.error("Push disable failed:", e); } finally { setBusy(false); } }, [apiBase, authHeaders]); return { supported: permission !== "unsupported", permission, isSubscribed, busy, subscribe, disable, }; }