"use client"; /** * Attention cues (server-26#238) — the opt-in half: an audible tone and * browser notifications, both OFF by default. * * Tone: Web Audio beep, no asset. Browsers only let audio start from a user * gesture, so the AudioContext is created by the click that turns the tone * on (and that click plays one confirmation beep). After a reload with the * tone already on, the first click/key anywhere on the page re-arms it; until * then nothing plays and the button says so. * * Notifications: Notification.requestPermission() on click; fired only while * the tab is hidden (a visible wall already shows the lane and pulse). The * body ends with the Gate A machine-output notice — the title it carries is * pipeline output. No transcript text goes into a notification. */ import { useCallback, useEffect, useRef, useState } from "react"; import { DEFAULT_KEYWORDS, parseKeywords, type AttentionEvent, type AttentionPrefs } from "@/lib/attention"; import { MACHINE_OUTPUT_NOTICE_TEXT } from "@/components/ui/MachineOutputNotice"; const TONE_GAP_MS = 4_000; const MAX_NOTIFICATIONS = 3; // ── Audio (one context per page, only ever created inside a user gesture) ──── let audioCtx: AudioContext | null = null; function createAudio(): AudioContext | null { try { if (!audioCtx) { const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!AC) return null; audioCtx = new AC(); } if (audioCtx.state === "suspended") void audioCtx.resume().catch(() => undefined); return audioCtx; } catch { return null; } } /** n short beeps, alternating 880/660 Hz. Silent unless a gesture already started the context. */ function beep(n: number) { const a = audioCtx; if (!a || a.state !== "running") return; try { for (let i = 0; i < n; i++) { const t0 = a.currentTime + i * 0.22; const osc = a.createOscillator(); const gain = a.createGain(); osc.type = "sine"; osc.frequency.value = i % 2 ? 660 : 880; gain.gain.setValueAtTime(0.0001, t0); gain.gain.exponentialRampToValueAtTime(0.25, t0 + 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.18); osc.connect(gain).connect(a.destination); osc.start(t0); osc.stop(t0 + 0.2); } } catch { /* audio unavailable */ } } type NotifyState = "unsupported" | "default" | "granted" | "denied"; function notifyState(): NotifyState { if (typeof window === "undefined" || !("Notification" in window)) return "unsupported"; return Notification.permission as NotifyState; } const HEADLINE: Record = { major: "Major incident", escalated: "Severity escalated", stop: "Pursuit path: new stop", keyword: "Priority keyword", }; /** * Fires the opted-in cues for a batch of in-view events. Returns `fire` (pass * to useAttention's onEvents) and the live audio state for the controls. */ export function useAttentionCues(prefs: AttentionPrefs, onOpenIncident: (id: string) => void) { const [armed, setArmed] = useState(false); const lastTone = useRef(0); const prefsRef = useRef(prefs); prefsRef.current = prefs; const openRef = useRef(onOpenIncident); openRef.current = onOpenIncident; // Tone on: track the context's state. Already on from an earlier visit → // the next gesture anywhere on the page creates/resumes the context. useEffect(() => { if (!prefs.tone) { setArmed(false); return; } const sync = () => setArmed(audioCtx?.state === "running"); const arm = () => { const a = createAudio(); if (a) { a.onstatechange = sync; a.resume().then(sync, () => undefined); } }; if (audioCtx) { audioCtx.onstatechange = sync; sync(); } window.addEventListener("pointerdown", arm, { once: true }); window.addEventListener("keydown", arm, { once: true }); return () => { window.removeEventListener("pointerdown", arm); window.removeEventListener("keydown", arm); }; }, [prefs.tone]); const fire = useCallback((events: AttentionEvent[]) => { if (!events.length) return; const p = prefsRef.current; if (p.tone && Date.now() - lastTone.current > TONE_GAP_MS) { lastTone.current = Date.now(); // Three beeps for anything reaching Major or a priority keyword, one otherwise. beep(events.some((e) => e.kind === "major" || e.kind === "keyword" || e.label.endsWith("Major")) ? 3 : 1); } if (!p.notify || typeof document === "undefined" || !document.hidden || notifyState() !== "granted") return; const send = (title: string, body: string, tag: string, incidentId: string | null) => { try { const n = new Notification(title, { body: `${body}\n${MACHINE_OUTPUT_NOTICE_TEXT}`, tag }); n.onclick = () => { window.focus(); if (incidentId) openRef.current(incidentId); n.close(); }; } catch { /* e.g. mobile Chrome needs a service worker — silently no notification */ } }; for (const e of events.slice(0, MAX_NOTIFICATIONS)) { const head = e.kind === "keyword" ? `${HEADLINE.keyword}: ${e.label}` : e.kind === "escalated" ? `${HEADLINE.escalated}: ${e.label.replace(/^↑\s*/, "")}` : HEADLINE[e.kind]; send(`DRB · ${head}`, e.title, `drb-attn:${e.incidentIds[0] ?? e.id}`, e.incidentIds[0] ?? null); } if (events.length > MAX_NOTIFICATIONS) { send("DRB · More activity", `${events.length - MAX_NOTIFICATIONS} more on the live wall.`, "drb-attn:more", null); } }, []); return { fire, armed }; } interface Props { prefs: AttentionPrefs; onChange: (p: AttentionPrefs) => void; armed: boolean; } export function AttentionControls({ prefs, onChange, armed }: Props) { const [open, setOpen] = useState(false); const [perm, setPerm] = useState("default"); const [kwDraft, setKwDraft] = useState(prefs.keywords.join(", ")); const boxRef = useRef(null); useEffect(() => { setPerm(notifyState()); }, [open]); useEffect(() => { setKwDraft(prefs.keywords.join(", ")); }, [prefs.keywords]); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [open]); const toggleTone = () => { if (prefs.tone) { onChange({ ...prefs, tone: false }); return; } // This click is the gesture that unlocks audio; confirm with one beep. const a = createAudio(); onChange({ ...prefs, tone: true }); if (a) a.resume().then(() => beep(1), () => undefined); }; const toggleNotify = async () => { if (prefs.notify) { onChange({ ...prefs, notify: false }); return; } if (notifyState() === "unsupported") { setPerm("unsupported"); return; } let p: NotifyState = notifyState(); if (p === "default") { try { p = (await Notification.requestPermission()) as NotifyState; } catch { p = notifyState(); } } if (p === "granted") { // Confirm with one notification (as the tone confirms with a beep). It // doubles as the support check: mobile Chrome grants permission but // throws on `new Notification()` without a service worker, which would // otherwise leave a switched-on control that never delivers anything. try { new Notification("DRB · Notifications on", { body: `Cues will appear here while the live wall tab is hidden.\n${MACHINE_OUTPUT_NOTICE_TEXT}`, tag: "drb-attn:on", }); } catch { p = "unsupported"; } } setPerm(p); if (p === "granted") onChange({ ...prefs, notify: true }); }; const commitKeywords = () => { const next = parseKeywords(kwDraft); if (next.join("\u0001") !== prefs.keywords.join("\u0001")) onChange({ ...prefs, keywords: next }); else setKwDraft(prefs.keywords.join(", ")); }; const notifyOn = prefs.notify && perm !== "denied" && perm !== "unsupported"; const summary = [prefs.tone ? (armed ? "tone" : "tone (click to arm)") : null, notifyOn ? "notify" : null].filter(Boolean).join(" · ") || "off"; const on = prefs.tone || notifyOn; return (
{open && (

Cues fire on: a Major opened, severity escalated, a pursuit path gaining a stop, or a priority keyword in a new call. Only for incidents the current filter shows.

setKwDraft(e.target.value)} onBlur={commitKeywords} onKeyDown={(e) => { if (e.key === "Enter") commitKeywords(); }} placeholder="comma-separated" className="w-full px-2 py-1 rounded border border-line bg-raised text-ink" />
Matched against new call transcripts.
)}
); }