Fishtank.live Chat Bot Silencer & Extended Mute Tools

Enables a filter to be turned on to your chat which prevents messages from appearing which contain commands for common bots (e.g. "!" and ">"), responses from said bots, specified keywords/phrases, specified usernames, or emote messages. Intercepts messages before rendering.

// ==UserScript==
// @name         Fishtank.live Chat Bot Silencer & Extended Mute Tools
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  Enables a filter to be turned on to your chat which prevents messages from appearing which contain commands for common bots (e.g. "!" and ">"), responses from said bots, specified keywords/phrases, specified usernames, or emote messages. Intercepts messages before rendering.
// @author       @c
// @match        https://fishtank.live/*
// @match        https://www.fishtank.live/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    const SCRIPT_PREFIX = 'fte_minimal_';
    const DEBUG_MODE = true; // Set to true if wanting to enable console logging for debugging.

    // --- DOM Selectors & IDs ---
    const CHAT_MESSAGES_CONTAINER_ID = 'chat-messages'; // ID of the main chat message container.
    const CHAT_MESSAGE_WRAPPER_SELECTOR = 'div[class*="chat-message-default_chat-message-default__"]'; // Selector for standard chat message wrappers.
    const EMOTE_MESSAGE_WRAPPER_SELECTOR = 'div[class*="chat-message-emote_chat-message-emote__"]'; // Selector for emote-only/system message wrappers.
    const ALL_MESSAGE_SELECTORS_QUERY = `${CHAT_MESSAGE_WRAPPER_SELECTOR}, ${EMOTE_MESSAGE_WRAPPER_SELECTOR}`; // Combined selector for all message types.
    const MESSAGE_TEXT_SELECTOR = 'span[class*="chat-message-default_message__"]'; // Selector for the text content of a message.
    const USERNAME_SELECTOR_DEFAULT = 'span[class*="chat-message-default_user__"]'; // Selector for usernames in standard messages.
    const USERNAME_SELECTOR_EMOTE = 'span[class*="chat-message-emote_user__"]'; // Selector for usernames in emote messages.
    const CHAT_HEADER_SELECTOR = 'div[class*="chat_header__"]'; // Selector for the chat header, where controls are added.

    // --- SVG Icons for UI elements ---
    const SVG_ICON_USER_FILTER_CONTROL = `<svg width="15" height="15" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="#D3D3D3"/><circle cx="12" cy="12" r="10" stroke="red" stroke-width="1.8" fill="none"/><line x1="4.93" y1="19.07" x2="19.07" y2="4.93" stroke="red" stroke-width="1.8"/></svg>`;
    const SVG_ICON_SETTINGS_GEAR = `<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd"><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0z"></path></svg>`;

    const SVG_ICON_ADD_PLUS = `<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"></path></svg>`;
    const SVG_ICON_TRASH = `<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zm2.46-7.12l1.41-1.41L12 12.59l2.12-2.12 1.41 1.41L13.41 14l2.12 2.12-1.41 1.41L12 15.41l-2.12 2.12-1.41-1.41L10.59 14l-2.13-2.12zM15.5 4l-1-1h-5l-1 1H5v2h14V4z"></path></svg>`;
    const SVG_ICON_HELP = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"/></svg>`;

    // --- Bot Specific Identifiers ---
    const GRIZZBOT_USER_ID = "3640961d-d3d2-434e-9e16-e40a9b72fb7f"; // Profile ID for Grizzbot.
    const LEXXBOT_USER_ID = "4585f683-4656-4468-8918-b9b5ce471d12"; // Profile ID for Lexxbot.
    const GRIZZBOT_USERNAME = "grizzbot"; // Username for Grizzbot.
    const LEXXBOT_USERNAME = "lexxbot"; // Username for Lexxbot.

    let settings = {}; // Holds the current user settings for the script.
    const defaultSettings = { // Default settings, used if no saved settings are found.
        masterFilterActive: true,
        commandFilterEnabled: false,
        usernameFilterEnabled: true,
        blockedUsernames: [],
        keywordFilterEnabled: true,
        blockedKeywords: [],
        muteGrizzbot: false,
        muteLexxbot: false,
        muteEmotes: false
    };

    // References to UI elements created by the script.
    let masterFilterBtn, settingsBtn, settingsPanelEl, chatObserver, controlsContainer;
    // Conditional logging function, only logs if DEBUG_MODE is true.
    const log = (...args) => DEBUG_MODE && console.log('[FTE ChatSilencer]', ...args);

    /**
     * Loads settings from browser storage (via GM_getValue).
     * Merges stored settings with defaults to ensure all settings are present.
     */
    function loadSettings() {
        const storedSettingsRaw = GM_getValue(SCRIPT_PREFIX + 'settings', null);
        let storedSettingsParsed = storedSettingsRaw ? JSON.parse(storedSettingsRaw) : {};
        // Migration: remove obsolete settings if they exist from older versions.
        delete storedSettingsParsed.userBlocklistEnabled;
        delete storedSettingsParsed.blockedUsers;
        // Merge default settings with stored settings, ensuring arrays are properly initialized.
        settings = {
            ...defaultSettings, ...storedSettingsParsed,
            blockedUsernames: Array.isArray(storedSettingsParsed.blockedUsernames) ? storedSettingsParsed.blockedUsernames : defaultSettings.blockedUsernames,
            blockedKeywords: Array.isArray(storedSettingsParsed.blockedKeywords) ? storedSettingsParsed.blockedKeywords : defaultSettings.blockedKeywords
        };
        log("Settings loaded:", JSON.parse(JSON.stringify(settings)));
    }

    /**
     * Saves the current settings to browser storage (via GM_setValue).
     */
    function saveSettings() {
        // Only save relevant settings to avoid storing unnecessary data.
        const settingsToSave = {
            masterFilterActive: settings.masterFilterActive,
            commandFilterEnabled: settings.commandFilterEnabled,
            usernameFilterEnabled: settings.usernameFilterEnabled,
            blockedUsernames: settings.blockedUsernames,
            keywordFilterEnabled: settings.keywordFilterEnabled,
            blockedKeywords: settings.blockedKeywords,
            muteGrizzbot: settings.muteGrizzbot,
            muteLexxbot: settings.muteLexxbot,
            muteEmotes: settings.muteEmotes
        };
        GM_setValue(SCRIPT_PREFIX + 'settings', JSON.stringify(settingsToSave));
        log("Settings saved.");
    }

    loadSettings(); // Load settings when the script starts.

    // Reasons for blocking a message, used for logging.
    const BLOCK_REASONS = {
        GRIZZBOT_ID: 'Muting grizzbot (User ID)',
        LEXXBOT_ID: 'Muting lexxbot (User ID)',
        GRIZZBOT_NAME: 'Muting grizzbot (Username)',
        LEXXBOT_NAME: 'Muting lexxbot (Username)',
        SYSTEM_EMOTE: 'Muting system emote message (e.g. /roll, /fall)',
        CUSTOM_EMOTE_ONLY: 'Muting message composed entirely of custom emotes',
        USERNAME_FILTER: 'Filtering username',
        COMMAND_FILTER: 'Filtering command',
        KEYWORD_FILTER: 'Filtering keyword in message'
    };

    /**
     * Determines if a message should be blocked based on current filter settings.
     * @param {object} details - An object containing message details (userId, username, text, emotes, isSystemEmote).
     * @param {object} currentSettings - The current filter settings.
     * @returns {object} An object with `blocked: true` and a `reason` if the message should be blocked, otherwise `blocked: false`.
     */
    function getBlockReason(details, currentSettings) {
        const { userId, username, text, emotes, isSystemEmote } = details;
        const usernameLower = username ? username.toLowerCase() : null; // For case-insensitive username matching.
        const textTrimmedStart = text ? text.trimStart() : null; // For checking command prefixes.
        const textLower = text ? text.toLowerCase() : null; // For case-insensitive keyword matching.

        // --- Bot Muting Filters ---
        if (userId) {
            if (userId === GRIZZBOT_USER_ID && currentSettings.muteGrizzbot) return { blocked: true, reason: BLOCK_REASONS.GRIZZBOT_ID, data: GRIZZBOT_USERNAME };
            if (userId === LEXXBOT_USER_ID && currentSettings.muteLexxbot) return { blocked: true, reason: BLOCK_REASONS.LEXXBOT_ID, data: LEXXBOT_USERNAME };
        }
        if (usernameLower) {
            if (usernameLower === GRIZZBOT_USERNAME && currentSettings.muteGrizzbot) return { blocked: true, reason: BLOCK_REASONS.GRIZZBOT_NAME, data: GRIZZBOT_USERNAME };
            if (usernameLower === LEXXBOT_USERNAME && currentSettings.muteLexxbot) return { blocked: true, reason: BLOCK_REASONS.LEXXBOT_NAME, data: LEXXBOT_USERNAME };
        }

        // --- Emote and Slash Command Muting Filter (if muteEmotes is enabled) ---
        // This section is modified to prioritize pre-renderable checks.
        if (currentSettings.muteEmotes) {
            // 1. Filter slash commands (e.g., /roll, /me, /shrug).
            //    This allows pre-filtering based on text content from WebSocket.
            if (textTrimmedStart && textTrimmedStart.startsWith('/')) {
                return { blocked: true, reason: BLOCK_REASONS.SYSTEM_EMOTE, data: `Slash command: ${text.substring(0, 20).trim()}${username ? ` by ${username}` : ''}` };
            }

            // 2. Filter messages composed entirely of custom emotes.
            //    Relies on `details.emotes` from WebSocket payload for pre-filtering.
            if (text && emotes && Object.keys(emotes).length > 0) {
                const trimmedTextContent = text.trim();
                if (trimmedTextContent) { // Ensure text is not just whitespace
                    const messageTokens = trimmedTextContent.split(/\s+/);
                    // Ensure there are tokens and all tokens are keys in the `emotes` map.
                    if (messageTokens.length > 0 && messageTokens.every(token => emotes.hasOwnProperty(token))) {
                        return { blocked: true, reason: BLOCK_REASONS.CUSTOM_EMOTE_ONLY, data: `Custom emotes: ${text.substring(0, 30).trim()}...` };
                    }
                }
            }

            // 3. Fallback for DOM-identified system emote messages (if not a slash command and not purely custom emotes).
            //    `details.isSystemEmote` is `true` only if called from `processMessageNode` (DOM filter path).
            //    For pre-filtering (WebSocket), `details.isSystemEmote` is `false`.
            if (isSystemEmote) {
                // This path is primarily for the DOM filter to catch messages that have the special emote class
                // but weren't identified by the text-based rules above.
                return { blocked: true, reason: BLOCK_REASONS.SYSTEM_EMOTE, data: `Marked emote message ${username ? `by ${username}` : '(system)'}${text ? ` (${text.substring(0,20).trim()}...)` : ''}` };
            }
        }

        // --- Other Filters ---
        // Check against blocked usernames list.
        if (currentSettings.usernameFilterEnabled && currentSettings.blockedUsernames.length > 0 && usernameLower) {
            if (currentSettings.blockedUsernames.some(bn => usernameLower.includes(bn.toLowerCase()))) return { blocked: true, reason: BLOCK_REASONS.USERNAME_FILTER, data: username };
        }
        // Check for bot command prefixes.
        if (currentSettings.commandFilterEnabled && textTrimmedStart && (textTrimmedStart.startsWith('!') || textTrimmedStart.startsWith('>'))) return { blocked: true, reason: BLOCK_REASONS.COMMAND_FILTER, data: text.substring(0, 20).trim() + '...' };
        // Check against blocked keywords list.
        if (currentSettings.keywordFilterEnabled && currentSettings.blockedKeywords.length > 0 && textLower) {
            if (currentSettings.blockedKeywords.some(kw => textLower.includes(kw.toLowerCase()))) return { blocked: true, reason: BLOCK_REASONS.KEYWORD_FILTER, data: text.substring(0,30).trim() + '...' };
        }
        return { blocked: false }; // If no rules match, don't block.
    }


    /**
     * Formats a log message for a blocked item.
     * @param {string} logContext - Context like 'PRE-FILTER' or 'DOM'.
     * @param {object} filterResult - The result from getBlockReason.
     * @returns {string} Formatted log string.
     */
    function formatBlockReasonLog(logContext, filterResult) {
        return `[${logContext}] ${filterResult.reason}${filterResult.data ? `: ${filterResult.data}` : ''}`;
    }

    // --- WebSocket Interception (Pre-filtering) ---
    const ORIGINAL_WEBSOCKET = window.WebSocket; // Store the original WebSocket constructor.
    const CHAT_WEBSOCKET_URL_PATTERN = /wss:\/\/chat\.fishtank\.live\/ws(?:\?.*)?$/; // Regex to identify the Fishtank chat WebSocket.

    /**
     * Parses raw WebSocket message data into a structured object for filtering.
     * @param {string} rawData - The raw message data from WebSocket.
     * @returns {object|null} Parsed message details or null if not a parsable/relevant message.
     */
    function parseWebSocketMessage(rawData) {
        try {
            const parsed = JSON.parse(rawData);
            // Check if it's a 'MESSAGE_CREATE' type with expected payload.
            if (parsed.type === 'MESSAGE_CREATE' && parsed.payload &&
                typeof parsed.payload.user_id !== 'undefined' &&
                typeof parsed.payload.message !== 'undefined' &&
                typeof parsed.payload.username !== 'undefined') {
                return { // Extract relevant fields.
                    userId: parsed.payload.user_id,
                    text: parsed.payload.message,
                    username: parsed.payload.username,
                    emotes: parsed.payload.emotes || {}, // Emotes mapping, if any.
                    isSystemEmote: false // Default to false for pre-filtering; DOM filter sets this based on class.
                };
            }
        } catch (e) {
            DEBUG_MODE && console.warn('[FTE ChatSilencer]', '[PRE-FILTER] Failed to parse WebSocket message or incomplete payload. Error:', e, 'Raw Data:', rawData);
        }
        return null; // Return null if parsing fails or message isn't relevant.
    }

    /**
     * Checks if a parsed WebSocket message should be pre-filtered (blocked before rendering).
     * @param {object} msgDataFromParser - Parsed message data from `parseWebSocketMessage`.
     * @returns {boolean} True if the message should be blocked, false otherwise.
     */
    function shouldPreFilterMessage(msgDataFromParser) {
        if (!msgDataFromParser || !settings.masterFilterActive) return false; // Don't filter if no data or master filter is off.
        const filterResult = getBlockReason(msgDataFromParser, settings);
        if (filterResult.blocked) {
            log(formatBlockReasonLog('PRE-FILTER', filterResult));
            return true; // Block the message.
        }
        return false; // Don't block.
    }

     // Override the global WebSocket constructor.
     window.WebSocket = function(url, protocols) {
        const socket = new ORIGINAL_WEBSOCKET(url, protocols); // Create a real WebSocket instance.
        // Check if this WebSocket is the Fishtank chat WebSocket.
        if (CHAT_WEBSOCKET_URL_PATTERN.test(url)) {
            log('[PRE-FILTER] Chat WebSocket identified, attaching proxy:', url);
            const originalAddEventListener = socket.addEventListener;
            // Intercept 'message' event listeners.
            socket.addEventListener = function(type, listener, options) {
                if (type === 'message') {
                    // Wrap the original listener to pre-filter messages.
                    const wrappedListener = event => {
                        const parsedMsg = parseWebSocketMessage(event.data);
                        if (shouldPreFilterMessage(parsedMsg)) return; // If blocked, don't call original listener.
                        if(typeof listener === 'function') listener.call(this, event); // Otherwise, proceed.
                    };
                    return originalAddEventListener.call(this, type, wrappedListener, options);
                }
                return originalAddEventListener.call(this, type, listener, options); // For other event types, pass through.
            };

            // Intercept 'onmessage' property assignments.
            let onmessageDescriptor = Object.getOwnPropertyDescriptor(ORIGINAL_WEBSOCKET.prototype, 'onmessage');
            // Fallback for some environments where prototype descriptor might not be standard for instances.
            if (!onmessageDescriptor && Object.prototype.hasOwnProperty.call(socket, 'onmessage')) {
                 onmessageDescriptor = Object.getOwnPropertyDescriptor(socket, 'onmessage');
            }

            let actualOnMessageHandler = null; // Store the actual onmessage handler.
            Object.defineProperty(socket, 'onmessage', {
                configurable: true, enumerable: true,
                get() {
                    // Return the original getter's value or our stored handler.
                    return (onmessageDescriptor && onmessageDescriptor.get) ? onmessageDescriptor.get.call(socket) : actualOnMessageHandler;
                },
                set(callback) {
                    actualOnMessageHandler = callback;
                    // Wrap the assigned callback to pre-filter messages.
                    const wrappedCallback = event => {
                        const parsedMsg = parseWebSocketMessage(event.data);
                        if (shouldPreFilterMessage(parsedMsg)) return; // If blocked, don't call.
                        if (actualOnMessageHandler && typeof actualOnMessageHandler === 'function') actualOnMessageHandler.call(socket, event); // Otherwise, proceed.
                    };
                    // Use original setter if available, otherwise directly assign our wrapped callback.
                    if (onmessageDescriptor && onmessageDescriptor.set) onmessageDescriptor.set.call(socket, wrappedCallback);
                    else actualOnMessageHandler = wrappedCallback;
                 }
            });
        }
        return socket; // Return the (potentially proxied) socket.
    };

    // Ensure the WebSocket prototype chain is correctly maintained.
    if (ORIGINAL_WEBSOCKET.prototype) {
        window.WebSocket.prototype = ORIGINAL_WEBSOCKET.prototype;
        window.WebSocket.prototype.constructor = window.WebSocket;
    } else {
        log('[PRE-FILTER] Warning: ORIGINAL_WEBSOCKET.prototype is undefined. Proxy might be incomplete.');
    }
    log('[PRE-FILTER] WebSocket proxy installed.');

    // --- CSS Styles for UI elements ---
    GM_addStyle(`
        /* Ensure the chat header establishes a positioning context */
        div[class*="chat_header__"] {
            position: relative !important;
        }

        /* Shift the existing channel selection menu to make space for our controls */
        div[class*="chat-room-selector"] {
            margin-right: 62px !important; /* Adjust this value if needed */
        }

        /* Container for script's control buttons in the chat header. */
        .fte-controls {
            display: flex;
            align-items: center; /* This will vertically center buttons if they have different heights */
            position: absolute;
            right: 10px;
            top: 50%;
            transform: translateY(-50%);
            z-index: 10;
            flex-shrink: 0;
        }

        /* General styling for script's buttons. */
        .fte-btn {
            box-sizing: border-box;
            padding: 2px; cursor: pointer; border: 1px solid #404040;
            background-color: #282930;
            border-radius: 4px;
            display: inline-flex;
            align-items: center; justify-content: center;
            transition: background-color 0.2s, border-color 0.2s, transform 0.1s;
            line-height: 0; /* ADDED: Prevent line-height from interfering with vertical alignment */
        }

        /* Styling for the settings panel button (gear icon). */
        .fte-settings-btn-app {
            box-sizing: border-box;
            background: transparent; border: none;
            /* MODIFIED: Increased vertical padding */
            padding: 4px 3px; /* Was: padding: 3px; (Now 4px top/bottom, 3px left/right) */
            margin-left: 5px;
            cursor: pointer; display: inline-flex; align-items: center;
            justify-content: center;
            color: #b0b3b8;
            transition: color 0.2s, transform 0.1s;
            line-height: 0; /* ADDED: Prevent line-height from interfering with vertical alignment */
        }

        .fte-btn:hover { background-color: #36393f; border-color: #505050; }
        .fte-btn.active {
            background-color: #701f1f;
            border-color: #501010;
        }
        .fte-btn.active:hover { background-color: #802f2f; border-color: #601818; }
        .fte-btn:active { transform: translateY(1px); }


        .fte-settings-btn-app:hover { color: #e1e3e6; }
        .fte-settings-btn-app:active { transform: translateY(1px); }

        .fte-btn svg, .fte-settings-btn-app svg {
            width: 15px;
            height: 15px;
            display: block;
        }

        /* Class to hide filtered messages. */
        .fte-hidden { display: none !important; }

        /* Main settings panel styling. */
        .fte-settings-panel {
            display: none; position: absolute;
            background-color: #1a1b1e; border: 1px solid #2d2f33;
            border-radius: 7px; padding: 9px; z-index: 2147483647 !important;
            min-width: 300px; box-shadow: 0 3px 7px rgba(0,0,0,0.15), 0 10px 20px rgba(0,0,0,0.15);
            color: #dcddde; font-family: 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
            font-size: 13px; pointer-events: auto !important;
            overflow: visible !important;
        }
        .fte-settings-panel * { pointer-events: auto !important; }

        /* Styling for sections within the settings panel. */
        .fte-settings-section {
            background-color: #232529; padding: 14px; border-radius: 5px;
            margin-bottom: 10px; box-shadow: 0 1px 2px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.08);
        }
        .fte-settings-section:last-child { margin-bottom: 0; }

        /* Header for each settings section. */
        .fte-settings-section-header {
            display: flex; justify-content: space-between; align-items: center;
            margin-bottom: 12px; padding-bottom: 5px; border-bottom: 1px solid #303236;
        }
        .fte-settings-section-header h4 {
            font-size: 1.0em; color: #e0e2e5; font-weight: 600; margin: 0;
            text-transform: uppercase; letter-spacing: 0.4px; display: inline;
        }

        /* Toggle switch styling. */
        .fte-toggle-switch-label { display: flex; align-items: center; cursor: pointer; font-size: 0.92em; color: #c8cacd; margin-bottom: 7px; }
        .fte-toggle-switch-label:last-of-type { margin-bottom: 0; }
        .fte-toggle-switch { position: relative; display: inline-block; width: 33px; height: 18px; margin-right: 10px; }
        .fte-toggle-switch input { opacity: 0; width: 0; height: 0; }
        .fte-toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #444950; transition: .3s ease; border-radius: 18px; }
        .fte-toggle-slider:before {
            position: absolute; content: ""; height: 13px; width: 13px;
            left: 2.5px; bottom: 2.5px; background-color: white;
            transition: .3s ease; border-radius: 50%;
        }
        .fte-toggle-switch input:checked + .fte-toggle-slider { background-color: #007bff; }
        .fte-toggle-switch input:focus + .fte-toggle-slider { box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.3); }
        .fte-toggle-switch input:checked + .fte-toggle-slider:before { transform: translateX(15px); }

        /* Input group for text input and add button. */
        .fte-input-group { display: flex; margin-top: 9px; }
        .fte-text-input {
            flex-grow: 1; padding: 8px 10px; background-color: #1c1d20;
            border: 1px solid #383a3f; color: #e1e3e6; border-radius: 4px;
            font-size: 0.92em; transition: border-color 0.2s, box-shadow 0.2s, background-color 0.2s;
        }
        .fte-text-input::placeholder { color: #6a6d73; }
        .fte-text-input:focus {
            border-color: #007bff; background-color: #24262a;
            box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); outline: none;
        }

        /* Action button style (e.g., "Add" button). */
        .fte-action-button {
            padding: 8px 12px; margin-left: 7px; background-color: #007bff;
            color: white; border: none; border-radius: 4px; cursor: pointer;
            font-size: 0.92em; font-weight: 500; display: inline-flex; align-items: center;
            transition: background-color 0.2s, transform 0.1s;
        }
        .fte-action-button:hover { background-color: #0069d9; }
        .fte-action-button:active { transform: translateY(1px); }
        .fte-action-button svg { margin-right: 5px; }

        /* Container for lists (blocked usernames/keywords). */
        .fte-list-container {
            margin-top: 9px; max-height: 95px; overflow-y: auto;
            background-color: #1c1d20; border-radius: 4px; padding: 5px;
            border: 1px solid #383a3f; scrollbar-width: thin;
            scrollbar-color: #4f545c #222427;
        }
        .fte-list-container::-webkit-scrollbar { width: 7px; }
        .fte-list-container::-webkit-scrollbar-thumb { background-color: #4f545c; border-radius: 3px; border: 1.5px solid #1c1d20; }
        .fte-list-container::-webkit-scrollbar-track { background-color: #1c1d20; border-radius: 3px; }

        /* List styling. */
        .fte-list { list-style: none; padding: 0; margin: 0; }
        .fte-list-item {
            display: flex; justify-content: space-between; align-items: center;
            padding: 6px 9px; margin-bottom: 3px; background-color: #2b2d31;
            border-radius: 3px; color: #b0b3b8; font-size: 0.9em;
            transition: background-color 0.15s;
        }
        .fte-list-item:last-child { margin-bottom: 0; }
        .fte-list-item:hover { background-color: #36393f; color: #dadce0; }
        .fte-list-item span { flex-grow: 1; overflow: hidden; text-overflow: ellipsis; margin-right: 7px; }

        /* Remove button for list items (trash icon). */
        .fte-list-remove-btn {
            background: transparent; border: none; color: #828589; cursor: pointer;
            padding: 2px; line-height: 1; display: inline-flex; align-items: center;
            transition: color 0.2s, transform 0.1s; border-radius: 3px;
        }
        .fte-list-remove-btn:hover { color: #f04747; background-color: rgba(240, 71, 71, 0.1); }
        .fte-list-remove-btn:active { transform: translateY(1px); }

       /* Tooltip styling. */
       .fte-tooltip-container {
            position: relative; display: inline-flex; align-items: center; justify-content: center;
            cursor: help; color: #72767d; transition: color 0.2s;
            margin-left: 7px; vertical-align: middle;
       }
      .fte-tooltip-container svg { width: 13px; height: 13px; display: block; }
       .fte-tooltip-container:hover { color: #b9bbbe; }
       .fte-tooltip-text {
            visibility: hidden; opacity: 0;
            width: 210px; background-color: #111214; color: #dcdde0;
            text-align: left; font-size: 0.9em; font-weight: normal;
            text-transform: none; letter-spacing: normal; line-height: 1.4;
            border-radius: 4px; border: 1px solid #303236; padding: 9px 10px;
            position: absolute; z-index: 100;
            box-shadow: 0 4px 12px rgba(0,0,0,0.4);
            transition: opacity 0.2s ease-in-out, visibility 0.2s;
            bottom: calc(100% + 9px);
            left: 50%; transform: translateX(-50%);
            transform-origin: bottom center;
        }
        .fte-tooltip-text::after {
           content: ""; position: absolute; top: 100%; left: 50%;
           transform: translateX(-50%); border-width: 5px; border-style: solid;
           border-color: #111214 transparent transparent transparent;
         }
        .fte-tooltip-container:hover .fte-tooltip-text { visibility: visible; opacity: 1; }
    `);
    /**
     * Escapes HTML special characters in a string to prevent XSS.
     * @param {string} str - The string to escape.
     * @returns {string} The escaped string.
     */
    function escapeHTML(str) {
       if (typeof str !== 'string') return ''; // Handle non-string inputs.
        const div = document.createElement('div');
        div.appendChild(document.createTextNode(str)); // Use text node to handle escaping.
        return div.innerHTML;
    }
    /**
     * Modifies the display of the online user count in the chat header
     * to remove the word "Online". This version carefully targets only
     * the text node containing " Online" to preserve internal HTML structure.
     * @param {HTMLElement} chatHeader - The chat header element, used as a root for searching and observing.
     */
    function modifyOnlineCountDisplay(chatHeader) {
        if (!chatHeader) {
            log('[OnlineCountMod] Chat header not provided.');
            return;
        }

        const presenceSelector = '.chat_presence__90XuO'; // Specific selector for the online count element
        const onlineTextSuffixPattern = /\s+Online$/i;  // Matches " Online" at the end of a string

        const updatePresenceText = () => {
            const presenceEl = chatHeader.querySelector(presenceSelector);
            if (presenceEl) {
                let textModifiedInLoop = false;
                // Iterate over child nodes of the presence element
                for (const child of presenceEl.childNodes) {
                    // We are looking for a TEXT_NODE whose value ends with " Online"
                    if (child.nodeType === Node.TEXT_NODE && onlineTextSuffixPattern.test(child.nodeValue)) {
                        // Modify only this specific text node's value
                        child.nodeValue = child.nodeValue.replace(onlineTextSuffixPattern, '');
                        log('[OnlineCountMod] Modified text node. New value:', `"${child.nodeValue.trim()}"`);
                        textModifiedInLoop = true;
                        // Assuming " Online" is unique or we act on the first relevant text node.
                        // This should be the case for "NUMBER Online" or "[NUMBER] Online" formats.
                        break;
                    }
                }
                // DEBUG: Log if the presence element was found but no suitable text node was modified.
                // if (!textModifiedInLoop) {
                //    log('[OnlineCountMod] Presence element found, but no child text node ending with " Online" was modified. Content:', `"${presenceEl.innerHTML}"`);
                // }
            } else {
                // log('[OnlineCountMod] Presence element not found with selector:', presenceSelector);
            }
        };

        // Initial attempt to update the text
        log('[OnlineCountMod] Initial attempt to modify "Online" text by targeting specific text node.');
        updatePresenceText();

        // Set up a MutationObserver to watch the chatHeader for changes.
        const headerObserver = new MutationObserver((mutationsList) => {
            // A simple check: if any character data or child list change happened within chatHeader,
            // it's possible the presence text was updated by the site.
            const potentiallyRelevantMutation = mutationsList.some(mutation =>
                mutation.type === 'characterData' || mutation.type === 'childList'
            );

            if (potentiallyRelevantMutation) {
                // log('[OnlineCountMod] Detected potential changes in chatHeader, re-evaluating presence text.');
                updatePresenceText(); // Re-apply the modification logic
            }
        });

        // Start observing the chatHeader.
        headerObserver.observe(chatHeader, {
            childList: true,
            characterData: true,
            subtree: true
        });
        log('[OnlineCountMod] Observer attached to chatHeader to monitor for online count text changes.');
    }

    // --- DOM Filtering ---

    /**
     * Processes a chat message DOM node to determine if it should be hidden.
     * This is a fallback/secondary filter for messages already in the DOM.
     * @param {HTMLElement} node - The chat message element.
     */
    function processMessageNode(node) {
        // Basic validation of the node.
        if (!node || !(typeof node.matches === 'function' || typeof node.querySelector === 'function')) {
            return;
        }
        let messageDetails = {}; // To store extracted message info.
        let shouldHide = false; // Flag to determine if the message should be hidden.

        if (settings.masterFilterActive) { // Only process if master filter is on.
            // Extract details based on message type (emote/system or standard).
            if (node.matches(EMOTE_MESSAGE_WRAPPER_SELECTOR)) {
                const userNameEl = node.querySelector(USERNAME_SELECTOR_EMOTE);
                const domUsername = userNameEl ? userNameEl.textContent.trim() : '';
                // Attempt to get text content if available, even from emote messages
                const textContentEl = node.querySelector('span[class*="message"]'); // A more generic selector for message content
                const text = textContentEl ? textContentEl.textContent.trim() : '';
                messageDetails = { username: domUsername, text: text, emotes: {}, isSystemEmote: true }; // Mark as system emote.
            } else if (node.matches(CHAT_MESSAGE_WRAPPER_SELECTOR)) {
                const userId = node.dataset.userId; // User ID from data attribute.
                const userNameEl = node.querySelector(USERNAME_SELECTOR_DEFAULT);
                const domUsername = userNameEl ? userNameEl.textContent.trim() : '';
                const textEl = node.querySelector(MESSAGE_TEXT_SELECTOR);
                const text = textEl ? textEl.textContent : '';
                // For DOM filtering, `emotes` object is not easily available; pre-filter handles this better.
                messageDetails = { userId: userId, username: domUsername, text: text, emotes: {}, isSystemEmote: false };
            } else {
                return; // Not a recognized message type.
            }
            // Apply filter logic.
            const filterResult = getBlockReason(messageDetails, settings);
            if (filterResult.blocked) {
                shouldHide = true;
                log(formatBlockReasonLog('DOM', filterResult)); // Log if blocked by DOM filter.
            }
        }
        // Toggle visibility based on filter result.
        node.classList.toggle('fte-hidden', shouldHide);
    }

    /**
     * Applies filters to all existing chat messages in the DOM.
     * Useful on script load or when filter settings change.
     */
    function applyAllFiltersToExistingDOM() {
        log('[DOM-FILTER] Applying filters to all existing messages in DOM.');
        // Select all message nodes and process each.
        document.querySelectorAll(`#${CHAT_MESSAGES_CONTAINER_ID} ${ALL_MESSAGE_SELECTORS_QUERY}`).forEach(processMessageNode);
    }

    // --- UI Management ---

    /**
     * Updates the appearance and title of the master filter toggle button.
     */
    function updateMasterFilterButton() {
        if (!masterFilterBtn) return; // Ensure button exists.
        masterFilterBtn.innerHTML = SVG_ICON_USER_FILTER_CONTROL; // Set icon.
        masterFilterBtn.classList.toggle('active', settings.masterFilterActive); // Style based on active state.
        masterFilterBtn.title = `Chat filters ${settings.masterFilterActive ? 'ON' : 'OFF'}. Click to toggle.`; // Tooltip.
    }

    /**
     * Creates and populates the HTML content of the settings panel.
     */
    function createSettingsPanelContent() {
        // Helper to render a toggle switch.
        const renderToggle = (key, lbl) => `<label class="fte-toggle-switch-label"><div class="fte-toggle-switch"><input type="checkbox" data-setting="${key}" ${settings[key] ? 'checked' : ''}><span class="fte-toggle-slider"></span></div>${escapeHTML(lbl)}</label>`;
        // Helper to render list items (e.g., blocked usernames).
        const renderListItems = (items, type) => items.map((item, i) => `<li class="fte-list-item"><span>${escapeHTML(item)}</span><button class="fte-list-remove-btn" data-${type}-index="${i}" title="Remove">${SVG_ICON_TRASH}</button></li>`).join('');
        // Helper to render section headers with optional tooltips.
        const renderHeader = (title, tooltip) => `
             <div class="fte-settings-section-header">
                 <h4>
                     ${escapeHTML(title)}
                     ${tooltip ? `<span class="fte-tooltip-container">${SVG_ICON_HELP}<span class="fte-tooltip-text">${escapeHTML(tooltip)}</span></span>` : ''}
                 </h4>
            </div>`;

         // Tooltip texts for different settings sections.
         const TOOLTIPS = {
            GENERAL: "Turns the plugin on or off.",
            BOT_MUTE: "Options to mute bots and emote messages (such as /roll, /lol, /shrug, or messages made entirely of custom emotes).", // Updated tooltip
            COMMAND: "Filters user messages which start with ! or > (! is used to command lexxbot, > is used to command grizzbot).",
            USERNAME: "Filters user messages based on their username (including staff) or clan tag.",
            KEYWORD: "Filters messages based on words or phrases. You can also add usernames and it will filter any message where that user is tagged.",
         };

        // Generate HTML for blocked usernames list, if not empty.
        let usernameListHTML = '';
        if (settings.blockedUsernames.length > 0) {
            usernameListHTML = `<div class="fte-list-container" id="fte-username-list-container"><ul class="fte-list">${renderListItems(settings.blockedUsernames, 'username')}</ul></div>`;
        }

        // Generate HTML for blocked keywords list, if not empty.
        let keywordListHTML = '';
        if (settings.blockedKeywords.length > 0) {
            keywordListHTML = `<div class="fte-list-container" id="fte-keyword-list-container"><ul class="fte-list">${renderListItems(settings.blockedKeywords, 'keyword')}</ul></div>`;
        }

        // Construct the full HTML for the settings panel.
        settingsPanelEl.innerHTML = `
            <div class="fte-settings-section">
                ${renderHeader('General', TOOLTIPS.GENERAL)}
                ${renderToggle('masterFilterActive', "Plugin Enabled")}
            </div>
            <div class="fte-settings-section">
                 ${renderHeader('Bot & Message Type Muting', TOOLTIPS.BOT_MUTE)}
                ${renderToggle('muteGrizzbot', `Mute ${GRIZZBOT_USERNAME}`)}
                ${renderToggle('muteLexxbot', `Mute ${LEXXBOT_USERNAME}`)}
                ${renderToggle('muteEmotes', "Mute emotes & slash commands (e.g. /roll, /lol, /shrug)")}
            </div>
            <div class="fte-settings-section">
                 ${renderHeader('Command Filter', TOOLTIPS.COMMAND)}
                 ${renderToggle('commandFilterEnabled', "Filter '!' or '>' commands")}
            </div>
            <div class="fte-settings-section">
                 ${renderHeader('Username & Clan Filter', TOOLTIPS.USERNAME)}
                 ${renderToggle('usernameFilterEnabled', "Enabled")}
                <div class="fte-input-group"><input type="text" id="fte-username-input" class="fte-text-input" placeholder="Add name or clan..."><button id="fte-add-username-btn" class="fte-action-button" title="Add">${SVG_ICON_ADD_PLUS}Add</button></div>
                ${usernameListHTML}
            </div>
             <div class="fte-settings-section">
                ${renderHeader('Keyword Filter', TOOLTIPS.KEYWORD)}
                ${renderToggle('keywordFilterEnabled', "Enabled")}
                <div class="fte-input-group"><input type="text" id="fte-keyword-input" class="fte-text-input" placeholder="Add keyword/phrase..."><button id="fte-add-keyword-btn" class="fte-action-button" title="Add">${SVG_ICON_ADD_PLUS}Add</button></div>
                ${keywordListHTML}
             </div>
        `;
    }

    /**
     * Handles 'change' events within the settings panel (e.g., toggling a switch).
     * @param {Event} event - The change event.
     */
    function handleSettingsPanelChange(event) {
        log('handleSettingsPanelChange fired. Target:', event.target);
         if (event.target.closest('.fte-tooltip-container')) return; // Ignore changes if it's part of a tooltip interaction.
        event.stopImmediatePropagation(); // Prevent event from bubbling further if handled here.
        const target = event.target;
        // Check if a setting toggle switch was changed.
        if (target.matches('input[type="checkbox"][data-setting]')) {
            const settingKey = target.dataset.setting; // Get setting key from data attribute.
            settings[settingKey] = target.checked; // Update setting value.
            log(`Setting '${settingKey}' toggled to ${target.checked}`);
            saveSettings(); // Save updated settings.
            if (settingKey === "masterFilterActive") updateMasterFilterButton(); // Update main button if master filter changed.
            applyAllFiltersToExistingDOM(); // Re-apply filters to chat.
        }
    }

    /**
     * Handles 'click' events within the settings panel (e.g., add/remove buttons).
     * @param {Event} event - The click event.
     */
    function handleSettingsPanelClick(event) {
        log('handleSettingsPanelClick fired. Target:', event.target);
         // Ignore clicks if it's part of a tooltip interaction.
         if (event.target.closest('.fte-tooltip-container')) {
              event.stopImmediatePropagation();
             return;
         }
        event.stopImmediatePropagation(); // Prevent event from bubbling further.
        const target = event.target.closest('button'); // Find the closest button ancestor.
        if (!target) { // If click wasn't on a button we care about.
             log('Clicked inside panel, but not on a button we handle.');
             return;
        }
        log('Button clicked inside panel:', target.id || `data-${Object.keys(target.dataset)[0]}`);

        // Helper function to process add/remove actions for lists (usernames, keywords).
        const processList = (listKey, inputId, type, action) => {
            if (action === 'add') {
                const inputEl = settingsPanelEl.querySelector(`#${inputId}`);
                const newItem = inputEl.value.trim();
                // Add item if it's not empty and not already in the list (case-insensitive).
                if (newItem && !settings[listKey].some(item => item.toLowerCase() === newItem.toLowerCase())) {
                    settings[listKey].push(newItem);
                    settings[listKey].sort((a,b) => a.toLowerCase().localeCompare(b.toLowerCase())); // Keep sorted.
                    log(`${type} added: "${newItem}"`);
                    inputEl.value = ''; inputEl.focus(); // Clear input and refocus.
                } else if (newItem) { // Item already exists or is invalid.
                    log(`${type} "${newItem}" already exists or is invalid.`);
                    inputEl.value = ''; inputEl.focus();
                } else { // Input was empty.
                    inputEl.focus();
                }
            } else if (action === 'remove') {
                // Remove item by index.
                const itemIndex = parseInt(target.dataset[`${type.toLowerCase()}Index`], 10);
                if (!isNaN(itemIndex) && itemIndex >= 0 && itemIndex < settings[listKey].length) {
                    const removed = settings[listKey].splice(itemIndex, 1)[0];
                    log(`${type} removed: "${removed}"`);
                }
            }
            saveSettings(); // Save changes.
            createSettingsPanelContent(); // Re-render panel to reflect list changes.
            applyAllFiltersToExistingDOM(); // Re-apply filters.
        };

        // Determine which button was clicked and call processList accordingly.
        if (target.id === 'fte-add-username-btn') processList('blockedUsernames', 'fte-username-input', 'Username', 'add');
        else if (target.id === 'fte-add-keyword-btn') processList('blockedKeywords', 'fte-keyword-input', 'Keyword', 'add');
        else if (target.dataset.usernameIndex !== undefined) processList('blockedUsernames', '', 'Username', 'remove');
        else if (target.dataset.keywordIndex !== undefined) processList('blockedKeywords', '', 'Keyword', 'remove');
    }

    /**
     * Creates and adds the script's UI controls (filter button, settings button, panel) to the chat header.
     * @param {HTMLElement} chatHeader - The chat header element.
     * @returns {boolean} True if controls were added, false otherwise.
     */
    function addControls(chatHeader) {
        if (chatHeader.querySelector('.fte-controls')) return true; // Controls already added.
        controlsContainer = document.createElement('div');
        controlsContainer.className = 'fte-controls';

        // Create master filter toggle button.
        masterFilterBtn = document.createElement('button');
        masterFilterBtn.className = 'fte-btn';
        masterFilterBtn.addEventListener('click', () => {
            settings.masterFilterActive = !settings.masterFilterActive; // Toggle setting.
            saveSettings();
            updateMasterFilterButton(); // Update button appearance.
            applyAllFiltersToExistingDOM(); // Re-apply filters.
        });
        controlsContainer.appendChild(masterFilterBtn);
        updateMasterFilterButton(); // Initial button state.

        // Create settings panel button.
        settingsBtn = document.createElement('button');
        settingsBtn.className = 'fte-settings-btn-app';
        settingsBtn.innerHTML = SVG_ICON_SETTINGS_GEAR;
        settingsBtn.title = 'Chat Enhancer Settings';
        settingsBtn.addEventListener('click', (e) => { // Use capturing phase for reliable panel opening/closing.
            log('Settings button clicked.');
            e.stopImmediatePropagation(); // Prevent other listeners from interfering.
            const isVisible = settingsPanelEl.style.display === 'block';

            if (!isVisible) {
                createSettingsPanelContent(); // Build panel content.
                // Temporarily show panel off-screen to calculate dimensions for positioning.
                settingsPanelEl.style.visibility = 'hidden';
                settingsPanelEl.style.display = 'block';
                 setTimeout(() => { // Defer positioning to allow DOM to update.
                    const panelWidth = settingsPanelEl.offsetWidth;
                    const panelHeight = settingsPanelEl.offsetHeight;
                    settingsPanelEl.style.display = 'none'; // Hide again before final positioning.
                    settingsPanelEl.style.visibility = 'visible';

                    // Calculate optimal panel position relative to controls, within viewport.
                    const controlsRect = controlsContainer.getBoundingClientRect();
                    let panelTop = controlsRect.bottom + window.scrollY + 6;
                    let panelLeft = controlsRect.right + window.scrollX - panelWidth; // Align right edge of panel with right edge of controls.
                    const viewportWidth = document.documentElement.clientWidth;
                    const viewportHeight = document.documentElement.clientHeight;
                    const margin = 12; // Margin from viewport edges.

                    // Adjust if panel overflows viewport.
                    if (panelLeft + panelWidth > viewportWidth + window.scrollX - margin) panelLeft = viewportWidth + window.scrollX - panelWidth - margin;
                    if (panelLeft < window.scrollX + margin) panelLeft = window.scrollX + margin;
                    if (panelTop + panelHeight > viewportHeight + window.scrollY - margin) { // If overflows bottom
                        // Try to position above controls if space allows.
                        if (controlsRect.top + window.scrollY - panelHeight - 6 > window.scrollY + margin) panelTop = controlsRect.top + window.scrollY - panelHeight - 6;
                        else panelTop = Math.max(margin, viewportHeight + window.scrollY - panelHeight - margin); // Else, fit at bottom with margin.
                    }
                    if (panelTop < window.scrollY + margin) panelTop = window.scrollY + margin; // Ensure not off-screen top.

                    settingsPanelEl.style.top = panelTop + 'px';
                    settingsPanelEl.style.left = panelLeft + 'px';
                    settingsPanelEl.style.display = 'block'; // Show panel.
                    // Focus first input field in the panel for better UX.
                    const firstInput = settingsPanelEl.querySelector('#fte-username-input') || settingsPanelEl.querySelector('#fte-keyword-input');
                    if(firstInput) firstInput.focus();
                }, 0);
            } else {
                settingsPanelEl.style.display = 'none'; // Hide panel if already visible.
            }
            log(`Settings panel display: ${settingsPanelEl.style.display}`);
        }, true); // Use capture phase for this listener.
        controlsContainer.appendChild(settingsBtn);

        // Create the settings panel element itself (initially hidden).
        settingsPanelEl = document.createElement('div');
        settingsPanelEl.className = 'fte-settings-panel';
        // Add event listeners for changes and clicks within the panel (using capture phase for reliability).
        settingsPanelEl.addEventListener('change', handleSettingsPanelChange, true);
        settingsPanelEl.addEventListener('click', handleSettingsPanelClick, true);
        document.body.appendChild(settingsPanelEl); // Append panel to body for global positioning.

        // Insert controls into the chat header.
        const roomSelector = chatHeader.querySelector('div[class*="chat-room-selector"]');
        if (roomSelector) roomSelector.insertAdjacentElement('beforebegin', controlsContainer); // Insert before room selector if present.
        else chatHeader.appendChild(controlsContainer); // Otherwise, append to header.
        log('Controls added.');

        // Add global click listener to close settings panel when clicking outside of it.
        document.addEventListener('click', (e) => {
            if (settingsPanelEl && settingsPanelEl.style.display === 'block' && // If panel is visible
                e.target !== settingsBtn && !settingsBtn.contains(e.target) && // And click is not on settings button
                !settingsPanelEl.contains(e.target)) { // And click is not inside the panel
                 if (e.target.closest('.fte-tooltip-container')) return; // Don't close if clicking a tooltip.
                log('Clicked outside settings panel and button, closing.');
                settingsPanelEl.style.display = 'none';
            }
        }, true); // Use capture phase.
        return true;
    }

    /**
     * Sets up a MutationObserver to watch for new chat messages added to the DOM
     * and apply filters to them.
     * @returns {boolean} True if observer was set up, false otherwise.
     */
    function observeChatForDOMChanges() {
        const chatContainer = document.getElementById(CHAT_MESSAGES_CONTAINER_ID);
        if (!chatContainer) { log('[DOM-FILTER] Chat container not found.'); return false; }

        applyAllFiltersToExistingDOM(); // Filter any messages already present.

        if (chatObserver) chatObserver.disconnect(); // Disconnect previous observer if any.
        chatObserver = new MutationObserver(mutations => {
            if (!settings.masterFilterActive) return; // Only observe if master filter is on.
            for (const mutation of mutations) {
                 if (mutation.type === 'childList') { // If new child nodes were added.
                    for (const node of mutation.addedNodes) {
                        if (node.nodeType === Node.ELEMENT_NODE) { // Ensure it's an element.
                            // Check if the added node itself is a message, or contains messages.
                            if (node.matches(ALL_MESSAGE_SELECTORS_QUERY)) processMessageNode(node);
                            else node.querySelectorAll(ALL_MESSAGE_SELECTORS_QUERY).forEach(processMessageNode);
                        }
                    }
                 }
            }
        });
        // Start observing the chat container for child additions and subtree changes.
        chatObserver.observe(chatContainer, { childList: true, subtree: true });
        log('[DOM-FILTER] MutationObserver attached.');
        return true;
    }

    /**
     * Initializes UI-dependent parts of the script, such as adding controls and setting up the DOM observer.
     * Retries if necessary DOM elements are not yet available.
     */
    function initUIDependantParts() {
        log('Initializing UI and DOM observer...');
        const chatHeader = document.querySelector(CHAT_HEADER_SELECTOR);
        const chatMessages = document.getElementById(CHAT_MESSAGES_CONTAINER_ID);

        // Check if essential Fishtank elements are present.
        if (chatHeader && chatMessages) {
            // Call the new function to modify the "ONLINE" text display
            modifyOnlineCountDisplay(chatHeader);
            // Try to add controls and set up observer.
            if (addControls(chatHeader) && observeChatForDOMChanges()) {
                log('UI & DOM observer init successful.');
            } else { // If failed, retry after a delay.
                log('Failed to fully init UI/DOM observer parts, retrying.');
                setTimeout(initUIDependantParts, 2000);
            }
        } else { // If core elements not found, retry after a delay.
            log('Core Fishtank elements not found. Retrying...');
            setTimeout(initUIDependantParts, 1500);
        }
    }

    // Start initialization based on document ready state.
    // `@run-at document-start` means this script runs very early.
    if (document.readyState === 'loading') {
        // If DOM is still loading, wait for DOMContentLoaded.
        document.addEventListener('DOMContentLoaded', initUIDependantParts);
    } else {
        // If DOM is already interactive or complete, initialize immediately.
        initUIDependantParts();
    }

})();