/** * Delivery: choose exactly one channel for one alert, at the moment the alert * is owed, and record what happened. * * @module @dsh-plugin/session-notify/client/core/delivery */ import { SETTLE_MS, THROTTLE_MS } from '../constants.js' import { windowIsAway } from '../platform.js' import { isOnScreen, stillOwed } from './session.js' /** * Build the delivery path. * @param deps - the plugin store; the shared `live` cell the observer writes and * this path reads at delivery time; the copy table; the popup stack; the * system channel; and the replay queue. * @returns the delivery operations, the settle runner, and the throttle reset a * window transition needs. */ export function createDelivery({ store, copy, live, toasts, system, replay }) { const pending = [] let settleTimer = 0 let lastDelivery = 0 let deliverySeq = 0 /** * Record what actually happened to the newest alert. * * Everything on the delivery path used to fail invisibly: the alert simply * never appeared, with no way to tell a wrong channel decision from a * refused constructor. The config page shows this record, so the next * question ("did it even try?") has an answer. * * The time is the user's own clock, not UTC: a log line the reader has to * translate by eight hours is worse than no log line. * @param outcome - which channel carried the alert, or why it fell back. * @param candidate - the alert being recorded. * @param monitor - the system channel's own record, when that is the channel. * @returns the stored record, so a later platform answer can update it. */ const recordDelivery = (outcome, candidate, monitor) => { deliverySeq += 1 const now = new Date() const pad = (value) => String(value).padStart(2, '0') const record = { seq: deliverySeq, outcome, at: `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`, title: copy.copyFor(candidate).title, shown: monitor?.shown, monitor, } store.set({ ...store.getSnapshot(), lastDelivery: record }) return record } /** * Deliver one candidate through exactly one channel: the system * notification while the window is away, the light popup while it is in * front, and nothing at all for the conversation on screen. * * The channel decision reads the window state right here, at delivery time, * instead of trusting the state the observer last remembered — that is what * an unreported blur used to defeat. An alert that leaves through the system * channel is additionally held for an in-app replay, because a desktop banner * raised while nobody is looking at the machine is a notification the user * never actually receives. * @param candidate - the alert to deliver. * @param ignoreThrottle - deliver immediately, bypassing the flood guard. * @returns `false` when the flood guard suppressed this alert, `true` when the * delivery path itself ran (including a deliberate skip for the conversation * on screen). */ const deliver = (candidate, ignoreThrottle) => { if (ignoreThrottle !== true) { const now = Date.now() if (now - lastDelivery < THROTTLE_MS) return false lastDelivery = now } if (!windowIsAway()) { if (isOnScreen(candidate.sessionId, live.list)) return true toasts.showToast(candidate) recordDelivery('popup', candidate) return true } const result = system.notifySystem(candidate, candidate.test === true) if (result.outcome === 'raised') { recordDelivery('system', candidate, result) if (candidate.test !== true) replay.holdForReplay(candidate, result) return true } // A test alert still has to reach the user, and so does a real one when // the system channel refuses: the popup is the channel that remains. toasts.showToast(candidate) recordDelivery(`system-refused-${result.outcome}`, candidate) return true } /** * Take the delivery decision on a later tick, from the freshest state. * * One candidate per tick keeps the throttle meaningful, and anything still * queued arms its own follow-up tick — a burst used to leave every candidate * after the first stranded in the queue with no timer to flush it. * * A pending interaction the flood guard holds back is put back in the queue * instead of being dropped: only the user can clear it, so it stays owed, and * the next tick retries it. Flood control still drops the completions it was * written for. */ const flushSettle = () => { settleTimer = 0 const candidate = pending.shift() if (candidate !== undefined && stillOwed(candidate, live)) { const delivered = deliver(candidate, false) if (delivered === false && candidate.pendingKind !== '') pending.push(candidate) } if (pending.length > 0) settleTimer = setTimeout(() => flushSettle(), SETTLE_MS) } /** Hold one candidate for the settle tick, collapsing a burst onto one timer. */ const queue = (candidate) => { pending.push(candidate) if (settleTimer !== 0) return settleTimer = setTimeout(() => flushSettle(), SETTLE_MS) } /** * Clear the flood guard. A window that just lost or regained the foreground is * exactly when the next alert matters most, so it must never be swallowed by a * delivery from the other side of that transition. */ const resetThrottle = () => { lastDelivery = 0 } /** Stop the pending settle tick: the plugin is being disposed. */ const dispose = () => { if (settleTimer !== 0) { clearTimeout(settleTimer) settleTimer = 0 } } return { deliver, recordDelivery, queue, flushSettle, resetThrottle, dispose } }