// Copyright (c) 2025-2026, RTE (https://www.rte-france.com) // This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. // If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, // you can obtain one at http://mozilla.org/MPL/2.0/. // SPDX-License-Identifier: MPL-2.0 // Typed notification store (D5, 2026-07). Replaces the two ad-hoc toast // channels — a sticky-but-undismissable `error` string and an // auto-3s-hiding `infoMessage` string whose green-vs-blue colour was // decided by a magic `'SUCCESS'` prefix — with ONE store carrying an // explicit severity, a sticky flag, per-item dismissal and (via // NotificationHost) an aria-live region. // // It is a module singleton (mirroring `interactionLogger`) so any layer — // App, hooks, even non-React helpers — can raise a notification without // prop-threading a setter, and `useNotifications()` subscribes React to it // through `useSyncExternalStore`. import { useSyncExternalStore } from 'react'; export type NotificationSeverity = 'error' | 'success' | 'info'; export interface Notification { /** Monotonic id, stable for the item's lifetime (used as React key). */ readonly id: number; readonly severity: NotificationSeverity; readonly message: string; /** Sticky items stay until dismissed; others auto-expire. */ readonly sticky: boolean; } export interface NotifyOptions { /** Override the severity default (errors sticky, info/success not). */ sticky?: boolean; /** Auto-dismiss delay for non-sticky items. Defaults to * `DEFAULT_TIMEOUT_MS`. Ignored when sticky. */ timeoutMs?: number; } /** Non-sticky items auto-dismiss after this long by default. */ export const DEFAULT_TIMEOUT_MS = 4000; type Timer = ReturnType; class NotificationStore { private items: readonly Notification[] = []; private readonly listeners = new Set<() => void>(); private readonly timers = new Map(); private seq = 0; subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; /** Stable reference between mutations — required by useSyncExternalStore. */ getSnapshot = (): readonly Notification[] => this.items; private emit(): void { for (const listener of this.listeners) listener(); } private clearTimer(id: number): void { const t = this.timers.get(id); if (t !== undefined) { clearTimeout(t); this.timers.delete(id); } } notify(severity: NotificationSeverity, message: string, options: NotifyOptions = {}): number { const text = (message ?? '').trim(); if (!text) return -1; const sticky = options.sticky ?? severity === 'error'; // De-dupe: an identical (severity, message) already showing is // refreshed rather than stacked — stops a debounced/retrying caller // (e.g. the SLD preview) from piling up copies of one error. const existing = this.items.find(n => n.severity === severity && n.message === text); if (existing) { if (!sticky) this.scheduleExpiry(existing.id, options.timeoutMs); return existing.id; } const id = ++this.seq; this.items = [...this.items, { id, severity, message: text, sticky }]; if (!sticky) this.scheduleExpiry(id, options.timeoutMs); this.emit(); return id; } private scheduleExpiry(id: number, timeoutMs?: number): void { this.clearTimer(id); this.timers.set(id, setTimeout(() => this.dismiss(id), timeoutMs ?? DEFAULT_TIMEOUT_MS)); } dismiss(id: number): void { this.clearTimer(id); const next = this.items.filter(n => n.id !== id); if (next.length !== this.items.length) { this.items = next; this.emit(); } } /** Remove every notification of a severity (the "clear the error" gesture). */ clearSeverity(severity: NotificationSeverity): void { const next = this.items.filter(n => n.severity !== severity); if (next.length !== this.items.length) { for (const n of this.items) if (n.severity === severity) this.clearTimer(n.id); this.items = next; this.emit(); } } /** Remove everything (e.g. on a study reset). */ clear(): void { if (this.items.length === 0) return; for (const id of this.timers.keys()) this.clearTimer(id); this.items = []; this.emit(); } } export const notifications = new NotificationStore(); // --- Convenience raisers ------------------------------------------------- /** Raise a sticky error toast (stays until dismissed). */ export function notifyError(message: string): number { return notifications.notify('error', message); } /** Raise an auto-dismissing info toast. */ export function notifyInfo(message: string): number { return notifications.notify('info', message); } /** Raise an auto-dismissing success toast (replaces the `'SUCCESS:'` prefix). */ export function notifySuccess(message: string): number { return notifications.notify('success', message); } export function dismissNotification(id: number): void { notifications.dismiss(id); } export function clearNotifications(): void { notifications.clear(); } // --- React binding ------------------------------------------------------- /** Subscribe a component to the live notification list. */ export function useNotifications(): readonly Notification[] { return useSyncExternalStore(notifications.subscribe, notifications.getSnapshot, notifications.getSnapshot); }