tblistener.js

import $ from 'jquery';

import TBLog from './tblog.ts';

const logger = TBLog('TBListener');

/**
 * Event listener aliases. Allows you to listen for `author` and get `postAuthor` and `commentAuthor` events,
 * for example.
 * @type {Object.<string, Array<string>>}
 */

const listenerAliases = {
    postAuthor: ['author'],
    commentAuthor: ['author'],
    TBcommentAuthor: ['author'],
    TBpostAuthor: ['author'],
    TBcomment: ['comment'],
    TBcommentOldReddit: ['comment'],
    TBpost: ['post'],
    TBuserHovercard: ['userHovercard'],
    TBmodmailCommentAuthor: ['author'],
};

/**
 * We run this inside a try catch
 * so that if any jobs error, we
 * are able to recover and continue
 * to flush the batch until it's empty.
 *
 * @private
 */
function runTasks (tasks) {
    logger.debug('run tasks');
    let task;
    while ((task = tasks.shift())) {
        task();
    }
}

/**
 * Remove an item from an Array.
 *
 * @param  {Array} array
 * @param  {*} item
 * @return {Boolean}
 */
function remove (array, item) {
    const index = array.indexOf(item);
    return !!~index && !!array.splice(index, 1);
}

class TBListener {
    /**
     * Create a new instance of TBListener. Nothing happens yet until TBListener.start() has been called
     */
    constructor () {
        // Simple array holding callbacks waiting to be handled.
        // If you want to put something in here directly, make sure to call scheduleFlush()
        this.queue = [];

        // Holding areference to the bound function so `removeEventListener` can be called later
        this.boundFunc = this.listener.bind(this);

        // Object holding all registered listeners.
        // Keys are listener names, with arrays of callbacks as their values
        this.listeners = {};

        // Used by stop() and start()
        this.started = false;

        // If you assign a function to this, every single `reddit` event will go to it
        this.debugFunc = null;

        this.scheduled = false;
    }

    /**
     * Starts the TBListener instance by registering an event listener for `reddit` events
     *
     * A `TBListenerLoaded` event is fired when everything is ready.
     */
    start () {
        if (!this.started) {
            const loadedEvent = new CustomEvent('TBListenerLoaded');

            const meta = document.createElement('meta');
            meta.name = 'jsapi.consumer';
            meta.content = 'toolbox';
            document.head.appendChild(meta);

            const readyEvent = new CustomEvent('reddit.ready');

            document.addEventListener('reddit', this.boundFunc, true);
            document.addEventListener('tbReddit', this.boundFunc, true);

            document.dispatchEvent(loadedEvent);
            meta.dispatchEvent(readyEvent);

            this.started = true;
        }
    }

    /**
     * Unregisters this instance's event listener
     */
    stop () {
        if (this.started) {
            document.removeEventListener('reddit', this.boundFunc);
            document.removeEventListener('tbReddit', this.boundFunc);
            this.started = false;
        }
    }

    /**
     * Register an event listener for a given event name for a callback.
     *
     * @param {string} Name of event
     * @param {TBListener~listenerCallback} Callback
     */
    on (event, callback) {
        if (!this.listeners[event]) {
            this.listeners[event] = [];
        }

        this.listeners[event].push(callback);
    }

    /**
     * Callback for a `reddit` event.
     * The callback's `this` is event.target
     *
     * @callback TBListener~listenerCallback
     * @param {CustomEvent} event
     * @param {string} responseMessage
     * @this HTMLElement
     */

    /**
     * The function that gets registered as a global event listener for `reddit` events.
     *
     * @param {CustomEvent}
     * @private
     */
    listener (event) {
        const eventType = event.detail.type;
        const target = event.target.querySelector('[data-name="toolbox"]');

        // If there is no target this is not for us.
        if (!target) {
            return;
        }

        // We already have seen this attribute and do not need duplicates.
        if (target.classList.contains('tb-frontend-container')) {
            return;
        }

        const $target = $(target);
        $target.data('tb-details', event.detail);
        $target.data('tb-type', event.detail.type);
        target.setAttribute('data-tb-type', event.detail.type);
        target.classList.add('tb-frontend-container');

        const internalEvent = {
            detail: event.detail,
            target,
        };

        // See if there's any registered listeners listening for eventType
        if (Array.isArray(this.listeners[eventType])) {
            for (const listener of this.listeners[eventType]) {
                this.queue.push(listener.bind(target, internalEvent));
            }
        }

        // Check and see if there are any aliases for `eventType` and run those on the queue
        if (Array.isArray(listenerAliases[eventType])) {
            for (const alias of listenerAliases[eventType]) {
                if (Array.isArray(this.listeners[alias])) {
                    for (const listener of this.listeners[alias]) {
                        this.queue.push(listener.bind(target, internalEvent));
                    }
                }
            }
        }

        // Run the debug function on the queue, if there's any
        if (this.debugFunc) {
            this.queue.push(this.debugFunc.bind(target, internalEvent));
        }

        // Flush the queue
        this.scheduleFlush();
    }

    /**
     * Clears a scheduled 'read' or 'write' task.
     *
     * @param {Object} task
     * @return {Boolean} success
     * @public
     */
    clear (task) {
        return remove(this.queue, task);
    }

    /**
     * Schedules a new read/write
     * batch if one isn't pending.
     *
     * @private
     */
    scheduleFlush () {
        if (!this.scheduled) {
            this.scheduled = true;
            requestAnimationFrame(this.flush.bind(this));
        }
    }

    /**
     * Runs queued tasks.
     *
     * Errors are caught and thrown by default.
     * If a `.catch` function has been defined
     * it is called instead.
     *
     * @private
     */
    flush () {
        const queue = this.queue;
        let error;

        try {
            runTasks(queue);
        } catch (e) {
            error = e;
        }

        this.scheduled = false;

        // If the batch errored we may still have tasks queued
        if (queue.length) {
            this.scheduleFlush();
        }

        if (error) {
            logger.error('task errored', error.message);
            if (this.catch) {
                this.catch(error);
            } else {
                throw error;
            }
        }
    }
}

export default new TBListener();