Discord custom nicknames

Assign custom nicknames to Discord usernames client-side

// ==UserScript==
// @name         Discord custom nicknames
// @namespace    https://github.com/aspiers/Discord-custom-nicks-userscript
// @version      0.3.3
// @description  Assign custom nicknames to Discord usernames client-side
// @author       Adam Spiers
// @license      GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0.txt
// @match        https://discord.com/channels/*
// @icon         https://www.google.com/s2/favicons?domain=discord.com
// @require      https://code.jquery.com/jquery-3.6.0.min.js
// @require      https://code.jquery.com/ui/1.12.1/jquery-ui.min.js
// @require      https://greasyfork.org/scripts/5392-waitforkeyelements/code/WaitForKeyElements.js?version=115012
// @resource     jQueryUI-css https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/vader/jquery-ui.min.css
// @resource     jQueryUI-icon1 https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/vader/images/ui-icons_666666_256x240.png
// @resource     jQueryUI-icon2 https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/vader/images/ui-icons_bbbbbb_256x240.png
// @resource     jqueryUI-icon3 https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/vader/images/ui-icons_c98000_256x240.png
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_getResourceText
// @grant        GM_getResourceURL
// @grant        GM_info
// @grant        GM_addStyle
// @run-at       document-end
// ==/UserScript==
//
// Browser userscript to assign custom names to Discord nicknames
// Copyright (C) 2021 Adam Spiers <userscripts@adamspiers.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

// Stop JSHint in Tampermonkey's CodeMirror editor from complaining
// about globals imported via @require:
// https://jshint.com/docs/#inline-configuration
/* globals jQuery waitForKeyElements */

(function() {
    'use strict';
    let $ = jQuery;
    unsafeWindow.jQuery = jQuery;

    // Don't replace more often than this number of milliseconds.
    const DEBOUNCE_MS = 2000;

    const ELEMENT_PREFIX = "Discord-custom-nicknames-";
    const DIALOG_ID = ELEMENT_PREFIX + "dialog";
    const TEXTAREA_ID = ELEMENT_PREFIX + "textarea";
    const DIALOG_SELECTOR = "#" + DIALOG_ID;
    const TEXTAREA_SELECTOR = "#" + TEXTAREA_ID;

    const ORIG_ATTR = "data-Discord-orig-nickname";
    const STORAGE = "Discord_custom_nicknames_mapping";

    function get_nick_map_str() {
        let map_str = GM_getValue(STORAGE);
        return typeof(map_str) == "string" ? map_str : "";
    }
    unsafeWindow.get_nick_map_str = get_nick_map_str;

    function set_nick_map_str(new_value) {
        GM_setValue(STORAGE, new_value);
    }
    unsafeWindow.set_nick_map_str = set_nick_map_str;

    function get_nick_map() {
        return parse_map(get_nick_map_str());
    }
    unsafeWindow.get_nick_map = get_nick_map;

    // function serialise_map(map_obj) {
    //     return Object.entries(map_obj).map(e => e[0] + "=" + e[1]).join("\n");
    // }

    function parse_map(map_str) {
        let map_obj = {};
        for (const pair of map_str.split("\n")) {
            if (pair.indexOf("=") != -1) {
                let [k, v] = pair.split("=");
                map_obj[k] = v;
            }
        }
        return map_obj;
    }
    window.parse_map = parse_map;

    const PREFIX = "[Discord custom nicknames]";

    function debug(...args) {
        console.debug(PREFIX, ...args);
    }

    function log(...args) {
        console.log(PREFIX, ...args);
    }

    function replace_nick(nick_map, element) {
        // debug("replace", element);
        let orig_nick = element.getAttribute(ORIG_ATTR);
        let Discord_nick = orig_nick || element.innerText;
        let at = "";
        if (Discord_nick.startsWith("@")) {
            at = "@";
            Discord_nick = Discord_nick.slice(1);
        }
        let mapped_name = nick_map[Discord_nick];
        if (mapped_name) {
            mapped_name = at + mapped_name;
            debug(`${at}${Discord_nick} -> ${mapped_name}`);
            if (!orig_nick && element.tagName !== "TITLE") {
                // Back up the original to an attribute so that we can remap later
                // without reloading the page.
                //
                // FIXME: Figure out a way to make this work
                // flawlessly for <title>.  Currently it's slightly
                // broken because <title> can change values when
                // switching between DM pages, so we can't back up
                // the original username to an attribute on it.
                element.setAttribute(ORIG_ATTR, element.innerText)
            }
            element.innerText = mapped_name;
        }
        else {
            // debug(`no mapping found for ${element.innerText}`);
            // This is required in case a nick mapping is removed:
            if (orig_nick) {
                element.innerText = orig_nick;
            }
        }
    }

    function replace_css_elements(nick_map, query) {
        let matches = jQuery(query);
        // debug(`replacing ${query}`, matches);
        if (matches && matches.each) {
            matches.each((i, elt) => replace_nick(nick_map, elt));
        }
    }

    function replace_all() {
        debug("replace_all()");
        let nick_map = get_nick_map();
        debug("parsed:", nick_map);

        for (let selector of CSS_SELECTORS) {
            replace_css_elements(nick_map, selector);
        }
    }

    function dialog_html() {
        return `
            <div id="${DIALOG_ID}" title="Discord custom nicknames">
              <p>
                  Enter your mappings here, one on each line.
              </p>
              <textarea rows="10" cols="50" id="${TEXTAREA_ID}"
                        placeholder="nickname=Real Name"></textarea>
              <p>
                  Each mapping should look something like
              </p>
              <pre><code>nickname=Firstname Lastname</code></pre>
              <p>
                  where the left-hand side of the <code>=</code>
                  sign is the normal Discord nickname (excluding
                  the <code>#1234</code> suffix), and the
                  right-hand side is what you want to see instead.
              </p>
            </div>
        `;
    }

    function handle_dialog_save(dialog) {
        let map_str = $(TEXTAREA_SELECTOR).val();
        debug(`${TEXTAREA_SELECTOR} dialog save:`, map_str);
        GM_setValue(STORAGE, map_str || "");
        replace_all();
        $(dialog).dialog("close");
    }

    function handle_dialog_open(dialog) {
        let orig = get_nick_map_str();
        debug(`restoring ${TEXTAREA_SELECTOR} to`, orig);
        $(TEXTAREA_SELECTOR).val(orig);
    }

    unsafeWindow.GM_info = GM_info;

    function insert_CSS() {
        let CSS = GM_getResourceText("jQueryUI-css");
        for (let resource of GM_info.script.resources) {
            let image = resource.url.match(/images\/.+\.png/);
            if (!image) {
                continue;
            }
            let URL = GM_getResourceURL(resource.name);
            let rel_path = image[0];
            CSS = CSS.replaceAll(
                `url("${rel_path}")`,
                `url("${URL}")`,
            );
        }
        GM_addStyle(CSS);
    }

    function insert_dialog() {
        $("body").append(dialog_html());
        $(TEXTAREA_SELECTOR).val(get_nick_map_str());

        $(DIALOG_SELECTOR).dialog({
            minWidth: 300,
            width: 700,
            maxWidth: 300,
            buttons: [
                {
                    text: "Save",
                    click: function() {
                        handle_dialog_save(this);
                    }
                },
                {
                    text: "Cancel",
                    click: function() {
                        $(this).dialog("close");
                    }
                }
            ],
            open: handle_dialog_open,
        });
    }

    function display_dialog() {
        if ($(DIALOG_SELECTOR).length == 0) {
            insert_CSS();
            insert_dialog();
        }
        $(DIALOG_SELECTOR).dialog("open");
    }

    GM_registerMenuCommand("Nickname mapping", display_dialog);

    const CSS_SELECTORS = [
        "title",

        /////////////////////////////////////////////////////////
        // Channel pages

        // User list on right-hand side
        "div[class^=membersWrap] span[class^=roleColor]",

        // Attributions in main chat pane
        "span[class^=headerText] span[class^=username]",

        // Mentions within messages
        "div[class*=messageContent] span.mention",

        // When replying, name of user we're replying to
        "div[class^=replyBar] span[class^=name]",

        /////////////////////////////////////////////////////////
        // DM pages

        // DM list in left bar
        "div#private-channels div[class^=nameAndDecorators]",

        // Main friends list when "Friends" is clicked on
        "div[class^=peopleList] div[class^=userInfo] span[class^=username]",

        // Top of individual DM page
        "div[class^=chat] section[class^=title] h3[class*=title]",

        // h3 under individual DM large avatar
        "div[id^=chat-messages] h3[class^=header]"

        // N.B. deliberately not replacing
        //
        // "This is the beginning of your direct message history with"
        //
        // because that's a useful place to show the mapping with
        // the original username.
    ];

    function init() {
        let lastWaited = {};
        let nick_map = get_nick_map();
        for (let selector of CSS_SELECTORS) {
            waitForKeyElements(
                selector,
                () => {
                    debug("waitForKeyElements triggered for", selector);
                    let last = lastWaited[selector];
                    if (!last || (new Date() - last > DEBOUNCE_MS)) {
                        replace_css_elements(nick_map, selector);
                        lastWaited[selector] = new Date();
                    }
                }
            );
        }
        setInterval(replace_all, 5000);
    }

    init();
})();