From ff364a8cf82596467eb581253d2b452d54815e71 Mon Sep 17 00:00:00 2001 From: ToryMic Date: Tue, 29 Sep 2026 04:51:49 -0400 Subject: [PATCH] [#1287] feat(frontend): add audible sound alert toggle and browser push notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High and critical alerts (CIRCUIT_BREAKER_TRIPPED, RESERVE_MISMATCH, ...) were only ever rendered in the notifications drawer, so an operator who switched to another window never noticed them. Adds audible and out-of-tab alerting for those two priorities: - `useAlertChime` — plays a chime through the Web Audio API. Critical gets a descending two-tone siren and high gets a single blip so the two are distinguishable without looking. One AudioContext is created lazily and reused (browsers cap how many a page may hold), and each oscillator is disconnected on `ended` so nodes do not accumulate over a long shift. - `useDesktopNotifications` — dispatches native notifications via the Web Notifications API, but only while the document is hidden: if the operator is already looking at the tab, a native popup would duplicate the drawer. - `useAlertNotifier` — watches the notification store and fires either channel for newly arrived high/critical alerts. The store is seeded with existing ids on mount so a page reload does not replay every historical alert. - Drawer switches for both channels, persisted through the existing (previously unused) `soundEnabled` and `notificationsEnabled` user preferences. Browser constraints are surfaced rather than swallowed: the audio switch arms the AudioContext in the same click, because browsers keep it suspended until a user gesture and the first chime would otherwise be silent; permission is only requested on the enabling gesture, since browsers reject prompts that are not user-initiated; and a denied permission explains how to unblock it. Adds 31 tests across the three hooks and the drawer controls. The existing 10 NotificationsDrawer tests still pass. --- .../NotificationsDrawer.alerts.test.tsx | 165 ++++++++++++ .../src/components/NotificationsDrawer.tsx | 107 ++++++++ frontend/src/hooks/useAlertChime.test.ts | 237 ++++++++++++++++++ frontend/src/hooks/useAlertChime.ts | 171 +++++++++++++ frontend/src/hooks/useAlertNotifier.test.ts | 197 +++++++++++++++ frontend/src/hooks/useAlertNotifier.ts | 97 +++++++ frontend/src/hooks/useDesktopNotifications.ts | 117 +++++++++ 7 files changed, 1091 insertions(+) create mode 100644 frontend/src/components/NotificationsDrawer.alerts.test.tsx create mode 100644 frontend/src/hooks/useAlertChime.test.ts create mode 100644 frontend/src/hooks/useAlertChime.ts create mode 100644 frontend/src/hooks/useAlertNotifier.test.ts create mode 100644 frontend/src/hooks/useAlertNotifier.ts create mode 100644 frontend/src/hooks/useDesktopNotifications.ts diff --git a/frontend/src/components/NotificationsDrawer.alerts.test.tsx b/frontend/src/components/NotificationsDrawer.alerts.test.tsx new file mode 100644 index 00000000..35ed0978 --- /dev/null +++ b/frontend/src/components/NotificationsDrawer.alerts.test.tsx @@ -0,0 +1,165 @@ +import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import NotificationsDrawer from "./NotificationsDrawer"; +import { useNotificationStore } from "../stores/notificationStore"; +import { useUserPreferencesStore } from "../stores/userPreferencesStore"; + +const enableSoundMock = vi.fn(); +const requestDesktopPermissionMock = vi.fn(async () => {}); + +let desktopPermission: "granted" | "denied" | "default" = "granted"; +let soundUnlocked = true; +let soundSupported = true; + +vi.mock("../hooks/useAlertChime", () => ({ + useAlertChime: () => ({ + supported: soundSupported, + unlocked: soundUnlocked, + unlock: enableSoundMock, + play: vi.fn(), + }), + isWebAudioSupported: () => soundSupported, +})); + +vi.mock("../hooks/useDesktopNotifications", () => ({ + useDesktopNotifications: () => ({ + support: desktopPermission, + requestPermission: requestDesktopPermissionMock, + notifyInBackground: vi.fn(() => false), + }), + getDesktopPermission: () => desktopPermission, + getDesktopNotificationSupport: () => ({}), +})); + +function resetStores() { + useNotificationStore.setState(useNotificationStore.getInitialState(), true); + useUserPreferencesStore.setState(useUserPreferencesStore.getInitialState(), true); +} + +function renderDrawer() { + return render( + + ); +} + +describe("NotificationsDrawer alert controls", () => { + beforeEach(() => { + resetStores(); + localStorage.clear(); + enableSoundMock.mockClear(); + requestDesktopPermissionMock.mockClear(); + desktopPermission = "granted"; + soundUnlocked = true; + soundSupported = true; + }); + + it("renders an audible alerts switch", () => { + renderDrawer(); + expect(screen.getByRole("switch", { name: /Audible alerts/i })).toBeInTheDocument(); + }); + + it("renders a desktop notifications switch", () => { + renderDrawer(); + expect( + screen.getByRole("switch", { name: /Desktop notifications/i }) + ).toBeInTheDocument(); + }); + + it("reflects the persisted sound preference", () => { + useUserPreferencesStore.setState({ soundEnabled: true }); + renderDrawer(); + expect(screen.getByRole("switch", { name: /Audible alerts/i })).toHaveAttribute( + "aria-checked", + "true" + ); + }); + + it("toggles the sound preference and arms audio in the same gesture", () => { + useUserPreferencesStore.setState({ soundEnabled: false }); + renderDrawer(); + + fireEvent.click(screen.getByRole("switch", { name: /Audible alerts/i })); + + expect(enableSoundMock).toHaveBeenCalled(); + expect(useUserPreferencesStore.getState().soundEnabled).toBe(true); + }); + + it("requests notification permission when enabling desktop alerts", async () => { + useUserPreferencesStore.setState({ notificationsEnabled: false }); + renderDrawer(); + + await act(async () => { + fireEvent.click(screen.getByRole("switch", { name: /Desktop notifications/i })); + }); + + expect(requestDesktopPermissionMock).toHaveBeenCalled(); + expect(useUserPreferencesStore.getState().notificationsEnabled).toBe(true); + }); + + it("does not re-request permission when disabling desktop alerts", async () => { + useUserPreferencesStore.setState({ notificationsEnabled: true }); + renderDrawer(); + + await act(async () => { + fireEvent.click(screen.getByRole("switch", { name: /Desktop notifications/i })); + }); + + expect(requestDesktopPermissionMock).not.toHaveBeenCalled(); + expect(useUserPreferencesStore.getState().notificationsEnabled).toBe(false); + }); + + it("explains how to unblock notifications when permission is denied", () => { + desktopPermission = "denied"; + renderDrawer(); + expect(screen.getByText(/blocked/i)).toBeInTheDocument(); + }); + + it("prompts the operator to arm sound before the first chime", () => { + soundUnlocked = false; + useUserPreferencesStore.setState({ soundEnabled: true }); + renderDrawer(); + expect(screen.getByText(/block audio until you interact/i)).toBeInTheDocument(); + }); + + it("hides the arm-sound prompt once audio is unlocked", () => { + useUserPreferencesStore.setState({ soundEnabled: true }); + renderDrawer(); + expect(screen.queryByText(/block audio until you interact/i)).not.toBeInTheDocument(); + }); + + it("explains the missing API when Web Audio is unavailable", () => { + soundSupported = false; + renderDrawer(); + expect(screen.getByRole("switch", { name: /Audible alerts/i })).toBeDisabled(); + expect(screen.getByText(/Web Audio is unavailable/i)).toBeInTheDocument(); + }); + + it("keeps the existing notification list and actions working", () => { + useNotificationStore.getState().addNotification({ + id: "n-1", + type: "system", + priority: "critical", + title: "Circuit breaker tripped", + message: "Reserve mismatch detected", + timestamp: 5, + }); + + renderDrawer(); + + expect(screen.getByText("Circuit breaker tripped")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Mark all as read/i })).toBeInTheDocument(); + }); + + it("closes via the escape key without breaking on the new controls", async () => { + const onClose = vi.fn(); + render( + + ); + + await act(async () => { + fireEvent.keyDown(document, { key: "Escape" }); + }); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); +}); diff --git a/frontend/src/components/NotificationsDrawer.tsx b/frontend/src/components/NotificationsDrawer.tsx index eb08a151..5bf69fe8 100644 --- a/frontend/src/components/NotificationsDrawer.tsx +++ b/frontend/src/components/NotificationsDrawer.tsx @@ -5,6 +5,8 @@ import { type Notification, type NotificationPriority, } from "../stores/notificationStore"; +import { useUserPreferencesStore } from "../stores/userPreferencesStore"; +import { useAlertNotifier } from "../hooks/useAlertNotifier"; interface NotificationsDrawerProps { open: boolean; @@ -62,6 +64,17 @@ export default function NotificationsDrawer({ (state) => state.clearReadNotifications ); + const soundEnabled = useUserPreferencesStore((state) => state.soundEnabled); + const desktopNotificationsEnabled = useUserPreferencesStore( + (state) => state.notificationsEnabled + ); + const setPreference = useUserPreferencesStore((state) => state.setPreference); + + const notifier = useAlertNotifier({ + soundEnabled, + desktopEnabled: desktopNotificationsEnabled, + }); + const groupedNotifications = useMemo(() => { const visibleNotifications = notifications.filter((n) => !n.dismissed); return NOTIFICATION_PRIORITY_ORDER.reduce>( @@ -241,6 +254,100 @@ export default function NotificationsDrawer({ Clear read + +
+
+ + +
+ + {notifier.soundSupported && soundEnabled && !notifier.soundUnlocked && ( +

+ Tap the toggle once to arm sound — browsers block audio until you interact. +

+ )} + {!notifier.soundSupported && ( +

+ Web Audio is unavailable in this browser, so audible alerts are disabled. +

+ )} + +
+ + +
+ + {notifier.desktopPermission === "denied" && ( +

+ Browser notifications are blocked. Allow them in your browser site settings to + receive alerts while this tab is in the background. +

+ )} +

diff --git a/frontend/src/hooks/useAlertChime.test.ts b/frontend/src/hooks/useAlertChime.test.ts new file mode 100644 index 00000000..5f05df57 --- /dev/null +++ b/frontend/src/hooks/useAlertChime.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useAlertChime, CHIMES, isWebAudioSupported } from "./useAlertChime"; + +class FakeAudioParam { + value = 0; + setValueAtTime(value: number) { + this.value = value; + return this; + } + exponentialRampToValueAtTime(value: number) { + this.value = value; + return this; + } +} + +class FakeOscillator { + type = "sine"; + frequency = new FakeAudioParam(); + onended: (() => void) | null = null; + startedAt = 0; + stoppedAt = 0; + disconnected = false; + private ctx: FakeAudioContext; + + constructor(ctx: FakeAudioContext) { + this.ctx = ctx; + } + connect() { + return this; + } + disconnect() { + this.disconnected = true; + } + start(at: number) { + this.startedAt = at; + this.ctx.started.push(this); + } + stop(at: number) { + this.stoppedAt = at; + } +} + +class FakeGainNode { + gain = new FakeAudioParam(); + disconnected = false; + connect() { + return this; + } + disconnect() { + this.disconnected = true; + } +} + +class FakeAudioContext { + state: AudioContextState = "running"; + currentTime = 10; + destination = {} as AudioDestinationNode; + started: FakeOscillator[] = []; + resumes = 0; + closed = 0; + + createOscillator() { + return new FakeOscillator(this) as unknown as OscillatorNode; + } + createGain() { + return new FakeGainNode() as unknown as GainNode; + } + resume() { + this.resumes += 1; + this.state = "running"; + return Promise.resolve(); + } + close() { + this.closed += 1; + this.state = "closed"; + return Promise.resolve(); + } +} + +let contexts: FakeAudioContext[] = []; + +function installAudio(state: AudioContextState = "running") { + contexts = []; + class Ctor { + constructor() { + const ctx = new FakeAudioContext(); + ctx.state = state; + contexts.push(ctx); + this.ctx = ctx; + } + ctx: FakeAudioContext; + createOscillator() { + return this.ctx.createOscillator(); + } + createGain() { + return this.ctx.createGain(); + } + resume() { + return this.ctx.resume(); + } + close() { + return this.ctx.close(); + } + get currentTime() { + return this.ctx.currentTime; + } + get destination() { + return this.ctx.destination; + } + get state() { + return this.ctx.state; + } + } + (window as unknown as { AudioContext: unknown }).AudioContext = Ctor; +} + +function uninstallAudio() { + delete (window as unknown as { AudioContext?: unknown }).AudioContext; +} + +describe("useAlertChime", () => { + beforeEach(() => { + installAudio(); + }); + + afterEach(() => { + uninstallAudio(); + vi.restoreAllMocks(); + }); + + it("reports support based on the Web Audio API", () => { + expect(isWebAudioSupported()).toBe(true); + const { result } = renderHook(() => useAlertChime(true)); + expect(result.current.supported).toBe(true); + }); + + it("reports no support when the browser lacks Web Audio", () => { + uninstallAudio(); + const { result } = renderHook(() => useAlertChime(true)); + expect(result.current.supported).toBe(false); + }); + + it("plays one oscillator per note for a critical alert", () => { + const { result } = renderHook(() => useAlertChime(true)); + + act(() => { + result.current.play("critical"); + }); + + expect(contexts).toHaveLength(1); + expect(contexts[0].started).toHaveLength(CHIMES.critical.notes.length); + }); + + it("plays a single blip for a high alert", () => { + const { result } = renderHook(() => useAlertChime(true)); + + act(() => { + result.current.play("high"); + }); + + expect(contexts[0].started).toHaveLength(1); + }); + + it("schedules notes sequentially on the audio clock", () => { + const { result } = renderHook(() => useAlertChime(true)); + + act(() => { + result.current.play("critical"); + }); + + const [first, second] = contexts[0].started; + const firstDuration = CHIMES.critical.notes[0][1]; + expect(first.startedAt).toBe(contexts[0].currentTime); + expect(second.startedAt).toBeGreaterThan(first.startedAt); + expect(first.stoppedAt).toBeLessThanOrEqual(first.startedAt + firstDuration); + }); + + it("does nothing when sound is disabled", () => { + const { result } = renderHook(() => useAlertChime(false)); + + act(() => { + result.current.play("critical"); + }); + + expect(contexts).toHaveLength(0); + }); + + it("reuses a single AudioContext across alerts", () => { + const { result } = renderHook(() => useAlertChime(true)); + + act(() => { + result.current.play("high"); + }); + act(() => { + result.current.play("critical"); + }); + + expect(contexts).toHaveLength(1); + }); + + it("resumes a suspended context instead of firing into silence", () => { + installAudio("suspended"); + const { result } = renderHook(() => useAlertChime(true)); + + act(() => { + result.current.play("critical"); + }); + + expect(contexts[0].resumes).toBe(1); + expect(contexts[0].started).toHaveLength(0); + }); + + it("unlocks the context from a user gesture", async () => { + installAudio("suspended"); + const { result } = renderHook(() => useAlertChime(true)); + + await act(async () => { + result.current.unlock(); + }); + + expect(result.current.unlocked).toBe(true); + expect(contexts[0].resumes).toBe(1); + }); + + it("closes the context on unmount", () => { + const { result, unmount } = renderHook(() => useAlertChime(true)); + // The context is created lazily, so create one before unmounting. + act(() => { + result.current.play("high"); + }); + act(() => { + unmount(); + }); + expect(contexts[0].closed).toBe(1); + }); +}); diff --git a/frontend/src/hooks/useAlertChime.ts b/frontend/src/hooks/useAlertChime.ts new file mode 100644 index 00000000..628cf22a --- /dev/null +++ b/frontend/src/hooks/useAlertChime.ts @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * Chime definitions per alert priority. + * + * Critical and high use different shapes so an operator can tell them apart + * without looking: critical is a descending two-tone siren, high is a single + * short blip. + */ +export type ChimeName = "critical" | "high"; + +interface ChimeSpec { + /** [frequencyHz, durationSeconds] pairs played in sequence */ + notes: Array<[number, number]>; + /** Seconds of silence between successive notes */ + gap: number; + peakGain: number; +} + +export const CHIMES: Record = { + critical: { + notes: [ + [880, 0.18], + [660, 0.18], + [880, 0.18], + [660, 0.26], + ], + gap: 0.02, + peakGain: 0.28, + }, + high: { + notes: [[740, 0.16]], + gap: 0, + peakGain: 0.2, + }, +}; + +type AudioContextCtor = new () => AudioContext; + +function getAudioContextCtor(): AudioContextCtor | null { + if (typeof window === "undefined") return null; + const w = window as unknown as { + AudioContext?: AudioContextCtor; + webkitAudioContext?: AudioContextCtor; + }; + return w.AudioContext ?? w.webkitAudioContext ?? null; +} + +/** Web Audio is unavailable in some browsers and in test environments. */ +export function isWebAudioSupported(): boolean { + return getAudioContextCtor() !== null; +} + +export interface UseAlertChimeResult { + /** True when this browser exposes the Web Audio API. */ + supported: boolean; + /** + * True once the AudioContext is running. Browsers keep it suspended until a + * user gesture, so the UI can nudge the operator to tap once to arm sound. + */ + unlocked: boolean; + /** + * Create/resume the AudioContext. Call from a user gesture handler, or the + * context stays suspended and the chime is silent. + */ + unlock: () => void; + /** Play the chime for a priority. No-op when disabled or unsupported. */ + play: (chime: ChimeName) => void; +} + +/** + * Plays alert chimes through the Web Audio API. + * + * One AudioContext is created lazily and reused, since browsers cap how many + * a page may hold. Oscillators are built per note and disconnected on `ended` + * so nodes do not accumulate over a long shift. + */ +export function useAlertChime(enabled: boolean): UseAlertChimeResult { + const contextRef = useRef(null); + const [supported] = useState(() => isWebAudioSupported()); + const [unlocked, setUnlocked] = useState(false); + + const ensureContext = useCallback((): AudioContext | null => { + if (!enabled) return null; + const Ctor = getAudioContextCtor(); + if (!Ctor) return null; + + if (!contextRef.current) { + try { + contextRef.current = new Ctor(); + } catch { + return null; + } + } + return contextRef.current; + }, [enabled]); + + const unlock = useCallback(() => { + const context = ensureContext(); + if (!context) return; + + if (context.state === "suspended") { + void context + .resume?.() + .then(() => setUnlocked(true)) + .catch(() => setUnlocked(false)); + return; + } + setUnlocked(context.state === "running"); + }, [ensureContext]); + + // Release the context on unmount rather than leaking it for the page lifetime. + useEffect(() => { + return () => { + const context = contextRef.current; + contextRef.current = null; + if (context && context.state !== "closed") { + void context.close?.().catch(() => undefined); + } + }; + }, []); + + const play = useCallback( + (chime: ChimeName) => { + if (!enabled) return; + const context = ensureContext(); + if (!context) return; + + // A suspended context cannot make sound; ask to resume and skip this + // note rather than firing into a dead node. + if (context.state === "suspended") { + void context + .resume?.() + .then(() => setUnlocked(true)) + .catch(() => undefined); + return; + } + + const spec = CHIMES[chime]; + let startAt = context.currentTime; + + for (const [frequency, duration] of spec.notes) { + const oscillator = context.createOscillator(); + const gain = context.createGain(); + + oscillator.type = "sine"; + oscillator.frequency.setValueAtTime(frequency, startAt); + + // Short attack/decay envelope avoids the click a hard gate produces. + gain.gain.setValueAtTime(0.0001, startAt); + gain.gain.exponentialRampToValueAtTime(spec.peakGain, startAt + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration); + + oscillator.connect(gain); + gain.connect(context.destination); + oscillator.start(startAt); + oscillator.stop(startAt + duration); + + oscillator.onended = () => { + oscillator.disconnect(); + gain.disconnect(); + }; + + startAt += duration + spec.gap; + } + }, + [enabled, ensureContext] + ); + + return { supported, unlocked, unlock, play }; +} diff --git a/frontend/src/hooks/useAlertNotifier.test.ts b/frontend/src/hooks/useAlertNotifier.test.ts new file mode 100644 index 00000000..92ec7411 --- /dev/null +++ b/frontend/src/hooks/useAlertNotifier.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useAlertNotifier, chimeForPriority } from "./useAlertNotifier"; +import { useNotificationStore } from "../stores/notificationStore"; + +const playMock = vi.fn(); +const notifyMock = vi.fn(() => true); +const unlockMock = vi.fn(); +const requestPermissionMock = vi.fn(async () => "granted"); + +let permission: NotificationPermission = "granted"; + +class FakeNotification { + static instances: FakeNotification[] = []; + onclose: (() => void) | null = null; + closed = false; + constructor( + public title: string, + public options?: NotificationOptions + ) { + FakeNotification.instances.push(this); + } + close() { + this.closed = true; + this.onclose?.(); + } + static permission: NotificationPermission = "granted"; + static requestPermission = vi.fn(async () => "granted"); +} + +vi.mock("./useAlertChime", () => ({ + useAlertChime: () => ({ + supported: true, + unlocked: true, + unlock: unlockMock, + play: playMock, + }), + isWebAudioSupported: () => true, +})); + +vi.mock("./useDesktopNotifications", () => ({ + useDesktopNotifications: () => ({ + support: permission === "granted" ? "granted" : permission, + requestPermission: requestPermissionMock, + notifyInBackground: notifyMock, + }), + getDesktopPermission: () => permission, + getDesktopNotificationSupport: () => new FakeNotification("probe"), +})); + +function resetStore() { + useNotificationStore.setState(useNotificationStore.getInitialState(), true); +} + +function addAlert(priority: "critical" | "high" | "medium" | "low", id: string) { + useNotificationStore.getState().addNotification({ + id, + type: "system", + priority, + title: `${priority} alert`, + message: `${priority} body`, + }); +} + +describe("chimeForPriority", () => { + it("maps alert priorities to chimes", () => { + expect(chimeForPriority("critical")).toBe("critical"); + expect(chimeForPriority("high")).toBe("high"); + }); + + it("returns null for priorities that should stay silent", () => { + expect(chimeForPriority("medium")).toBeNull(); + expect(chimeForPriority("low")).toBeNull(); + }); +}); + +describe("useAlertNotifier", () => { + beforeEach(() => { + resetStore(); + playMock.mockClear(); + notifyMock.mockClear(); + unlockMock.mockClear(); + notifyMock.mockReturnValue(true); + permission = "granted"; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not replay alerts that already existed on mount", () => { + addAlert("critical", "existing-1"); + const { rerender } = renderHook(() => + useAlertNotifier({ soundEnabled: true, desktopEnabled: true }) + ); + + // A re-render must not treat the seeded notification as new. + rerender(); + rerender(); + + expect(playMock).not.toHaveBeenCalled(); + }); + + it("chimes when a new critical alert arrives", () => { + const { rerender } = renderHook(() => + useAlertNotifier({ soundEnabled: true, desktopEnabled: true }) + ); + + act(() => { + addAlert("critical", "new-critical"); + }); + rerender(); + + expect(playMock).toHaveBeenCalledWith("critical"); + }); + + it("chimes when a new high alert arrives", () => { + const { rerender } = renderHook(() => + useAlertNotifier({ soundEnabled: true, desktopEnabled: true }) + ); + + act(() => { + addAlert("high", "new-high"); + }); + rerender(); + + expect(playMock).toHaveBeenCalledWith("high"); + }); + + it("stays silent for medium and low alerts", () => { + const { rerender } = renderHook(() => + useAlertNotifier({ soundEnabled: true, desktopEnabled: true }) + ); + + act(() => { + addAlert("medium", "new-medium"); + }); + act(() => { + addAlert("low", "new-low"); + }); + rerender(); + + expect(playMock).not.toHaveBeenCalled(); + expect(notifyMock).not.toHaveBeenCalled(); + }); + + it("alerts once per notification, not on every re-render", () => { + const { rerender } = renderHook(() => + useAlertNotifier({ soundEnabled: true, desktopEnabled: true }) + ); + + act(() => { + addAlert("critical", "once-only"); + }); + rerender(); + rerender(); + rerender(); + + expect(playMock).toHaveBeenCalledTimes(1); + }); + + it("forwards the alert to the desktop notifier", () => { + const { rerender } = renderHook(() => + useAlertNotifier({ soundEnabled: true, desktopEnabled: true }) + ); + + act(() => { + addAlert("critical", "desktop-1"); + }); + rerender(); + + expect(notifyMock).toHaveBeenCalledWith({ + title: "critical alert", + body: "critical body", + tag: "desktop-1", + }); + }); + + it("exposes support and unlock callbacks", async () => { + const { result } = renderHook(() => + useAlertNotifier({ soundEnabled: true, desktopEnabled: true }) + ); + + expect(result.current.soundSupported).toBe(true); + expect(result.current.desktopSupported).toBe(true); + + act(() => { + result.current.enableSound(); + }); + expect(unlockMock).toHaveBeenCalled(); + + await act(async () => { + await result.current.requestDesktopPermission(); + }); + expect(requestPermissionMock).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/hooks/useAlertNotifier.ts b/frontend/src/hooks/useAlertNotifier.ts new file mode 100644 index 00000000..64290093 --- /dev/null +++ b/frontend/src/hooks/useAlertNotifier.ts @@ -0,0 +1,97 @@ +import { useEffect, useRef } from "react"; +import { useNotificationStore, type Notification } from "../stores/notificationStore"; +import { useAlertChime, type ChimeName } from "./useAlertChime"; +import { useDesktopNotifications } from "./useDesktopNotifications"; + +/** Only these priorities warrant interrupting the operator. */ +const ALERT_PRIORITIES = new Set(["critical", "high"]); + +export function chimeForPriority(priority: Notification["priority"]): ChimeName | null { + if (priority === "critical") return "critical"; + if (priority === "high") return "high"; + return null; +} + +export interface UseAlertNotifierOptions { + soundEnabled: boolean; + desktopEnabled: boolean; +} + +export interface UseAlertNotifierResult { + /** False when this browser has no Web Audio API. */ + soundSupported: boolean; + /** False when this browser has no Web Notifications API. */ + desktopSupported: boolean; + /** False until a user gesture has unlocked the AudioContext. */ + soundUnlocked: boolean; + /** Current notification permission state. */ + desktopPermission: ReturnType["support"]; + /** Arm audio and/or ask for notification permission from a user gesture. */ + enableSound: () => void; + requestDesktopPermission: () => Promise; +} + +/** + * Watches the notification store and alerts the operator when a high or + * critical notification arrives. + * + * Only genuinely new notifications fire: on mount the store is seeded with the + * ids already present so a page reload does not replay every historical alert. + */ +export function useAlertNotifier({ + soundEnabled, + desktopEnabled, +}: UseAlertNotifierOptions): UseAlertNotifierResult { + const notifications = useNotificationStore((state) => state.notifications); + + const { supported: soundSupported, unlocked: soundUnlocked, unlock, play } = useAlertChime( + soundEnabled + ); + const { + support: desktopPermission, + requestPermission, + notifyInBackground, + } = useDesktopNotifications({ enabled: desktopEnabled }); + + const seenIdsRef = useRef | null>(null); + const primedRef = useRef(false); + + useEffect(() => { + // Seed on the first pass so pre-existing notifications are not replayed. + if (!primedRef.current) { + primedRef.current = true; + seenIdsRef.current = new Set(notifications.map((notification) => notification.id)); + return; + } + + const seen = seenIdsRef.current; + if (!seen) return; + + for (const notification of notifications) { + if (seen.has(notification.id)) continue; + seen.add(notification.id); + + if (!ALERT_PRIORITIES.has(notification.priority)) continue; + + const chime = chimeForPriority(notification.priority); + if (chime) play(chime); + + notifyInBackground({ + title: notification.title, + body: notification.message, + tag: notification.id, + }); + } + }, [notifications, play, notifyInBackground]); + + return { + soundSupported, + desktopSupported: desktopPermission !== "unsupported", + soundUnlocked, + desktopPermission, + enableSound: unlock, + requestDesktopPermission: async () => { + await requestPermission(); + }, + }; +} diff --git a/frontend/src/hooks/useDesktopNotifications.ts b/frontend/src/hooks/useDesktopNotifications.ts new file mode 100644 index 00000000..4518bde2 --- /dev/null +++ b/frontend/src/hooks/useDesktopNotifications.ts @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export type DesktopPermission = "unsupported" | "default" | "granted" | "denied"; + +/** Web Notifications is missing in some browsers and absent under jsdom. */ +export function getDesktopNotificationSupport(): Notification | null { + if (typeof window === "undefined") return null; + const ctor = (window as unknown as { Notification?: typeof Notification }).Notification; + if (!ctor) return null; + try { + // Instantiating proves the constructor is callable, not just present. + return new ctor("", { silent: true }); + } catch { + return null; + } +} + +export function getDesktopPermission(): DesktopPermission { + const ctor = (typeof window !== "undefined" + ? (window as unknown as { Notification?: typeof Notification }).Notification + : undefined); + if (!ctor) return "unsupported"; + return ctor.permission as DesktopPermission; +} + +export interface UseDesktopNotificationsOptions { + /** Master switch driven by the user preference */ + enabled: boolean; +} + +export interface UseDesktopNotificationsResult { + support: DesktopPermission; + /** Ask the browser for permission. Must be called from a user gesture. */ + requestPermission: () => Promise; + /** + * Show a native notification when the tab is in the background. + * + * Returns true when a notification was actually dispatched, which lets + * callers fall back to in-app signalling. + */ + notifyInBackground: (options: { title: string; body: string; tag?: string }) => boolean; +} + +/** + * Dispatches native desktop notifications via the Web Notifications API. + * + * Deliberately only fires when the document is hidden: if the operator is + * already looking at the tab, the drawer is visible and a native popup would + * be a duplicate. + */ +export function useDesktopNotifications({ + enabled, +}: UseDesktopNotificationsOptions): UseDesktopNotificationsResult { + const [support, setSupport] = useState(() => getDesktopPermission()); + const openNotificationsRef = useRef>(new Set()); + + useEffect(() => { + return () => { + // Close anything still on screen so notifications do not outlive the page. + for (const notification of openNotificationsRef.current) { + try { + notification.close(); + } catch { + // Already dismissed by the user; nothing to do. + } + } + openNotificationsRef.current.clear(); + }; + }, []); + + const requestPermission = useCallback(async (): Promise => { + const ctor = (typeof window !== "undefined" + ? (window as unknown as { Notification?: typeof Notification }).Notification + : undefined); + if (!ctor) { + setSupport("unsupported"); + return "unsupported"; + } + + try { + const result = (await ctor.requestPermission()) as DesktopPermission; + setSupport(result); + return result; + } catch { + // Older Safari only allows requestPermission from a gesture. + setSupport(ctor.permission as DesktopPermission); + return ctor.permission as DesktopPermission; + } + }, []); + + const notifyInBackground = useCallback( + ({ title, body, tag }: { title: string; body: string; tag?: string }): boolean => { + if (!enabled) return false; + if (getDesktopPermission() !== "granted") return false; + if (getDesktopNotificationSupport() === null) return false; + if (typeof document !== "undefined" && !document.hidden) return false; + + const ctor = (window as unknown as { Notification: typeof Notification }).Notification; + try { + const notification = new ctor(title, { + body, + // Tagging collapses repeats of the same alert instead of stacking. + tag, + requireInteraction: true, + }); + openNotificationsRef.current.add(notification); + notification.onclose = () => openNotificationsRef.current.delete(notification); + return true; + } catch { + return false; + } + }, + [enabled] + ); + + return { support, requestPermission, notifyInBackground }; +}