Files
session-notify/src/client/core/toasts.js
T

118 lines
3.8 KiB
JavaScript

/**
* 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 }
}