/** * @file Shows a browser notification — the single `notify()` both * `useNotifications` (live events) and Settings' "Send Test Notification" * button call, so the test proves the real delivery path and can report * where it actually landed instead of a bare fire-and-forget. * @author Nguyễn Ngọc Trí Vĩ */ export type NotifyVia = "native" | "push" | "local" | "blocked" | "none"; export interface NotifyError { statusCode?: number; message?: string; } export interface NotifyResult { via: NotifyVia; errors: NotifyError[]; } /** * Prefers a server-relayed push (so it can arrive even if this tab isn't the * active one, or the browser is backgrounded) and falls back to a local * service-worker/`Notification` call when the server didn't deliver. * @param title Notification title. * @param body Notification body text. * @param url Same-origin path to focus/navigate to on click (e.g. the lane * that raised this notification). Omit for notifications with no target. */ export async function notify(title: string, body: string, url?: string): Promise { if (!("Notification" in window) || Notification.permission !== "granted") { return { via: "blocked", errors: [] }; } const errors: NotifyError[] = []; // A 200 from /api/push/send means the request was ACCEPTED, not that anything // reached the user: with no push subscription (no service worker, a // non-secure origin, a browser that never subscribed) or a push service the // server cannot reach, it answers `{ok:true, native:false, pushed:0}` and // nobody is notified. Only `native` or `pushed > 0` is actual delivery — // anything else falls through to the local notification below, which is why // the fallback is keyed on the RESULT rather than on fetch throwing. try { const res = await fetch("/api/push/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, body, url }), }); const result = res.ok ? await res.json().catch(() => null) : null; if (result?.native) return { via: "native", errors }; if (result && result.pushed > 0) return { via: "push", errors }; if (!res.ok) errors.push({ statusCode: res.status }); } catch (e) { errors.push({ message: e instanceof Error ? e.message : String(e) }); } try { // Vector, not `/favicon.ico` (32x32 raster) — crisp at whatever size the // OS renders it, matching the icon server/lib/push.js already sends. const data = url ? { url } : undefined; if ("serviceWorker" in navigator) { const registration = await navigator.serviceWorker.ready; await registration.showNotification(title, { body, icon: "/favicon.svg", silent: false, data, }); } else { const n = new Notification(title, { body, icon: "/favicon.svg", data }); if (url) { // No service worker to catch the click here — navigate this tab directly. n.onclick = () => { window.focus(); window.location.assign(url); }; } } return { via: "local", errors }; } catch (e) { errors.push({ message: e instanceof Error ? e.message : String(e) }); return { via: "none", errors }; } }