Aloso / Exercism notifications

// ==UserScript==
// @namespace     exercism
// @name          Exercism notifications
// @description   Get a desktop notification when you receive a message on Exercism
// @copyright     2019, Aloso (https://openuserjs.org/users/Aloso)
// @license       MIT
// @version       1.2.0
// @include       https://exercism.io/*
// @grant         GM.xmlHttpRequest
// @updateURL     https://openuserjs.org/meta/Aloso/Exercism_notifications.meta.js
// @downloadURL   https://openuserjs.org/install/Aloso/Exercism_notifications.user.js
// ==/UserScript==

// ==OpenUserJS==
// @author Aloso
// ==/OpenUserJS==

/**
 * @typedef {{
 *     xmlHttpRequest: function(XmlRequestOptions)
 * }} GM
 */

/**
 * @typedef {{
 *     method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS',
 *     url: string,
 *     onload: function({ responseText: string }),
 *     onerror: function(Event),
 * }} XmlRequestOptions
 */


window.addEventListener('load', () => {
    'use strict'

    const msg = {
        prefix: 'N1_',

        /**
         * Substitutes the prefix from a raw `localStorage` key
         *
         * @param {string} key
         * @returns {string}
         */
        getKey(key) {
            return key.replace(/^N1_/, '')
        },

        /**
         * Persists a key-value pair, overwriting the previous value if present.
         * Also, sends a storage event to all other tabs.
         *
         * @param {string} key
         * @param {string} value
         */
        set(key, value) {
            localStorage.setItem(msg.prefix + key, value)
        },

        /**
         * Deletes a persisted key-value pair.
         * Also, sends a storage event to all other tabs.
         *
         * @param {string} key
         */
        remove(key) {
            localStorage.removeItem(msg.prefix + key)
        },

        /**
         * Returns the persisted value for a given key
         *
         * @param {string} key
         * @returns {string | null}
         */
        get(key) {
            return localStorage.getItem(msg.prefix + key)
        },

        /**
         * Returns whether a key exists in the storage.
         *
         * @param {string} key
         * @returns {boolean}
         */
        hasKey(key) {
            return localStorage.hasOwnProperty(msg.prefix + key)
        },

        /**
         * @param {string} key
         * @param {function(StorageEvent)} callback
         * @returns {function(StorageEvent)}
         */
        recv(key, callback) {
            const cb = (e) => {
                if (msg.getKey(e.key) === key) callback(e)
            }
            window.addEventListener('storage', cb)
            return cb
        },

        /** @param {function(StorageEvent)} callback */
        cancel(callback) {
            window.removeEventListener('storage', callback)
        }
    }

    /** @type {string[]} */
    let openTabs

    /** @type {HTMLButtonElement} */
    let button

    const uuid = createUuid()

    let active = msg.get('active')

    /** @type {?number} */
    let timeout = null

    if (!isLoggedIn() || Notification.permission === 'denied') {
        sendActive(null)
        return
    }

    msg.recv('ping', () => msg.set('pong', uuid))
    msg.recv('openTabs', () => {
        openTabs = getOpenTabIds(uuid)

        // if the openTabs is incomplete:
        if (!openTabs.includes(uuid)) {
            openTabs.push(uuid)
            setTimeout(() => msg.set('openTabs', openTabs.join(' ')))
        }
    })
    msg.recv('active', (e) => {
        recvActive(e.newValue)
    })

    window.addEventListener('beforeunload', () => {
        openTabs = openTabs.filter((t) => t !== uuid)
        msg.set('openTabs', openTabs.join(' '))
        checkActive()
    })

    button = createButton()
    if (Notification.permission === 'default' || active == null) {
        setDisabled(button)
    } else {
        setEnabled(button)
        getVerifiedOpenTabs(uuid).then(() => {
            checkActive()
        })
    }


    let prevCount = 0

    msg.recv('count', (e) => {
        prevCount = +e.newValue
        setRedDot(prevCount > 0)
    })

    /** @type {Notification} */
    let notification = null

    function loadNotis() {
        timeout = null
        console.log('checking for notifications...')
        GM.xmlHttpRequest({
            method: 'GET',
            url: `https://exercism.io/my/notifications?t=${new Date()}`, // prevent caching
            onload: (response) => updateNotiCount(countNotis(response.responseText)),
            onerror: (e) => console.warn('Error', e),
        })
    }

    function scheduleNotification() {
        if (timeout == null && active === uuid) {
            timeout = setTimeout(loadNotis, 30 * 1000)
        }
    }

    function cancelNotification() {
        if (timeout) clearTimeout(timeout)
        timeout = null
    }

    /** @returns {boolean} */
    function isLoggedIn() {
        return document.querySelector('header.logged-in') != null
    }

    /**
     * @param {string} uuid
     * @returns {string[]}
     */
    function getOpenTabIds(uuid) {
        return (msg.get('openTabs') || '').split(' ').filter(t => t.length)
    }

    /** @returns {Promise<string[]>} */
    function getVerifiedOpenTabs(/** string */ uuid) {
        return new Promise((resolve) => {
            const tabs = [uuid]

            const cb = msg.recv('pong', (e) => tabs.push(e.newValue))
            msg.set('ping', uuid)

            setTimeout(() => {
                msg.cancel(cb)
                openTabs = tabs
                msg.set('openTabs', tabs.join(' '))
                resolve(tabs)
            }, 400)
        })
    }

    /** @returns {string} */
    function createUuid() {
        return (+new Date() % 1000000000) + '.' + ((Math.random() * 10000000) | 0)
    }

    /** @returns {HTMLButtonElement} */
    function createButton() {
        document.querySelector('header.logged-in a.logo img').style.display = 'inline-block'

        const button = document.createElement('button')
        button.innerHTML = '...'
        button.style.color = 'white'
        button.style.backgroundColor = 'rgba(255, 255, 255, 0.05)'
        button.style.padding = '7px 10px'
        button.style.fontSize = '1em'
        button.style.lineHeight = '1.22em'
        button.style.border = 'none'
        button.style.borderRadius = '3px'
        button.style.margin = '10px 10px 0 10px'
        button.style.float = 'right'

        const parent = document.querySelector('header .lo-container .spacer')
        parent.append(button)

        button.addEventListener('click', () => {
            sendActive(active == null ? uuid : null)
            setTimeout(() => {
                button.blur()
            }, 1000)
        })

        return button
    }

    /** @param {HTMLButtonElement} button */
    function setEnabled(button) {
        Notification.requestPermission().then(result => {
            switch (result) {
                case 'default':
                    break;
                case 'denied':
                    button.remove()
                    break;
                case 'granted':
                    button.innerHTML = 'Disable live notifications'
                    button.style.backgroundColor = 'rgba(255, 255, 255, 0.05)'
                    button.style.color = 'rgba(255, 255, 255, 0.5)'

                    if (active === uuid) scheduleNotification()
                    else cancelNotification()
            }
        })
    }

    /** @param {HTMLButtonElement} button */
    function setDisabled(button) {
        button.innerHTML = 'Enable live notifications'
        button.style.backgroundColor = 'rgba(42,255,48,0.18)'
        button.style.color = 'white'
        button.style.boxShadow = ''
        cancelNotification()
    }


    /** @param {string | null} newActive */
    function sendActive(newActive) {
        active = newActive
        if (active != null) {
            if (msg.get('active') !== active) msg.set('active', uuid)
            if (button) setEnabled(button)
        } else {
            if (msg.hasKey('active')) msg.remove('active')
            if (button) setDisabled(button)
        }
    }

    /** @param {string | null} newActive */
    function recvActive(newActive) {
        active = newActive
        if (active != null) {
            if (button) setEnabled(button)
        } else {
            if (button) setDisabled(button)
        }
    }


    function checkActive() {
        if (active != null) {
            if (!openTabs.includes(active)) {
                active = openTabs[0]
                msg.set('active', active)
                if (active === uuid) setEnabled(button)
            }
        }
    }


    /**
     * @param {string} responseText
     * @returns {number}
     */
    function countNotis(responseText) {
        const el = document.createElement('div')
        el.innerHTML = responseText
        return el.querySelectorAll('.notification').length
    }

    function updateNotiCount(/** number */ count) {
        if (count !== prevCount) {
            setRedDot(count > 0)
            msg.set('count', '' + count)
        }

        if (count > prevCount) {
            if (notification != null) notification.close()

            notification = notify({
                body: `${count} new notification${count > 1 ? 's' : ''}!`,
                close: () => scheduleNotification(),
            })
            prevCount = Math.max(count, 0)
        } else {
            prevCount = prevCount < 0 ? 0 : count
            scheduleNotification()
        }
    }

    function setRedDot(/** boolean */ isSet) {
        const el = document.querySelector('header a.notifications')
        if (isSet) el.classList.add('active')
        else el.classList.remove('active')
    }


    /**
     * @param {string | undefined} options.title
     * @param {string} options.body
     * @param {function() | undefined} options.click
     * @param {function() | undefined} options.close
     * @returns {Notification}
     */
    function notify(options) {
        if (options.title == null) options.title = 'Exercism'
        const n = new Notification(options.title, { body: options.body })

        n.addEventListener('click', () => {
            if (options.click) options.click()
            n.close()
        })
        if (options.close) n.addEventListener('close', () => options.close())
        return n
    }
})