147 lines
5.6 KiB
JavaScript
147 lines
5.6 KiB
JavaScript
/**
|
|
* 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 }
|
|
}
|