a96b2570e3
PWA + Web Push infra was already fully built (SW, VAPID auto-gen,
subscribe/unsubscribe API, sendPushToUser called from orders/meetings/
notes/replies, iOS-PWA detection hook). The user wasn't getting
lock-screen alerts because iPhone/iPad weren't installed as a PWA —
iOS only delivers Web Push from a Home Screen icon, not a Safari tab.
This commit polishes the iOS install path so the gap is obvious:
1. push-enable-prompt.tsx — Added a dedicated iOS install-steps card
variant that renders on iPhone/iPad Safari when running outside
standalone mode. Shows Share → Add to Home Screen → open from icon.
Independent 14-day dismiss memory from the regular Enable card.
2. notification-settings.tsx — PushToggleRow now detects iOS-non-
standalone and shows the unsupported_ios_safari hint inline with a
disabled toggle, instead of teasing an Enable affordance that always
fails.
3. use-push-subscription.ts — Exported isIosSafariNonStandalone() so
both surfaces share the detection logic (was private).
4. lib/push.ts (server) — Added info logs in loadVapid() for both the
env-key and disk-key paths, removed an accidental duplicate disk-read
block introduced during editing. Now the production redeploy check
is a one-liner: grep for "VAPID keys loaded" in the api logs.
5. ar.json / en.json — Added notifSettings.push.iosInstall.{title,desc,
step1,step2,step3} bilingual strings for the new card.
No DB migrations. No deps changed. tx-os tsc passes; api-server tsc
errors are pre-existing (routes/push.ts handler type, font_settings)
and not touched by this change.
Smoke test on real iPhone/iPad still owed (out-of-band — task step #4
is a manual verification on the user's devices after redeploy).
266 lines
9.1 KiB
TypeScript
266 lines
9.1 KiB
TypeScript
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<ServiceWorkerRegistration | null> {
|
|
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<PushStatus>(() =>
|
|
!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<EnableResult> => {
|
|
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<void> => {
|
|
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 };
|
|
}
|