The subscribe/disable flow awaited several browser APIs that on certain devices — most reliably iOS Safari in a non-installed PWA — return a Promise that never resolves or rejects (requestPermission, serviceWorker .ready, pushManager.subscribe). When any of them hung, the try/finally never ran, so `busy` stayed true forever: the dashboard nudge button stuck at "..." and the settings toggle refused to flip. Every awaited step is now wrapped in withTimeout(15s). Failures surface as toasts instead of silent hangs (subscribe success, permission denied, generic error). iOS Safari without the site installed to home screen — where Web Push physically cannot work — short-circuits with an add-to-home-screen instruction before we ever try, so we don't burn a timeout waiting for a doomed subscribe(). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
252 lines
8.7 KiB
TypeScript
252 lines
8.7 KiB
TypeScript
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<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
|
|
return new Promise<T>((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<ArrayBuffer> {
|
|
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<PermissionState>("default");
|
|
const [isSubscribed, setIsSubscribed] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const authHeaders = useCallback(() => {
|
|
const headers: Record<string, string> = { "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<boolean> => {
|
|
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);
|
|
// Anything unexpected — surface it so the seller knows to retry.
|
|
toast({
|
|
title: "فعالسازی اعلان با خطا مواجه شد",
|
|
description: "لطفاً چند لحظه بعد دوباره تلاش کنید.",
|
|
variant: "destructive",
|
|
});
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}, [apiBase, token, authHeaders]);
|
|
|
|
const disable = useCallback(async (): Promise<void> => {
|
|
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,
|
|
};
|
|
}
|