/** * What the app has to say to whoever is looking at it. * * One stack, top centre, for both shells. Anything can call `notify(...)`; the * component that draws them subscribes. Kept out of React so a query's error * handler and a socket callback can raise one without a hook. * * Same hand-rolled external store as `connectionStore`, for the same reason: a * `useSyncExternalStore` snapshot is the whole requirement. * * Byte-identical with the portal's copy — see DESIGN-GUIDELINES.md § Sync rule. */ export type NotificationSeverity = "error" | "warning" | "info" | "success" export interface NotificationAction { label: string onClick: () => void variant?: "destructive" } export interface NotifyOptions { /** Naming one that already exists replaces it, rather than stacking a second. */ id?: string /** Stays until it is dismissed. Otherwise it goes on its own. */ persistent?: boolean actions?: NotificationAction[] } export interface AppNotification { id: string message: string severity: NotificationSeverity persistent?: boolean actions?: NotificationAction[] } /** * How many transient cards are kept. * * A storm of failures says the same thing five times over; past that the stack * is taller than the screen and the newest — the one being read — is the one * pushed off it. */ const MAX_TRANSIENT = 5 let state: AppNotification[] = [] let counter = 0 const listeners = new Set<() => void>() function emit(next: AppNotification[]) { state = next for (const listener of listeners) listener() } /** * Raise one. Returns its id, so a caller that wants to update or take it back * later can hold on to it. */ export function notify( message: string, severity: NotificationSeverity = "info", options: NotifyOptions = {}, ): string { const id = options.id ?? `n${++counter}` const entry: AppNotification = { id, message, severity, persistent: options.persistent, actions: options.actions, } const existing = state.findIndex((item) => item.id === id) if (existing >= 0) { // In place, so a card that keeps being told the same thing — an offline // notice counting up — does not re-enter and restart its own timer. const next = [...state] next[existing] = entry emit(next) return id } const appended = [...state, entry] // Persistent ones are somebody's to take back, so the trim only reaches the // rest — a stack of five errors cannot push out the offline notice. const transient = appended.filter((item) => !item.persistent) const dropped = new Set( transient.slice(0, Math.max(0, transient.length - MAX_TRANSIENT)), ) emit(dropped.size ? appended.filter((item) => !dropped.has(item)) : appended) return id } /** Take one back. Unknown ids are a no-op: it may have expired already. */ export function dismiss(id: string): void { if (!state.some((item) => item.id === id)) return emit(state.filter((item) => item.id !== id)) } export const notificationStore = { subscribe(listener: () => void) { listeners.add(listener) return () => listeners.delete(listener) }, snapshot(): AppNotification[] { return state }, }