TORN: Better Chat

Improvements to the usability of chats 2.0.

目前為 2023-11-02 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         TORN: Better Chat
// @namespace    dekleinekobini.betterchat
// @license      GPL-3
// @version      2.0.0
// @description  Improvements to the usability of chats 2.0.
// @author       DeKleineKobini [2114440]
// @match        https://www.torn.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @run-at       document-start
// @grant        GM_addStyle
// ==/UserScript==

"use strict";

class CommonUtilities {
    static _currentPlayerName;

    static TEXT_COLORS = {
        "torntools-green": "#7ca900",
    };

    static mergeRecursive(target, otherObject) {
        for (const key in otherObject) {
            try {
                if (typeof otherObject[key] == "object" && !Array.isArray(otherObject[key])) {
                    target[key] = CommonUtilities.mergeRecursive(target[key], otherObject[key]);
                } else {
                    target[key] = otherObject[key];
                }
            } catch (e) {
                target[key] = otherObject[key];
            }
        }

        return target;
    }

    static findByClass(node, className, subSelector = "") {
        return node.querySelector(`[class*='${className}'] ${subSelector}`.trim());
    }

    static findAllByClass(node, className, subSelector = "") {
        return [...node.querySelectorAll(`[class*='${className}'] ${subSelector}`.trim())];
    }

    static baseColor(color) {
        if (color in CommonUtilities.TEXT_COLORS) color = CommonUtilities.TEXT_COLORS[color];

        return color;
    }

    static convertColor(color) {
        color = CommonUtilities.baseColor(color);

        return color.length === 7 ? `${color}6e` : color;
    }

    static getCurrentPlayerName() {
        if (CommonUtilities._currentPlayerName) {
            return CommonUtilities._currentPlayerName;
        }

        const websocketElement = document.getElementById("websocketConnectionData");
        if (websocketElement) {
            const data = JSON.parse(websocketElement.textContent);

            CommonUtilities._currentPlayerName = data.playername;
            return data.playername;
        }

        const sidebarElement = CommonUtilities.findByClass(document, "menu-value___");
        if (sidebarElement) {
            CommonUtilities._currentPlayerName = sidebarElement.textContent;
            // Get name from the sidebar
            return sidebarElement.textContent;
        }

        const attackerElement = document.querySelector(".user-name.left");
        if (attackerElement) {
            CommonUtilities._currentPlayerName = attackerElement.textContent;
            // Attack name
            return attackerElement.textContent;
        }

        const chatSenderElement = document.querySelector("[class*='chat-box-body__message-box--self___'] [class*='chat-box-body__sender___']");
        if (chatSenderElement) {
            CommonUtilities._currentPlayerName = chatSenderElement.textContent;
            return chatSenderElement.textContent;
        }

        throw new Error("Failed to get the current player's name.");
    }
}

class BetterChatSettings {
    static SETTINGS_KEY = "better-chat-settings";

    static DEFAULT_SETTINGS = {
        messages: {
            hideAvatars: true,
            compact: true,
            leftAlignedText: true, // left align all text, prefixed by the name (supports the mini-profile as well), even for private chats
            highlight: [
                // Colors can be specified as:
                // - hex color (include #, only full format = 6 numbers)
                // - custom colors (check below); "torntools-green"
                // Search is just text, except "%player%" where it used the current players name.
                {id: 0, color: "torntools-green", search: "%player%"},
            ],
        },
        box: {
            groupRight: true, // opening chat logic to put private chat left of group chats
            hideAvatars: true,
        },
        accessibility: {
            describeButtons: true,
            presentationSender: true,
        }
    };

    constructor() {
        this._settings = this._loadSettings();
    }

    _loadSettings() {
        const storedSettings = localStorage.getItem(BetterChatSettings.SETTINGS_KEY);
        const settings = storedSettings ? JSON.parse(storedSettings) : {};

        const defaultSettings = BetterChatSettings.DEFAULT_SETTINGS;
        CommonUtilities.mergeRecursive(defaultSettings, settings);

        return defaultSettings;
    }

    save() {
        localStorage.setItem(BetterChatSettings.SETTINGS_KEY, JSON.stringify(this._settings));
    }

    get messages() {
        return this._settings.messages;
    }

    get box() {
        return this._settings.box;
    }

    get accessibility() {
        return this._settings.accessibility;
    }

    showSettingsIcon(panel) {
        if (panel.querySelector("#better-chat-settings-icon")) return;

        const icon = this._createScriptSettingsIcon();

        const panelHeader = CommonUtilities.findByClass(panel, "chat-list-header__actions___");
        panelHeader.insertAdjacentElement("afterbegin", icon);
    }

    _createScriptSettingsIcon() {
        const button = document.createElement("button");
        button.type = "button";
        button.ariaLabel = "Better Chat settings";
        button.textContent = "BS";
        button.addEventListener("click", (event) => {
            event.stopPropagation();
            this._showScriptSettings();
        }, {capture: true});

        const icon = document.createElement("div");
        icon.id = "better-chat-settings-icon";
        icon.appendChild(button);

        return icon;
    }

    _showScriptSettings() {
        if (document.querySelector(".better-chat-settings-overlay")) return;

        const popup = this._createPopup();

        const overlay = this._createOverlay();
        overlay.appendChild(popup);
        document.body.appendChild(overlay);

        popup.focus();
    }

    _createPopup() {
        const popup = document.createElement("div");
        popup.classList.add("better-chat-settings-popup");
        popup.setAttribute("autofocus", "");
        popup.setAttribute("tabindex", "0");

        appendTitle("Better Chat - Settings");
        appendDescription("You can change your Better Chat settings here. Reload after changes to apply them.");

        appendSubtitle("Messages");
        appendCheckbox(
            "messages-hideAvatars",
            "Hide avatars in the messages.",
            () => settings.messages.hideAvatars,
            (newValue) => settings.messages.hideAvatars = newValue,
        );
        appendCheckbox(
            "messages-compact",
            "Make the chat significantly compacter.",
            () => settings.messages.compact,
            (newValue) => settings.messages.compact = newValue,
        );
        appendCheckbox(
            "messages-leftAlignedText",
            "Left align all messages.",
            () => settings.messages.leftAlignedText,
            (newValue) => settings.messages.leftAlignedText = newValue,
        );

        appendSubtitle("Boxes");
        appendCheckbox(
            "box-groupRight",
            "Move group chats to always be to the right of private chats.",
            () => settings.box.groupRight,
            (newValue) => settings.box.groupRight = newValue,
        );
        appendCheckbox(
            "box-hideAvatars",
            "Hide avatars in the boxes.",
            () => settings.box.hideAvatars,
            (newValue) => settings.box.hideAvatars = newValue,
        );

        appendSubtitle("Highlights");
        appendHighlightList(
            () => settings.messages.highlight,
            ({search, color}) => {
                const removeIndex = settings.messages.highlight.findLastIndex((highlight) => highlight.search === search && highlight.color === color);

                settings.messages.highlight = settings.messages.highlight.filter((highlight, index) => index !== removeIndex);
            },
            (item) => settings.messages.highlight.push(item),
        );

        appendSubtitle("Accessibility");
        appendCheckbox(
            "accessibility-describeButtons",
            "Describe (most) buttons, for users with a screen reader.",
            () => settings.accessibility.describeButtons,
            (newValue) => settings.accessibility.describeButtons = newValue,
        );
        appendCheckbox(
            "accessibility-presentationSender",
            "Don't read out the button role of the sender.",
            () => settings.accessibility.presentationSender,
            (newValue) => settings.accessibility.presentationSender = newValue,
        );

        return popup;

        function appendTitle(title) {
            const titleElement = document.createElement("span");
            titleElement.classList.add("better-chat-settings-title");
            titleElement.textContent = title;

            const closeElement = document.createElement("button");
            closeElement.classList.add("better-chat-settings-close-popup");
            closeElement.textContent = "X";
            closeElement.addEventListener("click", () => {
                [...document.getElementsByClassName("better-chat-settings-overlay")].forEach(overlay => overlay.remove());
            });
            closeElement.ariaLabel = "Close better chat settings";

            const titleWrapper = document.createElement("div");
            titleWrapper.classList.add("better-chat-settings-title-wrapper");
            titleWrapper.appendChild(titleElement);
            titleWrapper.appendChild(closeElement);

            popup.appendChild(titleWrapper);
        }

        function appendDescription(title) {
            const titleElement = document.createElement("span");
            titleElement.classList.add("better-chat-settings-description");
            titleElement.innerText = title;

            popup.appendChild(titleElement);
        }

        function appendSubtitle(title) {
            const titleElement = document.createElement("span");
            titleElement.classList.add("better-chat-settings-subtitle");
            titleElement.innerText = title;

            popup.appendChild(titleElement);
        }

        function appendCheckbox(id, labelText, valueGetter, valueSetter) {
            const inputId = `setting-${id}`;

            const input = document.createElement("input");
            input.checked = valueGetter();
            input.id = inputId;
            input.type = "checkbox";
            input.addEventListener("change", (event) => {
                valueSetter(event.currentTarget.checked);
                settings.save();
            }, {capture: true});

            const label = document.createElement("label");
            label.setAttribute("for", inputId);
            label.innerText = labelText;

            const section = document.createElement("section");
            section.appendChild(input);
            section.appendChild(label);

            popup.appendChild(section);
        }

        function appendHighlightList(valueGetter, valueRemover, valueAdder) {
            const list = document.createElement("ul");

            valueGetter().forEach((item) => appendRow(item));

            const addButton = document.createElement("button");
            addButton.textContent = "add";
            addButton.addEventListener("click", () => {
                const item = {search: "%player%", color: "#7ca900"};

                valueAdder(item);
                appendRow(item, true);
                settings.save();
            });
            list.appendChild(addButton);

            popup.appendChild(list);

            function appendRow(item, beforeButton = false) {
                const itemElement = document.createElement("li");
                itemElement.classList.add("better-chat-settings-highlight-entry");

                const searchInput = document.createElement("input");
                searchInput.type = "text";
                searchInput.placeholder = "Search...";
                searchInput.value = item.search;
                searchInput.addEventListener("change", (event) => {
                    item.search = event.currentTarget.value;
                    settings.save();
                });
                itemElement.appendChild(searchInput);

                const colorInput = document.createElement("input");
                colorInput.type = "color";
                colorInput.value = CommonUtilities.baseColor(item.color);
                colorInput.addEventListener("change", (event) => {
                    item.color = event.currentTarget.value;
                    settings.save();
                });
                itemElement.appendChild(colorInput);

                const removeButton = document.createElement("button");
                removeButton.textContent = "remove";
                removeButton.addEventListener("click", () => {
                    itemElement.remove();
                    valueRemover(item);
                    settings.save();
                });
                itemElement.appendChild(removeButton);

                if (beforeButton) {
                    list.insertBefore(itemElement, addButton);
                } else {
                    list.appendChild(itemElement);
                }
            }
        }
    }

    _createOverlay() {
        const overlay = document.createElement("div");
        overlay.classList.add("better-chat-settings-overlay");
        overlay.addEventListener("click", (event) => {
            if (event.target !== overlay) return;

            overlay.remove();
        }, {once: true});

        return overlay;
    }
}

class ScriptEventHandler {

    static onLoad() {
        new MutationObserver((_, observer) => {
            const group = CommonUtilities.findByClass(document, "group-chat-box___");
            if (!group) return;

            observer.disconnect();
            ScriptEventHandler.onChatLoad(group);
        }).observe(document, {childList: true, subtree: true});
        new MutationObserver((_, observer) => {
            const chatList = CommonUtilities.findByClass(document, "chat-app__chat-list-chat-box-wrapper___");
            if (!chatList) return;

            ScriptEventHandler._handlePanel(chatList);
            new MutationObserver(
                () => ScriptEventHandler._handlePanel(chatList)
            ).observe(chatList, {childList: true});

            observer.disconnect();
        }).observe(document, {childList: true, subtree: true});

        // Features
        new StylingFeature().setupStyles();
    }

    static onChatLoad(root) {
        // Detect chat openings.
        root.childNodes.forEach(ScriptEventHandler.onChatOpened)
        new MutationObserver(
            (mutations) => mutations.flatMap((mutation) => [...mutation.addedNodes]).forEach(ScriptEventHandler.onChatOpened)
        ).observe(root, {childList: true});

        // Features
        AccessibilityFeature.describeNotepad();
        AccessibilityFeature.describePeople();
    }

    static onChatOpened(chat) {
        // Detect chat messages.
        const bodyElement = CommonUtilities.findByClass(chat, "chat-box-body___");
        bodyElement.childNodes.forEach(ScriptEventHandler.onMessageReceived);
        new MutationObserver(
            (mutations) => mutations.flatMap((mutation) => [...mutation.addedNodes]).forEach(ScriptEventHandler.onMessageReceived)
        ).observe(chat, {childList: true});
        new MutationObserver(
            () => bodyElement.childNodes.forEach(ScriptEventHandler.onMessageReceived)
        ).observe(bodyElement, {childList: true});

        // Features
        AccessibilityFeature.describeChatButtons(chat);
        ChatGroupFeature.moveGroupRight(chat);
    }

    static onMessageReceived(message) {
        if (message.querySelector(".color-chatError")) {
            // This is a "Connecting to the server" message, don't process it.
            return;
        }

        const senderElement = CommonUtilities.findByClass(message, "chat-box-body__sender___");

        const currentPlayer = CommonUtilities.getCurrentPlayerName();
        let senderName = senderElement.textContent.substring(0, senderElement.textContent.length - 1);
        if (senderName === "newMessage") {
            // Take the name from the sidebar.
            senderElement.textContent = `${currentPlayer}:`;
            senderName = currentPlayer;
        }

        // Features
        AccessibilityFeature.describeMessageButtons(message, senderName);
        MessageFeature.highlightMessages(message, senderName);
    }

    static onPeoplePanelLoad(panel) {
        settings.showSettingsIcon(panel);
        AccessibilityFeature.appPanelAccessibility(panel);
    }

    static _handlePanel(chatList) {
        const peoplePanel = CommonUtilities.findByClass(chatList, "chat-app__panel___");
        if (peoplePanel && !peoplePanel.querySelector(".better-chat-found")) {
            CommonUtilities.findByClass(peoplePanel, "chat-tab___").classList.add("better-chat-found");

            ScriptEventHandler.onPeoplePanelLoad(peoplePanel);
            new MutationObserver(() => {
                ScriptEventHandler.onPeoplePanelLoad(peoplePanel);
            }).observe(peoplePanel, {childList: true});
        }
    }
}

class StylingFeature {

    includeStyle(styleRules) {
        if (typeof GM_addStyle !== "undefined") {
            GM_addStyle(styleRules);
        } else {
            const styleElement = document.createElement("style");
            styleElement.setAttribute("type", "text/css");
            styleElement.innerHTML = styleRules;
            document.head.appendChild(styleElement);
        }
    }

    setupStyles() {
        if (settings.messages.hideAvatars) {
            this.includeStyle(`
                [class*='chat-box-body__avatar___'] {
                    display: none;
                }
            `);
        }
        if (settings.messages.compact) {
            this.includeStyle(`
                [class*='chat-box-body__wrapper___'] {
                    margin-bottom: 0px !important;
                }
    
                [class*='chat-box-body___'] > div:last-child {
                    margin-bottom: 8px !important;
                }
            `);
        }
        if (settings.box.groupRight) {
            this.includeStyle(`
                [class*='group-chat-box___'] {
                    gap: 3px;
                }
    
                [class*='group-chat-box__chat-box-wrapper___'] {
                    margin-right: 0 !important;
                }
            `);
        }

        if (settings.messages.leftAlignedText) {
            this.includeStyle(`
                [class*='chat-box-body__sender___'] {
                    display: unset !important;
                    font-weight: 700;
                }
    
                [class*='chat-box-body__message-box___'] [class*='chat-box-body__sender___'] {
                    margin-right: 4px;
                }
    
                [class*='chat-box-body__message-box___'] {
                    background: none !important;
                    border-radius: none !important;
                    color: initial !important;
                    padding: 0 !important;
                }
    
                [class*='chat-box-body__message-box--self___'] {
                    background: none !important;
                    border-radius: none !important;
                    color: initial !important;
                    padding: 0 !important;
                }
    
                [class*='chat-box-body__wrapper--self___'] {
                    justify-content: normal !important;
                }
    
                [class*='chat-box-body__wrapper--self___'] > [class*='chat-box-body__message___'],
                [class*='chat-box-body__message___'] {
                    color: var(--chat-text-color) !important;
                }
            `);
        }

        if (settings.box.hideAvatars) {
            this.includeStyle(`
                [class*='avatar__avatar-status-wrapper___'] > img {
                    display: none;
                }
            `);
        }

        this.includeStyle(`
            #better-chat-settings-icon {
                align-self: center;
            }
    
            #better-chat-settings-icon button {
                color: #f7f7f7;
            }
    
            .better-chat-settings-overlay {
                position: fixed;
                top: 0;
                left: 0;
                right: 0;
                bottom: 0;
                background-color: rgba(0, 0, 0, 0.5);
                z-index: 1000;
                display: flex;
                align-items: center;
                justify-content: center;
            }
    
            .better-chat-settings-popup {
                background-color: #f7f7f7;
                width: 300px;
                height: 350px;
                padding: 4px;
            }
    
            .better-chat-settings-title-wrapper {
                display: flex;
                justify-content: space-between;
            }
    
            .better-chat-settings-title {
                display: block;
                font-size: 1.25em;
                font-weight: bold;
                margin-bottom: 2px;
            }
    
            .better-chat-settings-close-popup {
                padding-inline: 5px;
            }
    
            .better-chat-settings-description {
                display: block;
                font-size: 0.9em;
                margin-bottom: 2px;
            }
    
            .better-chat-settings-subtitle {
                display: block;
                font-weight: bold;
                margin-bottom: 2px;
            }
    
            .better-chat-settings-subtitle:not(:first-child) {
                margin-top: 4px;
            }
    
            .better-chat-settings-popup > section {
                display: flex;
                align-items: center;
                gap: 2px;
                margin-bottom: 1px;
            }
    
            .better-chat-settings-popup button {
                cursor: pointer;
            }
    
            .better-chat-settings-highlight-entry {
                display: flex;
                gap: 4px;
            }
        `);
    }
}

class AccessibilityFeature {
    static describeChatButtons(chat) {
        if (!settings.accessibility.describeButtons) return;

        [...chat.querySelectorAll("button:not(.better-chat-described), *[role='button'][tabindex]")]
            .forEach((button) => AccessibilityFeature._describeChatButton(button));
    }

    static _describeChatButton(button) {
        let description;

        const svg = button.querySelector("svg");
        if (svg) {
            const className = svg.getAttribute("class") || "";

            if (className.includes("minimize-icon")) {
                description = "Minimize this chat";
            } else if (className.includes("close-icon")) {
                description = "Close this chat";
            }
        }

        if (!description) {
            // chat-box-header__info
            const className = button.getAttribute("class");

            if (className.includes("chat-box-header___")) {
                description = "Minimize this chat";
            } else if (className.includes("chat-box-footer__send-icon-wrapper___")) {
                description = "Send your message";
            } else if (className.includes("chat-box-body__sender-button___")) {
                // This is the link to the message sender. Handle this in the message processing.
                description = false;
            } else if (className.includes("chat-box-header__info-btn___")) {
                description = "Open possible actions";
            } else if (className.includes("chat-box-header__info___")) {
                // If this class is applied without the above 'btn' class, then it's a useless button.
                description = false;
            }
        }

        if (description) button.ariaLabel = description;
        else if (description === false) {
            // Don't describe this button.
        } else console.warn("[Better Chat] Failed to describe this button.", button);

        button.classList.add("better-chat-described");
    }

    static appPanelAccessibility(panel) {
        CommonUtilities.findAllByClass(panel, "chat-list-header__button___")
            .forEach((button) => AccessibilityFeature._describeAppPanelButton(button));
    }

    static _describeAppPanelButton(button) {
        let description;

        if (button.querySelector("#setting_default")) {
            description = "Open chat settings"
        } else if (button.querySelector("#_close_default_dark")) {
            description = "Close chat settings";
        } else console.warn("[Better Chat] Failed to describe this app panel button.", button);

        button.ariaLabel = description;
    }

    static describeMessageButtons(message, senderName) {
        if (!settings.accessibility.describeButtons) return;

        const senderElement = CommonUtilities.findByClass(message, "chat-box-body__sender___");

        if (settings.accessibility.presentationSender) {
            const senderButton = CommonUtilities.findByClass(message, "chat-box-body__sender-button___");
            senderButton.role = "presentation";

            senderElement.role = "presentation";
        } else if (settings.accessibility.describeButtons) {
            senderElement.ariaLabel = `Open ${senderName}'s profile`
        }
    }

    static describeNotepad() {
        if (!settings.accessibility.describeButtons) return;

        const notepadElement = CommonUtilities.findByClass(document, "chat-note-button___");
        notepadElement.ariaLabel = "Open your notepad";
    }

    static describePeople() {
        if (!settings.accessibility.describeButtons) return;

        const peopleElement = CommonUtilities.findByClass(document, "chat-list-button___");
        peopleElement.ariaLabel = "List all people";
    }
}

class ChatGroupFeature {

    static moveGroupRight(chat) {
        if (!settings.box.groupRight) return;

        const avatarElement = CommonUtilities.findByClass(chat, "chat-box-header__avatar___", "> *");
        const isGroup = avatarElement.tagName.toLowerCase() === "svg";

        if (isGroup) {
            chat.style.order = "1";
        }
    }
}


class MessageFeature {
    static highlightMessages(message, senderName) {
        if (!settings.messages.highlight.length) return;

        const highlights = MessageFeature._buildHighlights();

        MessageFeature._nameHighlight(message, highlights, senderName);
        MessageFeature._messageHighlight(message, highlights);
    }

    static _buildHighlights() {
        return settings.messages.highlight.map(({search, color}) => ({
            search: search.replaceAll("%player%", CommonUtilities.getCurrentPlayerName()),
            color: CommonUtilities.convertColor(color),
        }));
    }

    static _nameHighlight(message, highlights, senderName) {
        const nameHighlight = highlights.find(({search}) => senderName.toLowerCase() === search.toLowerCase());
        if (!nameHighlight) return;

        const senderElement = CommonUtilities.findByClass(message, "chat-box-body__sender___");
        senderElement.setAttribute("style", `background-color: ${nameHighlight.color} !important;`);
    }

    static _messageHighlight(message, highlights) {
        const messageElement = CommonUtilities.findByClass(message, "chat-box-body__message___");
        const messageHighlight = highlights.find(({search}) => messageElement.textContent.toLowerCase().includes(search.toLowerCase()));
        if (!messageHighlight) return

        const wrapperElement = CommonUtilities.findByClass(message, "chat-box-body__wrapper___");
        wrapperElement.setAttribute("style", `background-color: ${messageHighlight.color} !important;`);
    }
}


const settings = new BetterChatSettings();

(() => ScriptEventHandler.onLoad())();