CustomMessages.js

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