♻️ refactor(client): 浏览器半拆成 src 模块,client.js 改由构建产出
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 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, stillWorth } 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.
|
||||
*/
|
||||
const deliver = (candidate, ignoreThrottle) => {
|
||||
if (ignoreThrottle !== true) {
|
||||
const now = Date.now()
|
||||
if (now - lastDelivery < THROTTLE_MS) return
|
||||
lastDelivery = now
|
||||
}
|
||||
if (!windowIsAway()) {
|
||||
if (isOnScreen(candidate.sessionId, live.list)) return
|
||||
toasts.showToast(candidate)
|
||||
recordDelivery('popup', candidate)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const flushSettle = () => {
|
||||
settleTimer = 0
|
||||
const candidate = pending.shift()
|
||||
if (candidate !== undefined && stillWorth(candidate, live)) deliver(candidate, false)
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user