Files
app/frontend/src/lib/notificationStore.ts
T
stroblme 8ef78678a0 Replace sonner toasts with one top-centre notification stack
The app said things in two places: sonner's bottom-right toasts and the
custom top-centre chrome. This is the second of those, generalised — a
module-level store anyone can call, drawn as a stack of frosted cards that go
on their own unless raised as persistent, with room for buttons and for a
message longer than a line.

useCustomToast keeps its signatures, so the forty-odd call sites behind it and
handleError are untouched. The component and its store are byte-identical with
the portal's copy and checked by `make design-check`.
2026-08-27 16:00:47 +02:00

110 lines
3.1 KiB
TypeScript

/**
* 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
},
}