♻️ refactor(client): 浏览器半拆成 src 模块,client.js 改由构建产出
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user