/** * The light in-app popup stack: its lifetime, its ordering, and its bound. * * The lifetime is the user's, read from the store at the moment a card is armed: * the settings page offers a few delays plus "never", and re-arming after a hover * picks the current choice up. `0` means the card waits for the user instead of a * timer. * * @module @dsh-plugin/session-notify/client/core/toasts */ import { TOAST_DURATION_MS, TOAST_LIMIT } from '../constants.js' /** * Build the popup stack over one store. * @param deps - the plugin store, and the copy table a candidate is rendered with. * @returns the stack's operations, including the three the popup element calls back into. */ export function createToastStack({ store, copy }) { const timers = new Map() /** Cards the pointer is resting on: they carry no timer until it leaves. */ const held = new Set() let seq = 0 /** The configured lifetime, falling back to the default for a value this version cannot use. */ const lifetime = () => { const configured = store.getSnapshot().dismissAfterMs return typeof configured === 'number' && configured >= 0 ? configured : TOAST_DURATION_MS } /** Drop one popup and cancel its lifetime timer. */ const closeToast = (id) => { held.delete(id) const timer = timers.get(id) if (timer !== undefined) { clearTimeout(timer) timers.delete(id) } const snapshot = store.getSnapshot() if (!snapshot.toasts.some((toast) => toast.id === id)) return store.set({ ...snapshot, toasts: snapshot.toasts.filter((toast) => toast.id !== id) }) } /** * Arm one popup's lifetime; hovering calls `hold` first, then this again. * A lifetime of `0` arms nothing: the card stays until it is dismissed. */ const armToast = (id) => { held.delete(id) const timer = timers.get(id) if (timer !== undefined) clearTimeout(timer) timers.delete(id) const duration = lifetime() if (duration === 0) return timers.set(id, setTimeout(() => closeToast(id), duration)) } /** Pause one popup's lifetime while the pointer rests on it. */ const holdToast = (id) => { held.add(id) const timer = timers.get(id) if (timer === undefined) return clearTimeout(timer) timers.delete(id) } /** * Apply the current lifetime to the cards already on screen. * * Changing the setting restarts the countdown for what is visible — otherwise * a card raised under the old lifetime would still close on the old schedule. * A held card is left alone: the pointer is on it, so nothing may close it. */ const rearmAll = () => { for (const toast of store.getSnapshot().toasts) { if (!held.has(toast.id)) armToast(toast.id) } } /** * Show one light in-app popup: the trigger as the title, the conversation * and its detail as the description, so the heading says what happened and * the line under it says where. */ const showToast = (candidate) => { const notice = copy.copyFor(candidate) seq += 1 const toast = { id: `dsn-${String(seq)}`, kind: notice.kind, title: notice.title, body: notice.body, sessionId: candidate.sessionId, } const snapshot = store.getSnapshot() const next = [toast, ...snapshot.toasts] for (const dropped of next.slice(TOAST_LIMIT)) { const timer = timers.get(dropped.id) if (timer !== undefined) { clearTimeout(timer) timers.delete(dropped.id) } held.delete(dropped.id) } store.set({ ...snapshot, toasts: next.slice(0, TOAST_LIMIT) }) armToast(toast.id) } /** Cancel every lifetime timer: the plugin is being disposed, so nothing may fire later. */ const dispose = () => { for (const timer of timers.values()) clearTimeout(timer) timers.clear() held.clear() } return { showToast, closeToast, armToast, holdToast, rearmAll, dispose } }