🏠 Home 

YouTube Chat Filter

Set up filters for stream chats

  1. // ==UserScript==
  2. // @name YouTube Chat Filter
  3. // @version 1.23
  4. // @description Set up filters for stream chats
  5. // @author Callum Latham
  6. // @namespace https://greasyfork.org/users/696211-ctl2
  7. // @license MIT
  8. // @match *://www.youtube.com/*
  9. // @match *://youtube.com/*
  10. // @exclude *://www.youtube.com/embed/*
  11. // @exclude *://youtube.com/embed/*
  12. // @require https://update.greasyfork.org/scripts/446506/1537901/%24Config.js
  13. // @require https://greasyfork.org/scripts/449472-boolean/code/$Boolean.js?version=1081058
  14. // @grant GM.setValue
  15. // @grant GM.getValue
  16. // @grant GM.deleteValue
  17. // ==/UserScript==
  18. /* global $Config */
  19. /* global $Boolean */
  20. (() => {
  21. // Don't run outside the chat frame
  22. if (!window.frameElement || window.frameElement.id !== 'chatframe') {
  23. // noinspection JSAnnotator
  24. return;
  25. }
  26. window.addEventListener('load', async () => {
  27. // STATIC CONSTS
  28. const LONG_PRESS_TIME = 400;
  29. const ACTIVE_COLOUR = 'var(--yt-spec-call-to-action)';
  30. const CHAT_LIST_SELECTOR = '#items.yt-live-chat-item-list-renderer';
  31. const FILTER_CLASS = 'cf';
  32. const TAGS_FILTERABLE = [
  33. 'YT-LIVE-CHAT-TEXT-MESSAGE-RENDERER',
  34. 'YT-LIVE-CHAT-PAID-MESSAGE-RENDERER',
  35. 'YT-LIVE-CHAT-MEMBERSHIP-ITEM-RENDERER',
  36. 'YTD-SPONSORSHIPS-LIVE-CHAT-GIFT-PURCHASE-ANNOUNCEMENT-RENDERER',
  37. 'YTD-SPONSORSHIPS-LIVE-CHAT-GIFT-REDEMPTION-ANNOUNCEMENT-RENDERER',
  38. 'YT-LIVE-CHAT-PAID-STICKER-RENDERER',
  39. ];
  40. const PRIORITIES = {
  41. VERIFIED: 'Verification Badge',
  42. MODERATOR: 'Moderator Badge',
  43. MEMBER: 'Membership Badge',
  44. LONG: 'Long',
  45. RECENT: 'Recent',
  46. SUPERCHAT: 'Superchat',
  47. STICKER: 'Sticker',
  48. MEMBERSHIP_RENEWAL: 'Membership Purchase',
  49. MEMBERSHIP_GIFT_OUT: 'Membership Gift (Given)',
  50. MEMBERSHIP_GIFT_IN: 'Membership Gift (Received)',
  51. EMOJI: 'Emojis',
  52. };
  53. // ELEMENT CONSTS
  54. const STREAMER = window.parent.document.querySelector('#upload-info > #channel-name').innerText;
  55. const ROOT_ELEMENT = document.body.querySelector('#chat');
  56. const [BUTTON, SVG, COUNTER] = await (async () => {
  57. const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
  58. const [button, svgContainer, svg] = await new Promise((resolve) => {
  59. const template = document.body.querySelector('#live-chat-header-context-menu');
  60. const button = template.querySelector('button').cloneNode(true);
  61. const svgContainer = button.querySelector('yt-icon');
  62. button.style.visibility = 'hidden';
  63. button.querySelector('yt-touch-feedback-shape').remove();
  64. template.parentElement.insertBefore(button, template);
  65. window.setTimeout(() => {
  66. const path = document.createElementNS(SVG_NAMESPACE, 'path');
  67. 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');
  68. const rectangle = document.createElementNS(SVG_NAMESPACE, 'rect');
  69. rectangle.setAttribute('x', '13.95');
  70. rectangle.setAttribute('y', '0');
  71. rectangle.setAttribute('width', '294');
  72. rectangle.setAttribute('height', '45');
  73. const svg = document.createElementNS(SVG_NAMESPACE, 'svg');
  74. svg.setAttribute('viewBox', '-50 -50 400 400');
  75. svg.setAttribute('x', '0');
  76. svg.setAttribute('y', '0');
  77. svg.setAttribute('focusable', 'false');
  78. svg.append(path, rectangle);
  79. svgContainer.innerHTML = trustedTypes?.emptyHTML ?? '';
  80. svgContainer.append(svg);
  81. button.style.removeProperty('visibility');
  82. button.style.setProperty('display', 'contents');
  83. resolve([button, svgContainer, svg]);
  84. }, 0);
  85. });
  86. const counter = (() => {
  87. const container = document.createElement('div');
  88. container.style.position = 'absolute';
  89. container.style.left = '9px';
  90. container.style.bottom = '9px';
  91. container.style.fontSize = '1.1em';
  92. container.style.lineHeight = 'normal';
  93. container.style.width = '1.6em';
  94. container.style.display = 'flex';
  95. container.style.alignItems = 'center';
  96. const svg = (() => {
  97. const circle = document.createElementNS(SVG_NAMESPACE, 'circle');
  98. circle.setAttribute('r', '50');
  99. circle.style.color = 'var(--yt-live-chat-header-background-color)';
  100. circle.style.opacity = '0.65';
  101. const svg = document.createElementNS(SVG_NAMESPACE, 'svg');
  102. svg.setAttribute('viewBox', '-70 -70 140 140');
  103. svg.append(circle);
  104. return svg;
  105. })();
  106. const text = document.createElement('span');
  107. text.style.position = 'absolute';
  108. text.style.width = '100%';
  109. text.innerText = '?';
  110. container.append(text, svg);
  111. svgContainer.append(container);
  112. return text;
  113. })();
  114. return [button, svg, counter];
  115. })();
  116. // STATE INTERFACES
  117. const $active = new $Boolean('YTCF_IS_ACTIVE');
  118. const $config = new $Config(
  119. 'YTCF_TREE',
  120. {
  121. get: ({children: [{children}, {children: [{value: caseSensitive}]}]}, configs) => {
  122. const filters = [];
  123. const getRegex = caseSensitive ? ({value}) => new RegExp(value) : ({value}) => new RegExp(value, 'i');
  124. const matchesStreamer = (node) => getRegex(node).test(STREAMER);
  125. for (const filter of children) {
  126. const [{'children': streamers}, {'children': authors}, {'children': messages}] = filter.children;
  127. if (streamers.length === 0 || streamers.some(matchesStreamer)) {
  128. filters.push({
  129. authors: authors.map(getRegex),
  130. messages: messages.map(getRegex),
  131. });
  132. }
  133. }
  134. return Object.assign({filters}, ...configs);
  135. },
  136. children: [
  137. {
  138. label: 'Filters',
  139. children: [],
  140. seed: {
  141. label: 'Description',
  142. value: '',
  143. children: ['Streamer', 'Author', 'Message'].map((target) => ({
  144. label: `${target} Regex`,
  145. children: [],
  146. seed: {
  147. value: '^',
  148. predicate: (value) => {
  149. try {
  150. RegExp(value);
  151. } catch {
  152. return 'Value must be a valid regular expression.';
  153. }
  154. return true;
  155. },
  156. },
  157. })),
  158. },
  159. },
  160. {
  161. label: 'Options',
  162. children: [
  163. {
  164. label: 'Case-Sensitive Regex?',
  165. value: false,
  166. },
  167. {
  168. label: 'Pause on Mouse Over?',
  169. value: false,
  170. get: ({value: pauseOnHover}) => ({pauseOnHover}),
  171. },
  172. {
  173. label: 'Queue Time (ms)',
  174. value: 0,
  175. predicate: (value) => value >= 0 ? true : 'Queue time must be positive',
  176. get: ({value: queueTime}) => ({queueTime}),
  177. },
  178. ],
  179. },
  180. {
  181. label: 'Preferences',
  182. children: (() => {
  183. const EVALUATORS = (() => {
  184. const getEvaluator = (evaluator, isDesired) => isDesired ? evaluator : (_) => 1 - evaluator(_);
  185. return {
  186. // Special tests
  187. [PRIORITIES.RECENT]: getEvaluator.bind(null, () => 1),
  188. [PRIORITIES.LONG]: getEvaluator.bind(null, (_) => _.querySelector('#message').textContent.length),
  189. // Tests for message type
  190. [PRIORITIES.SUPERCHAT]: getEvaluator.bind(null, (_) => _.matches('yt-live-chat-paid-message-renderer')),
  191. [PRIORITIES.STICKER]: getEvaluator.bind(null, (_) => _.matches('yt-live-chat-paid-sticker-renderer')),
  192. [PRIORITIES.MEMBERSHIP_RENEWAL]: getEvaluator.bind(null, (_) => _.matches('yt-live-chat-membership-item-renderer')),
  193. [PRIORITIES.MEMBERSHIP_GIFT_OUT]: getEvaluator.bind(null, (_) => _.matches('ytd-sponsorships-live-chat-gift-purchase-announcement-renderer')),
  194. [PRIORITIES.MEMBERSHIP_GIFT_IN]: getEvaluator.bind(null, (_) => _.matches('ytd-sponsorships-live-chat-gift-redemption-announcement-renderer')),
  195. // Tests for descendant element presence
  196. [PRIORITIES.EMOJI]: getEvaluator.bind(null, (_) => Boolean(_.querySelector('.emoji'))),
  197. [PRIORITIES.MEMBER]: getEvaluator.bind(null, (_) => Boolean(_.querySelector('#chat-badges > [type=member]'))),
  198. [PRIORITIES.MODERATOR]: getEvaluator.bind(null, (_) => Boolean(_.querySelector('#chip-badges > [type=verified]'))),
  199. [PRIORITIES.VERIFIED]: getEvaluator.bind(null, (_) => Boolean(_.querySelector('#chat-badges > [type=moderator]'))),
  200. };
  201. })();
  202. const poolId = 0;
  203. return [
  204. {
  205. label: 'Requirements',
  206. get: (_, configs) => ({requirements: Object.assign(...configs)}),
  207. children: [
  208. ['OR', 'soft'],
  209. ['AND', 'hard'],
  210. ].map(([label, key]) => ({
  211. label,
  212. children: [],
  213. poolId,
  214. get: ({children}) => ({[key]: children.map(({label, 'value': isDesired}) => EVALUATORS[label](isDesired))}),
  215. })),
  216. },
  217. {
  218. label: 'Priorities (High to Low)',
  219. poolId,
  220. get: ({children}) => {
  221. const getComparitor = (getValue, low, high) => {
  222. low = getValue(low);
  223. high = getValue(high);
  224. return low < high ? -1 : low === high ? 0 : 1;
  225. };
  226. return {comparitors: children.map(({label, 'value': isDesired}) => getComparitor.bind(null, EVALUATORS[label](isDesired)))};
  227. },
  228. children: Object.values(PRIORITIES).map((label) => ({
  229. label,
  230. value: label !== PRIORITIES.EMOJI && label !== PRIORITIES.MEMBERSHIP_GIFT_IN,
  231. })),
  232. },
  233. ];
  234. })(),
  235. },
  236. ],
  237. },
  238. {
  239. headBase: '#c80000',
  240. headButtonExit: '#000000',
  241. borderHead: '#ffffff',
  242. borderTooltip: '#c80000',
  243. },
  244. {
  245. zIndex: 10000,
  246. scrollbarColor: 'initial',
  247. },
  248. );
  249. // CSS
  250. (function style() {
  251. function addStyle(sheet, selector, rules) {
  252. const ruleString = rules.map(
  253. ([selector, rule]) => `${selector}:${typeof rule === 'function' ? rule() : rule} !important;`,
  254. );
  255. sheet.insertRule(`${selector}{${ruleString.join('')}}`);
  256. }
  257. const styleElement = document.createElement('style');
  258. const {sheet} = document.head.appendChild(styleElement);
  259. const styles = [
  260. [`${CHAT_LIST_SELECTOR}`, [['bottom', 'inherit']]],
  261. [`${CHAT_LIST_SELECTOR} > :not(.${FILTER_CLASS})`, [['display', 'none']]],
  262. ];
  263. for (const style of styles) {
  264. addStyle(sheet, style[0], style[1]);
  265. }
  266. })();
  267. // STATE
  268. let queuedPost;
  269. // FILTERING
  270. function doFilter(isInitial = true) {
  271. const chatListElement = ROOT_ELEMENT.querySelector(CHAT_LIST_SELECTOR);
  272. let doQueue = false;
  273. let paused = false;
  274. function showPost(post, queueNext) {
  275. const config = $config.get();
  276. post.classList.add(FILTER_CLASS);
  277. queuedPost = undefined;
  278. if (queueNext && config && config.queueTime > 0) {
  279. // Start queueing
  280. doQueue = true;
  281. window.setTimeout(() => {
  282. doQueue = false;
  283. // Unqueue
  284. if (!paused) {
  285. acceptPost();
  286. }
  287. }, config.queueTime);
  288. }
  289. }
  290. function acceptPost(post = queuedPost, allowQueue = true) {
  291. if (!post) {
  292. return;
  293. }
  294. if (allowQueue && (doQueue || paused)) {
  295. queuedPost = post;
  296. } else {
  297. showPost(post, allowQueue);
  298. }
  299. }
  300. window.document.body.addEventListener('mouseenter', () => {
  301. const config = $config.get();
  302. if (config && config.pauseOnHover) {
  303. paused = true;
  304. }
  305. });
  306. window.document.body.addEventListener('mouseleave', () => {
  307. const config = $config.get();
  308. paused = false;
  309. if (config && config.pauseOnHover) {
  310. acceptPost();
  311. }
  312. });
  313. function processPost(post, allowQueue = true) {
  314. const config = $config.get();
  315. const isFilterable = config && $active.get() && TAGS_FILTERABLE.includes(post.tagName);
  316. if (isFilterable) {
  317. if (
  318. config.filters.some((filter) =>
  319. // Test author filter
  320. filter.authors.length > 0 && filter.authors.some((_) => _.test(post.querySelector('#author-name')?.textContent))
  321. // Test message filter
  322. || filter.messages.length > 0 && filter.messages.some((_) => _.test(post.querySelector('#message')?.textContent)),
  323. )
  324. // Test requirements
  325. || config.requirements.soft.length > 0 && !config.requirements.soft.some((passes) => passes(post))
  326. || config.requirements.hard.some((passes) => !passes(post))
  327. ) {
  328. return;
  329. }
  330. // Test inferior to queued post
  331. if (queuedPost) {
  332. for (const comparitor of config.comparitors) {
  333. const rating = comparitor(post, queuedPost);
  334. if (rating < 0) {
  335. return;
  336. }
  337. if (rating > 0) {
  338. break;
  339. }
  340. }
  341. }
  342. }
  343. acceptPost(post, isFilterable && allowQueue);
  344. }
  345. if (isInitial) {
  346. // Process initial messages
  347. for (const post of chatListElement.children) {
  348. processPost(post, false);
  349. }
  350. // Re-sizes the chat after removing initial messages
  351. chatListElement.parentElement.style.height = `${chatListElement.clientHeight}px`;
  352. // Restart if the chat element gets replaced
  353. // This happens when switching between 'Top Chat Replay' and 'Live Chat Replay'
  354. new MutationObserver((mutations) => {
  355. for (const {addedNodes} of mutations) {
  356. for (const node of addedNodes) {
  357. if (node.matches('yt-live-chat-item-list-renderer')) {
  358. doFilter(false);
  359. }
  360. }
  361. }
  362. }).observe(
  363. ROOT_ELEMENT.querySelector('#item-list'),
  364. {childList: true},
  365. );
  366. }
  367. // Handle new posts
  368. new MutationObserver((mutations) => {
  369. for (const {addedNodes} of mutations) {
  370. for (const addedNode of addedNodes) {
  371. processPost(addedNode);
  372. }
  373. }
  374. }).observe(
  375. chatListElement,
  376. {childList: true},
  377. );
  378. }
  379. // MAIN
  380. (() => {
  381. let timeout;
  382. const updateSvg = () => {
  383. SVG.style[`${$active.get() ? 'set' : 'remove'}Property`]('color', ACTIVE_COLOUR);
  384. };
  385. const updateCounter = () => {
  386. const config = $config.get();
  387. const count = config ? config.filters.length : 0;
  388. queuedPost = undefined;
  389. COUNTER.style[`${count > 0 ? 'set' : 'remove'}Property`]('color', ACTIVE_COLOUR);
  390. COUNTER.innerText = `${count}`;
  391. };
  392. const onShortClick = (event) => {
  393. if (timeout && event.button === 0) {
  394. timeout = window.clearTimeout(timeout);
  395. $active.toggle();
  396. updateSvg();
  397. }
  398. };
  399. const onLongClick = () => {
  400. timeout = undefined;
  401. $config.edit()
  402. .then(updateCounter)
  403. .catch(({message}) => {
  404. if (window.confirm(`${message}\n\nWould you like to erase your data?`)) {
  405. $config.reset();
  406. updateCounter();
  407. }
  408. });
  409. };
  410. Promise.all([
  411. $active.init()
  412. .then(updateSvg),
  413. $config.ready
  414. .catch(async (e) => {
  415. const tree = await GM.getValue('YTCF_TREE');
  416. const {children} = tree.children[2].children[1];
  417. if (children.some(({label}) => label === PRIORITIES.STICKER)) {
  418. throw e;
  419. }
  420. // Copy superchat info onto new sticker entry
  421. const refIndex = children.findIndex(({label}) => label === PRIORITIES.SUPERCHAT);
  422. // Try fixing error by adding the new 'Sticker' entry to the 'priorities' subtree
  423. children.splice(refIndex, 0, {
  424. label: PRIORITIES.STICKER,
  425. value: children[refIndex].value,
  426. });
  427. await GM.setValue('YTCF_TREE', tree);
  428. await $config.ready;
  429. })
  430. .finally(updateCounter),
  431. ])
  432. .then(() => {
  433. // Start filtering
  434. doFilter();
  435. // Add short click listener
  436. BUTTON.addEventListener('mouseup', onShortClick);
  437. // Add long click listener
  438. BUTTON.addEventListener('mousedown', (event) => {
  439. if (event.button === 0) {
  440. timeout = window.setTimeout(onLongClick, LONG_PRESS_TIME);
  441. }
  442. });
  443. });
  444. })();
  445. });
  446. })();