Skip to content

Commit 0786424

Browse files
committed
feat: add cross-page toast center with deduplicated notification queue
1 parent 39a4dbe commit 0786424

1 file changed

Lines changed: 58 additions & 0 deletions

File tree

frontend/src/context/ToastContext.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@ export interface ToastOptions {
77
description?: string;
88
duration?: number;
99
variant?: ToastVariant;
10+
/** Unique key for deduplication. If not provided, deduplication uses title+description */
11+
dedupeKey?: string;
1012
}
1113

1214
interface ToastItem extends ToastOptions {
1315
id: string;
1416
variant: ToastVariant;
1517
duration: number;
18+
timestamp: number;
1619
}
1720

1821
interface ToastContextType {
@@ -22,18 +25,33 @@ interface ToastContextType {
2225
error: (options: Omit<ToastOptions, "variant">) => string;
2326
warning: (options: Omit<ToastOptions, "variant">) => string;
2427
info: (options: Omit<ToastOptions, "variant">) => string;
28+
clearAll: () => void;
2529
}
2630

2731
const ToastContext = createContext<ToastContextType | undefined>(undefined);
2832

2933
const DEFAULT_DURATION = 5000;
34+
const DEDUPE_WINDOW_MS = 3000; // Don't show duplicate toasts within 3 seconds
35+
36+
/**
37+
* Generate a deduplication key from toast content.
38+
* Toasts with the same key within DEDUPE_WINDOW_MS are considered duplicates.
39+
*/
40+
function generateDedupeKey(options: ToastOptions): string {
41+
if (options.dedupeKey) {
42+
return options.dedupeKey;
43+
}
44+
return `${options.title}|${options.description || ''}|${options.variant || 'info'}`;
45+
}
3046

3147
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({
3248
children,
3349
}) => {
3450
const [toasts, setToasts] = useState<ToastItem[]>([]);
3551
const nextToastId = useRef(0);
3652
const timeoutRefs = useRef<Map<string, number>>(new Map());
53+
// Track recent toast keys for deduplication
54+
const recentToasts = useRef<Map<string, number>>(new Map());
3755

3856
const dismissToast = (id: string) => {
3957
setToasts((currentToasts) => currentToasts.filter((toast) => toast.id !== id));
@@ -45,17 +63,40 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({
4563
}
4664
};
4765

66+
const clearAll = () => {
67+
setToasts([]);
68+
timeoutRefs.current.forEach((timeoutId) => {
69+
window.clearTimeout(timeoutId);
70+
});
71+
timeoutRefs.current.clear();
72+
};
73+
4874
const showToast = ({
4975
variant = "info",
5076
duration = DEFAULT_DURATION,
5177
...options
5278
}: ToastOptions) => {
79+
const dedupeKey = generateDedupeKey({ ...options, variant });
80+
const now = Date.now();
81+
82+
// Check for duplicate within dedupe window
83+
const lastShown = recentToasts.current.get(dedupeKey);
84+
if (lastShown && now - lastShown < DEDUPE_WINDOW_MS) {
85+
// Duplicate detected - return the existing toast ID (we don't have it, so return empty)
86+
// In a production system, you might want to bump the existing toast or extend its duration
87+
return '';
88+
}
89+
90+
// Record this toast for deduplication
91+
recentToasts.current.set(dedupeKey, now);
92+
5393
nextToastId.current += 1;
5494
const id = `toast-${nextToastId.current}`;
5595
const nextToast: ToastItem = {
5696
id,
5797
variant,
5898
duration,
99+
timestamp: now,
59100
...options,
60101
};
61102

@@ -69,6 +110,22 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({
69110
return id;
70111
};
71112

113+
// Clean up old dedupe entries periodically
114+
useEffect(() => {
115+
const cleanupInterval = setInterval(() => {
116+
const now = Date.now();
117+
const staleKeys: string[] = [];
118+
recentToasts.current.forEach((timestamp, key) => {
119+
if (now - timestamp > DEDUPE_WINDOW_MS) {
120+
staleKeys.push(key);
121+
}
122+
});
123+
staleKeys.forEach((key) => recentToasts.current.delete(key));
124+
}, DEDUPE_WINDOW_MS);
125+
126+
return () => clearInterval(cleanupInterval);
127+
}, []);
128+
72129
useEffect(() => {
73130
const timeouts = timeoutRefs.current;
74131

@@ -85,6 +142,7 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({
85142
value={{
86143
showToast,
87144
dismissToast,
145+
clearAll,
88146
success: (options) => showToast({ ...options, variant: "success" }),
89147
error: (options) => showToast({ ...options, variant: "error" }),
90148
warning: (options) => showToast({ ...options, variant: "warning" }),

0 commit comments

Comments
 (0)