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