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