In-tab notification bridge so the operator gets a system notification
when a reminder fires successfully (or partly / fails) and when a
send-test message lands. Foundation for true background push later
(VAPID + service-worker subscription); this lands the wiring so
behaviour is testable today.
Pieces
------
- `lib/notifications.ts` — pure helper module:
* notificationSupport / getPermission — feature detection that
treats the SSR / unsupported-browser case as "denied" so callers
don't have to handle a third state.
* isOptedIn / setOptedIn — localStorage-backed opt-in flag
(key `cmbot.notifications.optedIn`). Survives gracefully when
window is missing or storage throws (private mode / quota).
* showNotification(opts) — gated dispatch returning a discriminated
result ({ ok: true, tag } | { ok: false, reason }) so callers
can fall back to a UI toast on opt-out / unsupported / error.
* reminderFiredToNotification + sendTestDoneToNotification —
pure mappers from the bot's SSE events into notification args.
Skips bookkeeping noise (status === "skipped") and failures
that the in-page toast already shows verbatim.
- `components/notification-manager.tsx` — client component mounted
once at the app shell. Subscribes to `reminder.fired` and
`send_test.done` via useEvents and forwards each through the pure
mappers. Renders no DOM.
- `components/notifications-toggle.tsx` — settings-page card with
three states (unsupported / not-granted / granted+opted-in).
"Send test" button fires a sample notification so the operator
can verify the wiring without waiting for a real reminder. The
blocked-by-browser path points them at site settings instead of
silently doing nothing.
- `app/settings/page.tsx` — new "Notifications" card sits above
the Appearance card.
- `app/layout.tsx` — `<NotificationManager />` rendered alongside
`<Toaster />` inside ThemeProvider so the SSE subscription is
active across all routes.
Bot side
--------
- `apps/bot/src/scheduler/fire-reminder.ts` — emits
`pgNotifyWeb({ type: "reminder.fired", reminderId, runId, status })`
after every run regardless of success/partial/failed. The web
side decides whether to surface it as a notification (skipped is
filtered out client-side).
- send_test.done was already emitted by `ipc/send-test-handler.ts`.
PWA service-worker tests (the original ask before this thread)
--------------------------------------------------------------
- Extracted the Serwist config into `pwa/config.ts` so the choices
(skipWaiting, clientsClaim, navigationPreload, runtimeCaching,
precacheEntries) are pinnable without booting a worker scope.
- 6 tests in `pwa/config.test.ts` lock the surface (no extra keys
appear silently, the manifest passes through unchanged, the
pinned booleans stay where production expects them).
- 6 tests in `app/manifest.webmanifest/route.test.ts` cover the
manifest contract (display=standalone, start_url=/, dark theme
colors match the OS, both icons are PNG + maskable, paths
match committed PNGs in public/).
Test counts
-----------
281 web + 31 shared + 26 bot = 338 total (was 306).
- +6 pwa/config (service-worker config pinning)
- +6 app/manifest.webmanifest (PWA manifest contract)
- +20 lib/notifications (full coverage of mappers + dispatch
gates + SSR / unsupported / blocked / opted-out paths)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
183 lines
5.8 KiB
TypeScript
183 lines
5.8 KiB
TypeScript
/**
|
|
* 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<NotificationPermission> {
|
|
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;
|
|
}): ShowNotificationOptions | null {
|
|
if (event.status === "skipped") return null;
|
|
const headline =
|
|
event.status === "success"
|
|
? "Reminder sent"
|
|
: event.status === "partial"
|
|
? "Reminder partly sent"
|
|
: "Reminder failed";
|
|
const body =
|
|
event.status === "success"
|
|
? "All groups received the message."
|
|
: event.status === "partial"
|
|
? "Some groups received the message; others failed. See activity."
|
|
: "No groups received the message. See activity.";
|
|
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}`,
|
|
};
|
|
}
|