GitHub Toggle Issue Comments

A userscript that toggles issues/pull request comments & messages

当前为 2018-05-10 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name GitHub Toggle Issue Comments
  3. // @version 1.2.0
  4. // @description A userscript that toggles issues/pull request comments & messages
  5. // @license MIT
  6. // @author Rob Garrison
  7. // @namespace https://github.com/Mottie
  8. // @include https://github.com/*
  9. // @run-at document-idle
  10. // @grant GM_addStyle
  11. // @grant GM_getValue
  12. // @grant GM_setValue
  13. // @require https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=596445
  14. // @icon https://assets-cdn.github.com/pinned-octocat.svg
  15. // ==/UserScript==
  16. (() => {
  17. "use strict";
  18.  
  19. GM_addStyle(`
  20. .ghic-button { float:right; }
  21. .ghic-button .btn:hover div.select-menu-modal-holder { display:block; top:auto; bottom:25px; right:0; }
  22. .ghic-right { float:right; }
  23. /* pre-wrap set for Firefox; see https://greasyfork.org/en/forum/discussion/9166/x */
  24. .ghic-menu label { display:block; padding:5px 15px; white-space:pre-wrap; }
  25. .ghic-button .select-menu-header, .ghic-participants { cursor:default; }
  26. .ghic-participants { border-top:1px solid #484848; padding:15px; }
  27. .ghic-avatar { display:inline-block; float:left; margin: 0 2px 2px 0; cursor:pointer; position:relative; }
  28. .ghic-avatar:last-child { margin-bottom:5px; }
  29. .ghic-avatar.comments-hidden svg { display:block; position:absolute; top:-2px; left:-2px; z-index:1; }
  30. .ghic-avatar.comments-hidden img { opacity:0.5; }
  31. .ghic-button .dropdown-item span { font-weight:normal; opacity:.5; }
  32. .ghic-button .dropdown-item.ghic-has-content span { opacity:1; }
  33. .ghic-button .dropdown-item.ghic-checked span { font-weight:bold; }
  34. .ghic-button .dropdown-item.ghic-checked svg,
  35. .ghic-button .dropdown-item:not(.ghic-checked) .ghic-count { display:inline-block; }
  36. .ghic-button .ghic-count { float:left; margin-right:5px; }
  37. .ghic-button .select-menu-modal { margin:0; }
  38. .ghic-button .ghic-participants { margin-bottom:20px; }
  39. /* for testing: ".ghic-hidden { opacity: 0.3; } */
  40. .ghic-hidden, .ghic-hidden-participant, .ghic-avatar svg, .ghic-button .ghic-right > *,
  41. .ghic-hideReactions .comment-reactions { display:none; }
  42. `);
  43.  
  44. const regex = /(svg|path)/i,
  45. // ZenHub addon active (include ZenHub Enterprise)
  46. hasZenHub = $(".zhio, .zhe") ? true : false,
  47.  
  48. settings = {
  49. // example: https://github.com/Mottie/Keyboard/issues/448
  50. title: {
  51. isHidden: false,
  52. name: "ghic-title",
  53. selector: ".discussion-item-renamed",
  54. label: "Title Changes"
  55. },
  56. labels: {
  57. isHidden: false,
  58. name: "ghic-labels",
  59. selector: ".discussion-item-labeled, .discussion-item-unlabeled",
  60. label: "Label Changes"
  61. },
  62. state: {
  63. isHidden: false,
  64. name: "ghic-state",
  65. selector: ".discussion-item-reopened, .discussion-item-closed",
  66. label: "State Changes (close/reopen)"
  67. },
  68.  
  69. // example: https://github.com/jquery/jquery/issues/2986
  70. milestone: {
  71. isHidden: false,
  72. name: "ghic-milestone",
  73. selector: ".discussion-item-milestoned",
  74. label: "Milestone Changes"
  75. },
  76. refs: {
  77. isHidden: false,
  78. name: "ghic-refs",
  79. selector: ".discussion-item",
  80. contains: ".discussion-item-ref-title",
  81. label: "References"
  82. },
  83. assigned: {
  84. isHidden: false,
  85. name: "ghic-assigned",
  86. selector: ".discussion-item-assigned",
  87. label: "Assignment Changes"
  88. },
  89.  
  90. // Pull Requests
  91. commits: {
  92. isHidden: false,
  93. name: "ghic-commits",
  94. selector: ".discussion-commits",
  95. label: "Commits"
  96. },
  97. reviews: {
  98. isHidden: false,
  99. name: "ghic-reviews",
  100. selector: ".discussion-item-review, .discussion-item-review_requested",
  101. label: "Reviews (All)"
  102. },
  103. outdated: {
  104. isHidden: false,
  105. name: "ghic-outdated",
  106. selector: ".discussion-item-review",
  107. contains: ".outdated-comment-label",
  108. label: "Reviews (Outdated)"
  109. },
  110. // example: https://github.com/jquery/jquery/pull/3014
  111. diffOld: {
  112. isHidden: false,
  113. name: "ghic-diffOld",
  114. selector: ".outdated-diff-comment-container",
  115. label: "Diff (outdated) Comments"
  116. },
  117. diffNew: {
  118. isHidden: false,
  119. name: "ghic-diffNew",
  120. selector: "[id^=diff-for-comment-]:not(.outdated-diff-comment-container)",
  121. label: "Diff (current) Comments"
  122. },
  123. // example: https://github.com/jquery/jquery/pull/2949
  124. merged: {
  125. isHidden: false,
  126. name: "ghic-merged",
  127. selector: ".discussion-item-merged",
  128. label: "Merged"
  129. },
  130. integrate: {
  131. isHidden: false,
  132. name: "ghic-integrate",
  133. selector: ".discussion-item-integrations-callout",
  134. label: "Integrations"
  135. },
  136.  
  137. // extras (special treatment - no selector)
  138. plus1: {
  139. isHidden: false,
  140. name: "ghic-plus1",
  141. label: "+1 Comments"
  142. },
  143. reactions: {
  144. isHidden: false,
  145. name: "ghic-reactions",
  146. label: "Reactions"
  147. },
  148. // page with lots of users to hide:
  149. // https://github.com/isaacs/github/issues/215
  150.  
  151. // ZenHub pipeline change
  152. pipeline: {
  153. isHidden: false,
  154. name: "ghic-pipeline",
  155. selector: ".discussion-item.zh-discussion-item",
  156. label: "ZenHub Pipeline Changes"
  157. }
  158. };
  159.  
  160. 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>`,
  161. iconCheck = `<svg class="octicon octicon-check" height="16" viewBox="0 0 12 16" width="12"><path d="M12 5L4 13 0 9l1.5-1.5 2.5 2.5 6.5-6.5 1.5 1.5z"></path></svg>`,
  162. 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">`;
  163.  
  164. function addMenu() {
  165. if ($("#discussion_bucket") && !$(".ghic-button")) {
  166. // update "isHidden" values
  167. getSettings();
  168. let name, bright, isHidden, isChecked,
  169. list = "",
  170. keys = Object.keys(settings),
  171. header = $(".discussion-sidebar-item:last-child"),
  172. menu = document.createElement("div");
  173.  
  174. for (name of keys) {
  175. if (!(name === "pipeline" && !hasZenHub)) {
  176. // make plus1 and reactions list items always bright
  177. bright = name === "plus1" ? " ghic-has-content" : "";
  178. isHidden = settings[name].isHidden;
  179. isChecked = isHidden ? "" : " ghic-checked";
  180. // not using multi-line backticks because it adds lots of white-space to the label
  181. list += `<label class="dropdown-item${bright}${isChecked}">` +
  182. `<span>${settings[name].label}</span>` +
  183. `<span class="ghic-right ${settings[name].name}">` +
  184. `<input type="checkbox"${isHidden ? "" : " checked"}>` +
  185. `${iconCheck}<span class="ghic-count"> </span>` +
  186. `</span></label>`;
  187. }
  188. }
  189.  
  190. menu.className = "ghic-button";
  191. menu.innerHTML = `
  192. <span class="btn btn-sm" role="button" tabindex="0" aria-haspopup="true">
  193. <span class="tooltipped tooltipped-w" aria-label="Toggle issue comments">
  194. <svg class="octicon octicon-comment-discussion" height="16" width="16" role="img" viewBox="0 0 16 16">
  195. <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>
  196. </svg>
  197. </span>
  198. <div class="select-menu-modal-holder">
  199. <div class="select-menu-modal" aria-hidden="true">
  200. <div class="select-menu-header" tabindex="-1">
  201. <span class="select-menu-title">Toggle items</span>
  202. </div>
  203. <div class="select-menu-list ghic-menu" role="menu">
  204. ${list}
  205. <div class="ghic-participants"></div>
  206. </div>
  207. </div>
  208. </div>
  209. </span>
  210. `;
  211. if (hasZenHub) {
  212. header.insertBefore(menu, header.childNodes[0]);
  213. } else {
  214. header.appendChild(menu);
  215. }
  216. addAvatars();
  217. }
  218. update();
  219. }
  220.  
  221. function addAvatars() {
  222. let indx = 0,
  223.  
  224. str = "<p><strong>Hide Comments from</strong></p>",
  225. unique = [],
  226. // get all avatars
  227. avatars = $$(".timeline-comment-avatar img"),
  228. len = avatars.length - 1, // last avatar is the new comment with the current user
  229.  
  230. loop = (callback) => {
  231. let el, name,
  232. max = 0;
  233. while (max < 50 && indx < len) {
  234. if (indx >= len) {
  235. return callback();
  236. }
  237. el = avatars[indx];
  238. name = (el.getAttribute("alt") || "").replace("@", "");
  239. if (!unique.includes(name)) {
  240. str += `<span class="ghic-avatar tooltipped tooltipped-n" aria-label="${name}">
  241. ${iconHidden}
  242. <img class="ghic-avatar avatar" width="24" height="24" src="${el.src}"/>
  243. </span>`;
  244. unique[unique.length] = name;
  245. max++;
  246. }
  247. indx++;
  248. }
  249. if (indx < len) {
  250. setTimeout(() => {
  251. loop(callback);
  252. }, 200);
  253. } else {
  254. callback();
  255. }
  256. };
  257. loop(() => {
  258. $(".ghic-participants").innerHTML = str;
  259. });
  260. }
  261.  
  262. function getSettings() {
  263. let name,
  264. keys = Object.keys(settings);
  265. for (name of keys) {
  266. settings[name].isHidden = GM_getValue(settings[name].name, false);
  267. }
  268. }
  269.  
  270. function saveSettings() {
  271. let name,
  272. keys = Object.keys(settings);
  273. for (name of keys) {
  274. GM_setValue(settings[name].name, settings[name].isHidden);
  275. }
  276. }
  277.  
  278. function getInputValues() {
  279. let name, item,
  280. keys = Object.keys(settings),
  281. menu = $(".ghic-menu");
  282. for (name of keys) {
  283. if (!(name === "pipeline" && !hasZenHub)) {
  284. item = closest(".dropdown-item", $("." + settings[name].name, menu));
  285. if (item) {
  286. settings[name].isHidden = !$("input", item).checked;
  287. toggleClass(item, "ghic-checked", !settings[name].isHidden);
  288. }
  289. }
  290. }
  291. }
  292.  
  293. function hideStuff(name, init) {
  294. const obj = settings[name],
  295. isHidden = obj.isHidden;
  296. let count, results,
  297. item = closest(".dropdown-item", $(".ghic-menu ." + obj.name));
  298. if (item && obj.selector) {
  299. results = $$(obj.selector);
  300. if (obj.contains) {
  301. results = results.filter(el => {
  302. return !!$(obj.contains, el);
  303. });
  304. }
  305. toggleClass(item, "ghic-checked", !isHidden);
  306. if (isHidden) {
  307. count = addClass(results, "ghic-hidden");
  308. $(".ghic-count", item).textContent = count ? "(" + count + " hidden)" : " ";
  309. } else if (!init) {
  310. // no need to remove classes on initialization
  311. removeClass(results, "ghic-hidden");
  312. }
  313. toggleClass(item, "ghic-has-content", results.length);
  314. } else if (name === "plus1") {
  315. hidePlus1(init);
  316. } else if (item && name === "reactions") {
  317. toggleClass($("body"), "ghic-hideReactions", isHidden);
  318. toggleClass(item, "ghic-has-content", $$(".has-reactions").length - 1);
  319. // make first comment reactions visible
  320. item = $(".has-reactions", $(".timeline-comment-wrapper"));
  321. if (item) {
  322. item.style.display = "block";
  323. }
  324. }
  325. }
  326.  
  327. function hidePlus1(init) {
  328. if (init && !settings.plus1.isHidden) {
  329. return;
  330. }
  331. let max,
  332. indx = 0,
  333. count = 0,
  334. total = 0,
  335. // keep a list of post authors to prevent duplicate +1 counts
  336. authors = [],
  337. // used https://github.com/isaacs/github/issues/215 for matches here...
  338. // matches "+1!!!!", "++1", "+!", "+99!!!", "-1", "+ 100", "thumbs up"; ":+1:^21425235"
  339. // ignoring -1's... add unicode for thumbs up; it gets replaced with an image in Windows
  340. regexPlus = /([?!*,.:^[\]()\'\"+-\d]|bump|thumbs|up|\ud83d\udc4d)/gi,
  341. // other comments to hide - they are still counted towards the +1 counter (for now?)
  342. // seen "^^^" to bump posts; "bump plleeaaassee"; "eta?"; "pretty please"
  343. // "need this"; "right now"; "still nothing?"; "super helpful"; "for gods sake"
  344. regexHide = new RegExp("(" + [
  345. "@\\w+",
  346. "\\b(it|is|a|so|the|and|no|on|oh|do|this|any|very|much|here)\\b",
  347. "pretty",
  348. "pl+e+a+s+e+",
  349. "y+e+s+",
  350. "eta",
  351. "need(ed)?",
  352. "fix",
  353. "right",
  354. "now",
  355. "hope(ful)?",
  356. "still",
  357. "wait(ing)?",
  358. "nothing",
  359. "really",
  360. "add(ed)?",
  361. "need(ed)?",
  362. "update[sd]?",
  363. "back",
  364. "feature",
  365. "useful",
  366. "super",
  367. "helpful",
  368. "thanks",
  369. "for\\sgods\\ssake",
  370. "c['emon]+" // c'mon, com'on, comeon
  371. ].join("|") + ")", "gi"),
  372. // image title ":{anything}:", etc.
  373. regexEmoji = /(:.*:)|[\u{1f300}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{1f900}-\u{1f9ff}]/gu,
  374. regexWhitespace = /\s+/g,
  375.  
  376. comments = $$(".js-discussion .timeline-comment-wrapper"),
  377. len = comments.length,
  378.  
  379. loop = () => {
  380. let wrapper, el, tmp, txt, img, hasLink, dupe;
  381. max = 0;
  382. while (max < 20 && indx < len) {
  383. if (indx >= len) {
  384. return;
  385. }
  386. wrapper = comments[indx];
  387. // save author list to prevent repeat +1s
  388. el = $(".timeline-comment-header .author", wrapper);
  389. txt = (el ? el.textContent || "" : "").toLowerCase();
  390. dupe = true;
  391. if (txt && authors.indexOf(txt) < 0) {
  392. authors[authors.length] = txt;
  393. dupe = false;
  394. }
  395. el = $(".comment-body", wrapper);
  396. // ignore quoted messages, but get all fragments
  397. tmp = $$(".email-fragment", el);
  398. // some posts only contain a link to related issues; these should not be counted as a +1
  399. // see https://github.com/isaacs/github/issues/618#issuecomment-200869630
  400. hasLink = $$(tmp.length ? ".email-fragment .issue-link" : ".issue-link", el).length;
  401. if (tmp.length) {
  402. // ignore quoted messages
  403. txt = getAllText(tmp);
  404. } else {
  405. txt = el.textContent.trim();
  406. }
  407. if (!txt) {
  408. img = $("img", el);
  409. if (img) {
  410. txt = img.getAttribute("title") || img.getAttribute("alt");
  411. }
  412. }
  413. // remove fluff
  414. txt = txt
  415. .replace(regexEmoji, "")
  416. .replace(regexHide, "")
  417. .replace(regexPlus, "")
  418. .replace(regexWhitespace, " ")
  419. .trim();
  420. if (txt === "" || (txt.length <= 4 && !hasLink)) {
  421. if (settings.plus1.isHidden) {
  422. wrapper.classList.add("ghic-hidden");
  423. total++;
  424. // one +1 per author
  425. if (!dupe) {
  426. count++;
  427. }
  428. } else if (!init) {
  429. wrapper.classList.remove("ghic-hidden");
  430. }
  431. max++;
  432. }
  433. indx++;
  434. }
  435. if (indx < len) {
  436. setTimeout(() => {
  437. window.requestAnimationFrame(loop);
  438. }, 200);
  439. } else {
  440. $(".ghic-menu .ghic-plus1 .ghic-count").textContent = total ? "(" + total + " hidden)" : " ";
  441. toggleClass($(".ghic-menu ." + settings.plus1.name), "ghic-has-content", total);
  442. addCountToReaction(count);
  443. }
  444. };
  445. loop();
  446. }
  447.  
  448. function getAllText(el) {
  449. let txt = "",
  450. indx = el.length;
  451. // text order doesn't matter
  452. while (indx--) {
  453. txt += el[indx].textContent.trim();
  454. }
  455. return txt;
  456. }
  457.  
  458. function addCountToReaction(count) {
  459. if (!count) {
  460. count = ($(".ghic-menu .ghic-plus1 .ghic-count").textContent || "")
  461. .replace(/[()]/g, "")
  462. .trim();
  463. }
  464. let comment = $(".timeline-comment"),
  465. tmp = $(
  466. ".has-reactions button[value='+1 react'], .has-reactions button[value='+1 unreact']",
  467. comment
  468. ),
  469. el = $(".ghic-count", comment);
  470. if (el) {
  471. // the count may have been appended to the comment & now
  472. // there is a reaction, so remove any "ghic-count" elements
  473. el.parentNode.removeChild(el);
  474. }
  475. if (count) {
  476. if (tmp) {
  477. el = document.createElement("span");
  478. el.className = "ghic-count";
  479. el.textContent = count ? " + " + count + " (from hidden comments)" : "";
  480. tmp.appendChild(el);
  481. } else {
  482. el = document.createElement("p");
  483. el.className = "ghic-count";
  484. el.innerHTML = "<hr>" + plus1Icon + " " + count + " (from hidden comments)";
  485. $(".comment-body", comment).appendChild(el);
  486. }
  487. }
  488. }
  489.  
  490. function hideParticipant(el) {
  491. if (el) {
  492. el.classList.toggle("comments-hidden");
  493. let name = el.getAttribute("aria-label"),
  494. results = $$(
  495. ".timeline-comment-wrapper, .commit-comment, .discussion-item"
  496. ).filter(el => {
  497. const author = $(".js-discussion .author", el);
  498. return author ? name === author.textContent.trim() : false;
  499. });
  500. // use a different participant class name to hide timeline events
  501. // or unselecting all users will show everything
  502. if (el.classList.contains("comments-hidden")) {
  503. addClass(results, "ghic-hidden-participant");
  504. } else {
  505. removeClass(results, "ghic-hidden-participant");
  506. }
  507. results = [];
  508. }
  509. }
  510.  
  511. function update() {
  512. if ($("#discussion_bucket") && $(".ghic-button")) {
  513. let keys = Object.keys(settings),
  514. indx = keys.length;
  515. while (indx--) {
  516. // true flag for init - no need to remove classes
  517. hideStuff(keys[indx], true);
  518. }
  519. }
  520. }
  521.  
  522. function checkItem(event) {
  523. if (document.getElementById("discussion_bucket")) {
  524. let name,
  525. target = event.target,
  526. wrap = target && target.parentNode;
  527. if (target && wrap) {
  528. if (target.nodeName === "INPUT" && wrap.classList.contains("ghic-right")) {
  529. getInputValues();
  530. saveSettings();
  531. // extract ghic-{name}, because it matches the name in settings
  532. name = wrap.className.replace("ghic-right", "").replace("ghic-has-content", "").trim();
  533. if (wrap.classList.contains(name)) {
  534. hideStuff(name.replace("ghic-", ""));
  535. }
  536. } else if (target.classList.contains("ghic-avatar")) {
  537. // make sure we're targeting the span wrapping the image
  538. hideParticipant(target.nodeName === "IMG" ? target.parentNode : target);
  539. } else if (regex.test(target.nodeName)) {
  540. // clicking on the SVG may target the svg or path inside
  541. hideParticipant(closest(".ghic-avatar", target));
  542. }
  543. }
  544. // Make button show if it is active
  545. target = $(".ghic-button .btn");
  546. if (target) {
  547. const active = $$(".ghic-hidden, .ghic-hidden-participant").length > 0;
  548. target.classList.toggle("btn-outline", active);
  549. }
  550. }
  551. }
  552.  
  553. function $(selector, el) {
  554. return (el || document).querySelector(selector);
  555. }
  556.  
  557. function $$(selector, el) {
  558. return Array.from((el || document).querySelectorAll(selector));
  559. }
  560.  
  561. function closest(selector, el) {
  562. while (el && el.nodeType === 1) {
  563. if (el.matches(selector)) {
  564. return el;
  565. }
  566. el = el.parentNode;
  567. }
  568. return null;
  569. }
  570.  
  571. function addClass(els, name) {
  572. let indx,
  573. len = els.length;
  574. for (indx = 0; indx < len; indx++) {
  575. els[indx].classList.add(name);
  576. }
  577. return len;
  578. }
  579.  
  580. function removeClass(els, name) {
  581. let indx,
  582. len = els.length;
  583. for (indx = 0; indx < len; indx++) {
  584. els[indx].classList.remove(name);
  585. }
  586. }
  587.  
  588. function toggleClass(els, name, flag) {
  589. els = Array.isArray(els) ? els : [els];
  590. let el,
  591. indx = els.length;
  592. while (indx--) {
  593. el = els[indx];
  594. if (el) {
  595. if (typeof flag === "undefined") {
  596. flag = !el.classList.contains(name);
  597. }
  598. if (flag) {
  599. el.classList.add(name);
  600. } else {
  601. el.classList.remove(name);
  602. }
  603. }
  604. }
  605. }
  606.  
  607. function init() {
  608. getSettings();
  609. addMenu();
  610. $("body").addEventListener("input", checkItem);
  611. $("body").addEventListener("click", checkItem);
  612. update();
  613. }
  614.  
  615. // update list when content changes
  616. document.addEventListener("ghmo:container", addMenu);
  617. document.addEventListener("ghmo:comments", update);
  618. init();
  619.  
  620. })();