/** Announcing a finished turn, so the reader hears about it from the next tab over. */ const KEY = "berry.notifications"; /** Whether this browser announces finished turns; permission belongs to the browser, so the choice does too. */ export function notificationsOn(): boolean { return localStorage.getItem(KEY) === "on"; } /** Whether this page can announce at all: browsers keep notifications to secure contexts. */ export const notificationsAvailable = () => typeof Notification !== "undefined"; /** Switch announcements on or off, asking for permission first; resolves whether they are on. */ export async function setNotifications(on: boolean): Promise { if (!on) { localStorage.removeItem(KEY); return false; } if (!notificationsAvailable()) return false; // browsers only grant permission from a gesture, so the settings switch has to be the ask const permission = Notification.permission === "default" ? await Notification.requestPermission() : Notification.permission; if (permission !== "granted") return false; localStorage.setItem(KEY, "on"); return true; } export type Notice = { tid: string; title: string; body: string }; /** What announcing needs of a conversation's summary. */ type Settled = { busy: boolean; unread: boolean; reply?: string }; /** * Follow a summaries feed and announce the conversations that stopped answering since the last one, except whichever * one is on screen in a window someone is looking at. */ export function announcements(options: { /** The conversation in view. */ active: () => string; /** What to call a conversation. */ name: (tid: string) => string; /** Where announcements go: the browser, unless a test says otherwise. */ speak?: (notice: Notice) => void; }): (summaries: Record) => void { const speak = options.speak ?? speakThroughBrowser; let previous: Record = {}; return (next) => { const before = previous; previous = next; if (!notificationsOn()) return; for (const [tid, summary] of Object.entries(next)) { if (before[tid]?.busy !== true || summary.busy || !summary.unread) continue; // an open conversation still deserves a notice once the reader looks elsewhere if ( document.visibilityState === "visible" && document.hasFocus() && options.active() === tid ) { continue; } speak({ tid, title: options.name(tid), body: summary.reply || "finished answering" }); } }; } function speakThroughBrowser({ tid, title, body }: Notice): void { if (!notificationsAvailable() || Notification.permission !== "granted") return; const notice = new Notification(title, { body, tag: tid }); notice.onclick = () => { document.defaultView?.focus(); location.hash = `#/${tid}`; notice.close(); }; }