import { atom } from 'jotai'; import type { IconSrc } from 'folds'; export type ToastNotif = { id: string; avatarUrl?: string; iconSrc?: IconSrc; // folds Icon src for a "system" toast (shown instead of an avatar/initials) displayName: string; body: string; roomName: string; roomId: string; hashPath?: string; // overrides window.location.hash navigation when set onClick?: () => void; // custom click handler; skips hash navigation when set sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click onDismiss?: () => void; // called when the user closes the toast with its X (not on click/eviction) actionLabel?: string; // [Gitea #253] a small button on the toast, e.g. "Details" onAction?: () => void; createdAt?: number; // set by toastQueueAtom when enqueued; used for #80 eviction ordering }; // Build a "download complete" system toast. Kept folds-free here (the icon src is // passed in) so this stays a pure, testable builder. roomId is empty + onClick is // set so a click only dismisses (never navigates to a room). export const createDownloadToast = (filename: string, iconSrc?: IconSrc): ToastNotif => ({ id: `download-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, displayName: 'Downloaded', body: filename, roomName: '', roomId: '', iconSrc, onClick: () => undefined, }); // Build an error/system toast (e.g. a failed command or account action). Mirrors // createDownloadToast: folds-free (icon src passed in), empty roomId + no-op // onClick so a click only dismisses (never navigates to a room). export const createErrorToast = ( body: string, iconSrc?: IconSrc, displayName = 'Something went wrong', ): ToastNotif => ({ id: `error-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, displayName, body, roomName: '', roomId: '', iconSrc, onClick: () => undefined, }); const baseAtom = atom([]); // Cap concurrent toasts so a burst (e.g. many rooms lighting up while focused) // can't stack unbounded and cover the viewport. const MAX_TOASTS = 5; // #80 — a toast younger than this hasn't had a fair chance to be read yet, so // eviction prefers dropping an older, already-seen toast over a fresh one when // a burst arrives while several toasts are still on screen. const MIN_VISIBLE_MS = 1500; // Pick the index (within [0, excludeLastIndex)) of the toast to evict: prefer // the oldest non-sticky toast that's already been visible at least // MIN_VISIBLE_MS; if none qualifies yet (e.g. a burst that all arrived within // the same beat), fall back to the oldest non-sticky toast regardless of age // so the cap is still enforced. Sticky action toasts are never evicted. const findEvictionIndex = (toasts: ToastNotif[], excludeLastIndex: number, now: number): number => { for (let i = 0; i < excludeLastIndex; i += 1) { const t = toasts[i]; if (!t.sticky && now - (t.createdAt ?? now) >= MIN_VISIBLE_MS) return i; } for (let i = 0; i < excludeLastIndex; i += 1) { if (!toasts[i].sticky) return i; } return -1; }; // Write-only setter used in ClientNonUIFeatures export const toastQueueAtom = atom( (get) => get(baseAtom), (get, set, notif) => { if (notif === null) return; // no-op guard // Stamp in place (not a copy) so the queue keeps holding the exact object // the caller passed in — callers/tests may rely on reference equality. notif.createdAt = Date.now(); const now = notif.createdAt; const next = [...get(baseAtom), notif]; // The last index (the just-appended newest toast) is never a candidate, so // a fresh toast is never the one dropped — if everything older is sticky // (or, now, too young) the cap simply stretches rather than eating the new // notice. while (next.length > MAX_TOASTS) { const idx = findEvictionIndex(next, next.length - 1, now); if (idx === -1) break; next.splice(idx, 1); } set(baseAtom, next); }, ); export const dismissToastAtom = atom(null, (get, set, id) => set( baseAtom, get(baseAtom).filter((t) => t.id !== id), ), );