113 lines
4.4 KiB
JavaScript
113 lines
4.4 KiB
JavaScript
/**
|
|
* 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 }
|
|
}
|