You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
vizality/renderer/src/api/Notifications.js

660 lines
20 KiB

/**
* The notifications API is meant for sending various types of notifications,
* including toasts and notices.
* @module Notifications
* @memberof API
* @namespace API.Notifications
* @version 1.0.0
*/
/**
* Vizality notice object.
* @typedef VizalityNotice
* @property {string} [id] Notice ID
* @property {string} message Notice message
* @property {string} [color='blurple'] Notice background color
* @property {Function} [callback] Callback function triggered when the notice closes
* @property {Array<NoticeButton>} [buttons] Notice buttons
* @property {string} caller Addon ID of notice sender. This property is set automatically.
*/
/**
* Vizality notice button.
* @typedef NoticeButton
* @property {Function} [onClick=()=> vizality.api.notifications.closeNotice(noticeId)] Button click executor. Closes the notice by default.
* @property {string} text Button text
*/
/**
* Vizality toast object.
* @typedef VizalityToast
* @property {string} [id] Toast ID
* @property {boolean} [showCloseButton=true] Toast close button
* @property {boolean|string|ToastIcon} [icon='InfoFilled'] Toast icon / image
* @property {string|ReactElement} header Toast header
* @property {string|ReactElement} [content] Toast content
* @property {Array<ToastButton>} [buttons] Toast buttons
* @property {boolean|number} [timeout] Time in milliseconds until the toast auto-closes, or a boolean of whether it should auto-close. This overrides the default user setting on a per-toast basis.
* @property {number} [delay] Delay toast appearing (in milliseconds)
* @property {Function} [callback] Callback function triggered when the toast closes
* @property {string} caller Addon ID of toast sender. This property is set automatically.
*/
/**
* Vizality toast icon.
* @typedef ToastIcon
* @property {string} [name='InfoFilled'] Icon name
* @property {string} [size='40'] Icon size in pixels
* @property {URL} [url] Icon external URL
* @property {string} [color] Icon color
*/
/**
* Vizality toast button.
* @typedef ToastButton
* @property {string} [size='small'] Button size
* @property {string} [look='filled'] Button appearance
* @property {string} [color='white'] Button color
* @property {Function} [onClick=()=> vizality.api.notifications.closeToast(toastId)] Button click executor. Closes the toast by default.
* @property {string} text Button text
*/
import { assertString, isString, isUrl, toSnakeCase } from '@vizality/util/string';
import { assertObject, isObject } from '@vizality/util/object';
import { assertNumber, isNumber } from '@vizality/util/number';
import { assertArray } from '@vizality/util/array';
import { isComponent } from '@vizality/util/react';
import { toast as _toast } from 'react-toastify';
import { getCaller } from '@vizality/util/file';
import { getModule } from '@vizality/webpack';
import React, { isValidElement } from 'react';
import { Events } from '@vizality/constants';
import { Icon } from '@vizality/components';
import { API } from '@vizality/entities';
/**
* All currently active/pending notifications.
*/
const notifications = {
notices: [],
toasts: []
};
/**
* @extends API
* @extends Events
*/
export default class Notifications extends API {
/**
* Shuts down the API, removing all listeners and stored objects.
*/
stop () {
this.closeAllNotices();
this.closeAllToasts();
this.removeAllListeners();
delete vizality.api.notifications;
}
/**
* Sends a notice (in the form of a banner at the top of the client).
* @param {VizalityNotice} notice Notice to send
* @emits Notifications#Events.VIZALITY_NOTICE_SEND
*/
sendNotice (notice) {
try {
assertObject(notice);
assertString(notice.message);
notice.caller = getCaller();
if (notice.id) {
assertString(notice.id);
if (this.isNotice(notice.id)) {
throw new Error(`Notice "${notice.id}" is already active!`);
}
} else {
notice.id = `${toSnakeCase(notice.caller?.id).toUpperCase()}_NOTICE_${this.getNoticesByCaller(notice.caller?.id)?.length + 1 || '1'}`;
}
if (notice.callback && typeof notice.callback !== 'function') {
throw new TypeError('Notice "callback" property value must be a function!');
}
if (notice.color) {
assertString(notice.color);
}
if (notice.buttons) {
assertArray(notice.buttons);
notice.buttons.forEach(button => {
assertString(button.text);
if (button.onClick) {
if (typeof button.onClick !== 'function') {
throw new TypeError('Notice button "onClick" property value must be a function!');
}
} else {
button.onClick = () => this.closeNotice(notice.id);
}
});
}
notifications.notices.push(notice);
this.emit(Events.VIZALITY_NOTICE_SEND, notice.id);
} catch (err) {
return this.error(this._labels.concat('sendNotice'), err);
}
}
/**
* Sends a toast notification.
* @param {VizalityToast|React.Component|function(): React.Element} toast Toast to send
* @emits Notifications#Events.VIZALITY_TOAST_SEND
*/
sendToast (toast) {
try {
if (!isComponent(toast) && !isObject(toast)) {
throw new Error('Toast must be either a React component or an object!');
}
if (isComponent(toast) || isValidElement(toast)) {
const component = toast;
toast = {};
toast.custom = component;
}
assertObject(toast);
toast.caller = getCaller();
if (toast.id) {
assertString(toast.id);
if (this.isToast(toast.id)) {
throw new Error(`Toast "${toast.id}" already exists!`);
}
} else {
toast.id = `${toSnakeCase(toast.caller?.id).toUpperCase()}_TOAST_${this.getToastsByCaller(toast.caller?.id)?.length + 1 || '1'}`;
}
if (toast.callback && typeof toast.callback !== 'function') {
throw new TypeError('Toast "callback" property value must be a function!');
}
if (toast.hasOwnProperty('showCloseButton')) {
if (typeof toast.showCloseButton !== 'boolean') {
throw new TypeError('Toast "showCloseButton" property value must be a boolean!');
}
} else {
toast.showCloseButton = true;
}
if (toast.delay) {
assertNumber(toast.delay);
}
if (toast.icon) {
if (isString(toast.icon)) {
if (isUrl(toast.icon)) {
const url = toast.icon;
toast.icon = {};
toast.icon.url = url;
} else {
if (Icon.Names.includes(toast.icon)) {
const name = toast.icon;
toast.icon = {};
toast.icon.name = name;
} else {
throw new Error(`Icon "${toast.icon}" asset not found!`);
}
}
} else if (isObject(toast.icon)) {
/**
* Check if an icon name property is provided.
*/
if (toast.icon.name) {
/**
* Make sure the icon name is a string.
*/
if (!isString(toast.icon.name)) {
throw new Error(`Toast icon "name" property value must be a string!`);
}
/**
* Delete the icon URL if one is provided, because the name overrides it.
*/
delete toast.icon.url;
} else {
/**
* Check if an icon URL is provided.
*/
if (toast.icon.url) {
/**
* Make sure the icon URL is a valid URL.
*/
if (!isUrl(toast.icon.url)) {
throw new Error(`Toast icon "url" property value must be a URL!`);
}
/**
* Use the icon name default.
*/
} else {
toast.icon.name = 'InfoFilled';
}
}
if (toast.icon.size) {
assertString(toast.icon.size);
}
if (toast.icon.color && toast.icon.color !== 'currentColor') {
assertString(toast.icon.color);
}
} else {
throw new TypeError(`Toast "icon" property value must be a boolean, string, or object!`);
}
}
if (toast.buttons) {
assertArray(toast.buttons);
toast.buttons.forEach(button => {
if (button.onClick) {
if (typeof button.onClick !== 'function') {
throw new TypeError('Toast button "onClick" property value must be a function!');
}
} else {
button.onClick = () => this.closeToast(toast.id);
}
if (!button.text || !isString(button.text)) {
throw new TypeError('Toast button "text" property value must be a string!');
}
/**
* Checks types and set defaults for button properties.
*/
if (button.size) {
assertString(button.size);
} else {
button.size = 'small';
}
if (button.look) {
assertString(button.look);
} else {
button.look = 'filled';
}
if (button.color) {
assertString(button.color);
} else {
button.color = 'white';
};
});
}
if (toast.hasOwnProperty('timeout') && (!isNumber(toast.timeout) && typeof toast.timeout !== 'boolean')) {
throw new TypeError('Toast "timeout" property value must be a number or boolean!');
}
assertString(toast.id);
notifications.toasts.push(toast);
this.emit(Events.VIZALITY_TOAST_SEND, toast.id);
} catch (err) {
return this.error(this._labels.concat('sendToast'), err);
}
}
/**
* Checks if a notice is active.
* @param {string} noticeId Notice ID
* @returns {boolean} Whether a notice with a given ID is active
*/
isNotice (noticeId) {
try {
assertString(noticeId);
return notifications.notices.some(notice => notice.id === noticeId);
} catch (err) {
return this.error(this._labels.concat('isNotice'), err);
}
}
/**
* Checks if a toast is currently active.
* @param {string} toastId Toast ID
* @returns {boolean} Whether a toast with a given ID is active
*/
isToastActive (toastId) {
try {
assertString(toastId);
return Boolean(_toast.isActive(toastId));
} catch (err) {
return this.error(this._labels.concat('isToast'), err);
}
}
/**
* Checks if a toast is in queue to be sent.
* @param {string} toastId Toast ID
* @returns {boolean} Whether a toast with a given ID is queued
*/
isToastQueued (toastId) {
try {
assertString(toastId);
return Boolean(this.isToast(toastId) && !this.isToastActive(toastId));
} catch (err) {
return this.error(this._labels.concat('isToast'), err);
}
}
/**
* Checks if a toast is currently active or is in queue to be sent.
* @param {string} toastId Toast ID
* @returns {boolean} Whether a toast with a given ID is active or queued
*/
isToast (toastId) {
try {
assertString(toastId);
return notifications.toasts.some(toast => toast.id === toastId);
} catch (err) {
return this.error(this._labels.concat('isToast'), err);
}
}
/**
* Gets the first notice found matching a given filter.
* @param {Function} filter Function to use to filter notices by
* @returns {object|null} Notice matching a given filter
*/
getNotice (filter) {
try {
if (!filter?.length) return null;
return notifications.notices.find(filter);
} catch (err) {
return this.error(this._labels.concat('getNotice'), err);
}
}
/**
* Gets the first toast found matching a given filter.
* @param {Function} filter Function to use to filter toasts by
* @returns {object|null} Toast matching a given filter
*/
getToast (filter) {
try {
if (!filter?.length) return null;
return notifications.toasts.find(filter);
} catch (err) {
return this.error(this._labels.concat('getToast'), err);
}
}
/**
* Gets a notice matching a given ID.
* @param {string} noticeId Notice ID
* @returns {object|null} Notice matching a given ID
*/
getNoticeById (noticeId) {
try {
assertString(noticeId);
return notifications.notices.find(notice => notice.id === noticeId);
} catch (err) {
return this.error(this._labels.concat('getNoticeById'), err);
}
}
/**
* Gets a toast matching a given ID.
* @param {string} toastId Toast ID
* @returns {object|null} Toast matching a given ID
*/
getToastById (toastId) {
try {
assertString(toastId);
return notifications.toasts.find(toast => toast.id === toastId);
} catch (err) {
return this.error(this._labels.concat('getToastById'), err);
}
}
/**
* Gets all notices found matching a given filter.
* @param {Function} filter Function to use to filter notices by
* @returns {Array<object|null>} Notices matching a given filter
*/
getNotices (filter) {
try {
if (!filter?.length) return null;
return notifications.notices.filter(filter);
} catch (err) {
return this.error(this._labels.concat('getNotices'), err);
}
}
/**
* Gets all toasts found matching a given filter.
* @param {Function} filter Function to use to filter toasts by
* @returns {Array<object|null>} Toasts matching a given filter
*/
getToasts (filter) {
try {
if (!filter?.length) return null;
return notifications.toasts.filter(filter);
} catch (err) {
return this.error(this._labels.concat('getToasts'), err);
}
}
/**
* Gets all notices matching a given caller.
* @param {string} addonId Addon ID
* @returns {Array<object|null>} Notices matching a given caller
*/
getNoticesByCaller (addonId) {
try {
assertString(addonId);
return notifications.notices.filter(notice => notice.caller?.id === addonId);
} catch (err) {
return this.error(this._labels.concat('getNoticesByCaller'), err);
}
}
/**
* Gets all toasts matching a given caller.
* @param {string} addonId Addon ID
* @returns {Array<object|null>} Toasts matching a given caller
*/
getToastsByCaller (addonId) {
try {
assertString(addonId);
return notifications.toasts.filter(toast => toast.caller?.id === addonId);
} catch (err) {
return this.error(this._labels.concat('getToastsByCaller'), err);
}
}
/**
* Gets all notices.
* @returns {Array<object|null>} All notices
*/
getAllNotices () {
try {
return notifications.notices;
} catch (err) {
return this.error(this._labels.concat('getAllNotices'), err);
}
}
/**
* Gets all toasts.
* @returns {Array<object|null>} All toasts
*/
getAllToasts () {
try {
return notifications.toasts;
} catch (err) {
return this.error(this._labels.concat('getAllToasts'), err);
}
}
/**
* Gets all active toasts.
* @returns {Array<object|null>} All active toasts
*/
getAllActiveToasts () {
try {
const activeToasts = [];
notifications.toasts.forEach(toast => {
if (this.isToastActive(toast.id)) {
activeToasts.push(toast);
}
});
return activeToasts;
} catch (err) {
return this.error(this._labels.concat('getAllActiveToasts'), err);
}
}
/**
* Gets all queued toasts.
* @returns {Array<object|null>} All queued toasts
*/
getAllQueuedToasts () {
try {
const queuedToasts = [];
notifications.toasts.forEach(toast => {
if (this.isToastQueued(toast.id)) {
queuedToasts.push(toast);
}
});
return queuedToasts;
} catch (err) {
return this.error(this._labels.concat('getAllQueuedToasts'), err);
}
}
/**
* Closes a notice.
* @param {string} noticeId Notice ID
* @emits Notifications#Events.VIZALITY_NOTICE_CLOSE
*/
closeNotice (noticeId) {
try {
assertString(noticeId);
const notice = this.getNoticeById(noticeId);
notifications.notices = this.getNotices(notice => notice.id !== noticeId);
if (typeof notice?.callback === 'function') {
try {
notice.callback();
} catch (err) {
return this.error(this._labels.concat('closeNotice'), `There was a problem invoking notice "${noticeId}" callback!`, err);
}
}
this.emit(Events.VIZALITY_NOTICE_CLOSE, noticeId);
} catch (err) {
return this.error(this._labels.concat('closeNotice'), err);
}
}
/**
* Closes a toast.
* @param {string} toastId Toast ID
* @emits Notifications#Events.VIZALITY_TOAST_CLOSE
*/
closeToast (toastId) {
try {
assertString(toastId);
const toast = this.getToastById(toastId);
if (!toast) return;
notifications.toasts = this.getToasts(toast => toast.id !== toastId);
_toast.dismiss(toastId);
if (typeof toast?.callback === 'function') {
try {
toast.callback();
} catch (err) {
return this.error(this._labels.concat('closeToast'), `There was a problem invoking toast "${toastId}" callback!`, err);
}
}
this.emit(Events.VIZALITY_TOAST_CLOSE, toastId);
} catch (err) {
return this.error(this._labels.concat('closeToast'), err);
}
}
/**
* Closes all notices sent by a given caller.
* @param {string} addonId Addon ID
* @emits Notifications#Events.VIZALITY_NOTICE_CLOSE_ALL_BY_CALLER
*/
closeNoticesByCaller (addonId) {
try {
assertString(addonId);
notifications.notices
.filter(notice => notice.caller?.id === addonId)
.forEach(notice => this.closeNotice(notice.id));
this.emit(Events.VIZALITY_NOTICE_REMOVE_ALL_BY_CALLER, addonId);
} catch (err) {
return this.error(this._labels.concat('closeNoticesByCaller'), err);
}
}
/**
* Closes all toasts sent by a given caller.
* @param {string} addonId Addon ID
* @emits Notifications#Events.VIZALITY_TOAST_CLOSE_ALL_BY_CALLER
*/
closeToastsByCaller (addonId) {
try {
assertString(addonId);
notifications.toasts
.filter(toast => toast.caller?.id === addonId)
.forEach(toast => this.closeToast(toast.id));
this.emit(Events.VIZALITY_TOAST_REMOVE_ALL_BY_CALLER, addonId);
} catch (err) {
return this.error(this._labels.concat('closeToastsByCaller'), err);
}
}
/**
* Closes all active notices.
* @emits Notifications#Events.VIZALITY_NOTICE_CLOSE_ALL
*/
closeAllNotices () {
try {
notifications.notices.forEach(notice => this.closeNotice(notice.id));
// Dismiss all notices sent by Discord as well
getModule(m => m.default?.dismiss)?.default?.dismiss();
this.emit(Events.VIZALITY_NOTICE_CLOSE_ALL);
} catch (err) {
return this.error(this._labels.concat('closeAllNotices'), err);
}
}
/**
* Closes all active (currently visible) toasts. If no toast limit is set, this will be functionally
* equivalent to the `closeAllToasts` method.
* @emits Notifications#Events.VIZALITY_TOAST_CLOSE_ALL_ACTIVE
*/
closeAllActiveToasts () {
try {
notifications.toasts.forEach(toast => {
if (this.isToastActive(toast.id)) {
notifications.toasts = this.getToasts(t => t.id !== toast.id);
}
});
_toast.dismiss();
this.emit(Events.VIZALITY_TOAST_CLOSE_ALL_ACTIVE);
} catch (err) {
return this.error(this._labels.concat('closeAllActiveToasts'), err);
}
}
/**
* Closes all queued toasts. If no toast limit is set, this method will simply emit
* an event.
* @emits Notifications#Events.VIZALITY_TOAST_CLOSE_ALL_QUEUED
*/
closeAllQueuedToasts () {
try {
notifications.toasts.forEach(toast => {
if (this.isToastQueued(toast.id)) {
notifications.toasts = this.getToasts(t => t.id !== toast.id);
}
});
_toast.clearWaitingQueue();
this.emit(Events.VIZALITY_TOAST_CLOSE_ALL_QUEUED);
} catch (err) {
return this.error(this._labels.concat('closeAllQueuedToasts'), err);
}
}
/**
* Closes all active toasts.
* @emits Notifications#Events.VIZALITY_TOAST_CLOSE_ALL
*/
closeAllToasts () {
try {
_toast.clearWaitingQueue();
_toast.dismiss();
notifications.toasts = [];
this.emit(Events.VIZALITY_TOAST_CLOSE_ALL);
} catch (err) {
return this.error(this._labels.concat('closeAllToasts'), err);
}
}
}