♻️ refactor(client): 浏览器半拆成 src 模块,client.js 改由构建产出

This commit is contained in:
pyh
2026-10-04 19:01:22 +08:00
parent 3986aac863
commit 68ab60273e
30 changed files with 3506 additions and 1394 deletions
+87
View File
@@ -0,0 +1,87 @@
/**
* What the settings page can do: the two test buttons, the permission request,
* and the trigger switches.
*
* @module @dsh-plugin/session-notify/client/core/actions
*/
import { notificationApi, notificationPermission } from '../platform.js'
import { writeStoredKinds } from './storage.js'
import { text } from './log.js'
/**
* Build the actions over one store.
* @param deps - the plugin store; the translate function; the delivery path (the
* test alert goes through the ordinary channel decision); the system channel
* (the channel-specific test); the popup stack (used when the system channel
* refuses a test); and `publish` for the one branch that only re-renders.
* @returns the actions the settings page and the permission row call.
*/
export function createActions({ store, t, delivery, system, toasts, publish }) {
/** Build one test alert that skips the throttle and the "on screen" rule. */
const testCandidate = (title) => ({
kind: 'completion',
sessionId: 'test',
title,
detail: '',
pendingKind: '',
test: true,
})
/**
* Raise one test alert through whichever channel the window state selects.
* Verified from the config page, where the user is looking at the app, so it
* normally lands in the popup — the way to prove the system channel is to
* press the button in a channel-specific test instead.
*/
const sendTest = () => {
delivery.deliver(testCandidate(t('config.testAny')), true)
}
/** Raise the test alert on the system channel specifically, whatever the focus is. */
const sendTestSystem = () => {
const candidate = testCandidate(t('config.testSystem'))
const result = system.notifySystem(candidate, true)
if (result.outcome === 'raised') {
delivery.recordDelivery('system', candidate, result)
return
}
toasts.showToast(candidate)
delivery.recordDelivery(`system-refused-${result.outcome}`, candidate)
}
/** Request notification permission inside a user gesture, then report the outcome. */
const requestPermission = async () => {
const Ctor = notificationApi()
if (Ctor === undefined) {
publish()
return
}
try {
let result = Ctor.requestPermission()
if (result === undefined) {
result = new Promise((resolve) => {
Ctor.requestPermission((value) => resolve(value))
})
}
await result
} catch (error) {
const snapshot = store.getSnapshot()
store.set({ ...snapshot, promptError: t('settings.permission.promptFailed', { message: text(error) }) })
return
}
const snapshot = store.getSnapshot()
store.set({ ...snapshot, permission: notificationPermission(), promptError: '' })
if (notificationPermission() === 'granted') sendTest()
}
/** Toggle one trigger and remember the choice. */
const setKindEnabled = (kind, enabled) => {
const snapshot = store.getSnapshot()
const kinds = { ...snapshot.kinds, [kind]: enabled }
writeStoredKinds(kinds)
store.set({ ...snapshot, kinds })
}
return { sendTest, sendTestSystem, requestPermission, setKindEnabled }
}
+68
View File
@@ -0,0 +1,68 @@
/**
* The delivered copy: what a candidate becomes once it is on screen, plus the
* two diagnoses the settings page prints about past deliveries.
*
* @module @dsh-plugin/session-notify/client/core/copy
*/
/**
* Build the copy table for one translate function.
* @param t - the translate function every string here goes through.
* @returns `copyFor` (candidate → notice) and the two diagnosis renderers.
*/
export function createCopy(t) {
/** Build the delivered copy for one candidate. */
const copyFor = (candidate) => {
const title = candidate.title === '' ? t('body.untitled') : candidate.title
if (candidate.kind === 'completion') {
return { kind: 'completion', title: t('notification.completion'), body: t('body.completion', { title }) }
}
if (candidate.kind === 'question') {
const question = candidate.detail
return {
kind: 'question',
title: t('notification.question'),
body: question === '' ? t('body.question', { title }) : `${title} · ${question}`,
}
}
// Kept in v1.0.6's order, which makes this branch unreachable today: the
// observer queues a plan review as `kind: 'question'`, so the branch above
// already answered with the plan's own text. Restoring the documented copy
// (`body.planReview`, "计划正在等待你确认") means testing `pendingKind`
// before `kind` — a user-visible behavior change, so it is deliberately NOT
// part of this refactor. `tests/unit/copy.test.js` pins today's output.
if (candidate.pendingKind === 'plan-review') {
return { kind: 'question', title: t('notification.question'), body: t('body.planReview', { title }) }
}
const tool = candidate.detail
return {
kind: 'approval',
title: t('notification.approval'),
body: tool === '' ? t('body.approvalPlain', { title }) : t('body.approval', { title, tool }),
}
}
/** Describe the newest alert's fate in one line. */
const describeDelivery = (last, tr) => {
if (last === null || last === undefined) return tr('config.diag.none')
const outcome = last.outcome.startsWith('system-refused')
? tr('config.diag.refused')
: last.outcome === 'system'
? last.shown === true
? tr('config.diag.systemShown')
: last.shown === false
? tr('config.diag.systemFailed')
: tr('config.diag.systemUnconfirmed')
: last.outcome === 'replay'
? tr('config.diag.replayDelivery')
: tr('config.diag.popup')
return `${tr('config.diag.last')}: ${last.at} · ${outcome} · ${last.title}`
}
/** Say how many away-channel alerts are still waiting to be replayed in-app. */
const describeReplay = (count, tr) => (
count === 0 ? tr('config.diag.replayNone') : tr('config.diag.replay', { count })
)
return { copyFor, describeDelivery, describeReplay }
}
+134
View File
@@ -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 }
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Diagnostics: one way to render an unknown failure, one way to log it.
*
* @module @dsh-plugin/session-notify/client/core/log
*/
/** Render one unknown failure as text. */
export function text(error) {
return error instanceof Error ? error.message : String(error)
}
/** Log one contained diagnostic through the package-tagged console. */
export function report(message) {
try {
console.error(`session-notify: ${message}`)
} catch {
/* a diagnostic never fails the plugin */
}
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Observation: one derivation pass over the client's own session state.
*
* @module @dsh-plugin/session-notify/client/core/observe
*/
import { questionText, servedPendingKind, titleOf } from './session.js'
import { report } from './log.js'
/**
* Build the observer.
* @param deps - the plugin store; the shared `live` cell the delivery path reads
* back at delivery time; and `queue`, which holds a derived alert for the
* settle tick.
* @returns the derivation pass, and the health publisher the slot entry reports
* its own state through.
*/
export function createObservation({ store, live, queue }) {
const runs = new Map()
const completionNotice = new Set()
const pendingNotice = new Map()
/**
* Publish the resident observer's own health into the plugin store.
*
* A slot entry that silently receives nothing is the one failure this plugin
* cannot detect from the outside, so the observer records whether it is
* actually seeing session state and the settings row shows it. Repeated
* identical states publish nothing, and a state this is not `watching` also
* reaches the tagged console.
* @param next - the health state observed on this render.
*/
const reportHealth = (next) => {
const snapshot = store.getSnapshot()
const current = snapshot.health
if (current.state === next.state && current.message === next.message) return
if (current.state !== 'watching') report(`observer health: ${next.state} ${next.message}`)
store.set({ ...snapshot, health: next })
}
/**
* One derivation pass over the client's own session state: remember the
* freshest snapshot for the settle tick, compare it against what this page
* already observed, emit at most one candidate per transition, and let the
* settle tick decide delivery. The window state is deliberately NOT captured
* here — the delivery reads it fresh, because it can change inside the
* settle window.
* @param list - the client's session-list snapshot.
* @param status - the client's per-session status selector.
*/
const observe = (list, status) => {
if (list === undefined || status === undefined) return
live.list = list
live.status = status
const kinds = store.getSnapshot().kinds
for (const sessionId of Object.keys(list.byId ?? {})) {
const summary = list.byId[sessionId]
if (summary === undefined || summary.origin === 'subagent') continue
const sessionStatus = status.get(sessionId)
const running = sessionStatus?.running ?? summary.running
if (running === true) {
runs.set(sessionId, true)
completionNotice.delete(sessionId)
} else {
if (runs.get(sessionId) === true) {
runs.set(sessionId, false)
if (summary.blank !== true && !completionNotice.has(sessionId) && kinds.completion) {
completionNotice.add(sessionId)
queue({
kind: 'completion',
sessionId,
title: titleOf(summary, sessionId),
detail: '',
pendingKind: '',
})
}
} else if (!runs.has(sessionId)) {
runs.set(sessionId, false)
}
}
const interaction = sessionStatus?.pendingInteraction
const pendingKind = servedPendingKind(interaction)
if (pendingKind === undefined) {
pendingNotice.delete(sessionId)
continue
}
if (pendingNotice.get(sessionId) === pendingKind) continue
pendingNotice.set(sessionId, pendingKind)
const trigger = pendingKind === 'approval' ? 'approval' : 'question'
if (!kinds[trigger]) continue
queue({
kind: trigger,
sessionId,
title: titleOf(summary, sessionId),
detail: pendingKind === 'approval'
? String(interaction?.toolName ?? '')
: questionText(interaction),
pendingKind,
})
}
for (const sessionId of [...runs.keys()]) {
if (list.byId?.[sessionId] !== undefined) continue
runs.delete(sessionId)
completionNotice.delete(sessionId)
pendingNotice.delete(sessionId)
}
}
return { observe, reportHealth }
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Replaying, in-app, the alerts the system channel carried while nobody was
* looking at the machine.
*
* @module @dsh-plugin/session-notify/client/core/replay
*/
import { REPLAY_LIMIT, REPLAY_QUIET_MS, REPLAY_TTL_MS, TOAST_LIMIT } from '../constants.js'
/**
* Build the replay queue.
* @param deps - the plugin store; `publish` so the settings row can count what
* is waiting; `showToast` for the in-app surface; and `recordDelivery`, which
* belongs to the delivery module and is reached through the composition root's
* late-bound runtime (this queue is built before that module exists).
* @returns the queue's operations plus the current depth.
*/
export function createReplay({ store, publish, showToast, recordDelivery }) {
let entries = []
/** Forget one queued replay: the user has just answered that alert in the system channel. */
const dropReplay = (candidate) => {
const next = entries.filter((entry) => (
entry.candidate.sessionId !== candidate.sessionId || entry.candidate.kind !== candidate.kind
))
if (next.length === entries.length) return
entries = next
publish()
}
/**
* Keep one away-channel alert for an in-app replay.
*
* A desktop notification raised while nobody is looking at the machine is
* easy to miss: its banner lives a few seconds, its history lives in the
* system's own notification centre, which the user may never open, and a
* refused display leaves no trace at all on the page. The alert is therefore
* replayed as a light in-app popup as soon as the window is back in the
* foreground, except when the platform confirmed the banner and the user was
* back within {@link REPLAY_QUIET_MS} — then the banner is still on screen and
* a second surface would only be noise. Entries older than
* {@link REPLAY_TTL_MS} are dropped instead of waiting for a user who has
* moved on.
* @param candidate - the alert to keep for a replay.
* @param monitor - the system channel's own record, so its confirmation can be read later.
*/
const holdForReplay = (candidate, monitor) => {
const now = Date.now()
entries = [
...entries.filter((entry) => (
entry.candidate.sessionId !== candidate.sessionId || entry.candidate.kind !== candidate.kind
)),
{ candidate, at: now, monitor },
].slice(-REPLAY_LIMIT)
publish()
}
/** Show every away-channel alert the user has not had a chance to see yet. */
const flushReplay = () => {
if (entries.length === 0) return
const now = Date.now()
const waiting = entries
entries = []
const due = waiting.filter((entry) => {
if (now - entry.at > REPLAY_TTL_MS) return false
// A banner that the platform confirmed and that is still inside its own
// visible window has already told the user; do not say it twice.
return !(entry.monitor?.shown === true && now - entry.at <= REPLAY_QUIET_MS)
})
const shown = due.slice(-TOAST_LIMIT)
for (const entry of shown) showToast(entry.candidate)
if (shown.length > 0) recordDelivery('replay', shown[shown.length - 1].candidate)
else publish()
}
/** How many alerts are still waiting for the user to come back. */
const size = () => entries.length
/** Forget everything: the plugin is being disposed. */
const dispose = () => {
entries = []
}
return { holdForReplay, dropReplay, flushReplay, size, dispose }
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Reading the client's own session state: the pure derivations the observer
* and the delivery path both ask questions of.
*
* @module @dsh-plugin/session-notify/client/core/session
*/
import { PENDING_KINDS } from '../constants.js'
/**
* Discriminate a pending interaction the client published. Only the three
* domains the Harness itself renders are served; anything else is ignored
* rather than guessed at.
* @param value - `status.pendingInteraction`.
* @returns a served kind, or undefined.
*/
export function servedPendingKind(value) {
if (value === undefined || value === null || typeof value !== 'object') return undefined
const kind = value.kind
return typeof kind === 'string' && PENDING_KINDS.includes(kind) ? kind : undefined
}
/** Trim free text down to one popup line. */
export function preview(value) {
if (typeof value !== 'string') return ''
const flat = value.replace(/\s+/g, ' ').trim()
return flat.length > 80 ? `${flat.slice(0, 79)}…` : flat
}
/** The first question text a pending interaction exposes, across its shapes. */
export function questionText(interaction) {
if (interaction === null || typeof interaction !== 'object') return ''
const first = Array.isArray(interaction.questions) ? interaction.questions[0] : undefined
if (typeof first?.question === 'string') return preview(first.question)
if (typeof first?.text === 'string') return preview(first.text)
if (typeof interaction.question === 'string') return preview(interaction.question)
if (typeof interaction.prompt === 'string') return preview(interaction.prompt)
return preview(interaction.displayReason?.text ?? interaction.reason?.text ?? '')
}
/** Copy one session title, falling back to its identity. */
export function titleOf(summary, sessionId) {
const title = typeof summary?.title === 'string' ? summary.title.trim() : ''
return title === '' ? sessionId.slice(0, 8) : title
}
/** Whether the user is looking at this exact conversation right now. */
export function isOnScreen(sessionId, list) {
if (list === undefined) return false
for (const id of Object.keys(list.byId ?? {})) {
const summary = list.byId[id]
if (summary !== undefined && (summary.retainedBy?.mainView ?? 0) > 0) return id === sessionId
}
return false
}
/**
* Whether a queued candidate is still worth delivering. Read from the LIVE
* snapshot rather than the one captured when the transition was seen: a
* conversation that resumed in the settle window owes no alert, and the
* snapshot the transition was derived from still carries the pre-transition
* `running` flag of the session record.
* @param candidate - the alert waiting for its settle tick.
* @param live - the freshest `{ list, status }` the observer has seen.
* @returns whether the alert is still owed.
*/
export function stillWorth(candidate, live) {
const list = live.list
if (candidate.test === true || list === undefined) return true
const summary = list.byId?.[candidate.sessionId]
if (summary === undefined) return true
const status = live.status
const running = status?.get?.(candidate.sessionId)?.running ?? summary.running
return running !== true
}
+43
View File
@@ -0,0 +1,43 @@
/**
* This package's browser-local preferences.
*
* @module @dsh-plugin/session-notify/client/core/storage
*/
import { KINDS } from '../constants.js'
/** Storage key for this package's own preferences. */
export const STORAGE_KEY = 'dsh-plugin/session-notify'
/**
* Read this package's stored trigger switches.
*
* These are browser preferences rather than cordis configuration, so they live
* in this origin's own storage instead of the profile's patch file: the plugin
* writes nothing into DSH's configuration, and every read is defensive because
* storage can be unavailable or hold something an older version wrote.
* @returns the stored switches, defaulting to everything on.
*/
export function readStoredKinds() {
const kinds = { completion: true, approval: true, question: true }
try {
const raw = globalThis.localStorage?.getItem(STORAGE_KEY)
if (typeof raw !== 'string' || raw === '') return kinds
const stored = JSON.parse(raw)
for (const kind of KINDS) {
if (typeof stored?.[kind] === 'boolean') kinds[kind] = stored[kind]
}
} catch {
/* unreadable or foreign storage keeps the defaults */
}
return kinds
}
/** Store one trigger switch, ignoring storage that refuses to write. */
export function writeStoredKinds(kinds) {
try {
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(kinds))
} catch {
/* a refused write leaves the switches live for this page only */
}
}
+25
View File
@@ -0,0 +1,25 @@
/**
* The plugin's own observable state.
*
* @module @dsh-plugin/session-notify/client/core/store
*/
/** Minimal observable store: the shape React reads with useSyncExternalStore. */
export function createStore(initial) {
let value = initial
const listeners = new Set()
return {
getSnapshot: () => value,
subscribe: (listener) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
set: (next) => {
if (next === value) return
value = next
for (const listener of [...listeners]) listener()
},
}
}
+112
View File
@@ -0,0 +1,112 @@
/**
* The system-notification channel: raising one, and reporting what the platform
* actually did with it.
*
* @module @dsh-plugin/session-notify/client/core/system-channel
*/
import { RAISED_LIMIT } from '../constants.js'
import { focusWindow, notificationPermission, systemIcon } from '../platform.js'
import { text } from './log.js'
/**
* Build the system channel over one store.
* @param deps - the plugin store; the copy table; `notify` for contained
* diagnostics; `openSession` for a click that must land on the conversation;
* and `dropReplay`, so clicking the banner cancels its in-app replay.
* @returns the channel's operations.
*/
export function createSystemChannel({ store, copy, notify, openSession, dropReplay }) {
/** System notifications this page still holds open, so nothing collects them mid-display. */
const raised = new Set()
/**
* The system-notification channel's own answer about one notification.
*
* A browser reports the platform's verdict asynchronously: `show` means the
* notification reached the desktop, `error` means the platform refused it, and
* silence means neither answer arrived. That third case is the one that used to
* lose alerts silently, so it is recorded rather than assumed to be a success.
* @param monitor - the record returned by {@link notifySystem} for this notification.
* @param shown - `true` when the platform displayed it, `false` when it refused.
*/
const confirmDelivery = (monitor, shown) => {
if (monitor === null || monitor === undefined || typeof monitor !== 'object') return
monitor.shown = shown
const snapshot = store.getSnapshot()
const last = snapshot.lastDelivery
if (last === null || last === undefined || last.monitor !== monitor || last.shown === shown) return
store.set({ ...snapshot, lastDelivery: { ...last, shown } })
}
/** Hold one raised notification open, bounded, so it cannot be collected while pending. */
const keepRaised = (notification) => {
raised.add(notification)
while (raised.size > RAISED_LIMIT) {
const oldest = raised.values().next().value
if (oldest === undefined) break
raised.delete(oldest)
}
}
/**
* Raise one system notification, tolerating every refusal a browser may give,
* and report what the platform said about it.
* @param candidate - the alert to raise.
* @param force - raise it even when the permission is not granted (the settings
* page's own test button, which exists to prove the channel on its own).
* @returns a record whose `outcome` is the channel's verdict (`raised`,
* `permission`, `unsupported`, or `threw`) and whose `shown` is filled in
* later by {@link confirmDelivery} — `true` displayed, `false` refused,
* `undefined` while the platform has said nothing.
*/
const notifySystem = (candidate, force) => {
const Ctor = globalThis.Notification
const permission = notificationPermission()
if (permission === 'unsupported') return { outcome: 'unsupported' }
if (!force && permission !== 'granted') return { outcome: 'permission' }
try {
const notice = copy.copyFor(candidate)
const options = {
body: notice.body,
tag: `dsh-session-${candidate.sessionId}`,
requireInteraction: false,
}
if (systemIcon !== undefined) options.icon = systemIcon
const notification = new Ctor(notice.title, options)
const record = { outcome: 'raised', notice, shown: undefined }
keepRaised(notification)
notification.onshow = () => {
confirmDelivery(record, true)
}
notification.onerror = (event) => {
confirmDelivery(record, false)
notify(`system notification was refused: ${String(event?.message ?? 'unknown')}`)
}
notification.onclose = () => {
raised.delete(notification)
}
notification.onclick = () => {
dropReplay(candidate)
focusWindow()
openSession(candidate.sessionId)
try {
notification.close()
} catch {
/* close is best effort on every platform */
}
}
return record
} catch (error) {
notify(`system notification failed: ${text(error)}`)
return { outcome: 'threw' }
}
}
/** Drop the held references: the plugin is being disposed. */
const dispose = () => {
raised.clear()
}
return { notifySystem, confirmDelivery, dispose }
}
+80
View File
@@ -0,0 +1,80 @@
/**
* The light in-app popup stack: its lifetime, its ordering, and its bound.
*
* @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()
let seq = 0
/** Drop one popup and cancel its lifetime timer. */
const closeToast = (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. */
const armToast = (id) => {
const timer = timers.get(id)
if (timer !== undefined) clearTimeout(timer)
timers.set(id, setTimeout(() => closeToast(id), TOAST_DURATION_MS))
}
/** Pause one popup's lifetime while the pointer rests on it. */
const holdToast = (id) => {
const timer = timers.get(id)
if (timer === undefined) return
clearTimeout(timer)
timers.delete(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)
}
}
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()
}
return { showToast, closeToast, armToast, holdToast, dispose }
}