Sellers running from an installed PWA still hit failures inside subscribe (iOS 16.4+ PWA has its own quirks even when standalone), and a generic "لطفاً چند لحظه بعد دوباره تلاش کنید" doesn't tell whoever is helping them what actually broke. The catch handler now surfaces the underlying Error.message (e.g. "timeout: pushManager.subscribe", "NotAllowedError") in the toast description so field debugging works over screenshots. Co-Authored-By: Claude <noreply@anthropic.com>
255 lines
8.9 KiB
TypeScript
255 lines
8.9 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);
|
|
// 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<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,
|
|
};
|
|
}
|