import { useCallback, useEffect, useState } from "react"; const BASE = import.meta.env.BASE_URL.replace(/\/$/, ""); type PushStatus = | "unsupported" | "denied" | "default" | "subscribed"; /** * Granular reason returned by `enable()` so the calling UI can show a * specific toast instead of a generic "could not enable" message. * * - `unsupported` — browser lacks ServiceWorker / PushManager / Notification. * - `unsupported_ios_safari` — iPhone/iPad Safari that isn't installed as a PWA. * iOS only delivers Web Push when the page runs * from the Home Screen (`navigator.standalone === true`). * - `permission_denied` — user (or OS) declined the system permission prompt. * - `vapid_unavailable` — backend `/api/push/vapid-public-key` is missing/down. * - `subscribe_failed` — `pushManager.subscribe()` threw (e.g. AbortError). * - `server_rejected` — backend `/api/push/subscribe` returned non-2xx. */ export type EnableReason = | "unsupported" | "unsupported_ios_safari" | "permission_denied" | "permission_dismissed" | "vapid_unavailable" | "subscribe_failed" | "server_rejected"; export type EnableResult = | { ok: true } | { ok: false; reason: EnableReason }; function urlBase64ToUint8Array(base64: string): Uint8Array { const padding = "=".repeat((4 - (base64.length % 4)) % 4); const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/"); const raw = atob(b64); const out = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; } /** * True when the page is running on iPhone/iPad Safari that hasn't been * installed to the Home Screen. iOS Safari exposes the Push APIs even * in normal browsing, but `subscribe()` only succeeds in standalone * PWA mode — calling it from a regular tab fails silently in some * iOS builds, which is what was producing the generic * "تعذّر تفعيل الإشعارات" toast. */ export function isIosSafariNonStandalone(): boolean { if (typeof window === "undefined" || typeof navigator === "undefined") { return false; } const ua = navigator.userAgent || ""; // iPhone / iPod / iPad. Modern iPadOS reports MacIntel + touch // support instead of "iPad", so we check for that too. const isIos = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === "MacIntel" && typeof (navigator as Navigator & { maxTouchPoints?: number }) .maxTouchPoints === "number" && (navigator as Navigator & { maxTouchPoints: number }).maxTouchPoints > 1); if (!isIos) return false; // On iOS, Chrome (CriOS), Firefox (FxiOS), Edge (EdgiOS), Opera // (OPiOS), DuckDuckGo (DuckDuckGo), etc. all wrap WebKit but cannot // register a push subscription even after Add-to-Home-Screen — only // mobile Safari can. So only show the install-steps prompt when the // user is actually in Safari; otherwise there's nothing actionable // they can do regardless of installing to the Home Screen. const isSafari = /Safari\//.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS|DuckDuckGo|YaBrowser|GSA\//.test(ua); if (!isSafari) return false; const standalone = (navigator as Navigator & { standalone?: boolean }).standalone === true || (typeof window.matchMedia === "function" && window.matchMedia("(display-mode: standalone)").matches); return !standalone; } function isSupported(): boolean { return ( typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window ); } async function getRegistration(): Promise { if (!("serviceWorker" in navigator)) return null; // Wait for the SPA's registration in main.tsx to settle. `ready` // resolves once an active SW controls the page. return navigator.serviceWorker.ready; } /** * Hook that exposes the user's current Web Push subscription state and * `enable()` / `disable()` actions. Talks to the backend at * `${BASE}/api/push/*`. * * Web Push on iOS Safari only works when the page is installed as a * PWA (Add to Home Screen). The hook surfaces "unsupported" in that * case so the UI can prompt the user to install first. * * #576: `enable()` now returns `{ ok, reason }` instead of a bare * boolean so the calling UI can render a specific toast (iOS-needs-PWA, * permission denied, VAPID missing, subscribe failed, server rejected) * instead of a generic "could not enable" message that left the user * unable to diagnose why notifications wouldn't turn on. */ export function usePushSubscription() { const [status, setStatus] = useState(() => !isSupported() ? "unsupported" : Notification.permission === "denied" ? "denied" : "default", ); const [busy, setBusy] = useState(false); const refresh = useCallback(async () => { if (!isSupported()) { setStatus("unsupported"); return; } if (Notification.permission === "denied") { setStatus("denied"); return; } const reg = await getRegistration(); const sub = await reg?.pushManager.getSubscription(); if (sub) setStatus("subscribed"); else setStatus(Notification.permission === "granted" ? "default" : "default"); }, []); useEffect(() => { void refresh(); }, [refresh]); const enable = useCallback(async (): Promise => { if (!isSupported()) { setStatus("unsupported"); return { ok: false, reason: "unsupported" }; } // #576: iOS Safari outside Home Screen — subscribe() will fail with // a confusing AbortError. Surface the real cause so the user knows // to install the PWA first. if (isIosSafariNonStandalone()) { return { ok: false, reason: "unsupported_ios_safari" }; } setBusy(true); try { const perm = await Notification.requestPermission(); if (perm !== "granted") { setStatus(perm === "denied" ? "denied" : "default"); // Distinguish a hard "blocked" decision from the user just // dismissing the prompt without choosing — the messages need // to differ so we don't tell users to "unblock" when they // simply haven't decided yet. return { ok: false, reason: perm === "denied" ? "permission_denied" : "permission_dismissed", }; } const reg = await getRegistration(); if (!reg) return { ok: false, reason: "subscribe_failed" }; let publicKey: string; try { const keyRes = await fetch(`${BASE}/api/push/vapid-public-key`, { credentials: "include", }); if (!keyRes.ok) { return { ok: false, reason: "vapid_unavailable" }; } publicKey = ((await keyRes.json()) as { publicKey: string }).publicKey; if (!publicKey) return { ok: false, reason: "vapid_unavailable" }; } catch { return { ok: false, reason: "vapid_unavailable" }; } // If a stale subscription exists from a previous VAPID key, // unsubscribe locally first so applicationServerKey isn't // mismatched. const existing = await reg.pushManager.getSubscription(); if (existing) { try { await existing.unsubscribe(); } catch { /* ignore */ } } let sub: PushSubscription; try { sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) .buffer as ArrayBuffer, }); } catch { return { ok: false, reason: "subscribe_failed" }; } const json = sub.toJSON(); const subRes = await fetch(`${BASE}/api/push/subscribe`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint: json.endpoint, keys: json.keys, }), }); if (!subRes.ok) { try { await sub.unsubscribe(); } catch { /* ignore */ } return { ok: false, reason: "server_rejected" }; } setStatus("subscribed"); return { ok: true }; } catch { return { ok: false, reason: "subscribe_failed" }; } finally { setBusy(false); } }, []); const disable = useCallback(async (): Promise => { if (!isSupported()) return; setBusy(true); try { const reg = await getRegistration(); const sub = await reg?.pushManager.getSubscription(); if (sub) { const endpoint = sub.endpoint; try { await sub.unsubscribe(); } catch { /* ignore */ } await fetch(`${BASE}/api/push/unsubscribe`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint }), }).catch(() => {}); } setStatus(Notification.permission === "denied" ? "denied" : "default"); } finally { setBusy(false); } }, []); return { status, busy, enable, disable, refresh }; }