// ==UserScript==
// @name GitHub Sort Reactions
// @version 0.2.17
// @description A userscript that sorts comments by reaction
// @license MIT
// @author Rob Garrison
// @namespace https://github.com/Mottie
// @match 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=1108163
// @icon https://github.githubassets.com/pinned-octocat.svg
// @supportURL https://github.com/Mottie/GitHub-userscripts/issues
// ==/UserScript==
(() => {
"use strict";
const nonInteger = /[^\d]/g;
const reactionValues = {
"THUMBS_UP": 1,
"HOORAY": 1,
"HEART": 1,
"LAUGH": 0.5,
"CONFUSED": -0.5,
"THUMBS_DOWN": -1
};
const currentSort = {
init: false,
el: null,
dir: 0, // 0 = unsorted, 1 = desc, 2 = asc
busy: false,
type: GM_getValue("selected-reaction", "NONE")
};
const emojiSrc = "https://github.githubassets.com/images/icons/emoji/unicode";
const sortBlock = `
<div class="TimelineItem ghsr-sort-block ghsr-is-collapsed js-timeline-progressive-focus-container">
<div class="avatar-parent-child TimelineItem-avatar border ghsr-sort-avatar ghsr-no-selection">
<div class="ghsr-icon-wrap tooltipped tooltipped-n" aria-label="Click to toggle reaction sort menu">
<svg aria-hidden="true" class="octicon ghsr-sort-icon" xmlns="http://www.w3.org/2000/svg" width="25" height="40" viewBox="0 0 16 16">
<path d="M15 8 1 8 8 0zM15 9 1 9 8 16z"/>
</svg>
</div>
<g-emoji></g-emoji>
<button class="ghsr-sort-button ghsr-avatar-sort btn btn-sm tooltipped tooltipped-n" aria-label="Toggle selected reaction sort direction">
<span></span>
</button>
</div>
<div class="timeline-comment ml-n3">
<div class="timeline-comment-header comment comment-body">
<h3 class="timeline-comment-header-text f5 text-normal">
<button class="ghsr-sort-button btn btn-sm tooltipped tooltipped-n" type="button" aria-label="Sort by +1 reaction" data-sort="THUMBS_UP">
<g-emoji alias="+1" class="emoji" fallback-src="${emojiSrc}/1f44d.png">👍</g-emoji>
</button>
<button class="ghsr-sort-button btn btn-sm tooltipped tooltipped-n" type="button" aria-label="Sort by -1 reaction" data-sort="THUMBS_DOWN">
<g-emoji alias="-1" class="emoji" fallback-src="${emojiSrc}/1f44e.png">👎</g-emoji>
</button>
<button class="ghsr-sort-button btn btn-sm tooltipped tooltipped-n" type="button" aria-label="Sort by laugh reaction" data-sort="LAUGH">
<g-emoji alias="smile" class="emoji" fallback-src="${emojiSrc}/1f604.png">😄</g-emoji>
</button>
<button class="ghsr-sort-button btn btn-sm tooltipped tooltipped-n" type="button" aria-label="Sort by hooray reaction" data-sort="HOORAY">
<g-emoji alias="tada" class="emoji" fallback-src="${emojiSrc}/1f389.png">🎉</g-emoji>
</button>
<button class="ghsr-sort-button btn btn-sm tooltipped tooltipped-n" type="button" aria-label="Sort by confused reaction" data-sort="CONFUSED">
<g-emoji alias="thinking_face" class="emoji" fallback-src="${emojiSrc}/1f615.png">😕</g-emoji>
</button>
<button class="ghsr-sort-button btn btn-sm tooltipped tooltipped-n" type="button" aria-label="Sort by heart reaction" data-sort="HEART">
<g-emoji alias="heart" class="emoji" fallback-src="${emojiSrc}/2764.png">❤️</g-emoji>
</button>
<button class="ghsr-sort-button btn btn-sm tooltipped tooltipped-n tooltipped-multiline" type="button" aria-label="Sort by reaction evaluation
(thumbs up, hooray & heart = +1;
laugh = +0.5; confused = -0.5;
thumbs down = -1)" data-sort="ACTIVE">
<g-emoji alias="speak_no_evil" class="emoji" fallback-src="${emojiSrc}/1f64a.png">🙊</g-emoji>
</button>
</h3>
</div>
</div>
</div>`;
function sumOfReactions(el) {
return Object.keys(reactionValues).reduce((sum, item) => {
const elm = $(`.comment-reactions-options button[value*="${item}"]`, el);
return sum + (getValue(elm) * reactionValues[item]);
}, 0);
}
function getValue(elm) {
return elm ?
parseInt(elm.textContent.replace(nonInteger, "") || "0", 10) :
0;
}
function extractSortValue(elm, type, dir) {
if (dir === 0 || type === "NONE" || type === "ACTIVE") {
return parseFloat(
elm.dataset[`sortComment${dir === 0 ? "Date" : "Sum"}`]
);
}
return getValue($(`.comment-reactions button[value*="${type}"]`, elm));
}
function stableSortValue(elm) {
return parseInt(elm.dataset.sortCommentDate, 10);
}
function updateAvatar() {
GM_setValue("selected-reaction", currentSort.type);
const block = $(".ghsr-sort-block"),
avatar = $(".ghsr-sort-avatar", block),
icon = $(".ghsr-sort-button span", avatar);
if (avatar) {
let current = $(`.comment-body [data-sort=${currentSort.type}]`, block);
avatar.classList.remove("ghsr-no-selection");
avatar.replaceChild(
$("g-emoji", current).cloneNode(true),
$("g-emoji", avatar)
);
if (currentSort.dir === 0) {
// use unsorted svg in sort button
current = $(".ghsr-sort-icon", avatar).cloneNode(true);
current.classList.remove("ghsr-sort-icon");
icon.textContent = "";
icon.appendChild(current);
} else {
icon.textContent = currentSort.dir !== 1 ? "▲" : "▼";
}
}
}
function sort() {
currentSort.busy = true;
const fragment = document.createDocumentFragment(),
container = $(".js-discussion"),
sortBlock = $(".ghsr-sort-block"),
loadMore = $("#progressive-timeline-item-container"),
dir = currentSort.dir,
sortAsc = dir !== 1,
type = currentSort.el ? currentSort.el.dataset.sort : "NONE";
currentSort.type = type;
updateAvatar();
$$(".js-timeline-item")
.sort((a, b) => {
const av = extractSortValue(a, type, dir),
bv = extractSortValue(b, type, dir);
if (av === bv) {
return stableSortValue(a) - stableSortValue(b);
}
return sortAsc ? av - bv : bv - av;
})
.forEach(el => {
fragment.appendChild(el);
});
container.appendChild(fragment);
if (loadMore) {
// Move load more comments to top
sortBlock.parentNode.insertBefore(loadMore, sortBlock.nextSibling);
}
setTimeout(() => {
currentSort.busy = false;
}, 100);
}
function update() {
if (!currentSort.init || $$(".has-reactions").length < 2) {
return toggleSortBlock(false);
}
toggleSortBlock(true);
const items = $$(".js-timeline-item:not([data-sort-comment-date])");
if (items) {
items.forEach(el => {
let date = $("[datetime]", el);
if (date) {
date = date.getAttribute("datetime");
el.setAttribute("data-sort-comment-date", Date.parse(date));
}
// Add reset date & most active summation
el.setAttribute("data-sort-comment-sum", sumOfReactions(el));
});
}
if (currentSort.el && !currentSort.busy) {
sort();
}
}
function initSort(event) {
let direction,
target = event.target;
if (target.classList.contains("ghsr-sort-button")) {
event.preventDefault();
event.stopPropagation();
if (target.classList.contains("ghsr-avatar-sort")) {
// Using avatar sort button; retarget button
target = $(`.ghsr-sort-button[data-sort="${currentSort.type}"]`);
currentSort.el = target;
}
$$(".ghsr-sort-button").forEach(el => {
el.classList.toggle("selected", el === target);
el.classList.remove("asc", "desc");
});
if (currentSort.el === target) {
currentSort.dir = (currentSort.dir + 1) % 3;
} else {
currentSort.el = target;
currentSort.dir = 1;
}
if (currentSort.dir !== 0) {
direction = currentSort.dir === 1 ? "desc" : "asc";
currentSort.el.classList.add(direction);
$(".ghsr-avatar-sort").classList.add(direction);
}
sort();
} else if (target.matches(".ghsr-sort-avatar, .ghsr-icon-wrap")) {
$(".ghsr-sort-block").classList.toggle("ghsr-is-collapsed");
}
}
function toggleSortBlock(show) {
const block = $(".ghsr-sort-block");
if (block) {
block.style.display = show ? "block" : "none";
} else if (show) {
addSortBlock();
}
}
function addSortBlock() {
currentSort.busy = true;
const first = $(".TimelineItem");
if (first) {
first.classList.add("ghsr-skip-sort");
first.insertAdjacentHTML("afterEnd", sortBlock);
}
currentSort.busy = false;
}
function init() {
if (!currentSort.init) {
GM_addStyle(`
.ghsr-sort-block .comment-body { padding: 0 10px; }
.ghsr-sort-block .timeline-comment-header { position: relative; }
.ghsr-sort-block .emoji { vertical-align: baseline; pointer-events: none; }
.ghsr-sort-block .btn.asc .emoji:after { content: "▲"; }
.ghsr-sort-block .btn.desc .emoji:after { content: "▼"; }
.ghsr-sort-avatar, .ghsr-icon-wrap { height: 48px; width: 44px; text-align: center; }
.ghsr-sort-avatar { background: rgba(128, 128, 128, 0.2); border: #777 1px solid; }
.ghsr-sort-avatar .emoji { position: relative; top: -36px; }
.ghsr-sort-avatar svg { pointer-events: none; }
.ghsr-sort-avatar.ghsr-no-selection { cursor: pointer; padding: 0 4px 0 0; }
.ghsr-sort-avatar.ghsr-no-selection .emoji,
.ghsr-sort-avatar.ghsr-no-selection .btn,
.ghsr-sort-avatar:not(.ghsr-no-selection) svg.ghsr-sort-icon { display: none; }
.ghsr-sort-avatar .btn { border-radius: 20px; width: 20px; height: 20px; position: absolute; bottom: -5px; right: -5px; }
.ghsr-sort-avatar .btn span { position: absolute; left: 5px; top: 0; pointer-events: none; }
.ghsr-sort-avatar .btn.asc span { top: -3px; }
.ghsr-sort-avatar .btn span svg { height: 10px; width: 10px; vertical-align: unset; }
.ghsr-sort-block.ghsr-is-collapsed h3,
.ghsr-sort-block.ghsr-is-collapsed .timeline-comment:before,
.ghsr-sort-block.ghsr-is-collapsed .timeline-comment:after { display: none; }
.ghsr-sort-block.ghsr-is-collapsed .timeline-comment { margin: 10px 0; }
.ghsr-sort-block.ghsr-is-collapsed .TimelineItem-avatar { top: 6px; }
`);
document.addEventListener("ghmo:container", update);
document.addEventListener("ghmo:comments", update);
document.addEventListener("click", initSort);
currentSort.init = true;
update();
// "NONE" can only be seen on userscript init/factory reset
if ($(".ghsr-sort-block") && currentSort.type !== "NONE") {
updateAvatar();
}
}
}
function $(selector, el) {
return (el || document).querySelector(selector);
}
function $$(selector, el) {
return [...(el || document).querySelectorAll(selector)];
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", update, {once: true});
} else {
init();
}
})();