YouTube Chat Filter

Filters messages in YouTube stream chat.

La data de 14-08-2022. Vezi ultima versiune.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        YouTube Chat Filter
// @version     1.2
// @description Filters messages in YouTube stream chat.
// @author      Callum Latham
// @namespace   https://greasyfork.org/users/696211-ctl2
// @license     MIT
// @match       *://www.youtube.com/*
// @match       *://youtube.com/*
// @require     https://greasyfork.org/scripts/446506-config/code/$Config.js?version=1081062
// @require     https://greasyfork.org/scripts/449472-boolean/code/$Boolean.js?version=1081058
// @grant       GM.setValue
// @grant       GM.getValue
// @grant       GM.deleteValue
// ==/UserScript==

// Don't run outside the chat frame
if (!window.frameElement || window.frameElement.id !== 'chatframe') {
    // noinspection JSAnnotator
    return;
}

window.addEventListener('load', async () => {
    // STATIC CONSTS

    const LONG_PRESS_TIME = 400;
    const ACTIVE_COLOUR = 'var(--yt-spec-call-to-action)';
    const CHAT_LIST_SELECTOR = '#items.yt-live-chat-item-list-renderer';
    const FILTER_CLASS = 'cf';
    const TAGS_FILTERABLE = [
        'YT-LIVE-CHAT-TEXT-MESSAGE-RENDERER',
        'YT-LIVE-CHAT-PAID-MESSAGE-RENDERER',
        'YT-LIVE-CHAT-MEMBERSHIP-ITEM-RENDERER',
        'YTD-SPONSORSHIPS-LIVE-CHAT-GIFT-PURCHASE-ANNOUNCEMENT-RENDERER',
        'YTD-SPONSORSHIPS-LIVE-CHAT-GIFT-REDEMPTION-ANNOUNCEMENT-RENDERER'
    ];
    const PRIORITIES = {
        'VERIFIED': 'Verification Badge',
        'MODERATOR': 'Moderator Badge',
        'MEMBER': 'Membership Badge',
        'LONG': 'Long',
        'RECENT': 'Recent',
        'SUPERCHAT': 'Superchat',
        'MEMBERSHIP_RENEWAL': 'Membership Purchase',
        'MEMBERSHIP_GIFT_OUT': 'Membership Gift (Given)',
        'MEMBERSHIP_GIFT_IN': 'Membership Gift (Received)',
        'EMOJI': 'Emojis'
    };

    // ELEMENT CONSTS

    const STREAMER = window.parent.document.querySelector('#channel-name').innerText;
    const ROOT_ELEMENT = document.body.querySelector('#chat');
    const [BUTTON, SVG, COUNTER] = await (async () => {
        const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';

        const [button, svgContainer, svg] = await new Promise((resolve) => {
            const template = document.body.querySelector('#overflow');
            const button = template.cloneNode(true);
            const svgContainer = button.querySelector('yt-icon');

            button.style.visibility = 'hidden';

            template.parentElement.insertBefore(button, template);

            window.setTimeout(() => {
                const path = document.createElementNS(SVG_NAMESPACE, 'path');

                path.setAttribute('d', 'M128.25,175.6c1.7,1.8,2.7,4.1,2.7,6.6v139.7l60-51.3v-88.4c0-2.5,1-4.8,2.7-6.6L295.15,65H26.75L128.25,175.6z');

                const rectangle = document.createElementNS(SVG_NAMESPACE, 'rect');

                rectangle.setAttribute('x', '13.95');
                rectangle.setAttribute('y', '0');
                rectangle.setAttribute('width', '294');
                rectangle.setAttribute('height', '45');

                const svg = document.createElementNS(SVG_NAMESPACE, 'svg');

                svg.setAttribute('viewBox', '-50 -50 400 400');
                svg.setAttribute('x', '0');
                svg.setAttribute('y', '0');
                svg.setAttribute('focusable', 'false');

                svg.append(path, rectangle);

                svgContainer.innerHTML = '';
                svgContainer.append(svg);

                button.style.removeProperty('visibility');

                resolve([button, svgContainer, svg]);
            }, 0);
        });

        const counter = (() => {
            const container = document.createElement('div');

            container.style.position = 'absolute';
            container.style.left = '9px';
            container.style.bottom = '9px';
            container.style.fontSize = '1.1em';
            container.style.lineHeight = 'normal';
            container.style.width = '1.6em';
            container.style.display = 'flex';
            container.style.alignItems = 'center';

            const svg = (() => {
                const circle = document.createElementNS(SVG_NAMESPACE, 'circle');

                circle.setAttribute('r', '50');
                circle.style.color = 'var(--yt-live-chat-header-background-color)';
                circle.style.opacity = '0.65';

                const svg = document.createElementNS(SVG_NAMESPACE, 'svg');

                svg.setAttribute('viewBox', '-70 -70 140 140');

                svg.append(circle);

                return svg;
            })();

            const text = document.createElement('span');

            text.style.position = 'absolute';
            text.style.width = '100%';
            text.innerText = '?';

            container.append(text, svg);

            svgContainer.append(container);

            return text;
        })();

        return [button, svg, counter];
    })();

    // STATE INTERFACES

    const $active = new $Boolean('YTCF_IS_ACTIVE');

    const $config = new $Config(
        'YTCF_TREE',
        (() => {
            const regexPredicate = (value) => {
                try {
                    RegExp(value);
                } catch (_) {
                    return 'Value must be a valid regular expression.';
                }

                return true;
            };

            return {
                'children': [
                    {
                        'label': 'Filters',
                        'children': [],
                        'seed': {
                            'label': 'Description',
                            'value': '',
                            'children': [
                                {
                                    'label': 'Streamer Regex',
                                    'children': [],
                                    'seed': {
                                        'value': '^',
                                        'predicate': regexPredicate
                                    }
                                },
                                {
                                    'label': 'Author Regex',
                                    'children': [],
                                    'seed': {
                                        'value': '^',
                                        'predicate': regexPredicate
                                    }
                                },
                                {
                                    'label': 'Message Regex',
                                    'children': [],
                                    'seed': {
                                        'value': '^',
                                        'predicate': regexPredicate
                                    }
                                }
                            ]
                        }
                    },
                    {
                        'label': 'Options',
                        'children': [
                            {
                                'label': 'Case-Sensitive Regex?',
                                'value': false
                            },
                            {
                                'label': 'Pause on Mouse Over?',
                                'value': false
                            },
                            {
                                'label': 'Queue Time (ms)',
                                'value': 0,
                                'predicate': (value) => value >= 0 ? true : 'Queue time must be positive'
                            }
                        ]
                    },
                    {
                        'label': 'Preferences',
                        'children': [
                            {
                                'label': 'Requirements',
                                'children': [
                                    {
                                        'label': 'OR',
                                        'children': [],
                                        'poolId': 0
                                    },
                                    {
                                        'label': 'AND',
                                        'children': [],
                                        'poolId': 0
                                    }
                                ]
                            },
                            {
                                'label': 'Priorities (High to Low)',
                                'poolId': 0,
                                'children': Object.values(PRIORITIES).map(label => ({
                                    label,
                                    'value': label !== PRIORITIES.EMOJI && label !== PRIORITIES.MEMBERSHIP_GIFT_IN
                                }))
                            }
                        ]
                    }
                ]
            };
        })(),
        (() => {
            const EVALUATORS = (() => {
                const getEvaluator = (evaluator, isDesired) => isDesired ? evaluator : (_) => 1 - evaluator(_);

                return {
                    // Special tests
                    [PRIORITIES.RECENT]: getEvaluator.bind(null, () => 1),
                    [PRIORITIES.LONG]: getEvaluator.bind(null, _ => _.querySelector('#message').textContent.length),
                    // Tests for message type
                    [PRIORITIES.SUPERCHAT]: getEvaluator.bind(null, _ => _.matches('yt-live-chat-paid-message-renderer')),
                    [PRIORITIES.MEMBERSHIP_RENEWAL]: getEvaluator.bind(null, _ => _.matches('yt-live-chat-membership-item-renderer')),
                    [PRIORITIES.MEMBERSHIP_GIFT_OUT]: getEvaluator.bind(null, _ => _.matches('ytd-sponsorships-live-chat-gift-purchase-announcement-renderer')),
                    [PRIORITIES.MEMBERSHIP_GIFT_IN]: getEvaluator.bind(null, _ => _.matches('ytd-sponsorships-live-chat-gift-redemption-announcement-renderer')),
                    // Tests for descendant element presence
                    [PRIORITIES.EMOJI]: getEvaluator.bind(null, _ => Boolean(_.querySelector('.emoji'))),
                    [PRIORITIES.MEMBER]: getEvaluator.bind(null, _ => Boolean(_.querySelector('#chat-badges > [type=member]'))),
                    [PRIORITIES.MODERATOR]: getEvaluator.bind(null, _ => Boolean(_.querySelector('#chip-badges > [type=verified]'))),
                    [PRIORITIES.VERIFIED]: getEvaluator.bind(null, _ => Boolean(_.querySelector('#chat-badges > [type=moderator]')))
                };
            })();

            return ([rawFilters, options, {'children': [{'children': [softRequirements, hardRequirements]}, priorities]}]) => ({
                'filters': (() => {
                    const filters = [];

                    const getRegex = options.children[0].value ?
                        ({value}) => new RegExp(value) :
                        ({value}) => new RegExp(value, 'i');
                    const matchesStreamer = (node) => getRegex(node).test(STREAMER);

                    for (const filter of rawFilters.children) {
                        const [{'children': streamers}, {'children': authors}, {'children': messages}] = filter.children;

                        if (streamers.length === 0 || streamers.some(matchesStreamer)) {
                            filters.push({
                                'authors': authors.map(getRegex),
                                'messages': messages.map(getRegex)
                            });
                        }
                    }

                    return filters;
                })(),
                'pauseOnHover': options.children[1].value,
                'queueTime': options.children[2].value,
                'requirements': {
                    'soft': softRequirements.children.map(({
                        label, 'value': isDesired
                    }) => EVALUATORS[label](isDesired)),
                    'hard': hardRequirements.children.map(({
                        label, 'value': isDesired
                    }) => EVALUATORS[label](isDesired))
                },
                'comparitors': (() => {
                    const getComparitor = (getValue, low, high) => {
                        low = getValue(low);
                        high = getValue(high);

                        return low < high ? -1 : low === high ? 0 : 1;
                    };

                    return priorities.children.map(({
                        label, 'value': isDesired
                    }) => getComparitor.bind(null, EVALUATORS[label](isDesired)));
                })()
            });
        })(),
        'YouTube Chat Filter',
        {
            'headBase': '#ff0000',
            'headButtonExit': '#000000',
            'borderHead': '#ffffff',
            'nodeBase': ['#222222', '#111111'],
            'borderTooltip': '#570000'
        },
        {'zIndex': 10000}
    );

    // CSS

    (function style() {
        function addStyle(sheet, selector, rules) {
            const ruleString = rules.map(
                ([selector, rule]) => `${selector}:${typeof rule === 'function' ? rule() : rule} !important;`
            );

            sheet.insertRule(`${selector}{${ruleString.join('')}}`);
        }

        const styleElement = document.createElement('style');
        const {sheet} = document.head.appendChild(styleElement);

        const styles = [
            [`${CHAT_LIST_SELECTOR}`, [
                ['bottom', 'inherit']
            ]],
            [`${CHAT_LIST_SELECTOR} > :not(.${FILTER_CLASS})`, [
                ['display', 'none']
            ]]
        ];

        for (const style of styles) {
            addStyle(sheet, style[0], style[1]);
        }
    })();

    // STATE

    let queuedPost;

    // FILTERING

    function doFilter(isInitial = true) {
        const chatListElement = ROOT_ELEMENT.querySelector(CHAT_LIST_SELECTOR);

        let doQueue = false;
        let paused = false;

        function showPost(post, queueNext) {
            const config = $config.get();

            post.classList.add(FILTER_CLASS);

            queuedPost = undefined;

            if (queueNext && config && config.queueTime > 0) {
                // Start queueing
                doQueue = true;

                window.setTimeout(() => {
                    doQueue = false;

                    // Unqueue
                    if (!paused) {
                        acceptPost();
                    }
                }, config.queueTime);
            }
        }

        function acceptPost(post = queuedPost, allowQueue = true) {
            if (!post) {
                return;
            }

            if (allowQueue && (doQueue || paused)) {
                queuedPost = post;
            } else {
                showPost(post, allowQueue);
            }
        }

        window.document.body.addEventListener('mouseenter', () => {
            const config = $config.get();

            if (config && config.pauseOnHover) {
                paused = true;
            }
        });

        window.document.body.addEventListener('mouseleave', () => {
            const config = $config.get();

            paused = false;

            if (config && config.pauseOnHover) {
                acceptPost();
            }
        });

        function processPost(post, allowQueue = true) {
            const config = $config.get();
            const isFilterable = config && $active.get() && TAGS_FILTERABLE.includes(post.tagName);

            if (isFilterable) {
                if (
                    config.filters.some(filter =>
                        // Test author filter
                        (filter.authors.length > 0 && filter.authors.some(_ => _.test(post.querySelector('#author-name')?.textContent))) ||
                        // Test message filter
                        (filter.messages.length > 0 && filter.messages.some(_ => _.test(post.querySelector('#message')?.textContent)))
                    ) ||
                    // Test requirements
                    (config.requirements.soft.length > 0 && !config.requirements.soft.some(passes => passes(post))) ||
                    config.requirements.hard.some(passes => !passes(post))
                ) {
                    return;
                }

                // Test inferior to queued post
                if (queuedPost) {
                    for (const comparitor of config.comparitors) {
                        const rating = comparitor(post, queuedPost);

                        if (rating < 0) {
                            return;
                        }

                        if (rating > 0) {
                            break;
                        }
                    }
                }
            }

            acceptPost(post, isFilterable && allowQueue);
        }

        if (isInitial) {
            // Process initial messages
            for (const post of chatListElement.children) {
                processPost(post, false);
            }

            // Re-sizes the chat after removing initial messages
            chatListElement.parentElement.style.height = `${chatListElement.clientHeight}px`;

            // Restart if the chat element gets replaced
            // This happens when switching between 'Top Chat Replay' and 'Live Chat Replay'
            new MutationObserver((mutations) => {
                for (const {addedNodes} of mutations) {
                    for (const node of addedNodes) {
                        if (node.matches('yt-live-chat-item-list-renderer')) {
                            doFilter(false);
                        }
                    }
                }
            }).observe(
                ROOT_ELEMENT.querySelector('#item-list'),
                {childList: true}
            );
        }

        // Handle new posts
        new MutationObserver((mutations) => {
            for (const {addedNodes} of mutations) {
                for (const addedNode of addedNodes) {
                    processPost(addedNode);
                }
            }
        }).observe(
            chatListElement,
            {childList: true}
        );
    }

    // MAIN

    (() => {
        let timeout;

        const updateSvg = () => {
            SVG.style[`${$active.get() ? 'set' : 'remove'}Property`]('color', ACTIVE_COLOUR);
        };

        const updateCounter = () => {
            const config = $config.get();
            const count = config ? config.filters.length : 0;

            queuedPost = undefined;

            COUNTER.style[`${count > 0 ? 'set' : 'remove'}Property`]('color', ACTIVE_COLOUR);

            COUNTER.innerText = `${count}`;
        };

        const onShortClick = (event) => {
            if (timeout && event.button === 0) {
                timeout = window.clearTimeout(timeout);

                $active.toggle();

                updateSvg();
            }
        };

        const onLongClick = () => {
            timeout = undefined;

            $config.edit()
                .then(updateCounter)
                .catch(({message}) => {
                    if (window.confirm(`${message}\n\nWould you like to erase your data?`)) {
                        $config.reset();

                        updateCounter();
                    }
                });
        };

        Promise.allSettled([
            $active.init()
                .then(updateSvg),
            $config.init()
                .then(updateCounter)
        ])
            .then((responses) => {
                // Start filtering
                doFilter();

                // Inform users of issues
                for (const response of responses) {
                    if ('reason' in response) {
                        window.alert(response.reason.message);
                    }
                }

                // Add short click listener
                BUTTON.addEventListener('mouseup', onShortClick);

                // Add long click listener
                BUTTON.addEventListener('mousedown', (event) => {
                    if (event.button === 0) {
                        timeout = window.setTimeout(onLongClick, LONG_PRESS_TIME);
                    }
                });
            });
    })();
});