NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Subreddit tab icons // @description Replaces tab icons (favicons) on reddit with icons of subreddits. // @version 4 // @license MIT // @author Andrei Rybak // @match https://www.reddit.com/* // @match https://new.reddit.com/* // @match https://old.reddit.com/* // @icon https://www.redditstatic.com/desktop2x/img/favicon/android-icon-192x192.png // @namespace https://github.com/rybak // @grant none // ==/UserScript== /* * Copyright (c) 2023 Andrei Rybak * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ /* jshint esversion: 6 */ (function() { 'use strict'; const LOG_PREFIX = '[reddit tab icons]'; const DEBUG_ENABLED = false; function error(...toLog) { console.error(LOG_PREFIX, ...toLog); } function warn(...toLog) { console.warn(LOG_PREFIX, ...toLog); } function log(...toLog) { console.log(LOG_PREFIX, ...toLog); } function debug(...toLog) { console.debug(LOG_PREFIX, ...toLog); } /* * Delay to wait after an error until next attempt, in milliseconds. * Doubles after every error. */ var delayMs = 1000; const SPECIAL_NAMES = ['all', 'friends', 'popular']; const DEFAULT_REDDIT_ICON = 'https://www.redditstatic.com/desktop2x/img/favicon/favicon-96x96.png'; let srDataUrl = ''; let srName = ''; function getSrName() { const srNameRegex = /https:[/][/](www|old|new)[.]reddit[.]com[/]r[/](\w+)/g; log('Getting subreddit name from', document.location.href); const match = srNameRegex.exec(document.location.href); if (!match || !match[0]) { warn(`Could not find subreddit URL in "${document.location.href}".`); return ''; } return match[2]; } function resetToDefaultIcon() { /* * Here we either on a special subreddit as a new page load, * or as a load-less switch in New Reddit. In latter case, * we need to reset the icon from whatever previous subreddit * might have been loaded. */ setFavicon(DEFAULT_REDDIT_ICON, () => { log('Could not reset the icon. Aborting.'); }); } function replaceOnNewPage() { log('Replacing on new page', document.location.href); const srNameRegex = /https:[/][/](www|old|new)[.]reddit[.]com[/]r[/](\w+)/g; const match = srNameRegex.exec(document.location.href); if (!match || !match[0]) { warn(`Could not find subreddit URL in "${document.location.href}". Resetting the icon to the default.`); resetToDefaultIcon(); return; } srName = match[2]; if (SPECIAL_NAMES.includes(srName)) { log(`Detected special subreddit "${srName}". Resetting the icon to the default.`); resetToDefaultIcon(); return; } const srUrl = match[0]; srDataUrl = `${srUrl}/about.json`; replaceFavicon(); } function tryAgain(errorFn) { log(`Trying again after ${delayMs} ms...`); setTimeout(errorFn, delayMs); delayMs = delayMs * 2; } function setFavicon(url, errorFn) { const faviconNodes = document.querySelectorAll('link[rel="icon"]'); if (!faviconNodes || faviconNodes.length == 0) { warn("Couldn't find favicon elements."); tryAgain(errorFn); return; } log('Using new URL =', url); faviconNodes.forEach(node => { log('Replacing old URL =', node.href); node.href = url; }); log('Done.'); } /* * For some reason .community_icon is partially HTML encoded. * Seems like a Reddit bug. Work around the bug by HTML decoding * the string. */ function cleanUpCommunityIcon(url) { if (!url || url.length == 0) { return url; } // https://stackoverflow.com/a/34064434/1083697 function htmlDecode(input) { const doc = new DOMParser().parseFromString(input, "text/html"); return doc.documentElement.textContent; } const res = htmlDecode(url); log(`Converted community_icon from "${url}" to "${res}".`); return res; } /* * I couldn't figure out a simple way to _reliably_ determine * if a website on www.reddit.com is Old Reddit or New Reddit. * So that's why there is this weird ping-pong error handling. */ function replaceFavicon() { /* * For old.reddit.com */ function replaceFaviconOld() { function useSrData(data) { if (DEBUG_ENABLED) { debug('Received JSON:', data); } /* * Not every subreddit has all these different images * defined in their style/theme/look-and-feel/whatever. * Therefore, try several different options. */ const communityIcon = cleanUpCommunityIcon(data.community_icon); const options = [communityIcon, data.icon_img, data.header_img]; for (const img of options) { if (img && img.length > 0) { setFavicon(img, replaceFaviconNew); return; } } /* * If we loaded "about.json" and it come up empty for * all three options of different fields for the icon, * it means that the subreddit likely doesn't have * the icon defined in the settings at all. Abort in * such cases. */ warn(`It seems that subreddit "${srName}" doesn't have its own icons defined. Resetting the icon to the default.`); resetToDefaultIcon(); } /* * Download data about the subreddit from Reddit API. * https://old.reddit.com/r/redditdev/comments/dot8tn/how_can_i_get_the_icon_of_a_subreddit/ */ log(`Loading from "${srDataUrl}"...`); const srDataPromise = fetch(srDataUrl); // https://stackoverflow.com/a/43175774/1083697 srDataPromise.then(res => res.json()) .then(json => useSrData(json.data)) .catch(err => { error(`Got error while getting ${srDataUrl}`, err); tryAgain(replaceFaviconNew); }); } /* * For new.reddit.com */ function replaceFaviconNew() { const srIcon = document.querySelector('img[alt="Subreddit-Symbol"]'); if (!srIcon) { warn("Couldn't find the icon in HTML of New Reddit."); tryAgain(replaceFaviconOld); return; } setFavicon(srIcon.src, replaceFaviconOld); } /* * Users of Old Reddit are more likely to use explicit URL * old.reddit.com than users of New Reddit, so check for * "old" first. */ if (document.location.hostname.includes('old')) { replaceFaviconOld(); } else { /* * Here the user is either using www.reddit.com or * explicit new.reddit.com. Users of www.reddit.com are * more likely to be using New Reddit (i.e. the default), * so try it out first. */ replaceFaviconNew(); } } replaceOnNewPage(); /* * Clicking on a link on New Reddit doesn't trigger a page load (sometimes, * at least). To cover such cases, we need to automatically detect that * the subreddit in the URL has changed. * * For whatever reason (either limitations of userscripts, or trickery of * New Reddit, or both), listener for popstate events doesn't work to * detect a change in the URL. * https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event * * As a workaround, observe the changes in the <title> tag, since most * subreddits will have different <title>s. */ const observer = new MutationObserver((mutationsList) => { const maybeNewSrName = getSrName(); log('Mutation to', maybeNewSrName); if (maybeNewSrName != srName) { log('MutationObserver: subreddit has changed:', document.location.href); replaceOnNewPage(); } }); observer.observe(document.querySelector('title'), { subtree: true, characterData: true, childList: true }); log('Added MutationObserver'); })();