YouTube Chat Filter

Filters messages in YouTube stream chat.

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name        YouTube Chat Filter
// @version     1.22
// @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/*
// @exclude     *://www.youtube.com/embed/*
// @exclude     *://youtube.com/embed/*
// @require     https://update.greasyfork.org/scripts/446506/1424453/%24Config.js
// @require     https://greasyfork.org/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 = trustedTypes?.emptyHTML ?? '';
				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: '#c80000',
			headButtonExit: '#000000',
			borderHead: '#ffffff',
			nodeBase: ['#222222', '#111111'],
			borderTooltip: '#c80000',
		},
		{
			zIndex: 10000,
			scrollbarColor: 'initial',
		},
	);
	
	// 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);
					}
				});
			});
	})();
});