YouTube Chat Filter

Filters messages in YouTube stream chat.

Verzia zo dňa 18.07.2023. Pozri najnovšiu verziu.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name        YouTube Chat Filter
// @version     1.5
// @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;
}
//misidentifying
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('#upload-info > #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('#live-chat-header-context-menu');
            const button = template.querySelector('button').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');

                button.style.setProperty('display', 'contents')

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