🐛 fix(client): 后台提醒回到窗口补发轻弹窗,系统通知记录是否弹出

This commit is contained in:
pyh
2026-09-30 19:13:59 +08:00
parent 080d873f9a
commit 3986aac863
4 changed files with 238 additions and 41 deletions
+214 -37
View File
@@ -49,6 +49,18 @@ window.__ModuleLoader__.load({
/** In-app popup lifetime, and how many may stack. */
const TOAST_DURATION_MS = 6000
const TOAST_LIMIT = 3
/** Notifications kept referenced, so a collection can never cancel a pending display. */
const RAISED_LIMIT = 8
/** Alerts carried by the system channel and still worth replaying in-app on return. */
const REPLAY_LIMIT = 8
/** How long a system notification outlives the moment the user comes back. */
const REPLAY_TTL_MS = 30 * 60 * 1000
/**
* A confirmed banner that the user came back to within this window is treated as
* seen: the desktop notification is still on screen at that moment, so replaying
* it in-app would be a duplicate rather than a reminder.
*/
const REPLAY_QUIET_MS = 20 * 1000
const zh = {
'notification.completion': '会话已完成',
@@ -98,9 +110,14 @@ window.__ModuleLoader__.load({
'config.diag.away': '不在前台(会走系统通知)',
'config.diag.last': '最近一次投递',
'config.diag.none': '还没有投递过',
'config.diag.system': '系统通知',
'config.diag.systemShown': '系统通知(系统已确认弹出)',
'config.diag.systemUnconfirmed': '系统通知(系统没有回报,回到窗口时补发轻弹窗)',
'config.diag.systemFailed': '系统通知没弹出来,回到窗口时补发轻弹窗',
'config.diag.replayDelivery': '应用内轻弹窗(回到窗口时补发)',
'config.diag.popup': '应用内轻弹窗',
'config.diag.refused': '系统通知被拒绝,改用了轻弹窗',
'config.diag.replay': '等你回到窗口时补发:{count} 条',
'config.diag.replayNone': '没有待补发的提醒',
}
const en = {
'notification.completion': 'Conversation finished',
@@ -150,9 +167,14 @@ window.__ModuleLoader__.load({
'config.diag.away': 'not in front (system notification is used)',
'config.diag.last': 'Last delivery',
'config.diag.none': 'nothing delivered yet',
'config.diag.system': 'system notification',
'config.diag.systemShown': 'system notification (confirmed on screen)',
'config.diag.systemUnconfirmed': 'system notification (no confirmation, replayed in-app on return)',
'config.diag.systemFailed': 'system notification never appeared, replayed in-app on return',
'config.diag.replayDelivery': 'in-app popup (replayed when you came back)',
'config.diag.popup': 'in-app popup',
'config.diag.refused': 'system channel refused, popup was used instead',
'config.diag.replay': 'waiting for you to come back: {count}',
'config.diag.replayNone': 'nothing waiting to be replayed',
}
/**
@@ -563,6 +585,11 @@ window.__ModuleLoader__.load({
settleTimer: 0,
lastDelivery: 0,
toastSeq: 0,
deliverySeq: 0,
/** System notifications this page still holds open, so nothing collects them mid-display. */
raised: new Set(),
/** Alerts the user has not been able to see yet, replayed in-app when the window returns. */
replay: [],
live: {
list: undefined,
status: undefined,
@@ -677,14 +704,47 @@ window.__ModuleLoader__.load({
}
/**
* Raise one system notification, tolerating every refusal a browser may give.
* @returns the outcome the config page reports, so a refusal is never silent.
* 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 systemNotify} 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) => {
state.raised.add(notification)
while (state.raised.size > RAISED_LIMIT) {
const oldest = state.raised.values().next().value
if (oldest === undefined) break
state.raised.delete(oldest)
}
}
/**
* Raise one system notification, tolerating every refusal a browser may give,
* and report what the platform said about it.
* @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 systemNotify = (candidate, force) => {
const Ctor = globalThis.Notification
const permission = notificationPermission()
if (permission === 'unsupported') return 'unsupported'
if (!force && permission !== 'granted') return 'permission'
if (permission === 'unsupported') return { outcome: 'unsupported' }
if (!force && permission !== 'granted') return { outcome: 'permission' }
try {
const notice = copyFor(candidate)
const options = {
@@ -694,7 +754,20 @@ window.__ModuleLoader__.load({
}
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 = () => {
state.raised.delete(notification)
}
notification.onclick = () => {
dropReplay(candidate)
focusWindow()
openSession(candidate.sessionId)
try {
@@ -703,13 +776,70 @@ window.__ModuleLoader__.load({
/* close is best effort on every platform */
}
}
return 'raised'
return record
} catch (error) {
notify(`system notification failed: ${text(error)}`)
return 'threw'
return { outcome: 'threw' }
}
}
// ------------------------------------------------------------ replay
/** Forget one queued replay: the user has just answered that alert in the system channel. */
const dropReplay = (candidate) => {
const next = state.replay.filter((entry) => (
entry.candidate.sessionId !== candidate.sessionId || entry.candidate.kind !== candidate.kind
))
if (next.length === state.replay.length) return
state.replay = 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()
state.replay = [
...state.replay.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 (state.replay.length === 0) return
const now = Date.now()
const waiting = state.replay
state.replay = []
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()
}
// -------------------------------------------------------------- delivery
/** Whether the user is looking at this exact conversation right now. */
@@ -729,16 +859,28 @@ window.__ModuleLoader__.load({
* 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) => {
store.set({
...store.getSnapshot(),
lastDelivery: {
outcome,
at: new Date().toISOString().slice(11, 19),
title: copyFor(candidate).title,
},
})
const recordDelivery = (outcome, candidate, monitor) => {
state.deliverySeq += 1
const now = new Date()
const pad = (value) => String(value).padStart(2, '0')
const record = {
seq: state.deliverySeq,
outcome,
at: `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
title: copyFor(candidate).title,
shown: monitor?.shown,
monitor,
}
store.set({ ...store.getSnapshot(), lastDelivery: record })
return record
}
/**
@@ -765,7 +907,10 @@ window.__ModuleLoader__.load({
*
* 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 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.
*/
const deliver = (candidate, ignoreThrottle) => {
if (ignoreThrottle !== true) {
@@ -779,26 +924,31 @@ window.__ModuleLoader__.load({
recordDelivery('popup', candidate)
return
}
const outcome = systemNotify(candidate, candidate.test === true)
if (outcome === 'raised') {
recordDelivery('system', candidate)
const result = systemNotify(candidate, candidate.test === true)
if (result.outcome === 'raised') {
recordDelivery('system', candidate, result)
if (candidate.test !== true) 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.
showToast(candidate)
recordDelivery(`system-refused-${outcome}`, 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 = () => {
state.settleTimer = 0
if (disposed) return
const candidate = state.pending.shift()
if (disposed || candidate === undefined) return
if (!stillWorth(candidate)) return
deliver(candidate, false)
if (candidate !== undefined && stillWorth(candidate)) deliver(candidate, false)
if (state.pending.length > 0) state.settleTimer = setTimeout(() => flushSettle(), SETTLE_MS)
}
/** Hold one candidate for the settle tick, collapsing a burst onto one timer. */
@@ -903,13 +1053,13 @@ window.__ModuleLoader__.load({
/** Raise the test alert on the system channel specifically, whatever the focus is. */
const sendTestSystem = () => {
const candidate = testCandidate(t('config.testSystem'))
const outcome = systemNotify(candidate, true)
if (outcome === 'raised') {
recordDelivery('system', candidate)
const result = systemNotify(candidate, true)
if (result.outcome === 'raised') {
recordDelivery('system', candidate, result)
return
}
showToast(candidate)
recordDelivery(`system-refused-${outcome}`, candidate)
recordDelivery(`system-refused-${result.outcome}`, candidate)
}
/** Request notification permission inside a user gesture, then report the outcome. */
@@ -1159,7 +1309,8 @@ window.__ModuleLoader__.load({
className: `dsn-status${healthFailed ? ' dsn-status-error' : ''}`,
role: healthFailed ? 'alert' : 'status',
}, healthText),
h('p', { className: 'dsn-status' }, deliveryText(tr))),
h('p', { className: 'dsn-status' }, deliveryText(tr)),
h('p', { className: 'dsn-status' }, replayText(tr))),
h('button', {
type: 'button',
className: 'dsn-button',
@@ -1174,28 +1325,52 @@ window.__ModuleLoader__.load({
const outcome = last.outcome.startsWith('system-refused')
? tr('config.diag.refused')
: last.outcome === 'system'
? tr('config.diag.system')
: tr('config.diag.popup')
? 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. */
function replayText(tr) {
const pending = state.replay.length
return pending === 0 ? tr('config.diag.replayNone') : tr('config.diag.replay', { count: pending })
}
// ---------------------------------------------------------- registration
/** Keep the page's own focus events owned by this plugin's effect. */
const installBridges = () => {
const onFocusChange = () => {
const onBlur = () => {
state.lastDelivery = 0
}
// Coming back is the moment the user can finally be told about whatever the
// system channel carried while the window was away, and it also clears the
// throttle so the first alert after returning is never swallowed.
const onReturn = () => {
state.lastDelivery = 0
flushReplay()
}
const onVisibilityChange = () => {
if (!windowIsAway()) onReturn()
}
try {
window.addEventListener('focus', onFocusChange)
window.addEventListener('blur', onFocusChange)
window.addEventListener('focus', onReturn)
window.addEventListener('blur', onBlur)
document.addEventListener('visibilitychange', onVisibilityChange)
} catch {
/* an unreadable window still delivers through live focus reads */
}
return () => {
try {
window.removeEventListener('focus', onFocusChange)
window.removeEventListener('blur', onFocusChange)
window.removeEventListener('focus', onReturn)
window.removeEventListener('blur', onBlur)
document.removeEventListener('visibilitychange', onVisibilityChange)
} catch {
/* nothing to detach */
}
@@ -1205,6 +1380,8 @@ window.__ModuleLoader__.load({
}
for (const timer of timers.values()) clearTimeout(timer)
timers.clear()
state.raised.clear()
state.replay = []
}
}