Files
session-notify/src/client/core/system-channel.js
T

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 }
}