RandomTips.js

/**
 * Shows random tips to the user, if wanted.
 *
 * @public
 * @module RandomTips
 * @requires ../lib/lodash/debounce
 * @requires ../AddonSettings
 * @requires ../MessageHandler/CustomMessages
 */

// lodash
import debounce from "../lodash/debounce.js";

import * as AddonSettings from "../AddonSettings/AddonSettings.js";
import * as CustomMessages from "../MessageHandler/CustomMessages.js";

/**
 * The saved settings of a specific tip.
 *
 * @public
 * @typedef {Object} TipConfigObject
 * @property {integer} shownCount how often the tip has already been shown
 * @property {integer} dismissedCount how often the tip has already been dismissed
 * @property {Object.<string, integer>} [shownContext] how often the tip has
 * already been shown in a specific context. The aquivalent to {@link TipObject#shownInContext}
 * See {@link module:RandomTips.setContext|setContext}}.
 */

/**
 * The configuration stored by this module.
 *
 * It is stored in the key {@link TIP_SETTING_STORAGE_ID} in the data storage.
 *
 * @public
 * @typedef {Object} ModuleConfig
 * @property {Object.<string, module:RandomTips~TipConfigObject>} tips config for each tip
 * @property {integer} triggeredOpen how often the whole module/add-on has been
 * triggered, i.e. how often the {@link module:RandomTips.init|init} method has been called.
 */

const TIP_MESSAGE_BOX_ID = "messageTips";
const TIP_SETTING_STORAGE_ID = "randomTips";
const GLOBAL_RANDOMIZE = 0.2; // (%)
const DEBOUNCE_SAVING = 1000; // ms
const MESSAGE_TIP_ID = "messageTip";

// default values/settings for tip/tipconfig
/**
 * @private
 * @type {TipObject}
 */
const DEFAULT_TIP_SPEC = Object.freeze({
    requiredTriggers: 10,
    randomizeDisplay: false,
    allowDismiss: true
});

/**
 * @private
 * @type {TipConfigObject}
 */
const DEFAULT_TIP_CONFIG = Object.freeze({
    shownCount: 0,
    dismissedCount: 0,
    shownContext: {}
});

/**
 * @private
 * @type {TipObject[]}
 * @see {@link tips}
 */
let tips;

/**
 * @private
 * @type {ModuleConfig}
 */
let moduleConfig = {
    tips: {}
};

/**
 * @private
 * @type {TipConfigObject}
 */
let tipShowing = null;
/**
 * @private
 * @type {string}
 */
let context = null;

/**
 * Save the current config.
 *
 * @function
 * @name saveConfig
 * @private
 * @returns {void}
 */
let saveConfig = null; // will be assigned in init()

/**
 * Hook for the dismiss event.
 *
 * @private
 * @param  {Object} param
 * @returns {void}
 */
function messageDismissed(param) {
    const elMessage = param.elMessage;

    const id = elMessage.dataset.tipId;
    if (tipShowing.id !== id) {
        throw new Error("cached tip and dismissed tip differ");
    }

    // update config
    moduleConfig.tips[id].dismissedCount = (moduleConfig.tips[id].dismissedCount || 0) + 1;
    saveConfig();

    // cleanup values
    tipShowing = null;
    delete elMessage.dataset.tipId;

    console.info(`Tip ${id} has been dismissed.`);
}

/**
 * Returns true or false at random. The passed procentage indicates how
 * much of the calls should return "true" on average.
 *
 * @private
 * @param  {number} percentage
 * @returns {bool}
 */
function randomizePassed(percentage) {
    return (Math.random() < percentage);
}

/**
 * Shows this tip.
 *
 * @private
 * @param  {TipObject} tipSpec
 * @param  {TipConfigObject} thisTipConfig the settings of the tip
 * @returns {void}
 */
function showTip(tipSpec, thisTipConfig) {
    const elMessage = CustomMessages.getHtmlElement(MESSAGE_TIP_ID);
    elMessage.dataset.tipId = tipSpec.id;
    CustomMessages.showMessage(MESSAGE_TIP_ID, tipSpec.text, tipSpec.allowDismiss, tipSpec.actionButton);

    // update config
    thisTipConfig.shownCount = thisTipConfig.shownCount + 1;
    thisTipConfig.shownContext[context] = (thisTipConfig.shownContext[context] || 0) + 1;
    saveConfig();

    tipShowing = tipSpec;
}

/**
 * Returns true or false at random. The passed procentage indicates how
 * much of the calls should return "true" on average.
 *
 * @private
 * @param  {TipObject} tipSpecOrig
 * @returns {Array.<TipObject, Object>}
 */
function applyTipSpecAndConfigDefaults(tipSpecOrig) {
    // shallow-clone object (no deep object are being modified)
    const tipSpec = Object.assign({}, DEFAULT_TIP_SPEC, tipSpecOrig);

    // also convert to default value if just set to "true"
    if (tipSpec.randomizeDisplay === true) {
        // default value for randomizeDisplay = 0.5
        tipSpec.randomizeDisplay = 0.5;
    }

    // create option if needed
    let thisTipConfig = moduleConfig.tips[tipSpec.id];
    if (thisTipConfig === undefined) {
        // deep-clone default object
        thisTipConfig = JSON.parse(JSON.stringify(DEFAULT_TIP_CONFIG));
        // save it actually in config
        moduleConfig.tips[tipSpec.id] = thisTipConfig;
        saveConfig();
    }

    console.log("Applied default values for tip spec and tip config:", tipSpec, thisTipConfig);
    return [tipSpec, thisTipConfig];
}

/**
 * Returns whether the tip has already be shown enough times or may not
 * be shown, because of some other requirement.
 *
 * @private
 * @param  {TipObject} tipSpec
 * @param  {TipConfigObject} thisTipConfig
 * @param  {Object} tipSpecOrig
 * @returns {bool}
 */
function shouldBeShown(tipSpec, thisTipConfig, tipSpecOrig) {
    if (tipSpec.showTip) {
        const returnValue = tipSpec.showTip(tipSpec, thisTipConfig, tipSpecOrig, moduleConfig);
        saveConfig();
        switch (returnValue) {
        // pass-through booleans
        case true:
        case false:
            return returnValue;
        case null:
            // continue checking when null is returned
            break;
        default:
            throw new Error(`tipSpec.showTip was expected to return a boolean
                or null, but returned "${returnValue}".`);
        }
    }

    // require some global triggers, if needed
    if (moduleConfig.triggeredOpen < tipSpec.requiredTriggers) {
        return false;
    }
    // 1 : x -> if one number is not selected, do not display result
    if (tipSpec.randomizeDisplay && !randomizePassed(tipSpec.randomizeDisplay)) {
        return false;
    }

    // do not show if it has been dismissed enough times
    if (tipSpec.maximumDismiss && thisTipConfig.dismissedCount >= tipSpec.maximumDismiss) {
        return false;
    }

    // block when it is shown too much times in a given context
    if (tipSpec.maximumInContest) {
        if (context in tipSpec.maximumInContest) {
            const tipShownInCurrentContext = moduleConfig.tips[tipSpec.id].shownContext[context] || 0;

            if (tipShownInCurrentContext >= tipSpec.maximumInContest[context]) {
                return false;
            }
        }
    }

    // NOTE: do not return true above this line (for obvious reasons)
    // or has it been shown enough times already?

    // dismiss is shown enough times?
    let requiredDismissCount;
    if (Number.isFinite(tipSpec.requireDismiss)) {
        requiredDismissCount = tipSpec.requireDismiss;
    } else if (tipSpec.requireDismiss === true) { // bool
        requiredDismissCount = tipSpec.requiredShowCount;
    } else {
        // ignore dismiss count
        requiredDismissCount = null;
    }

    // check context check if needed
    if (tipSpec.showInContext) {
        if (context in tipSpec.showInContext) {
            const tipShownInCurrentContext = moduleConfig.tips[tipSpec.id].shownContext[context] || 0;

            if (tipShownInCurrentContext < tipSpec.showInContext[context]) {
                return true;
            }
        }
    }

    return (tipSpec.requiredShowCount === null || thisTipConfig.shownCount < tipSpec.requiredShowCount) // not already shown enough times already?
        || (requiredDismissCount !== null && thisTipConfig.dismissedCount < requiredDismissCount); // not dismissed enough times?
}

/**
 * Sets the context for the current session.
 *
 * @public
 * @param {string} newContext
 * @returns {void}
 */
export function setContext(newContext) {
    context = newContext;
}

/**
 * Selects and shows a random tip.
 *
 * @public
 * @returns {void}
 */
export function showRandomTip() {
    // only try to select tip, if one is even available
    if (tips.length === 0) {
        console.info("no tips to show available anymore");
        return;
    }

    // randomly select element
    const randomNumber = Math.floor(Math.random() * tips.length);
    const tipSpecOrig = tips[randomNumber];

    // prepare tip spec
    const [tipSpec, thisTipConfig] = applyTipSpecAndConfigDefaults(tipSpecOrig);

    if (!shouldBeShown(tipSpec, thisTipConfig, tipSpecOrig)) {
        // remove tip
        tips.splice(randomNumber, 1);

        // retry random selection
        showRandomTip();
        return;
    }

    console.info("selected tip to be shown:", randomNumber, tipSpec);

    showTip(tipSpec, thisTipConfig);
}

/**
 * Shows the random tip only randomly so the user is not annoyed.
 *
 * @public
 * @returns {void}
 */
export function showRandomTipIfWanted() {
    saveConfig();

    // randomize tip showing in general
    if (!randomizePassed(GLOBAL_RANDOMIZE)) {
        console.info("show no random tip, because randomize did not pass");
        return;
    }

    showRandomTip();
}

/**
 * Initialises the module.
 *
 * @public
 * @param {TipObject[]} tipsToShow the tips object to init
 * @returns {Promise.<void>}
 */
export function init(tipsToShow) {
    // use local shallow copy, so we can modify it
    // inner objects won't be modified, so we do not need to deep-clone it.
    tips = tipsToShow.slice();

    // load function
    // We need to assign it here to make it testable.
    saveConfig = debounce(() => {
        AddonSettings.set(TIP_SETTING_STORAGE_ID, moduleConfig);
    }, DEBOUNCE_SAVING);

    // register HTMLElement
    CustomMessages.registerMessageType(MESSAGE_TIP_ID, document.getElementById(TIP_MESSAGE_BOX_ID));
    CustomMessages.setHook(MESSAGE_TIP_ID, "dismissStart", messageDismissed);

    return AddonSettings.get(TIP_SETTING_STORAGE_ID).then((randomTips) => {
        moduleConfig = randomTips;
        // count one open trigger
        moduleConfig.triggeredOpen = (moduleConfig.triggeredOpen || 0) + 1;
    });
}