GitHub Issue Comments

A userscript that toggles issues/pull request comments & messages

2019-02-17 يوللانغان نەشرى. ئەڭ يېڭى نەشرىنى كۆرۈش.

You will need to install an extension such as Tampermonkey, Greasemonkey 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 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.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

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        GitHub Issue Comments
// @version     1.3.7
// @description A userscript that toggles issues/pull request comments & messages
// @license     MIT
// @author      Rob Garrison
// @namespace   https://github.com/Mottie
// @include     https://github.com/*
// @run-at      document-idle
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @require     https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=666427
// @icon        https://github.githubassets.com/pinned-octocat.svg
// ==/UserScript==
(() => {
	"use strict";

	GM_addStyle(`
		.ghic-button { float:right; }
		.ghic-button .btn:hover div.select-menu-modal-holder { display:block; top:auto; bottom:25px; right:0; }
		.ghic-right { position:absolute; right:10px; top:9px; }
		.ghic-button .select-menu-header, .ghic-participants { cursor:default; display:block; }
		.ghic-participants { border-top:1px solid #484848; padding:15px; }
		.ghic-avatar { display:inline-block; float:left; margin: 0 2px 2px 0; cursor:pointer; position:relative; }
		.ghic-avatar:last-child { margin-bottom:5px; }
		.ghic-avatar.comments-hidden svg { display:block; position:absolute; top:-2px; left:-2px; z-index:1; }
		.ghic-avatar.comments-hidden img { opacity:0.5; }
		.ghic-button .dropdown-item { font-weight:normal; position:relative; }
		.ghic-button .dropdown-item span { font-weight:normal; opacity:.5; }
		.ghic-button .dropdown-item.ghic-has-content span { opacity:1; }
		.ghic-button .dropdown-item.ghic-checked span { font-weight:bold; }
		.ghic-button .dropdown-item.ghic-checked svg,
			.ghic-button .dropdown-item:not(.ghic-checked) .ghic-count { display:inline-block; }
		.ghic-button .dropdown-item:not(.ghic-checked) { text-decoration:line-through; }
		.ghic-button .ghic-count { margin-left:5px; }
		.ghic-button .select-menu-modal { margin:0; }
		.ghic-button .ghic-participants { margin-bottom:20px; }
		/* for testing: ".ghic-hidden { opacity: 0.3; } */
		.ghic-hidden, .ghic-hidden-participant, .ghic-avatar svg, .ghic-button .ghic-count,
			.ghic-hideReactions .comment-reactions,
			.select-menu-header.ghic-active + .select-menu-list .dropdown-item:not(.ghic-has-content) { display:none; }
		.ghic-menu-wrapper input[type=checkbox] { height:0; width:0; visibility:hidden; position:absolute; }
		.ghic-menu-wrapper .ghic-toggle { cursor:pointer; text-indent:-9999px; width:20px; height:10px;
			background:grey; display:block; border-radius:10px; position:relative; }
		.ghic-menu-wrapper .ghic-toggle:after { content:''; position:absolute; top:0; left:1px; width:9px;
			height:9px; background:#fff; border-radius:9px; transition:.3s; }
		.ghic-menu-wrapper input:checked + .ghic-toggle { background:#070; }
		.ghic-menu-wrapper input:checked + .ghic-toggle:after { top:0; left:calc(100% - 1px);
			transform:translateX(-100%); }
		.ghic-menu-wrapper .ghic-toggle:active:after { width:13px; }
		.timeline-comment-wrapper.ghic-highlight .comment { border-color:#800 !important; }
`);

	const regex = /(svg|path)/i,
		// ZenHub addon active (include ZenHub Enterprise)
		hasZenHub = $(".zhio, .zhe") ? true : false,

		exceptions = [
			"ghsr-sort-block" // sort reactions block (github-sort-reactions.user.js)
		],

		settings = {
			// example: https://github.com/Mottie/Keyboard/issues/448
			title: {
				isHidden: false,
				name: "ghic-title",
				selector: ".discussion-item-renamed",
				label: "Title Changes"
			},
			labels: {
				isHidden: false,
				name: "ghic-labels",
				selector: ".discussion-item-labeled, .discussion-item-unlabeled",
				label: "Label Changes"
			},
			state: {
				isHidden: false,
				name: "ghic-state",
				selector: ".discussion-item-reopened, .discussion-item-closed",
				label: "State Changes (close/reopen)"
			},

			// example: https://github.com/jquery/jquery/issues/2986
			milestone: {
				isHidden: false,
				name: "ghic-milestone",
				selector: ".discussion-item-milestoned",
				label: "Milestone Changes"
			},
			refs: {
				isHidden: false,
				name: "ghic-refs",
				selector: ".discussion-item",
				contains: ".discussion-item-ref-title",
				label: "References"
			},
			assigned: {
				isHidden: false,
				name: "ghic-assigned",
				selector: ".discussion-item-assigned",
				label: "Assignment Changes"
			},

			// Pull Requests
			commits: {
				isHidden: false,
				name: "ghic-commits",
				selector: ".discussion-commits",
				label: "Commits"
			},
			reviews: {
				isHidden: false,
				name: "ghic-reviews",
				selector: ".discussion-item-review, .discussion-item-review_requested",
				label: "Reviews (All)"
			},
			outdated: {
				isHidden: false,
				name: "ghic-outdated",
				selector: ".discussion-item-review",
				contains: ".outdated-comment-label",
				label: "Reviews (Outdated)"
			},
			// example: https://github.com/jquery/jquery/pull/3014
			diffOld: {
				isHidden: false,
				name: "ghic-diffOld",
				selector: ".outdated-diff-comment-container",
				label: "Diff (outdated) Comments"
			},
			diffNew: {
				isHidden: false,
				name: "ghic-diffNew",
				selector: "[id^=diff-for-comment-]:not(.outdated-diff-comment-container)",
				label: "Diff (current) Comments"
			},
			// example: https://github.com/jquery/jquery/pull/2949
			merged: {
				isHidden: false,
				name: "ghic-merged",
				selector: ".discussion-item-merged",
				label: "Merged"
			},
			integrate: {
				isHidden: false,
				name: "ghic-integrate",
				selector: ".discussion-item-integrations-callout",
				label: "Integrations"
			},

			// similar comments
			similar: {
				isHidden: false,
				name: "ghic-similar",
				selector: `.js-discussion > .Details-element.details-reset,
					#js-progressive-timeline-item-container > .Details-element.details-reset`,
				label: "Similar comments"
			},

			// extras (special treatment - no selector)
			plus1: {
				isHidden: false,
				name: "ghic-plus1",
				label: "+1 Comments"
			},
			reactions: {
				isHidden: false,
				name: "ghic-reactions",
				label: "Reactions"
			},
			projects: {
				isHidden: false,
				name: "ghic-projects",
				selector: `.discussion-item-added_to_project,
					.discussion-item-moved_columns_in_project,
					.discussion-item-removed_from_project`,
				label: "Project Changes"
			},
			// page with lots of users to hide:
			// https://github.com/isaacs/github/issues/215

			// ZenHub pipeline change
			pipeline: {
				isHidden: false,
				name: "ghic-pipeline",
				selector: ".discussion-item.zh-discussion-item",
				label: "ZenHub Pipeline Changes"
			}
		};

	const iconHidden = `<svg class="octicon" xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 9 9"><path fill="#777" d="M7.07 4.5c0-.47-.12-.9-.35-1.3L3.2 6.7c.4.25.84.37 1.3.37.35 0 .68-.07 1-.2.32-.14.6-.32.82-.55.23-.23.4-.5.55-.82.13-.32.2-.65.2-1zM2.3 5.8l3.5-3.52c-.4-.23-.83-.35-1.3-.35-.35 0-.68.07-1 .2-.3.14-.6.32-.82.55-.23.23-.4.5-.55.82-.13.32-.2.65-.2 1 0 .47.12.9.36 1.3zm6.06-1.3c0 .7-.17 1.34-.52 1.94-.34.6-.8 1.05-1.4 1.4-.6.34-1.24.52-1.94.52s-1.34-.18-1.94-.52c-.6-.35-1.05-.8-1.4-1.4C.82 5.84.64 5.2.64 4.5s.18-1.35.52-1.94.8-1.06 1.4-1.4S3.8.64 4.5.64s1.35.17 1.94.52 1.06.8 1.4 1.4c.35.6.52 1.24.52 1.94z"/></svg>`,
		plus1Icon = `<img src="https://assets-cdn.github.com/images/icons/emoji/unicode/1f44d.png" class="emoji" title=":+1:" alt=":+1:" height="20" width="20" align="absmiddle">`;

	function addMenu() {
		if ($("#discussion_bucket") && !$(".ghic-button")) {
			// update "isHidden" values
			getSettings();
			let name, isHidden, isChecked,
				list = "",
				keys = Object.keys(settings),
				onlyActive = GM_getValue("onlyActive", false),
				header = $(".discussion-sidebar-item:last-child"),
				menu = document.createElement("div");

			for (name of keys) {
				if (!(name === "pipeline" && !hasZenHub)) {
					isHidden = settings[name].isHidden;
					isChecked = isHidden ? "" : "ghic-checked";
					list += `<label class="dropdown-item ${isChecked} ${settings[name].name}" data-ghic="${name}">
							<span>${settings[name].label} <span class="ghic-count"> </span></span>
							<span class="ghic-right">
								<input type="checkbox"${isHidden ? "" : " checked"}>
								<span class="ghic-toggle"></span>
							</span>
						</label>`;
				}
			}

			menu.className = "ghic-button";
			menu.innerHTML = `
				<span class="btn btn-sm" role="button" tabindex="0" aria-haspopup="true">
					<span class="tooltipped tooltipped-w" aria-label="Toggle issue comments">
						<svg class="octicon octicon-comment-discussion" height="16" width="16" role="img" viewBox="0 0 16 16">
							<path d="M15 2H6c-0.55 0-1 0.45-1 1v2H1c-0.55 0-1 0.45-1 1v6c0 0.55 0.45 1 1 1h1v3l3-3h4c0.55 0 1-0.45 1-1V10h1l3 3V10h1c0.55 0 1-0.45 1-1V3c0-0.55-0.45-1-1-1zM9 12H4.5l-1.5 1.5v-1.5H1V6h4v3c0 0.55 0.45 1 1 1h3v2z m6-3H13v1.5l-1.5-1.5H6V3h9v6z"></path>
						</svg>
					</span>
					<div class="select-menu-modal-holder ghic-menu-wrapper">
						<div class="select-menu-modal" aria-hidden="true">
							<div class="select-menu-header ${onlyActive ? "ghic-active" : ""}" tabindex="-1">
								<span class="select-menu-title">Toggle items</span>
								<label class="ghic-right tooltipped tooltipped-w" aria-label="Only show active items">
									<input id="ghic-only-active" type="checkbox" ${onlyActive ? "checked" : ""}>
									<span class="ghic-toggle"></span>
								</label>
							</div>
							<div class="select-menu-list ghic-menu" role="menu">
								${list}
								<div class="ghic-participants">
									<p><strong>Hide Comments from</strong></p>
									<div class="ghic-list"></div>
								</div>
							</div>
						</div>
					</div>
				</span>
			`;
			if (hasZenHub) {
				header.insertBefore(menu, header.childNodes[0]);
			} else {
				header.appendChild(menu);
			}
			addAvatars();
		}
		update();
	}

	function addAvatars() {
		let indx = 0,
			str = "",
			list = $(".ghic-list"),
			unique = $$("span.ghic-avatar", list).map(el => el.getAttribute("aria-label")),
			// get all avatars
			avatars = $$(".timeline-comment-avatar img"),
			len = avatars.length - 1, // last avatar is the new comment with the current user
			updateAvatars = () => {
				list.innerHTML += str;
				str = "";
			},

			loop = () => {
				let el, name,
					max = 0;
				while (max < 50 && indx < len) {
					if (indx >= len) {
						return updateAvatars();
					}
					el = avatars[indx];
					name = (el.getAttribute("alt") || "").replace("@", "");
					if (!unique.includes(name)) {
						str += `<span class="ghic-avatar tooltipped tooltipped-n" aria-label="${name}">
								${iconHidden}
								<img class="ghic-avatar avatar" width="24" height="24" src="${el.src}"/>
							</span>`;
						unique[unique.length] = name;
						max++;
					}
					indx++;
				}
				updateAvatars();
				if (indx < len) {
					setTimeout(() => {
						loop();
					}, 200);
				}
			};
		loop();
	}

	function getSettings() {
		let name,
			keys = Object.keys(settings);
		for (name of keys) {
			settings[name].isHidden = GM_getValue(settings[name].name, false);
		}
	}

	function saveSettings() {
		let name,
			keys = Object.keys(settings);
		for (name of keys) {
			GM_setValue(settings[name].name, settings[name].isHidden);
		}
	}

	function getInputValues() {
		let name, item,
			keys = Object.keys(settings),
			menu = $(".ghic-menu");
		for (name of keys) {
			if (!(name === "pipeline" && !hasZenHub)) {
				item = closest(".dropdown-item", $("." + settings[name].name, menu));
				if (item) {
					settings[name].isHidden = !$("input", item).checked;
					toggleClass(item, "ghic-checked", !settings[name].isHidden);
				}
			}
		}
	}

	function hideStuff(name, init) {
		const obj = settings[name],
			isHidden = obj.isHidden;
		let count, results,
			item = $(".ghic-menu .dropdown-item." + obj.name);
		if (name === "plus1") {
			hidePlus1(init, item);
		} else if (item && name === "reactions") {
			toggleClass($("body"), "ghic-hideReactions", isHidden);
			toggleClass(item, "ghic-has-content", $$(".has-reactions").length - 1 > 0);
			// make first comment reactions visible
			item = $(".has-reactions", $(".timeline-comment-wrapper"));
			if (item) {
				item.style.display = "block";
			}
		} else if (item && obj.selector) {
			results = $$(obj.selector);
			if (obj.contains) {
				results = results.filter(el => {
					return !!$(obj.contains, el);
				});
			}
			toggleClass(item, "ghic-checked", !isHidden);
			if (isHidden) {
				count = addClass(results, "ghic-hidden");
				$(".ghic-count", item).textContent = count ? "(" + count + " hidden)" : " ";
			} else if (!init) {
				// no need to remove classes on initialization
				removeClass(results, "ghic-hidden");
			}
			toggleClass(item, "ghic-has-content", results.length);
		}
	}

	function hidePlus1(init, item) {
		let max,
			indx = 0,
			count = 0,
			total = 0,
			// keep a list of post authors to prevent duplicate +1 counts
			authors = [],
			// used https://github.com/isaacs/github/issues/215 for matches here...
			// matches "+1!!!!", "++1", "+!", "+99!!!", "-1", "+ 100", "thumbs up"; ":+1:^21425235"
			// ignoring -1's... add unicode for thumbs up; it gets replaced with an image in Windows
			regexPlus = /([?!*,.:^[\]()\'\"+-\d]|bump|thumbs|up|\ud83d\udc4d)/gi,
			// other comments to hide - they are still counted towards the +1 counter (for now?)
			// seen "^^^" to bump posts; "bump plleeaaassee"; "eta?"; "pretty please"
			// "need this"; "right now"; "still nothing?"; "super helpful"; "for gods sake"
			regexHide = new RegExp("(" + [
				"@\\w+",
				"\\b(it|is|a|so|the|and|no|on|oh|do|this|any|very|much|here|just|my|me|too|want|yet|image)\\b",
				"pretty",
				"pl+e+a+s+e+",
				"plz",
				"totally",
				"y+e+s+",
				"eta",
				"fix",
				"right",
				"now",
				"hope(ful)?",
				"still",
				"wait(ed|ing)?",
				"nothing",
				"really",
				"add(ed|ing)?",
				"need(ed|ing)?",
				"updat(es|ed|ing)?",
				"(months|years)\\slater",
				"back",
				"features?",
				"infinity", // +Infinity
				"useful",
				"super",
				"helpful",
				"thanks",
				"for\\sgod'?s\\ssake",
				"c['emon]+" // c'mon, com'on, comeon
			].join("|") + ")", "gi"),
			// image title ":{anything}:", etc.
			regexEmoji = /(:.*:)|[\u{1f300}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{1f900}-\u{1f9ff}]/gu,
			regexWhitespace = /\s+/g,

			comments = $$(".js-discussion .timeline-comment-wrapper")
				.filter(comment => {
					const classes = comment.className.split(" ");
					return !exceptions.some(ex => classes.includes(ex));
				}),
			len = comments.length,

			loop = () => {
				let wrapper, el, tmp, txt, img, hasLink, dupe;
				max = 0;
				while (max < 20 && indx < len) {
					if (indx >= len) {
						if (init) {
							item.classList.toggle("ghic-has-content", count > 0);
						}
						return;
					}
					wrapper = comments[indx];
					// save author list to prevent repeat +1s
					el = $(".timeline-comment-header .author", wrapper);
					txt = (el ? el.textContent || "" : "").toLowerCase();
					dupe = true;
					if (txt && authors.indexOf(txt) < 0) {
						authors[authors.length] = txt;
						dupe = false;
					}
					el = $(".comment-body", wrapper);
					// ignore quoted messages, but get all fragments
					tmp = $$(".email-fragment", el);
					// some posts only contain a link to related issues; these should not be counted as a +1
					// see https://github.com/isaacs/github/issues/618#issuecomment-200869630
					hasLink = $$(tmp.length ? ".email-fragment .issue-link" : ".issue-link", el).length;
					if (tmp.length) {
						// ignore quoted messages
						txt = getAllText(tmp);
					} else {
						txt = (el ? el.textContent || "" : "").trim();
					}
					if (!txt) {
						img = $("img", el);
						if (img) {
							txt = img.getAttribute("title") || img.getAttribute("alt");
						}
					}
					// remove fluff
					txt = (txt || "")
						.replace(regexEmoji, "")
						.replace(regexHide, "")
						.replace(regexPlus, "")
						.replace(regexWhitespace, " ")
						.trim();
					if (txt === "" || (txt.length <= 4 && !hasLink)) {
						if (init && !settings.plus1.isHidden) {
							// +1 Comments has-content
							item.classList.toggle("ghic-has-content", true);
							return;
						}
						if (settings.plus1.isHidden) {
							wrapper.classList.add("ghic-hidden", "ghic-highlight");
							total++;
							// one +1 per author
							if (!dupe) {
								count++;
							}
						} else if (!init) {
							wrapper.classList.remove("ghic-hidden");
						}
						max++;
					}
					indx++;
				}
				if (indx < len) {
					setTimeout(() => {
						window.requestAnimationFrame(loop);
					}, 200);
				} else {
					if (init) {
						item.classList.toggle("ghic-has-content", count > 0);
					}
					$(".ghic-menu .ghic-plus1 .ghic-count").textContent = total ? "(" + total + " hidden)" : " ";
					addCountToReaction(count);
				}
			};
		loop();
	}

	function getAllText(el) {
		let txt = "",
			indx = el.length;
		// text order doesn't matter
		while (indx--) {
			txt += el[indx].textContent.trim();
		}
		return txt;
	}

	function addCountToReaction(count) {
		if (!count) {
			count = ($(".ghic-menu .ghic-plus1 .ghic-count").textContent || "")
				.replace(/[()]/g, "")
				.trim();
		}
		let comment = $(".timeline-comment"),
			tmp = $(
				".has-reactions button[value='+1 react'], .has-reactions button[value='+1 unreact']",
				comment
			),
			el = $(".ghic-count", comment);
		if (el) {
			// the count may have been appended to the comment & now
			// there is a reaction, so remove any "ghic-count" elements
			el.parentNode.removeChild(el);
		}
		if (count) {
			if (tmp) {
				el = document.createElement("span");
				el.className = "ghic-count";
				el.textContent = count ? " + " + count + " (from hidden comments)" : "";
				tmp.appendChild(el);
			} else {
				el = document.createElement("p");
				el.className = "ghic-count";
				el.innerHTML = "<hr>" + plus1Icon + " " + count + " (from hidden comments)";
				$(".comment-body", comment).appendChild(el);
			}
		}
	}

	function hideParticipant(el) {
		if (el) {
			el.classList.toggle("comments-hidden");
			let name = el.getAttribute("aria-label"),
				results = $$(
					".timeline-comment-wrapper, .commit-comment, .discussion-item"
				).filter(el => {
					const author = $(".js-discussion .author", el);
					return author ? name === author.textContent.trim() : false;
				});
			// use a different participant class name to hide timeline events
			// or unselecting all users will show everything
			if (el.classList.contains("comments-hidden")) {
				addClass(results, "ghic-hidden-participant");
			} else {
				removeClass(results, "ghic-hidden-participant");
			}
			results = [];
		}
	}

	function update() {
		if ($("#discussion_bucket") && $(".ghic-button")) {
			let keys = Object.keys(settings),
				indx = keys.length;
			while (indx--) {
				// true flag for init - no need to remove classes
				hideStuff(keys[indx], true);
			}
			addAvatars();
		}
	}

	function checkItem(event) {
		if (document.getElementById("discussion_bucket")) {
			let name,
				target = event.target,
				wrap = target && target.closest(".dropdown-item, .ghic-participants");
			if (target && wrap) {
				if (target.nodeName === "INPUT") {
					getInputValues();
					saveSettings();
					name = wrap.dataset.ghic;
					if (name) {
						hideStuff(name);
					}
				} else if (target.classList.contains("ghic-avatar")) {
					// make sure we're targeting the span wrapping the image
					hideParticipant(target.nodeName === "IMG" ? target.parentNode : target);
				} else if (regex.test(target.nodeName)) {
					// clicking on the SVG may target the svg or path inside
					hideParticipant(closest(".ghic-avatar", target));
				}
			} else if (target.id === "ghic-only-active") {
				closest(".select-menu-header", target).classList.toggle("ghic-active", target.checked);
				GM_setValue("onlyActive", target.checked);
			}
			// Make button show if it is active
			target = $(".ghic-button .btn");
			if (target) {
				const active = $$(".ghic-hidden, .ghic-hidden-participant").length > 0;
				target.classList.toggle("btn-outline", active);
			}
		}
	}

	function $(selector, el) {
		return (el || document).querySelector(selector);
	}

	function $$(selector, el) {
		return Array.from((el || document).querySelectorAll(selector));
	}

	function closest(selector, el) {
		while (el && el.nodeType === 1) {
			if (el.matches(selector)) {
				return el;
			}
			el = el.parentNode;
		}
		return null;
	}

	function addClass(els, name) {
		let indx,
			len = els.length;
		for (indx = 0; indx < len; indx++) {
			els[indx].classList.add(name);
		}
		return len;
	}

	function removeClass(els, name) {
		let indx,
			len = els.length;
		for (indx = 0; indx < len; indx++) {
			els[indx].classList.remove(name);
		}
	}

	function toggleClass(els, name, flag) {
		els = Array.isArray(els) ? els : [els];
		let el,
			indx = els.length;
		while (indx--) {
			el = els[indx];
			if (el) {
				if (typeof flag === "undefined") {
					flag = !el.classList.contains(name);
				}
				if (flag) {
					el.classList.add(name);
				} else {
					el.classList.remove(name);
				}
			}
		}
	}

	function init() {
		getSettings();
		addMenu();
		$("body").addEventListener("click", checkItem);
		update();
	}

	// update list when content changes
	document.addEventListener("ghmo:container", addMenu);
	document.addEventListener("ghmo:comments", update);
	init();

})();