UNPKG

@socketsupply/socket

Version:

A Cross-Platform, Native Runtime for Desktop and Mobile Apps — Create apps using HTML, CSS, and JavaScript. Written from the ground up to be small and maintainable.

1,047 lines (909 loc) 28.5 kB
/* global CustomEvent, Event, ErrorEvent, EventTarget */ /** * @module notification * The Notification modules provides an API to configure and display * desktop and mobile notifications to the user. It also includes mechanisms * for request permissions to use notifications on the user's device. */ import { Enumeration } from './enumeration.js' import permissions from './internal/permissions.js' import { rand64 } from './crypto.js' import language from './language.js' import location from './location.js' import hooks from './hooks.js' import URL from './url.js' import ipc from './ipc.js' import os from './os.js' const isLinux = os.platform() === 'linux' const NativeNotification = ( globalThis.Notification || class NativeNotification extends EventTarget {} ) /** * Default number of max actions a notification can perform. * @ignore * @type {number} */ const DEFAULT_MAX_ACTIONS = 2 /** * The global event dispatched when a `Notification` is presented to * the user. * @ignore * @type {string} */ export const NOTIFICATION_PRESENTED_EVENT = 'notificationpresented' /** * The global event dispatched when a `Notification` has a response * from the user. * @ignore * @type {string} */ export const NOTIFICATION_RESPONSE_EVENT = 'notificationresponse' /** * A container to proxy native notification events to a runtime notication. * @ignore */ class NativeNotificationProxy extends NativeNotification { /** * @type {Notification} * @ignore */ #notification = null /** * `NativeNotificationProxy` class constructor. * @param {Notification} notification * @ignore */ constructor (notification) { super(notification.title, notification) let clicked = false let error = false this.#notification = notification // @ts-ignore this.onerror = (event) => { error = true notification.dispatchEvent(new ErrorEvent('error', { error: ( event.error || event.message || new Error('An unknown error occured', { cause: event }) ) })) } // @ts-ignore this.onclose = (event) => { if (error) return if (!clicked) { const event = new CustomEvent(NOTIFICATION_RESPONSE_EVENT, { detail: { id: notification.id, action: 'dismiss' } }) globalThis.dispatchEvent(event) } } // @ts-ignore this.onclick = () => { if (error) return clicked = true const event = new CustomEvent(NOTIFICATION_RESPONSE_EVENT, { detail: { id: notification.id, action: 'default' } }) globalThis.dispatchEvent(event) } // @ts-ignore this.onshow = () => { if (error) return const event = new CustomEvent(NOTIFICATION_PRESENTED_EVENT, { detail: { id: notification.id } }) globalThis.dispatchEvent(event) } } /** * The underlying `Notification` this proxy dispatches events to. * @type {Notification} * @ignore */ get notification () { return this.#notification } } /** * An enumeratino of notification test directions: * - 'auto' Automatically determined by the operating system * - 'ltr' Left-to-right text direction * - 'rtl' Right-to-left text direction * @type {Enumeration} * @ignore */ export const NotificationDirection = Enumeration.from([ 'auto', 'ltr', 'rtl' ]) /** * An enumeration of permission types granted by the user for the current * origin to display notifications to the end user. * - 'granted' The user has explicitly granted permission for the current * origin to display system notifications. * - 'denied' The user has explicitly denied permission for the current * origin to display system notifications. * - 'default' The user decision is unknown; in this case the application * will act as if permission was denied. * @type {Enumeration} * @ignore */ export const NotificationPermission = Enumeration.from([ 'granted', 'denied', 'default' ]) /** * A validated notification action object container. * You should never need to construct this. * @ignore */ export class NotificationAction { #action = null #title = null #icon = null /** * `NotificationAction` class constructor. * @ignore * @param {object} options * @param {string} options.action * @param {string} options.title * @param {string|URL=} [options.icon = ''] */ constructor (options) { if (options?.action === undefined) { throw new TypeError( 'Failed to read the \'action\' property from ' + `'NotificationAction': Required member is ${options.action}.` ) } if (options?.title === undefined) { throw new TypeError( 'Failed to read the \'title\' property from ' + `'NotificationAction': Required member is ${options.title}.` ) } this.#action = String(options.action) this.#title = String(options.title) this.#icon = String(options.icon ?? '') } /** * A string identifying a user action to be displayed on the notification. * @type {string} */ get action () { return this.#action } /** * A string containing action text to be shown to the user. * @type {string} */ get title () { return this.#title } /** * A string containing the URL of an icon to display with the action. * @type {string} */ get icon () { return this.#icon } } /** * A validated notification options object container. * You should never need to construct this. * @ignore */ export class NotificationOptions { #actions = [] #badge = '' #body = '' #data = null #dir = 'auto' #icon = '' #image = '' #lang = '' #renotify = false #requireInteraction = false #silent = false #tag = '' #vibrate = [] /** * `NotificationOptions` class constructor. * @ignore * @param {object} [options = {}] * @param {string=} [options.dir = 'auto'] * @param {NotificationAction[]=} [options.actions = []] * @param {string|URL=} [options.badge = ''] * @param {string=} [options.body = ''] * @param {?any=} [options.data = null] * @param {string|URL=} [options.icon = ''] * @param {string|URL=} [options.image = ''] * @param {string=} [options.lang = ''] * @param {string=} [options.tag = ''] * @param {boolean=} [options.boolean = ''] * @param {boolean=} [options.requireInteraction = false] * @param {boolean=} [options.silent = false] * @param {number[]=} [options.vibrate = []] */ constructor (options = {}, allowServiceWorkerGlobalScope = false) { if ('dir' in options) { // @ts-ignore if (!(options.dir in NotificationDirection)) { throw new TypeError( 'Failed to read the \'dir\' property from \'NotificationOptions\': ' + `The provided value '${options.dir}' is not a valid enum value of ` + 'type NotificationDirection.' ) } // @ts-ignore this.#dir = options.dir } if ('actions' in options) { // @ts-ignore if (!(Symbol.iterator in options.actions)) { throw new TypeError( 'Failed to read the \'actions\' property from ' + '\'NotificationOptions\': The object must have a callable ' + '@@iterator property.' ) } // @ts-ignore for (const action of options.actions) { try { this.#actions.push(new NotificationAction(action)) } catch (err) { throw new TypeError( 'Failed to read the \'actions\' property from ' + `'NotificationOptions': ${err.message}` ) } if (this.#actions.length === state.maxActions) { break } } } if (allowServiceWorkerGlobalScope !== true) { if (this.#actions.length && globalThis.isServiceWorkerScope) { throw new TypeError( 'Failed to construct \'Notification\': Actions are only supported ' + 'for persistent notifications shown using ' + 'ServiceWorkerRegistration.showNotification().' ) } } if ('badge' in options && options.badge) { this.#badge = String(new URL(String(options.badge), location.href)) } if ('body' in options && options.body) { this.#body = String(options.body) } if ('data' in options && options.data !== undefined) { this.#data = options.data } if ('icon' in options && options.icon) { this.#icon = String(new URL(String(options.icon), location.href)) } if ('image' in options && options.image) { this.#image = String(new URL(String(options.image), location.href)) } if ('lang' in options && options.lang !== undefined) { if (typeof options.lang === 'string' && options.lang.length > 2) { this.#lang = language.describe(options.lang)[0]?.tag || '' } } if ('tag' in options && options.tag !== undefined) { this.#tag = String(options.tag) } if ('renotify' in options && options.renotify !== undefined) { this.#renotify = Boolean(options.renotify) if (this.#renotify === true && !this.#tag.length) { throw new TypeError( 'Notifications which set the renotify flag must specify a non-empty tag.' ) } } if ('requireInteraction' in options && options.requireInteraction !== undefined) { this.#requireInteraction = Boolean(options.requireInteraction) } if ('silent' in options && options.silent !== undefined) { this.#silent = Boolean(options.silent) } if ('vibrate' in options && options.vibrate !== undefined) { if (Array.isArray(options.vibrate)) { this.#vibrate = options.vibrate } else if (options.vibrate) { this.#vibrate = [options.vibrate] } else { this.#vibrate = [0] } if (this.#vibrate.length) { throw new TypeError( 'Silent notifications must not specify vibration patterns.' ) } this.#vibrate = this.#vibrate .map((v) => parseInt(v) || 0) .map((v) => Math.min(v, 10000)) .map((v) => v < 0 ? 10000 : v) } } /** * An array of actions to display in the notification. * @type {NotificationAction[]} */ get actions () { return this.#actions } /** * A string containing the URL of the image used to represent * the notification when there isn't enough space to display the * notification itself. * @type {string} */ get badge () { return this.#badge } /** * A string representing the body text of the notification, * which is displayed below the title. * @type {string} */ get body () { return this.#body } /** * Arbitrary data that you want associated with the notification. * This can be of any data type. * @type {?any} */ get data () { return this.#data } /** * The direction in which to display the notification. * It defaults to 'auto', which just adopts the environments * language setting behavior, but you can override that behavior * by setting values of 'ltr' and 'rtl'. * @type {'auto'|'ltr'|'rtl'} */ get dir () { return this.#dir } /** A string containing the URL of an icon to be displayed in the notification. * @type {string} */ get icon () { return this.#icon } /** * The URL of an image to be displayed as part of the notification, as * specified in the constructor's options parameter. * @type {string} */ get image () { return this.#image } /** * The notification's language, as specified using a string representing a * language tag according to RFC 5646. * @type {string} */ get lang () { return this.#lang } /** * A boolean value specifying whether the user should be notified after a * new notification replaces an old one. The default is `false`, which means * they won't be notified. If `true`, then tag also must be set. * @type {boolean} */ get renotify () { return this.#renotify } /** * Indicates that a notification should remain active until the user clicks * or dismisses it, rather than closing automatically. * The default value is `false`. * @type {boolean} */ get requireInteraction () { return this.#requireInteraction } /** * A boolean value specifying whether the notification is silent (no sounds * or vibrations issued), regardless of the device settings. * The default is `false`, which means it won't be silent. If `true`, then * vibrate must not be present. * @type {boolean} */ get silent () { return this.#silent } /** * A string representing an identifying tag for the notification. * The default is the empty string. * @type {string} */ get tag () { return this.#tag } /** * A vibration pattern for the device's vibration hardware to emit with * the notification. If specified, silent must not be `true`. * @type {number[]} * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Vibration_API#vibration_patterns} */ get vibrate () { return this.#vibrate } /** * @ignore * @return {object} */ toJSON () { return { actions: this.#actions, badge: this.#badge, body: this.#body, data: this.#data, dir: this.#dir, icon: this.#icon, image: this.#image, lang: this.#lang, renotify: this.#renotify, requireInteraction: this.#requireInteraction, silent: this.#silent, tag: this.#tag, vibrate: this.#vibrate } } } /** * Show a notification. Creates a `Notification` instance and displays * it to the user. * @param {string} title * @param {NotificationOptions=} [options] * @param {function(Event)=} [onclick] * @param {function(Event)=} [onclose] * @return {Promise} */ export async function showNotification (title, options, onclick = null, onshow = null) { const notification = new Notification(title, options) await new Promise((resolve, reject) => { notification.onclick = onclick notification.onshow = onshow notification.onerror = (e) => reject(e.error) // @ts-ignore notification.onshow = () => resolve() }) } /** * Internal state * @ignore */ const state = { permission: 'default', maxActions: DEFAULT_MAX_ACTIONS, pending: new Map() } /** * The Notification interface is used to configure and display * desktop and mobile notifications to the user. */ export class Notification extends EventTarget { /** * A read-only property that indicates the current permission granted * by the user to display notifications. * @type {'prompt'|'granted'|'denied'} */ static get permission () { return state.permission } /** * The maximum number of actions supported by the device. * @type {number} */ static get maxActions () { return state.maxActions } /** * Requests permission from the user to display notifications. * @param {object=} [options] * @param {boolean=} [options.alert = true] - (macOS/iOS only) * @param {boolean=} [options.sound = false] - (macOS/iOS only) * @param {boolean=} [options.badge = false] - (macOS/iOS only) * @param {boolean=} [options.force = false] * @return {Promise<'granted'|'default'|'denied'>} */ static async requestPermission (options = null) { if (isLinux) { // @ts-ignore if (typeof NativeNotification?.requestPermission === 'function') { // @ts-ignore return await NativeNotification.requestPermission() } return 'denied' } // explicitly unsubscribe with `AbortController` to prevent // any result state changes further updates const controller = new AbortController() // query for 'granted' status and return early const query = await permissions.query({ name: 'notifications' }, { signal: controller.signal }) // if already granted, return early // any non-standard macOS/iOS options given will be ignored as they // must be configured by the user unless the request is "forced" if (options?.force !== true && query.state === 'granted') { controller.abort() return query.state } // request permission and resolve the normalized `state.permission` value // when the query status changes const request = await permissions.request({ name: 'notifications' }, { signal: controller.signal, // macOS/iOS only options alert: Boolean(options?.alert !== false), // (defaults to `true`) badge: Boolean(options?.badge), sound: Boolean(options?.sound) }) if (request.state === 'granted') { controller.abort() return request.state } return new Promise((resolve) => { request.onchange = () => { controller.abort() resolve(state.permission) } query.onchange = () => { controller.abort() resolve(state.permission) } }) } #onclick = null #onclose = null #onerror = null #onshow = null #options = null #timestamp = Date.now() #title = null #id = null #closed = false #proxy = null /** * `Notification` class constructor. * @param {string} title * @param {NotificationOptions=} [options] */ constructor (title, options = {}, existingState = null) { super() if (arguments.length === 0) { throw new TypeError( 'Failed to construct \'Notification\': ' + '1 argument required, but only 0 present.' ) } if (options === null || options === undefined) { options = {} } this.#title = String(title) if (typeof options !== 'object') { throw new TypeError( 'Failed to construct \'Notification\': ' + 'The provided value is not of type \'NotificationOptions\'.' ) } try { this.#options = new NotificationOptions(options, existingState !== null) } catch (err) { throw new TypeError( `Failed to construct 'Notification': ${err.message}` ) } // @ts-ignore this.#id = existingState?.id ?? (rand64() & 0xFFFFn).toString() this.#timestamp = existingState?.timestamp ?? this.#timestamp const channel = new BroadcastChannel('socket.runtime.notification') // if internal `existingState` is present, then this is just a view over the instance if (existingState) { channel.addEventListener('message', async (event) => { if (event.data?.id === this.#id) { if (!this.#closed) { if (event.data.action === 'close') { this.#closed = true } this.dispatchEvent(new Event(event.data.action)) } } }) } else { if (globalThis.isServiceWorkerScope) { throw new TypeError( 'Failed to construct \'Notification\': Illegal constructor. ' + 'Use ServiceWorkerRegistration.showNotification() instead.' ) } if (isLinux) { const proxy = new NativeNotificationProxy(this) const request = new Promise((resolve) => { // @ts-ignore proxy.addEventListener('show', () => resolve({})) // @ts-ignore proxy.addEventListener('error', (e) => resolve({ err: e.error })) }) this.#proxy = proxy this[Symbol.for('socket.runtime.Notification.request')] = request } else { const request = ipc.request('notification.show', { body: this.body, icon: this.icon, id: this.#id, image: this.image, lang: this.lang, tag: this.tag || '', title: this.title, silent: this.silent }) this[Symbol.for('socket.runtime.Notification.request')] = request } state.pending.set(this.id, this) const removeNotificationPresentedListener = hooks.onNotificationPresented((event) => { if (event.detail.id === this.id) { removeNotificationPresentedListener() return this.dispatchEvent(new Event('show')) } }) const removeNotificationResponseListener = hooks.onNotificationResponse((event) => { if (event.detail.id === this.id) { this.#closed = true const eventName = event.detail.action === 'dismiss' ? 'close' : 'click' removeNotificationResponseListener() this.dispatchEvent(new Event(eventName)) if (eventName === 'click') { queueMicrotask(() => { this.dispatchEvent(new Event('close')) channel.postMessage({ id: this.id, action: 'close' }) }) } channel.postMessage({ id: this.id, action: eventName }) } }) // propagate error to caller this[Symbol.for('socket.runtime.Notification.request')].then((result) => { if (result?.err) { // @ts-ignore state.pending.delete(this.id, this) removeNotificationPresentedListener() removeNotificationResponseListener() return this.dispatchEvent(new ErrorEvent('error', { message: result.err.message, error: result.err })) } }) channel.addEventListener('message', async (event) => { if (event.data?.id === this.#id) { if (!this.#closed) { if (event.data.action === 'close') { removeNotificationPresentedListener() removeNotificationResponseListener() await this.close() } this.dispatchEvent(new Event(event.data.action)) } } }) } } /** * @ignore */ get options () { return this.#options } /** * A unique identifier for this notification. * @type {string} */ get id () { return this.#id } /** * `true` if the notification was closed, otherwise `false`. * @type {boolea} */ get closed () { return this.#closed } /** * The click event is dispatched when the user clicks on * displayed notification. * @type {?function} */ get onclick () { return this.#onclick } set onclick (onclick) { if (this.#onclick === onclick) { return } if (this.#onclick) { this.removeEventListener('click', this.#onclick) this.#onclick = null } if (typeof onclick === 'function') { this.#onclick = onclick this.addEventListener('click', onclick) } } /** * The close event is dispatched when the notification closes. * @type {?function} */ get onclose () { return this.#onclose } set onclose (onclose) { if (this.#onclose === onclose) { return } if (this.#onclose) { this.removeEventListener('close', this.#onclose) this.#onclose = null } if (typeof onclose === 'function') { this.#onclose = onclose this.addEventListener('close', onclose) } } /** * The eror event is dispatched when the notification fails to display * or encounters an error. * @type {?function} */ get onerror () { return this.#onerror } set onerror (onerror) { if (this.#onerror === onerror) { return } if (this.#onerror) { this.removeEventListener('error', this.#onerror) this.#onerror = null } if (typeof onerror === 'function') { this.#onerror = onerror this.addEventListener('error', onerror) } } /** * The click event is dispatched when the notification is displayed. * @type {?function} */ get onshow () { return this.#onshow } set onshow (onshow) { if (this.#onshow === onshow) { return } if (this.#onshow) { this.removeEventListener('show', this.#onshow) this.#onshow = null } if (typeof onshow === 'function') { this.#onshow = onshow this.addEventListener('show', onshow) } } /** * An array of actions to display in the notification. * @type {NotificationAction[]} */ get actions () { return this.#options.actions } /** * A string containing the URL of the image used to represent * the notification when there isn't enough space to display the * notification itself. * @type {string} */ get badge () { return this.#options.badge } /** * A string representing the body text of the notification, * which is displayed below the title. * @type {string} */ get body () { return this.#options.body } /** * Arbitrary data that you want associated with the notification. * This can be of any data type. * @type {?any} */ get data () { return this.#options.data } /** * The direction in which to display the notification. * It defaults to 'auto', which just adopts the environments * language setting behavior, but you can override that behavior * by setting values of 'ltr' and 'rtl'. * @type {'auto'|'ltr'|'rtl'} */ get dir () { return this.#options.dir } /** * A string containing the URL of an icon to be displayed in the notification. * @type {string} */ get icon () { return this.#options.icon } /** * The URL of an image to be displayed as part of the notification, as * specified in the constructor's options parameter. * @type {string} */ get image () { return this.#options.image } /** * The notification's language, as specified using a string representing a * language tag according to RFC 5646. * @type {string} */ get lang () { return this.#options.lang } /** * A boolean value specifying whether the user should be notified after a * new notification replaces an old one. The default is `false`, which means * they won't be notified. If `true`, then tag also must be set. * @type {boolean} */ get renotify () { return this.#options.renotify } /** * Indicates that a notification should remain active until the user clicks * or dismisses it, rather than closing automatically. * The default value is `false`. * @type {boolean} */ get requireInteraction () { return this.#options.requireInteraction } /** * A boolean value specifying whether the notification is silent (no sounds * or vibrations issued), regardless of the device settings. * The default is `false`, which means it won't be silent. If `true`, then * vibrate must not be present. * @type {boolean} */ get silent () { return this.#options.silent } /** * A string representing an identifying tag for the notification. * The default is the empty string. * @type {string} */ get tag () { return this.#options.tag } /** * A vibration pattern for the device's vibration hardware to emit with * the notification. If specified, silent must not be `true`. * @type {number[]} * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Vibration_API#vibration_patterns} */ get vibrate () { return this.#options.vibrate } /** * The timestamp of the notification. * @type {number} */ get timestamp () { return this.#timestamp } /** * The title read-only property of the `Notification` instace indicates * the title of the notification, as specified in the `title` parameter * of the `Notification` constructor. * @type {string} */ get title () { return this.#title } /** * Closes the notification programmatically. */ async close () { if (this.#closed) { return } this.#closed = true if (globalThis.isServiceWorkerScope) { return globalThis.postMessage({ notificationclose: { id: this.id } }) } if (isLinux) { if (this.#proxy) { return this.#proxy.close() } return } const result = await ipc.request('notification.close', { id: this.id }) if (result.err) { console.warn('Failed to close \'Notification\': %s', result.err.message) } } } hooks.onReady(() => { if (os.host() === 'iphonesimulator' || os.host() === 'android-emulator') { return } // listen for 'notification' permission changes where applicable permissions.query({ name: 'notifications' }).then((result) => { result.addEventListener('change', () => { // 'prompt' -> 'default' state.permission = result.state.replace('prompt', 'default') }) }) }) export default Notification