GitHub Toggle Issue Comments

A userscript that toggles issues/pull request comments & messages

Fra og med 17.07.2016. Se den nyeste version.

// ==UserScript==
// @name          GitHub Toggle Issue Comments
// @version       1.0.18
// @description   A userscript that toggles issues/pull request comments & messages
// @license       https://creativecommons.org/licenses/by-sa/4.0/
// @namespace     http://github.com/Mottie
// @include       https://github.com/*
// @run-at        document-idle
// @grant         GM_addStyle
// @grant         GM_getValue
// @grant         GM_setValue
// @author        Rob Garrison
// ==/UserScript==
/* global GM_addStyle, GM_getValue, GM_setValue */
/*jshint unused:true, esnext:true */
(function() {
  "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 { float:right; }
    /* pre-wrap set for Firefox; see https://greasyfork.org/en/forum/discussion/9166/x */
    .ghic-menu label { display:block; padding:5px 15px; white-space:pre-wrap; }
    .ghic-button .select-menu-header, .ghic-participants { cursor:default; }
    .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 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.ghic-checked .ghic-count { display:inline-block; }
    .ghic-button .ghic-count { float:left; margin-right: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-right > *,
      .ghic-hideReactions .comment-reactions { display:none; }
  `);

  let targets,
    busy = false,
    // ZenHub addon active (include ZenHub Enterprise)
    hasZenHub = $(".zhio, .zhe") ? true : false;

  const regex = /(svg|path)/i,

  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-ref, .discussion-item-head_ref_deleted",
      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"
    },
    // 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"
    },

    // extras (special treatment - no selector)
    plus1: {
      isHidden: false,
      name: "ghic-plus1",
      label: "Hide +1s"
    },
    reactions: {
      isHidden: false,
      name: "ghic-reactions",
      label: "Reactions"
    },
    // 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>`,
  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>`,
  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 $(selector, el) {
    return (el || document).querySelector(selector);
  }
  function $$(selector, el) {
    return Array.from((el || document).querySelectorAll(selector));
  }
  function closest(el, selector) {
    while (el && el.nodeName !== "BODY" && !el.matches(selector)) {
      el = el.parentNode;
    }
    return el && el.matches(selector) ? el : 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 addMenu() {
    busy = true;
    if ($("#discussion_bucket") && !$(".ghic-button")) {
      // update "isHidden" values
      getSettings();
      let name, bright, isHidden, isChecked,
        list = "",
        keys = Object.keys(settings),
        header = $(".discussion-sidebar-item:last-child"),
        menu = document.createElement("div");

      for (name of keys) {
        if (!(name === "pipeline" && !hasZenHub)) {
          // make plus1 and reactions list items always bright
          bright = name === "plus1" ? " ghic-has-content" : "";
          isHidden = settings[name].isHidden;
          isChecked = isHidden ? " ghic-checked": "";
          // not using multi-line backticks because it adds lots of white-space to the label
          list += `<label class="dropdown-item${bright}${isChecked}">` +
            `<span>${settings[name].label}</span>` +
            `<span class="ghic-right ${settings[name].name}">` +
              `<input type="checkbox"${isHidden ? " checked" : ""}>` +
              `${iconCheck}<span class="ghic-count"> </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">
            <div class="select-menu-modal" aria-hidden="true">
              <div class="select-menu-header" tabindex="-1">
                <span class="select-menu-title">Toggle items</span>
              </div>
              <div class="select-menu-list ghic-menu" role="menu">
                ${list}
                <div class="ghic-participants"></div>
              </div>
            </div>
          </div>
        </span>
      `;
      if (hasZenHub) {
        header.insertBefore(menu, header.childNodes[0]);
      } else {
        header.appendChild(menu);
      }
      addAvatars();
    }
    update();
    busy = false;
  }

  function addAvatars() {
    let indx = 0,

    str = "<h3>Hide Comments from</h3>",
    unique = [],
    // get all avatars
    avatars = $$(".timeline-comment-avatar"),
    len = avatars.length - 1, // last avatar is the new comment with the current user

    loop = function(callback) {
      let el, name,
        max = 0;
      while (max < 50 && indx < len) {
        if (indx >= len) {
          return callback();
        }
        el = avatars[indx];
        name = (el.getAttribute("alt") || "").replace("@", "");
        if (unique.indexOf(name) < 0) {
          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++;
      }
      if (indx < len) {
        setTimeout(function() {
          loop(callback);
        }, 200);
      } else {
        callback();
      }
    };
    loop(function() {
      $(".ghic-participants").innerHTML = str;
    });
  }

  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($("." + settings[name].name, menu), ".dropdown-item");
        settings[name].isHidden = $("input", item).checked;
        toggleClass(item, "ghic-checked", settings[name].isHidden);
      }
    }
  }

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

  function hidePlus1(init) {
    if (init && !settings.plus1.isHidden) { return; }
    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+",
      "pretty",
      "pl+e+a+s+e+",
      "y+e+s+",
      "eta",
      "much",
      "need(ed)?",
      "fix",
      "this",
      "right",
      "now",
      "still",
      "nothing",
      "super",
      "helpful",
      "for\\sgods\\ssake",
      "c'?mon",
      "come\\son"
    ].join("|") + ")", "gi"),
    // image title ":{anything}:", etc.
    regexEmoji = /:(.*):/,

    comments = $$(".js-discussion .timeline-comment-wrapper"),
    len = comments.length,

    loop = function() {
      let wrapper, el, tmp, txt, img, hasLink, dupe;
      max = 0;
      while (max < 20 && indx < len) {
        if (indx >= len) {
          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.textContent.trim();
        }
        if (!txt) {
          img = $("img", el);
          if (img) {
            txt = img.getAttribute("title") || img.getAttribute("alt");
          }
        }
        // remove fluff
        txt = txt.replace(regexEmoji, "").replace(regexPlus, "").replace(regexHide, "").trim();
        if (txt === "" || (txt.length < 4 && !hasLink)) {
          if (settings.plus1.isHidden) {
            wrapper.classList.add("ghic-hidden");
            total++;
            // one +1 per author
            if (!dupe) {
              count++;
            }
          } else if (!init) {
            wrapper.classList.remove("ghic-hidden");
          }
          max++;
        }
        indx++;
      }
      if (indx < len) {
        setTimeout(function() {
          loop();
        }, 200);
      } else {
        $(".ghic-menu .ghic-plus1 .ghic-count").textContent = total ? "(" + total + ")" : " ";
        toggleClass($(".ghic-menu ." + settings.plus1.name), "ghic-has-content", total);
        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) {
    let els, indx, len, hide, name,
      results = [];
    if (el) {
      el.classList.toggle("comments-hidden");
      hide = el.classList.contains("comments-hidden");
      name = el.getAttribute("aria-label");
      els = $$(".js-discussion .author");
      len = els.length;
      for (indx = 0; indx < len; indx++) {
        if (els[indx].textContent.trim() === name) {
          results[results.length] = closest(els[indx], ".timeline-comment-wrapper, .commit-comment, .discussion-item");
        }
      }
      // 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() {
    busy = true;
    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);
      }
    }
    busy = false;
  }

  function checkItem(event) {
    busy = true;
    if (document.getElementById("discussion_bucket")) {
      let name,
        target = event.target,
        wrap = target && target.parentNode;
      if (target && wrap) {
        if (target.nodeName === "INPUT" && wrap.classList.contains("ghic-right")) {
          getInputValues();
          saveSettings();
          // extract ghic-{name}, because it matches the name in settings
          name = wrap.className.replace("ghic-right", "").replace("ghic-has-content", "").trim();
          if (wrap.classList.contains(name)) {
            hideStuff(name.replace("ghic-", ""));
          }
        } 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(target, ".ghic-avatar"));
        }
      }
    }
    busy = false;
  }

  function init() {
    busy = true;
    getSettings();
    addMenu();
    $("body").addEventListener("input", checkItem);
    $("body").addEventListener("click", checkItem);
    update();
    busy = false;
  }

  // DOM targets - to detect GitHub dynamic ajax page loading
  targets = $$("#js-repo-pjax-container, #js-pjax-container, .js-discussion");

  // update TOC when content changes
  Array.prototype.forEach.call(targets, function(target) {
    new MutationObserver(function(mutations) {
      mutations.forEach(function(mutation) {
        // preform checks before adding code wrap to minimize function calls
        if (!busy && mutation.target === target) {
          addMenu();
        }
      });
    }).observe(target, {
      childList: true,
      subtree: true
    });
  });

  init();

})();