/**
* Shows and hides messages to the user.
*
* @public
* @module CustomMessages
*/
// lodash
import isFunction from "../lodash/isFunction.js";
const HOOK_TEMPLATE = Object.freeze({
show: null,
hide: null,
});
const HOOK_TEMPLATE_DISMISS = Object.freeze({
dismissStart: null,
dismissEnd: null,
});
const HOOK_TEMPLATE_ACTION_BUTTON = Object.freeze({
actionButton: null
});
let globalHooks = Object.assign({}, HOOK_TEMPLATE, HOOK_TEMPLATE_DISMISS, HOOK_TEMPLATE_ACTION_BUTTON); /* eslint-disable-line */
let hooks = {};
let htmlElements = {};
let designClasses = {};
let ariaLabelTypes = {};
/**
* Runs a hook set by some module.
*
* It automatically also runs the global hook.
*
* @function
* @private
* @param {string} hooktype the type you want to call
* @param {Object} param the parameter to pass to the function
* @returns {void}
*/
function runGlobalHook(hooktype, param) {
const hook = globalHooks[hooktype];
if (hook !== null) {
hook(param);
}
}
/**
* Runs a hook set by some module.
*
* It automatically also runs the global hook.
*
* @function
* @private
* @param {MESSAGE_LEVEL} messageType
* @param {string} hooktype the type you want to call
* @param {Object} param the parameter to pass to the function
* @returns {void}
*/
function runHook(messageType, hooktype, param) {
runGlobalHook(hooktype, param);
if (messageType instanceof HTMLElement) {
return; // TODO: handle error
}
const hook = hooks[messageType][hooktype];
if (hook !== null) {
hook(param);
}
}
/**
* Hides a message.
*
* The following commands are implemented:
* "hide" – hides the message with animation and resolves has been hidden
* successfully
* "hideStart" – hides the message with animation and resolves if the hiding
* process started
* "hideEnd" – finishes hiding (after the animation, i.e. just makes the
* message invisible) and resolves, if this is done.
*
* The Promises resolve with elMessage and (optionally, in "hideEnd") the
* messageType.
*
* @private
* @param {HTMLElement} elMessage
* @param {string} [action="hide"] the action to do
* @returns {Promise}
*/
function hideMessageAnimate(elMessage, action = "hide") {
return new Promise((resolve) => {
// ignore invalid message boxes as events are often passed in here and
// it may not the correct one from the message box
verifyIsValidMessageHtml(elMessage);
// if button is just clicked trigger hiding
switch (action) {
case "hide":
case "hideStart": {
// trigger hiding
elMessage.classList.add("fade-hide");
// add handler to hide message completly after transition
const callback = async () => {
await hideMessageAnimate(elMessage, "hideEnd");
// remove set handler
elMessage.removeEventListener("transitionend", callback);
if (action === "hide") {
resolve();
}
};
elMessage.addEventListener("transitionend", callback);
if (action === "hideStart") {
resolve(elMessage);
}
break;
}
case "hideEnd": {
const messageType = getMessageTypeFromElement(elMessage);
// hide message (and icon)
hideMessage(messageType);
resolve(elMessage, messageType);
break;
}
default:
throw new TypeError(`invalid action command: "${action}"`);
}
});
}
/**
* Dismisses (i.e. hides with animation) a message when the dismiss button is clicked.
*
* It automatically detects whether it is run as a trigger (click event) or
* as the "finish event" ("transitionend") after the hiding is animated and
* hides the message.
*
* @function
* @private
* @param {Object} event
* @returns {Promise}
*/
function dismissMessage(event) {
const elDismissIcon = event.target;
const elMessage = elDismissIcon.parentElement;
const messageType = getMessageTypeFromElement(elMessage);
const fullyDismissed = hideMessageAnimate(elMessage, "hide").then(() => {
return runHook(messageType, "dismissEnd", {
elMessage,
messageType,
event
});
});
runHook(messageType, "dismissStart", {
elMessage,
messageType,
event
});
return fullyDismissed;
}
/**
* Returns the message type based on the passed element.
*
* @function
* @private
* @param {HTMLElement} elMessage
* @returns {MESSAGE_LEVEL|HTMLElement}
* @throws {Error}
*/
function getMessageTypeFromElement(elMessage) {
const messageType = Object.keys(htmlElements).find((messageType) => {
// skip, if element does not exist
if (!htmlElements[messageType]) {
return false;
}
return htmlElements[messageType].isEqualNode(elMessage);
});
if (messageType === undefined) {
throw new Error(`${elMessage} is no registered element of a message type.`);
}
return messageType;
}
/**
* The action button event handler, when clicked.
*
* @function
* @private
* @param {Object} event
* @returns {void}
*/
function actionButtonClicked(event) {
const elActionButtonLink = event.currentTarget;
const elMessage = elActionButtonLink.parentNode;
const messageType = getMessageTypeFromElement(elMessage);
console.info("action button clicked for ", messageType, event);
runHook(messageType, "actionButton", {
elMessage,
messageType,
event
});
}
/**
* Returns the design this message resembles.
*
* Please DO NOT use this with the built-in message elements.
*
* @function
* @param {MESSAGE_LEVEL|HTMLElement} messageBoxOrType
* @param {MESSAGE_LEVEL} newDesignType
* @returns {void}
*/
export function setMessageDesign(messageBoxOrType, newDesignType) {
const elMessage = getHtmlElementFromOptionalType(messageBoxOrType);
const newDesign = designClasses[newDesignType];
const elActionButton = elMessage.getElementsByClassName("message-action-button")[0];
// set new design
elMessage.classList.add(newDesign);
if (elActionButton) {
elActionButton.classList.add(newDesign);
}
// set aria label
elMessage.setAttribute("aria-label", ariaLabelTypes[newDesignType]);
// unset old design
Object.values(designClasses).forEach((oldDesign) => {
// do not remove newly set design
if (oldDesign === newDesign) {
return;
}
elMessage.classList.remove(oldDesign);
if (elActionButton) {
elActionButton.classList.remove(oldDesign);
}
});
}
/**
* Shows a message to the user.
*
* Pass as many strings/output as you want. They will be localized
* automatically, before presented to the user.
*
* If you pass a HTMLElement as the first parameter, you can use your own
* custom node for the message.
* Attention: This is a "low-level function" and does thus not run the show hook!
*
* @function
* @param {MESSAGE_LEVEL|HTMLElement} messageType
* @param {string} [message] optional, string to show or to translate if omitted no new text is shown
* @param {boolean} [isDismissable] optional, set to true, if user should be able to dismiss the message
* @param {Object|null} [actionButton] optional to show an action button
* @param {string} actionButton.text
* @param {string|function} actionButton.action URL to site to open on link OR function to execute
* @param {...*} args optional parameters for translation
* @returns {void}
*/
export function showMessage(...args) {
if (args.length === 0) {
console.error("showMessage has been called without parameters");
return;
}
// also log message to console
console.log(...args);
// get first element
const messageType = args.shift();
const elMessage = getHtmlElement(messageType);
// and stuff inside we need later
const elDismissIcon = elMessage.getElementsByClassName("icon-dismiss")[0];
const elActionButton = elMessage.getElementsByClassName("message-action-button")[0];
let elActionButtonLink = null;
if (elActionButton) {
elActionButtonLink = elActionButton.parentNode;
}
if (!elMessage) {
throw new Error("The message could not be shown, because the DOM element is missing.", messageType, args);
}
/* check value type/usage of first argument */
let mainMessage = null;
let isDismissable = false; // not dismissable by default
let actionButton = null; // no action button by default
if (typeof args[0] === "string") {
mainMessage = args.shift();
}
if (typeof args[0] === "boolean") {
isDismissable = args.shift();
}
if (args[0] && args[0].text && args[0].action) {
actionButton = args.shift();
}
// localize string or fallback to first string ignoring all others
if (mainMessage !== null) {
// add message to beginning of array
args.unshift(mainMessage);
const localizedString = browser.i18n.getMessage.apply(null, args) || mainMessage || browser.i18n.getMessage("errorShowingMessage");
elMessage.getElementsByClassName("message-text")[0].textContent = localizedString;
}
if (isDismissable === true && elDismissIcon) {
// add an icon which dismisses the message if clicked
elDismissIcon.classList.remove("invisible");
} else if (elDismissIcon) {
elDismissIcon.classList.add("invisible");
}
// show action button, if needed
if (actionButton !== null && elActionButton && elActionButtonLink) {
if (isFunction(actionButton.action)) {
// save option to be called later
hooks[messageType].actionButton = actionButton.action;
// potentiall remove previous set thing
elActionButtonLink.removeAttribute("href");
} else {
const url = browser.i18n.getMessage(actionButton.action) || actionButton.action;
elActionButtonLink.setAttribute("href", url);
// unset potential previously set handler
hooks[messageType].actionButton = null;
}
elActionButton.textContent = browser.i18n.getMessage(actionButton.text) || actionButton.text;
elActionButton.classList.remove("invisible");
} else if (elActionButton) {
elActionButton.classList.add("invisible");
}
elMessage.classList.remove("invisible");
elMessage.classList.remove("fade-hide");
// run hook
// TODO: gets message type first, that is wrong!
runHook(messageType, "show", args);
}
/**
* Hides the message type(s), you specify.
*
* If you pass no messageType or "null", it hides all messages. (except custom ones)
* If a HTMLElement is passed, it automatically hides the target of the event.
* Attention: This is a "low-level function" and does thus not run the hide hook!
*
* @function
* @param {string|int} messageType
* @param {Object} [options]
* @param {boolean} [options.animate=false] set to true to animate the hiding
* @returns {HTMLElement|HTMLElement[]} the element(s) hidden
*/
export function hideMessage(messageType = null, options = {}) {
// hide all messages if type is not specified
if (messageType === null) {
// hide all of them
for (const currentType of Object.keys(htmlElements)) {
// recursive call myself to hide element
hideMessage(currentType);
}
return Object.keys(htmlElements);
}
const elMessage = getHtmlElement(messageType);
// do not re-hide if already hidden
if (elMessage.classList.contains("invisible")) {
console.info("message is already hidden, skip hiding again", elMessage);
return elMessage; // silently
}
// fade, if specified
if (options.animate === true) {
// trigger hiding
hideMessageAnimate(elMessage);
// Note this will automatically come back here (to hideMessage()), so
// that the hook is called in any case
return elMessage;
}
// hide single message
const elDismissIcon = elMessage.getElementsByClassName("icon-dismiss")[0];
elMessage.classList.add("invisible");
if (elDismissIcon) {
elDismissIcon.classList.add("invisible");
}
console.info("message is hidden", elMessage);
// run hook
runHook(messageType, "hide");
return elMessage;
}
/**
* Clones a message HTMLElement you specify.
*
* It sorts the message directly after the message you clone.
* The message is hidden by default – regardless of the state of the origin
* message (type).
*
* CURRENTLY UNUSED.
*
* @function
* @param {MESSAGE_LEVEL|HTMLElement} messageBoxOrType
* @param {string} newId New ID to use for that element
* @returns {HTMLElement}
*/
export function cloneMessage(messageBoxOrType, newId) {
let elMessage = null;
elMessage = getHtmlElementFromOptionalType(messageBoxOrType);
// hide the message to reset it if needed
hideMessage(getMessageTypeFromElement(elMessage));
// clone message
const clonedElMessage = elMessage.cloneNode(true);
clonedElMessage.id = newId;
// attach to DOM
elMessage.insertAdjacentElement("afterend", clonedElMessage);
return clonedElMessage;
}
/**
* Returns whether the message type is already registred.
*
* @private
* @param {string} messageType
* @returns {boolean}
*/
function isMessageTypeRegistered(messageType) {
return messageType in htmlElements;
}
/**
* Verifies that the message type is alreayd registered.
*
* It throws, if it is not.
*
* @private
* @param {string} messageType
* @returns {void}
* @throws {Error}
*/
function verifyMessageType(messageType) {
if (!isMessageTypeRegistered(messageType)) {
throw new Error(`Unregistered message type ${messageType} passed.`);
}
}
/**
* Registers a new message type.
*
* @function
* @param {int|string} messageType
* @param {HTMLElement} elMessage element to register with it
* @param {string} [designClass] the class to apply
* @param {string} [ariaLabelType] the "aria-label" for this message type
* @returns {void}
* @throws {Error}
*/
export function registerMessageType(messageType, elMessage, designClass, ariaLabelType) {
if (isMessageTypeRegistered(messageType)) {
throw new Error(`Message type ${messageType} is already registered. Cannot register again.`);
}
// TODO: verify it's not already regsitered
verifyIsValidMessageHtml(elMessage);
// save HTMLElement
htmlElements[messageType] = elMessage;
// set empty hook
const newHook = Object.assign({}, HOOK_TEMPLATE);
// optionally set design type
if (designClass) {
designClasses[messageType] = designClass;
}
if (ariaLabelType) {
ariaLabelTypes[messageType] = ariaLabelType;
}
// add event listener
const dismissIcon = elMessage.getElementsByClassName("icon-dismiss");
const actionButton = elMessage.getElementsByClassName("message-action-button");
// add properties/hook types only if possible
if (dismissIcon.length > 0) {
dismissIcon[0].addEventListener("click", dismissMessage);
Object.assign(newHook, HOOK_TEMPLATE_DISMISS);
}
if (actionButton.length > 0) {
actionButton[0].parentElement // to bind to link element and not to button
.addEventListener("click", actionButtonClicked);
Object.assign(newHook, HOOK_TEMPLATE_ACTION_BUTTON);
}
hooks[messageType] = newHook;
}
/**
* Verifies the HTMLElement is a valid one for a message.
*
* @private
* @param {HTMLElement} elMessage
* @returns {void}
* @throws {Error}
*/
function verifyIsValidMessageHtml(elMessage) {
if (!elMessage.classList.contains("message-box")) {
throw new Error(`${elMessage} is no valid message type HTML.`);
}
}
/**
* Returns the HTML element of a message type or, if it is already an HTML
* element for a message, the element itself.
*
* @private
* @param {MESSAGE_LEVEL|HTMLElement} messageBoxOrType
* @returns {HTMLElement}
*/
function getHtmlElementFromOptionalType(messageBoxOrType) {
if (messageBoxOrType instanceof HTMLElement) {
verifyIsValidMessageHtml(messageBoxOrType);
return messageBoxOrType;
}
return getHtmlElement(messageBoxOrType);
}
/**
* Returns the message element by the given message type.
*
* @public
* @param {int|string} messageType
* @returns {HTMLElement}
*/
export function getHtmlElement(messageType) {
verifyMessageType(messageType);
return htmlElements[messageType];
}
/**
* Verifies that a hook is valid.
*
* @private
* @param {string} hookType
* @param {function|null} hookFunction the callback to run
* @returns {void}
* @throws {TypeError|Error} if invalid hook type is used
*/
function verifyHook(hookType, hookFunction) {
if (!(hookType in HOOK_TEMPLATE) &&
!(hookType in HOOK_TEMPLATE_DISMISS) &&
!(hookType in HOOK_TEMPLATE_ACTION_BUTTON)) {
throw new TypeError(`Hook type "${hookType}" is not unknown.`);
}
// verify function
if (!isFunction(hookFunction) && hookFunction !== null) {
throw new TypeError(`Hook function "${hookFunction}" is not a valid data type. It must be a function or "null".`);
}
}
/**
* Let's other functions set a hook to be called when a message type is
* shown or hidden.
*
* Set parameters to null or undefined (i.e. do not set) in order to disable
* the hook.
* The errorShown function gets one parameter: The arguments passed to the
* function, as an array.
*
* Pass "null" to it to unset it.
*
* @public
* @param {MESSAGE_LEVEL|HTMLElement} messageType
* @param {string} hookType
* @param {function|null} hookFunction the callback to run
* @returns {void}
* @throws {TypeError|Error} if invalid hook type is used
*/
export function setHook(messageType, hookType, hookFunction) {
verifyMessageType(messageType);
verifyHook(hookType, hookFunction);
if (!(hookType in hooks[messageType])) {
throw new Error(`Hook type "${hookType}" is not valid for this message type.`);
}
hooks[messageType][hookType] = hookFunction;
}
/**
* Resets whole module and thus unregisters all messages.
*
* @function
* @returns {void}
*/
export function reset() {
globalHooks = Object.assign({}, HOOK_TEMPLATE, HOOK_TEMPLATE_DISMISS, HOOK_TEMPLATE_ACTION_BUTTON); /* eslint-disable-line */
hooks = {};
htmlElements = {};
designClasses = {};
ariaLabelTypes = {};
}
/**
* Add a global hook that is triggered for all messages.
*
* @public
* @param {string} hookType
* @param {function|null} hookFunction the callback to run
* @returns {void}
* @throws {TypeError|Error} if invalid hook type is used
*/
export function setGlobalHook(hookType, hookFunction) {
verifyHook(hookType, hookFunction);
if (!(hookType in globalHooks)) {
// this should, currently, never happen as all hooks use the template
throw new Error(`Hook type "${hookType}" is not valid for the global hook.`);
}
globalHooks[hookType] = hookFunction;
}