Files
AgentHub/client/src/lib/notify.ts
T
nntrivi2001 b71024f244 feat(ui): Orca Dark / Orca Light themes and notification sounds
Two optional named themes from Orca's neutral palette join the Appearance
picker (Orca Dark keeps a blue accent so white-on-accent buttons stay
legible; Orca Light's status colours are darkened to clear 4.5:1 on their
chips). A shown notification can play a sound — None (default), Chime, Pop
or Alert — chosen globally and per notification type in Settings →
Notifications; the WAVs are generated by scripts/gen-notification-sounds.js.
2026-10-01 17:10:46 +07:00

105 lines
3.8 KiB
TypeScript

/**
* @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. `playSound`
* plays one of the generated `public/sounds/*.wav` (see
* `scripts/gen-notification-sounds.js`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { NotifSound } from "./notifPrefs";
const audio = new Map<NotifSound, HTMLAudioElement>();
/** Plays a notification sound from the start; "none" plays nothing. A browser
* that refuses autoplay before a user gesture is left to refuse quietly. */
export function playSound(kind: NotifSound): void {
if (kind === "none") return;
let el = audio.get(kind);
if (!el) {
el = new Audio(`/sounds/${kind}.wav`);
audio.set(kind, el);
}
el.currentTime = 0;
void el.play()?.catch(() => {});
}
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<NotifyResult> {
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 };
}
}