TORN: Better Chat

Improvements to the usability of chats 2.0.

目前为 2023-11-02 提交的版本。查看 最新版本

// ==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())();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址