YouTube Chat Filter

Set up filters for stream chats

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

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.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

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.23
// @description Set up filters for stream chats
// @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/1537901/%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',
		{
			get: ({children: [{children}, {children: [{value: caseSensitive}]}]}, configs) => {
				const filters = [];
				
				const getRegex = caseSensitive ? ({value}) => new RegExp(value) : ({value}) => new RegExp(value, 'i');
				const matchesStreamer = (node) => getRegex(node).test(STREAMER);
				
				for (const filter of 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 Object.assign({filters}, ...configs);
			},
			children: [
				{
					label: 'Filters',
					children: [],
					seed: {
						label: 'Description',
						value: '',
						children: ['Streamer', 'Author', 'Message'].map((target) => ({
							label: `${target} Regex`,
							children: [],
							seed: {
								value: '^',
								predicate: (value) => {
									try {
										RegExp(value);
									} catch {
										return 'Value must be a valid regular expression.';
									}
									
									return true;
								},
							},
						})),
					},
				},
				{
					label: 'Options',
					children: [
						{
							label: 'Case-Sensitive Regex?',
							value: false,
						},
						{
							label: 'Pause on Mouse Over?',
							value: false,
							get: ({value: pauseOnHover}) => ({pauseOnHover}),
						},
						{
							label: 'Queue Time (ms)',
							value: 0,
							predicate: (value) => value >= 0 ? true : 'Queue time must be positive',
							get: ({value: queueTime}) => ({queueTime}),
						},
					],
				},
				{
					label: 'Preferences',
					children: (() => {
						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]'))),
							};
						})();
						
						const poolId = 0;
						
						return [
							{
								label: 'Requirements',
								get: (_, configs) => ({requirements: Object.assign(...configs)}),
								children: [
									['OR', 'soft'],
									['AND', 'hard'],
								].map(([label, key]) => ({
									label,
									children: [],
									poolId,
									get: ({children}) => ({[key]: children.map(({label, 'value': isDesired}) => EVALUATORS[label](isDesired))}),
								})),
							},
							{
								label: 'Priorities (High to Low)',
								poolId,
								get: ({children}) => {
									const getComparitor = (getValue, low, high) => {
										low = getValue(low);
										high = getValue(high);
										
										return low < high ? -1 : low === high ? 0 : 1;
									};
									
									return {comparitors: children.map(({label, 'value': isDesired}) => getComparitor.bind(null, EVALUATORS[label](isDesired)))};
								},
								children: Object.values(PRIORITIES).map((label) => ({
									label,
									value: label !== PRIORITIES.EMOJI && label !== PRIORITIES.MEMBERSHIP_GIFT_IN,
								})),
							},
						];
					})(),
				},
			],
		},
		{
			headBase: '#c80000',
			headButtonExit: '#000000',
			borderHead: '#ffffff',
			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);
					}
				});
			});
	})();
});
})();