Files
webmail/lib/plugin-sandbox/host-api-toasts.ts
T

69 lines
2.9 KiB
TypeScript

import { toast, useToastStore } from '@/stores/toast-store';
import type { ToastType } from '@/components/ui/toast';
const MAX_TOAST_LENGTH = 300;
const TOASTS_PER_TEN_SECONDS = 5;
const MAX_PROGRESS_TOASTS = 5;
const recentToasts = new Map<string, number[]>();
const progressToasts = new Map<string, { pluginId: string; signal: AbortSignal; release: () => void }>();
function toastText(value: unknown): string {
const text = String(value ?? '');
return text.length > MAX_TOAST_LENGTH ? text.slice(0, MAX_TOAST_LENGTH - 1) + '…' : text;
}
/** A plugin may show a few toasts; past that they are dropped, not queued.
* Progress toasts share this budget. */
function mayToast(pluginId: string): boolean {
const now = Date.now();
const recent = (recentToasts.get(pluginId) ?? []).filter((time) => now - time < 10_000);
recentToasts.set(pluginId, recent);
if (recent.length >= TOASTS_PER_TEN_SECONDS) return false;
recent.push(now);
return true;
}
export function showPluginToast(pluginId: string, type: ToastType, message: unknown): void {
if (mayToast(pluginId)) toast[type](toastText(message));
}
/** Frees the toast and its slot, also when the user closes it. */
function releaseProgressToast(id: string): void {
const entry = progressToasts.get(id);
if (!entry) return;
progressToasts.delete(id);
entry.signal.removeEventListener('abort', entry.release);
toast.dismiss(id);
}
/** Shows a progress toast that belongs to one plugin frame and goes away
* when that frame is destroyed. */
export function showPluginProgressToast(pluginId: string, message: unknown, signal?: AbortSignal): string {
if (!signal || signal.aborted) throw new Error('Progress toasts require a live sandbox instance');
// Drop entries whose toast is gone from the store without onDismiss.
const visible = new Set(useToastStore.getState().toasts.map((item) => item.id));
for (const id of progressToasts.keys()) {
if (!visible.has(id)) releaseProgressToast(id);
}
const active = [...progressToasts.values()].filter((entry) => entry.pluginId === pluginId).length;
if (active >= MAX_PROGRESS_TOASTS || !mayToast(pluginId)) throw new Error('Plugin has too many toasts');
const id = useToastStore.getState().addToast({
type: 'info', title: toastText(message), progress: null, duration: 0,
onDismiss: () => releaseProgressToast(id),
});
const release = () => releaseProgressToast(id);
progressToasts.set(id, { pluginId, signal, release });
signal.addEventListener('abort', release, { once: true });
return id;
}
/** Dismisses one of this frame's progress toasts. Repeated calls are harmless
* and don't count against the toast budget. */
export function dismissPluginToast(pluginId: string, id: unknown, signal?: AbortSignal): void {
if (typeof id !== 'string') throw new Error('A toast handle is required');
const entry = progressToasts.get(id);
if (!entry || entry.pluginId !== pluginId || entry.signal !== signal) return;
releaseProgressToast(id);
}