/** * Browser Notification helper. Pure-ish wrapper around the Web * Notification API plus a localStorage flag for the operator's * opt-in preference. We use this for surfaced, in-tab notifications * — when a tab is open, fire a notification on: * * - reminder.fired (status=success / partial) * - send_test.done (ok=true) * * For background push (closed app) we'd need a service-worker * subscription + VAPID, which is a bigger lift. This module is the * foundation that swap-in lands on later. */ const STORAGE_KEY = "cmbot.notifications.optedIn"; export type NotificationPermission = "default" | "granted" | "denied"; export type NotificationCapability = "supported" | "unsupported"; /** Detect whether the browser exposes the Notification API at all. */ export function notificationSupport(): NotificationCapability { if (typeof window === "undefined") return "unsupported"; if (typeof window.Notification === "undefined") return "unsupported"; return "supported"; } /** Read the current permission state, treating unsupported browsers * as "denied" so callers don't have to handle a third state. */ export function getPermission(): NotificationPermission { if (notificationSupport() === "unsupported") return "denied"; return window.Notification.permission as NotificationPermission; } /** Has the operator opted in (saved in localStorage)? */ export function isOptedIn(): boolean { if (typeof window === "undefined") return false; try { return window.localStorage.getItem(STORAGE_KEY) === "1"; } catch { return false; } } export function setOptedIn(value: boolean): void { if (typeof window === "undefined") return; try { if (value) { window.localStorage.setItem(STORAGE_KEY, "1"); } else { window.localStorage.removeItem(STORAGE_KEY); } } catch { // localStorage can throw in private mode / quota — non-fatal. } } /** * Ask the browser for permission. Resolves with the resulting * permission state. Idempotent if already granted/denied. */ export async function requestPermission(): Promise { if (notificationSupport() === "unsupported") return "denied"; const result = await window.Notification.requestPermission(); return result as NotificationPermission; } export interface ShowNotificationOptions { title: string; body?: string; /** Stable identifier so repeats of the same event coalesce. */ tag?: string; /** Path the click handler should navigate to (page reuses or * opens a new tab). Optional. */ href?: string; /** Override the default icon; defaults to `/icon-192.png`. */ icon?: string; } export type NotificationDispatch = | { ok: true; tag: string | undefined } | { ok: false; reason: "unsupported" | "permission" | "opted-out" | "error"; error?: string }; /** * Show a notification if everything's lined up: * - browser supports it * - operator has opted in * - permission is granted * * Returns a discriminated result so callers can decide whether to * fall back to a UI toast. */ export function showNotification(opts: ShowNotificationOptions): NotificationDispatch { if (notificationSupport() === "unsupported") { return { ok: false, reason: "unsupported" }; } if (!isOptedIn()) { return { ok: false, reason: "opted-out" }; } if (getPermission() !== "granted") { return { ok: false, reason: "permission" }; } try { const n = new window.Notification(opts.title, { body: opts.body, tag: opts.tag, icon: opts.icon ?? "/icon-192.png", badge: "/icon-192.png", }); if (opts.href) { n.onclick = () => { try { window.focus(); window.location.href = opts.href!; } catch { // ignore — focus can fail under iframe sandboxing } }; } return { ok: true, tag: opts.tag }; } catch (err) { return { ok: false, reason: "error", error: (err as Error).message }; } } // --------------------------------------------------------------------------- // Event → notification mapping // --------------------------------------------------------------------------- /** * Map a `reminder.fired` SSE event into the notification arguments * the manager component should call `showNotification` with. * * Returns null when the run isn't worth notifying about (e.g. * `skipped` runs are bookkeeping noise, not user-facing events). */ export function reminderFiredToNotification(event: { type: "reminder.fired"; reminderId: string; runId: string; status: string; sent?: number; total?: number; }): ShowNotificationOptions | null { if (event.status === "skipped") return null; const headline = event.status === "success" ? "Reminder sent" : event.status === "paused" ? "Reminder paused" : event.status === "partial" ? "Reminder partly sent" : "Reminder failed"; let body = event.status === "success" ? "All groups received the message." : event.status === "paused" ? "Delivery window closed before all groups got the message." : event.status === "partial" ? "Some groups received the message; others failed. See activity." : "No groups received the message. See activity."; if (event.status === "paused" && event.sent !== undefined && event.total !== undefined) { body = `${event.sent} of ${event.total} groups delivered. Tap to resume or cancel.`; } else if ( event.status === "partial" && event.sent !== undefined && event.total !== undefined ) { body = `${event.sent} of ${event.total} groups delivered. See activity for details.`; } return { title: headline, body, // Coalesce repeat fires of the same reminder on the same tab — // only the most recent one stays visible. tag: `reminder:${event.reminderId}`, href: `/reminders/${event.reminderId}`, }; } /** * Map a `send_test.done` SSE event into notification arguments. * Returns null on the failure case so the in-page toast (which * shows the error verbatim) stays the source of truth. */ export function sendTestDoneToNotification(event: { type: "send_test.done"; groupId: string; ok: boolean; }): ShowNotificationOptions | null { if (!event.ok) return null; return { title: "Test message sent", body: "WhatsApp delivered the test message.", tag: `send-test:${event.groupId}`, href: `/groups/${event.groupId}`, }; }