- // ==UserScript==
- // @name YouTube Chat Filter
- // @version 1.9
- // @description Filters messages in YouTube stream chat.
- // @author Callum Latham
- // @namespace https://gf.qytechs.cn/users/696211-ctl2
- // @license MIT
- // @match *://www.youtube.com/*
- // @match *://youtube.com/*
- // @require https://update.gf.qytechs.cn/scripts/446506/1401643/%24Config.js
- // @require https://gf.qytechs.cn/scripts/449472-boolean/code/$Boolean.js?version=1081058
- // @grant GM.setValue
- // @grant GM.getValue
- // @grant GM.deleteValue
- // ==/UserScript==
-
- /* global $Config */
- /* global $Boolean */
-
- // 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',
- 'YT-LIVE-CHAT-PAID-STICKER-RENDERER',
- ];
- const PRIORITIES = {
- VERIFIED: 'Verification Badge',
- MODERATOR: 'Moderator Badge',
- MEMBER: 'Membership Badge',
- LONG: 'Long',
- RECENT: 'Recent',
- SUPERCHAT: 'Superchat',
- STICKER: 'Sticker',
- 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';
-
- button.querySelector('yt-touch-feedback-shape').remove();
-
- 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.STICKER]: getEvaluator.bind(null, (_) => _.matches('yt-live-chat-paid-sticker-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.all([
- $active.init()
- .then(updateSvg),
- $config.ready()
- .catch(async (e) => {
- const tree = await GM.getValue('YTCF_TREE');
- const {children} = tree.children[2].children[1];
-
- if (children.some(({label}) => label === PRIORITIES.STICKER)) {
- throw e;
- }
-
- // Copy superchat info onto new sticker entry
- const refIndex = children.findIndex(({label}) => label === PRIORITIES.SUPERCHAT);
-
- // Try fixing error by adding the new 'Sticker' entry to the 'priorities' subtree
- children.splice(refIndex, 0, {
- label: PRIORITIES.STICKER,
- value: children[refIndex].value,
- });
-
- await GM.setValue('YTCF_TREE', tree);
-
- await $config.ready();
- })
- .finally(updateCounter),
- ])
- .then(() => {
- // Start filtering
- doFilter();
-
- // 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);
- }
- });
- });
- })();
- });