diff --git a/app/hooks/usePushNotifications.ts b/app/hooks/usePushNotifications.ts index c6aa4f3..e3193c6 100644 --- a/app/hooks/usePushNotifications.ts +++ b/app/hooks/usePushNotifications.ts @@ -1,6 +1,7 @@ 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. @@ -8,10 +9,42 @@ import { getApiBaseUrl } from "~/utils/api-config"; * 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, "/"); @@ -62,8 +95,8 @@ export function usePushNotifications() { return; } setPermission(Notification.permission); - navigator.serviceWorker.ready - .then((reg) => reg.pushManager.getSubscription()) + 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) { @@ -80,38 +113,97 @@ export function usePushNotifications() { 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 Notification.requestPermission(); + const perm = await withTimeout( + Notification.requestPermission(), + STEP_TIMEOUT_MS, + "requestPermission", + ); setPermission(perm); - if (perm !== "granted") return false; + 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 fetch(`${apiBase}/api/notif/v1/push/vapid-public-key/`); + 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 navigator.serviceWorker.ready; - let sub = await reg.pushManager.getSubscription(); + 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 reg.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(publicKey), - }); + sub = await withTimeout( + reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }), + STEP_TIMEOUT_MS, + "pushManager.subscribe", + ); } - const res = await fetch(`${apiBase}/api/notif/v1/push/subscribe/`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify(sub.toJSON()), - }); + 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); + // Anything unexpected — surface it so the seller knows to retry. + toast({ + title: "فعال‌سازی اعلان با خطا مواجه شد", + description: "لطفاً چند لحظه بعد دوباره تلاش کنید.", + variant: "destructive", + }); return false; } finally { setBusy(false); @@ -122,8 +214,16 @@ export function usePushNotifications() { if (!isPushSupported()) return; setBusy(true); try { - const reg = await navigator.serviceWorker.ready; - const sub = await reg.pushManager.getSubscription(); + 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", @@ -133,6 +233,8 @@ export function usePushNotifications() { await sub.unsubscribe().catch(() => {}); } setIsSubscribed(false); + } catch (e) { + console.error("Push disable failed:", e); } finally { setBusy(false); }