feat(notifications): coalesce identical notifications with a repeat count
Fold a burst of identical notifications (same level+title+message within a short window) into one entry with a ×N count instead of stacking copies — tames a crash-retry loop or a repeatedly-failing poll. Shown in the toast and centre.
This commit is contained in:
parent
e4dcfb3845
commit
7f358f63e3
3 changed files with 63 additions and 5 deletions
|
|
@ -320,7 +320,14 @@ function ClientActivityRow({
|
||||||
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
|
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
|
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
|
||||||
<div className="text-sm break-words">{n.message}</div>
|
<div className="text-sm break-words">
|
||||||
|
{n.message}
|
||||||
|
{n.repeat > 1 && (
|
||||||
|
<span className="ml-1.5 align-middle text-[11px] font-semibold text-muted tabular-nums">
|
||||||
|
×{n.repeat}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
<span className="text-[11px] text-muted">{relativeFromMs(n.ts)}</span>
|
<span className="text-[11px] text-muted">{relativeFromMs(n.ts)}</span>
|
||||||
{needsAction && (
|
{needsAction && (
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,14 @@ export default function Toaster() {
|
||||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
|
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
|
||||||
<div className="text-sm break-words">{toast.message}</div>
|
<div className="text-sm break-words">
|
||||||
|
{toast.message}
|
||||||
|
{toast.repeat > 1 && (
|
||||||
|
<span className="ml-1.5 align-middle text-[11px] font-semibold text-muted tabular-nums">
|
||||||
|
×{toast.repeat}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{toast.action && (
|
{toast.action && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,11 @@ export interface Notification {
|
||||||
// timer) that the producer removes itself when the condition resolves. Never persisted
|
// timer) that the producer removes itself when the condition resolves. Never persisted
|
||||||
// to history, so a reload can't orphan it with no live handle to clear it.
|
// to history, so a reload can't orphan it with no live handle to clear it.
|
||||||
transient: boolean;
|
transient: boolean;
|
||||||
|
// How many times an identical notification (same level+title+message) fired in quick
|
||||||
|
// succession and was coalesced into this one entry (see notify). 1 = fired once; shown as
|
||||||
|
// "×N" in the toast/center when >1. Guards against a burst (e.g. an ErrorBoundary catching
|
||||||
|
// the same crash on every retry) spamming the center with dozens of copies.
|
||||||
|
repeat: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotifyInput {
|
export interface NotifyInput {
|
||||||
|
|
@ -136,7 +141,10 @@ function load(): Notification[] {
|
||||||
// resurrect as active toasts on reload — their auto-dismiss timers aren't
|
// resurrect as active toasts on reload — their auto-dismiss timers aren't
|
||||||
// re-armed across a reload, so otherwise they'd stick forever. Also drop the
|
// re-armed across a reload, so otherwise they'd stick forever. Also drop the
|
||||||
// live `action` callback, which can't survive serialization.
|
// live `action` callback, which can't survive serialization.
|
||||||
return raw.map((n) => ({ ...n, action: undefined, dismissed: true, transient: false }) as Notification);
|
return raw.map(
|
||||||
|
(n) =>
|
||||||
|
({ repeat: 1, ...(n as object), action: undefined, dismissed: true, transient: false }) as Notification
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -184,10 +192,13 @@ export function removeByMetaKind(kind: NotifMeta["kind"]): void {
|
||||||
if (items.length !== before) emit();
|
if (items.length !== before) emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Window within which an identical notification is coalesced into the prior one (see below).
|
||||||
|
const COALESCE_WINDOW_MS = 5000;
|
||||||
|
|
||||||
export function notify(input: NotifyInput): number {
|
export function notify(input: NotifyInput): number {
|
||||||
const id = counter++;
|
|
||||||
const requiresInteraction = input.requiresInteraction ?? false;
|
const requiresInteraction = input.requiresInteraction ?? false;
|
||||||
const level = input.level ?? "info";
|
const level = input.level ?? "info";
|
||||||
|
const now = Date.now();
|
||||||
// config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed).
|
// config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed).
|
||||||
// A transient status stays put until its producer clears it (no auto-dismiss timer),
|
// A transient status stays put until its producer clears it (no auto-dismiss timer),
|
||||||
// same as an interaction-awaiting notice.
|
// same as an interaction-awaiting notice.
|
||||||
|
|
@ -198,6 +209,38 @@ export function notify(input: NotifyInput): number {
|
||||||
: config.durationMs > 0
|
: config.durationMs > 0
|
||||||
? config.durationMs
|
? config.durationMs
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Coalesce a rapid burst of identical notifications (same level+title+message within the
|
||||||
|
// window) into one entry with a bumped repeat count, instead of appending a fresh copy each
|
||||||
|
// time. This tames spam like an ErrorBoundary catching the same crash on every render retry,
|
||||||
|
// or a poll that keeps failing the same way. Skipped when the input carries a live `action`
|
||||||
|
// (its callback identity matters) or is a transient status (those are singleton-guarded by
|
||||||
|
// their producer). Distinct events — any difference in level/title/message — are never merged.
|
||||||
|
if (!input.action && !input.transient) {
|
||||||
|
const prior = items.find(
|
||||||
|
(n) =>
|
||||||
|
!n.transient &&
|
||||||
|
!n.action &&
|
||||||
|
now - n.ts <= COALESCE_WINDOW_MS &&
|
||||||
|
n.level === level &&
|
||||||
|
(n.title ?? "") === (input.title ?? "") &&
|
||||||
|
n.message === input.message
|
||||||
|
);
|
||||||
|
if (prior) {
|
||||||
|
// Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer.
|
||||||
|
items = items.map((n) =>
|
||||||
|
n.id === prior.id
|
||||||
|
? { ...n, ts: now, repeat: n.repeat + 1, dismissed: false, read: false, duration }
|
||||||
|
: n
|
||||||
|
);
|
||||||
|
emit();
|
||||||
|
if (duration) setTimeout(() => dismiss(prior.id), duration);
|
||||||
|
// Deliberately no re-beep: the first occurrence already sounded; a loop must stay quiet.
|
||||||
|
return prior.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = counter++;
|
||||||
items = [
|
items = [
|
||||||
...items,
|
...items,
|
||||||
{
|
{
|
||||||
|
|
@ -209,10 +252,11 @@ export function notify(input: NotifyInput): number {
|
||||||
meta: input.meta,
|
meta: input.meta,
|
||||||
requiresInteraction,
|
requiresInteraction,
|
||||||
duration,
|
duration,
|
||||||
ts: Date.now(),
|
ts: now,
|
||||||
read: false,
|
read: false,
|
||||||
dismissed: false,
|
dismissed: false,
|
||||||
transient: input.transient ?? false,
|
transient: input.transient ?? false,
|
||||||
|
repeat: 1,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
emit();
|
emit();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue