NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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 } })