Improves YouTube Livechat emoji menu performance by hiding non-membership/YouTuber-specific emoji categories; also hides annoying first-time-chat tooltip
// ==UserScript== // @name YouTube - Livechat Emoji Fixes // @namespace https://gist.github.com/lbmaian/e2a60a4aa2c534c1575547a60711613a // @version 0.4 // @description Improves YouTube Livechat emoji menu performance by hiding non-membership/YouTuber-specific emoji categories; also hides annoying first-time-chat tooltip // @author lbmaian // @match https://www.youtube.com/live_chat* // @icon https://www.youtube.com/favicon.ico // @run-at document-end // @grant none // ==/UserScript== (function() { 'use strict'; const logContext = '[YouTube - Livechat Emoji Fixes]'; const console = { ...window.console, debug(...args) { //window.console.debug(logContext, ...args); // uncomment to disable debugging }, log(...args) { window.console.log(logContext, ...args); }, warn(...args) { window.console.warn(logContext, ...args); }, error(...args) { window.console.error(logContext, ...args); }, }; function waitForLiveChatMessageInput(callback, ...args) { const eltMessageInput = document.getElementById('live-chat-message-input'); if (eltMessageInput) { callback(eltMessageInput, ...args); } else { new MutationObserver((records, observer) => { const eltMessageInput = document.getElementById('live-chat-message-input'); if (eltMessageInput) { observer.disconnect(); callback(eltMessageInput, ...args); } }).observe(document.body, { childList: true, subtree: true, }); } } function watchEmojiPickers(eltMessageInput) { console.debug('#live-chat-message-input', eltMessageInput); // Hack to remove the 'When you send a message, people will be able to see that you subscribe to this channel.' one-time tooltip whenever it pops up const eltApp = eltMessageInput.closest('yt-live-chat-app'); console.debug('yt-live-chat-app', eltApp); new MutationObserver((records, observer) => { for (const record of records) { for (const node of record.addedNodes) { if (node.nodeType === 1 && node.tagName.toLowerCase() === 'tp-yt-iron-dropdown') { console.debug('found tp-yt-iron-dropdown', node); // Note: the 'When you send a message, people will be able to see that you subscribe to this channel.' hasn't been set yet, // so we can't filter for that, so just filter out any tooltip (afaik, this is the only such tooltip anyway). const eltTooltipRenderer = node.firstElementChild?.firstElementChild; if (eltTooltipRenderer && eltTooltipRenderer.tagName.toLowerCase() === 'yt-tooltip-renderer') { //observer.disconnect(); // not disconnecting in case more tooltips pop up console.log('removing tooltip', eltTooltipRenderer); node.remove(); } } } } }).observe(eltApp, { childList: true, }); // yt-live-chat-app > div#contents > yt-live-chat-renderer > iron-pages#content-pages > div#chat-messages > div#contents (note: non-unique id) // div#ticker // div#chat // iframe#chatframe // ytd-message-renderer.ytd-live-chat-frame // iron-pages#panel-pages // div#input-panel (message input) // yt-live-chat-message-input-renderer#live-chat-message-input>div#container (always exists?) // div#top > div#input-container > yt-live-chat-text-input-field-renderer#input // div#input (text input; note: non-unique id) // tp-yt-iron-dropdown#dropdown (emoji dropdown when manually typing :...) // iron-pages#pickers>yt-emoji-picker-renderer#emoji (emoji picker) // div#search-panel // div#category-buttons (emoji picker category buttons) // div#categories-wrapper>div#categories (emoji picker categories) // yt-emoji-picker-category-renderer (emoji picker category) // div#buttons // div#picker-buttons>yt-live-chat-icon-toggle-button-renderer#emoji (emoji picker toggle) // div#buy-flow (superchat buying) // yt-live-chat-message-buy-flow-renderer (only exists when buying superchats or milestone chats) // iron-pages>div#preview>div#message>div#pickers-container // iron-pages#pickers>yt-emoji-picker-renderer#emoji (emoji picker - same as above) // div#picker-buttons>yt-live-chat-icon-toggle-button-renderer#emoji (emoji picker toggle - same as above) watchEmojiPicker(eltMessageInput, true); // Superchat emoji picker only exists when div#buy-flow is non-empty (its empty whenever not buying superchats or milestone chats), // so need to watch for when it's added. const eltBuyflow = document.getElementById('buy-flow'); console.debug('#buy-flow', eltBuyflow); new MutationObserver((records, observer) => { for (const record of records) { for (const node of record.addedNodes) { if (node.nodeType === 1 && node.tagName.toLowerCase() === 'yt-live-chat-message-buy-flow-renderer') { const eltBuyflowRenderer = node; console.debug('yt-live-chat-message-buy-flow-renderer', eltBuyflowRenderer); watchEmojiPicker(eltBuyflowRenderer, false); return; } } } }).observe(eltBuyflow, { childList: true, }); } function watchEmojiPicker(eltContainer, watchForCategoriesRemoval) { // "categories" id isn't necessarily unique, so not using document.getElementById. const eltCategories = eltContainer.querySelector('#categories'); // If chat is hidden, emoji categories won't be found. if (!eltCategories) { console.log('#categories not found - assuming chat is hidden'); return; } console.log('watching #categories', eltCategories, 'in container', eltContainer); // Keep only only members-only (class CATEGORY_TYPE_CUSTOM) and YouTube-specific (class CATEGORY_TYPE_GLOBAL) emojis. let emojiClassesExist = false; new MutationObserver((records, observer) => { for (const record of records) { for (const node of record.addedNodes) { if (node.nodeType === 1) { // element for (const child of node.children) { if (child.id === 'emoji') { if (child.classList.contains('CATEGORY_TYPE_CUSTOM') || child.classList.contains('CATEGORY_TYPE_GLOBAL')) { emojiClassesExist = true; } else { console.log('removing category', node); eltCategories.removeChild(node); break; } // Legacy code in case the new classes don't exist: remove emoji categories that contain SVGs. // This no longer works since emojis should now all be png natively, but code kept just in case. if (!emojiClassesExist) { const eltEmoji = child.firstElementChild; if (eltEmoji && eltEmoji.src && eltEmoji.src.endsWith('svg')) { console.log('removing category', node); eltCategories.removeChild(node); break; } } } } } } } }).observe(eltCategories, { childList: true, }); if (watchForCategoriesRemoval) { // When user joins membership, #categories is removed and refreshed, so need to rewatch emoji pickers. // Specifically, the #live-chat-message-input container gets replaced within its parent #input-panel. console.debug('watching for #categories removal up to', eltContainer.parentElement); watchForElementRemoval(eltCategories, () => { console.log('#categories', eltCategories, 'was removed - assuming it was refreshed'); // Should already be replaced, but if it's somehow not, will wait for it. waitForLiveChatMessageInput(watchEmojiPicker, watchForCategoriesRemoval) }, eltContainer.parentElement); } // Hide the category picker since there's only going to be 1 or 2 emoji categories. Also has non-unique id. const eltCategoryButtons = eltContainer.querySelector('#category-buttons'); if (eltCategoryButtons) { console.log('removed #category-buttons', eltCategoryButtons); eltCategoryButtons.remove(); } else { console.log('#category-buttons not found - ignoring'); } } // Unfortunately there's no direct way to watch for a target element being removed. // The most performant way I've found so far is to recursively observe child removals for all the ancestors of the target up to root // (as opposed to observing the whole subtree of the root for removals, which is much more expensive). // When the target element is removed, given callback is called with (target, the ancestor that removed the subtree containing target). // If the root already does not contain the target, logs an error and throws. function watchForElementRemoval(target, callback, root) { if (!root) { root = target.ownerDocument; } if (!root.contains(target)) { console.error('root', root, 'does not contain target', target); throw new Error('root does not contain target'); } if (root.nodeType === 9) { // document root = root.documentElement; } const observer = new MutationObserver((records, observer) => { // If root is document element, probably faster to check for target.isConnected (assuming that element hasn't been re-added) // but following allows determining what exactly removed the element for (const record of records) { let found = false; for (const node of record.removedNodes) { if (node.contains(target)) { console.debug('element', target, 'was removed via ancestor', record.target); if (!found) { found = true; observer.disconnect(); console.debug('all mutation records:', records); } callback(target, record.target); } } } }); const options = { childList: true, }; let element = target.parentNode; // don't observe the target (or rather, its children) itself let end = root.parentNode; // ensure root is observed in following loop while (element !== end) { observer.observe(element, options); element = element.parentNode; } } // Workaround for any extensions where iframes that were document.write'd having its location inherit from the calling code's frame // (e.g. if document.write called from either a script in parent frame or extension content script matching parent frame, // then the iframe's location would be the same as parent frame's location). const url = frameElement && frameElement.contentDocument?.URL === parent.document.URL ? frameElement.src || 'about:blank' : document.URL; console.debug('url:', url); if (url.startsWith('https://www.youtube.com/live_chat')) { waitForLiveChatMessageInput(watchEmojiPickers); } })();