Habr Comments Filter

Hides comments with low rating.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

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.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==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);
})();