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`.
110 lines
3.1 KiB
TypeScript
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
|
|
},
|
|
}
|