Habr Comments Filter

Hides comments with low rating.

// ==UserScript==
// @name           Habr Comments Filter
// @name:ru        Хабр Фильтр комментов
// @namespace      habr_comments_filter
// @version        2.0.1
// @description    Hides comments with low rating.
// @description:ru Спрятать комменты с оценкой ниже выбранной.
// @author         Dystopian
// @license        WTFPL
// @match          https://habr.com/*
// @icon           https://habr.com/favicon.ico
// @grant          GM_log
// @run-at         document-end
// ==/UserScript==

(function() {
	'use strict';

	const COMMENT_HIGHLIGHT_LIGHT = '#fff7d7';
	const COMMENT_HIGHLIGHT_DARK = '#484028';

	let DEBUG = 0;
	let currentThreshold = null;
	let observer;
	let isCreatingButton = false;

	// custom log - useful for mobile version debug
	let isCustomLog = false;
	let isCustomLogCollapsed = true;
	const customLogBoxID = 'custom-log-box';
	const customLogBuffer = [];
	function getTimestamp() {
		const d = new Date();
		const pad = (n, z = 2) => ('00' + n).slice(-z);
		return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
	}
	function formatElement(el) {
		if (!(el instanceof Element)) return String(el);
		let s = `<${el.tagName.toLowerCase()}`;
		if (el.id) s += `#${el.id}`;
		if (el.className) s += '.' + el.className.trim().replace(/\s+/g, '.');
		return s + '>';
	}
	function smartFormat(x) {
		if (x instanceof Event) return `[Event ${x.type} ${x.constructor.name}] target=${formatElement(x.target)}`;
		if (x instanceof Element) return formatElement(x);
		try {return JSON.stringify(x);}
		catch {return String(x);}
	}
	function customLog(...args) {
		const maxLines = 100;
		const msg = args.map(smartFormat).join(' ');
		customLogBuffer.push(`${getTimestamp()} ${msg}`);
		if (customLogBuffer.length > maxLines) customLogBuffer.shift();
		renderCustomLog();
	}
	function renderCustomLog() {
		let pre = document.getElementById(customLogBoxID);
		if (!pre) {
			const box = document.createElement('div');
			Object.assign(box.style, {
				position: 'fixed',
				bottom: '0',
				left: '0',
				width: '100%',
				maxHeight: '10em',
				overflowY: 'auto',
				color: 'lime',
				fontSize: '10px',
				fontFamily: 'monospace',
				zIndex: '999999',
				padding: '4px',
			});
			const btn = document.createElement('button');
			Object.assign(btn.style, {
				position: 'fixed',
				right: '4px',
				background: 'rgba(0,0,0,0.8)',
				border: '1px solid lime',
				color: 'inherit',
			});
			btn.onclick = () => {
				isCustomLogCollapsed = !isCustomLogCollapsed;
				if (isCustomLogCollapsed) {
					pre.style.display = 'none';
					box.style.height = '1.5em';
					box.style.background = '';
					btn.textContent = '+';
				} else {
					pre.style.display = '';
					box.style.height = '';
					box.style.background = 'rgba(0,0,0,0.8)';
					btn.textContent = '−';
					pre.textContent = customLogBuffer.join('\n');
					box.scrollTop = box.scrollHeight;
				}
			};
			box.appendChild(btn);
			pre = document.createElement('pre');
			pre.id = customLogBoxID;
			Object.assign(pre.style, {
				margin: '0',
				whiteSpace: 'pre-wrap',
				fontFamily: 'inherit',
				fontSize: 'inherit',
				color: 'inherit',
			});
			box.appendChild(pre);
			document.body.appendChild(box);
			isCustomLogCollapsed = !isCustomLogCollapsed;
			btn.click(); // init state
		}
		if (!isCustomLogCollapsed) {
			pre.textContent = customLogBuffer.join('\n');
			pre.parentElement.scrollTop = pre.parentElement.scrollHeight;
		}
	}
	function log(level, ...args) {
		if (DEBUG < level) return;
		if (isCustomLog) customLog(...args);
		console.log('[comments-filter]', ...args);
	}

	const cssVarName = '--good-comment-background';
	const cssColor = `var(${cssVarName})`;
	function isDarkTheme() {
		const darkLink = document.querySelector('#dark-colors');
		const lightLink = document.querySelector('#light-colors');
		if (darkLink?.media === 'all' && !darkLink.disabled) return true;
		if (lightLink?.media === 'all' && !lightLink.disabled) return false;
		return window.matchMedia('(prefers-color-scheme: dark)').matches;
	}
	function applyHighlightColor() {
		const newValue = isDarkTheme() ? COMMENT_HIGHLIGHT_DARK : COMMENT_HIGHLIGHT_LIGHT;
		const rootStyle = document.documentElement.style;
		const current = rootStyle.getPropertyValue(cssVarName).trim();
		if (current !== newValue) {
			log(1, "applyHighlightColor", current, newValue);
			rootStyle.setProperty(cssVarName, newValue);
		}
	}
	function initHighlightColors() {
		applyHighlightColor();
		const observer = new MutationObserver(applyHighlightColor);
		observer.observe(document.head, { attributes: true, subtree: true });
		const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
		mediaQuery.addEventListener('change', applyHighlightColor);
	}

	function findCommentsWrapper() {
		return document.querySelector('div.tm-comments-wrapper__wrapper');
	}
	function findCommentsTree(wrapper) {
		return wrapper.querySelector('div.tm-comments__tree');
	}
	function findCommentsHeader(wrapper) {
		return wrapper.querySelector('header.tm-comments-wrapper__header');
	}
	function getAllSections(tree) {
		return tree.querySelectorAll('section.tm-comment-thread');
	}
	 function getChildSections(section) {
		return section.querySelectorAll(':scope > div.tm-comment-thread__children > section.tm-comment-thread');
	}
   function getSectionChildrenBlock(section) {
		return section.querySelector(':scope > div.tm-comment-thread__children');
	}
	function getSectionHeader(section) {
		return section.querySelector(':scope > article > div > div.tm-comment > header.tm-comment__header');
	}
	function getSectionBody(section) {
		return section.querySelector(':scope > article > div, :scope > article > span.tm-comment-thread__ufo');
	}

	function extractCommentScore(section) {
		const span = section.querySelector(':scope > article span:is(.tm-votes-meter__value, .tm-votes-lever__score-counter)');
		if (!span) return 0;
		log(3, "extractCommentScore", span.textContent);
		return parseInt(span.textContent.trim(), 10) || 0;
	}

	function setFlag(el, key, value) {
		el.dataset[key] = value ? "1" : "";
	}
	function getFlag(el, key) {
		return el.dataset[key] === "1";
	}

	function resetCommentsFilter(tree) {
		log(1, 'resetCommentsFilter');
		tree.querySelectorAll('a.comment-placeholder').forEach(p => p.remove());
		getAllSections(tree).forEach(section => {
			section.style.display = '';
			getSectionBody(section)?.style?.setProperty('display', '');
			getSectionChildrenBlock(section)?.style?.setProperty('display', '');
			getSectionHeader(section)?.style?.setProperty('background-image', '');
			section.dataset.isGood = '';
			section.dataset.hasGood = '';
			section.dataset.hasGoodChild = '';
		});
	}

	function markComments(section, parentComment = null) {
		const score = extractCommentScore(section);
		section.dataset.score = score; // for debug
		const isGood = score >= currentThreshold;
		setFlag(section, "isGood", isGood);
		if (parentComment && isGood) setFlag(parentComment, "hasGoodChild", true);

		let hasGood = isGood;
		getChildSections(section).forEach(childSection => {
			if (markComments(childSection, section)) {
				hasGood = true;
			}
		});
		setFlag(section, "hasGood", hasGood);
		log(2, 'mark', {score, isGood, hasGood});
		return hasGood;
	}

	function collapseCommentWithPlaceholder(section, withChildren = true) {
		log(2, 'collapseCommentWithPlaceholder', section, withChildren);
		const article = section.querySelector(':scope > article');
		if (!article) return;

		const articleUfoSelector = ':scope > span.tm-comment-thread__ufo';
		const sectionChildrenBlock = getSectionChildrenBlock(section);

		// do not hide UFO without children
		if (!sectionChildrenBlock && article.querySelector(articleUfoSelector)) return;

		if (withChildren && sectionChildrenBlock) {
			sectionChildrenBlock.style.setProperty('display', 'none');
		}

		const placeholder = document.createElement('a');
		placeholder.textContent = 'раскрыть';
		placeholder.href = '#';
		placeholder.className = 'comment-placeholder';
		placeholder.style.textDecoration = 'none';
		const articleContent = article.querySelector(':scope > div, ' + articleUfoSelector);
		if (articleContent) {
			placeholder.className += ' ' + articleContent.className; // copy margin
			articleContent.style.display = 'none';
		}
		article.appendChild(placeholder);
	}

	function processCommentThread(section, isRoot = false) {
		log(2, 'processCommentThread', section, isRoot, {score: section.dataset.score, isGood: section.dataset.isGood, hasGood: section.dataset.hasGood, hasGoodChild: section.dataset.hasGoodChild});

		if (getFlag(section, "isGood") || getFlag(section, "hasGoodChild")) {
			log(2, 'isGood or hasGoodChild', section);
			if (getFlag(section, "isGood")) {
				const header = getSectionHeader(section);
				log(2, 'check header=', header);
				if (header) {
					// if header is already colored, blend it; else fill
					const headerCurrentBackgroundColor = getComputedStyle(header).backgroundColor;
					log(2, 'header color', headerCurrentBackgroundColor);
					const emptyColorComputedString = 'rgba(0, 0, 0, 0)'; // getComputedStyle returns exactly this
					// cssColor here handles cleared background right after resetCommentsFilter
					const firstGradientColor = [emptyColorComputedString, cssColor].includes(headerCurrentBackgroundColor) ? cssColor : emptyColorComputedString;
					header.style.backgroundImage = `linear-gradient(to right, ${firstGradientColor}, ${cssColor})`;
				}
			}
			getChildSections(section).forEach(childSection => {
				log(3, 'childSection', childSection, {score: childSection.dataset.score, isGood: childSection.dataset.isGood, hasGood: childSection.dataset.hasGood, hasGoodChild: childSection.dataset.hasGoodChild});
				if (getFlag(childSection, "hasGood")) {
					processCommentThread(childSection);
				} else {
					collapseCommentWithPlaceholder(childSection);
				}
			});
		} else if (getFlag(section, "hasGood")) {
			log(2, 'hasGood', section);
			collapseCommentWithPlaceholder(section, false);
			getChildSections(section).forEach(childSection => {
				if (getFlag(childSection, "hasGood")) {
					processCommentThread(childSection);
				} else {
					childSection.style.display = 'none';
				}
			});
		} else {
			if (isRoot) {
				log(2, 'bad root', section);
				section.style.display = 'none';
			} else {
				log(2, 'bad non-root', section);
				collapseCommentWithPlaceholder(section);
			}
		}
	}

	function applyCommentsFilter(tree) {
		log(0, 'applyCommentsFilter threshold=', currentThreshold);
		const roots = tree.querySelectorAll(':scope > section.tm-comment-thread');
		roots.forEach(root => {
			markComments(root);
			processCommentThread(root, true);
		});
	}

	function onPlaceholderClick(e) {
		const placeholder = e.target.closest('a.comment-placeholder');
		if (!placeholder) return;
		e.preventDefault();

		const section = placeholder.parentElement.parentElement;
		placeholder.remove();
		log(1, 'placeholder clicked', section);

		section.style.pointerEvents = 'none'; // remove pointerEvents to prevent hover popup
		section.style.display = '';
		getSectionBody(section)?.style?.setProperty('display', '');
		getSectionChildrenBlock(section)?.style?.setProperty('display', '');

		getChildSections(section).forEach(childSection => {
			if (!getFlag(childSection, "hasGood")) collapseCommentWithPlaceholder(childSection);
		});

		// get pointerEvents back
		const events = ['pointermove','mousemove','scroll'];
		const restore = (e) => {
			section.style.pointerEvents = '';
			log(1, 'event restore', e);
			events.forEach(evt => document.removeEventListener(evt, restore));
		};
		events.forEach(evt => {
			document.addEventListener(evt, restore, { passive: true });
		});
	}

	function onVotesClick(e) {
		const a = e.target.closest('a.votes-summary__item');
		if (!a) return;
		e.preventDefault();

		const score = parseInt(a.dataset.score, 10);
		log(0,'filter click score=', score, 'current=', currentThreshold);

		const wrapper = findCommentsWrapper();
		const tree = findCommentsTree(wrapper);
		if (currentThreshold === score) {
			resetCommentsFilter(tree);
			currentThreshold = null;
		} else {
			if (currentThreshold !== null) resetCommentsFilter(tree);
			currentThreshold = score;
			applyCommentsFilter(tree);
		}

		// (un)highlight summary votes
		wrapper.querySelectorAll('a.votes-summary__item').forEach(item => {
			const itemScore = parseInt(item.dataset.score, 10);
			if (currentThreshold !== null && itemScore >= currentThreshold) {
				item.style.background = cssColor;
			} else {
				item.style.background = '';
			}
		});
	}

	async function scrollAllComments() {
		log(1,"scrolling");
		const oldY = window.pageYOffset;
		const oldVis = document.body.style.visibility;
		// делаем невидимым, чтобы не мельтешило
		document.body.style.visibility = "hidden";
		for (let y = window.scrollY + window.innerHeight; y < document.scrollingElement.scrollHeight + window.innerHeight; y += window.innerHeight) {
			window.scrollTo(0, y);
			// await new Promise(r => setTimeout(r, 0)); // дать движку отработать
			await new Promise(r => requestAnimationFrame(r)); // дать движку отработать
		}
		window.scrollTo(0, oldY);
		document.body.style.visibility = oldVis;
	}

	async function buildVotesSummary(button) {
		if (observer) observer.disconnect();
		await scrollAllComments();

		const wrapper = findCommentsWrapper();
		const votes = {};
		const allSections = getAllSections(findCommentsTree(wrapper));
		const total = allSections.length;
		allSections.forEach(section => {
			const score = extractCommentScore(section);
			votes[score] = (votes[score] || 0) + 1;
			log(2, section, score);
		});

		const sorted = Object.entries(votes).sort((a,b)=>b[0]-a[0]);
		log(0, 'votes summary built', total, sorted);

		const frag = document.createDocumentFragment();
		const totalSpan = document.createElement('span');
		totalSpan.textContent = total + ': ';
		frag.appendChild(totalSpan);

		sorted.forEach(([score, count]) => {
			const a = document.createElement('a');
			a.href='#';
			a.textContent = score;
			a.className = 'votes-summary__item tm-votes-lever__score-counter';
			a.style.textDecoration = 'none';
			a.style.padding = '3px';
			if (score > 0) {
				a.classList.add('tm-votes-lever__score-counter_positive');
			} else if (score < 0) {
				a.classList.add('tm-votes-lever__score-counter_negative');
			}
			a.dataset.score = score;
			frag.appendChild(a);
			frag.append(count <= 1 ? ' ' : "(" + count + ") ");
		});

		wrapper.querySelector('#votes-summary')?.remove();
		const summary = document.createElement('div');
		summary.id = 'votes-summary';
		summary.style.color = 'var(--mine-shaft)'; // for dark theme
		summary.appendChild(frag);
		findCommentsHeader(wrapper).insertAdjacentElement('afterend', summary);
		button.disabled = false;
	}

	function createVotesButton(header) {
		log(0, 'createVotesButton', header);
		const button = document.createElement('button');
		button.id = 'comments-votes-button';
		button.textContent = 'Оценки';
		button.onclick = () => {
			button.disabled = true;
			// если есть кнопка "Показать все комментарии"
			document.querySelector('button.tm-height-limiter__expand-button')?.click();
			setTimeout(() => buildVotesSummary(button), 0); // wait for expand button
		};
		header.appendChild(button);
		isCreatingButton = false;
	}
	function ensureVotesButton(caller) {
		log(1, "ensureVotesButton", caller);
		// sometimes button disappears when comments load. We have to recreate it. We stop observer on button click
		if (isCreatingButton || document.getElementById('comments-votes-button')) return;
		const wrapper = findCommentsWrapper();
		if (!wrapper) return;
		if (!findCommentsTree(wrapper)) return;
		const header = findCommentsHeader(wrapper);
		if (!header) return;
		isCreatingButton = true;
		createVotesButton(header);
	}

	function init() {
		log(0, 'init start');
		if (!document.querySelector('div.tm-article-comments, div.tm-article-blocks')) return; // ignore pages without comments

		document.body.addEventListener('click', onVotesClick);
		document.body.addEventListener('click', onPlaceholderClick);

		ensureVotesButton(0);
		observer = new MutationObserver((mutations) => {
			for (const m of mutations) {if (m.target.closest('#' + customLogBoxID)) return;} // ignore custom log changes
			ensureVotesButton(1)
		});
		observer.observe(document.body, { childList: true, subtree: true });

		initHighlightColors();
	}

	window.addEventListener('load', init);
})();