Files
TX/artifacts/tx-os/src/hooks/use-push-subscription.ts
T
Riyadh bf5bfa29c4 #576: diagnose push enable + open full edit page from quick actions
Three connected fixes on the Executive Meetings schedule.

1) Push enable diagnostics (use-push-subscription.ts):
   - `enable()` now returns { ok, reason } instead of a bare boolean.
   - Reasons: unsupported, unsupported_ios_safari, permission_denied,
     vapid_unavailable, subscribe_failed, server_rejected.
   - Detects iPhone/iPad Safari not running as a PWA (checks
     navigator.standalone + display-mode media query, with iPadOS
     MacIntel + maxTouchPoints fallback) and short-circuits with
     unsupported_ios_safari so the user gets a concrete
     "open from Home Screen first" message instead of a silent fail.
   - Wrapped VAPID fetch and pushManager.subscribe in their own
     try/catches so each failure mode maps to its own reason.

2) Specific failure toasts (push-enable-prompt.tsx,
   notification-settings.tsx):
   - Both call sites now look up
     `notifSettings.push.errors.<reason>` and fall back to the
     legacy generic copy if the key is missing.
   - Added enableSuccess toast on the toggle path too — previously
     only the prompt-card path showed feedback on success.
   - New AR + EN strings under notifSettings.push.errors.*.

3) Quick-actions row sheet (executive-meetings.tsx):
   - Edit + Postpone buttons now sit SIDE BY SIDE on every viewport
     (flex + flex-1 + min-w-0 instead of flex-wrap + min-w-[11rem]),
     same height, equal width — fixes the stacked / over-sized Edit
     button shown in the user's iPhone screenshot.
   - "Edit" no longer opens the small Meeting edit Dialog. It now
     flips the whole schedule into edit mode (same as the toolbar
     pencil) and paints a transient accent ring on the originating
     row (~1.6s) + smooth-scrolls it into view. Matches image 3 in
     #576 where the user expects to see the full editable list.
   - Added `highlightEdit` prop on MeetingRow that composes an
     extra inset+outer ring on top of the existing quickOpen /
     selection / current / press shadows, plus a soft background
     tint, and clears automatically when ScheduleSection clears
     highlightEditRowId.

Out of scope (left for follow-up tasks): server-side push delivery
(payloads/sounds), the edit-mode UI itself.
2026-05-17 17:19:42 +00:00

247 lines
8.2 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"
| "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.
*/
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;
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");
return { ok: false, reason: "permission_denied" };
}
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 };
}