Greasy Fork is available in English.

YouTube - Livechat Emoji Fixes

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