GitHub Reactions on lists

Delivers shiny emoji reactions to issues and pull requests right to listings!

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 Reactions on lists
// @namespace    http://niewiarowski.it/
// @version      0.4.1
// @author       marsjaninzmarsa
// @description  Delivers shiny emoji reactions to issues and pull requests right to listings!
// @copyright    2017+, Kuba Niewiarowski (niewiarowski.it)
// @license      CC-BY-SA-3.0; http://creativecommons.org/licenses/by-sa/3.0/
// @license      MIT
// @homepageURL  https://github.com/marsjaninzmarsa/userscripts/
// @supportURL   https://github.com/marsjaninzmarsa/userscripts/issues
// @match        https://github.com/*
// @grant        GM_log
// @grant        GM_info
// @grant        GM_xmlhttpRequest
// @grant        GM_notification
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addValueChangeListener
// @grant        GM_openInTab
// @domain       api.github.com
// @require      https://code.jquery.com/jquery-3.2.1.js#md5=09dd64a64ba840c31a812a3ca25eaeee,sha384=p7RDedFtQzvcp0/3247fDud39nqze/MUmahi6MOWjyr3WKWaMOyqhXuCT1sM9Q+l
// @require      https://update.greasyfork.org/scripts/28721/1108163/mutations.js
// @require      https://openuserjs.org/src/libs/cuzi/RequestQueue.js
// @require      https://openuserjs.org/src/libs/marsjaninzmarsa/webtoolkit.base64.min.js
// @compatible   Firefox with GreaseMonkey
// @compatible   Chrome with Tempermonkey
// @compatible   Opera with ViolentMonkey
// ==/UserScript==
// ==OpenUserJS==
// @author marsjaninzmarsa
// ==/OpenUserJS==

(function($) {
	var rq = new RequestQueue(10);
	var uuid = GM_info.uuid || GM_info.script.uuid || GM_getValue('uuid') || GM_setValue('uuid', $.now()) || GM_getValue('uuid');
	var username = $('meta[name=user-login]').attr('content');

	function process() {
		switch(checkMatchers()) {
			case "list":
				processIssues();
			break;
			case "tokens":
				processTokens();
			break;
		}
	}

	function checkMatchers() {
		if([
			/.+\/.+\/issues\/\d+/i,
			/.+\/.+\/pulls\/\d+/i,
		].some(function(regexp) {
			return regexp.test(window.location.pathname);
		})) {
			GM_log('Matchers: false');
			return false;
		}

		if([
			/.+\/.+\/issues/i,
			/.+\/.+\/pulls/i,
		].some(function(regexp) {
			return regexp.test(window.location.pathname);
		})) {
			GM_log('Matchers: list');
			return "list";
		}

		if([
			/settings\/tokens/i,
		].some(function(regexp) {
			return regexp.test(window.location.pathname);
		})) {
			GM_log('Matchers: tokens');
			return "tokens";
		}
	}

	function processIssues() {
		$('#js-issues-toolbar ~ [role=group] .js-issue-row').each(function() {
			processIssue(this);
		});
	}

	function processIssue(issue) {
		id = $(issue).data('id');
		cached = getDataFromCache(id);

		if(!rq.hasReachedTotal()) {
			headers = {
				"Accept": "application/vnd.github.squirrel-girl-preview",
			};
			if(cached.etag && !$.isEmptyObject(cached.reactions)) {
				headers["If-None-Match"] = cached.etag;
			} else if(cached.modified) {
				headers["If-Modified-Since"] = cached.modified;
			}
			if(token = GM_getValue('token')) {
				headers["Authorization"] = "Basic "+Base64.encode(token);
			}
			rq.add({
			// GM_log({
				method:  "GET",
				url:     "https://api.github.com/repos" + $(issue).find('a.js-navigation-open').attr('href').replace('pull', 'issues') + "/reactions",
				responseType: "json",
				context: issue,
				headers: headers,
				onload: function(response) {
					response.headers = parseResponseHeaders(response.responseHeaders);
					reactions = processResponse(response);
					showReactions(response.context, reactions);
				}
			});
		} else {
			showReactions(issue, cached.reactions);
		}
	}

	function getDataFromCache(id) {
		return JSON.parse(
			window.sessionStorage.getItem('githubReactionsUserJs-'+id)
		) || {etag: null, modified: null, reactions: {}};
	}

	function putDataToCache(id, etag, reactions, modified) {
		window.sessionStorage.setItem('githubReactionsUserJs-'+id,
			JSON.stringify(
				{etag: etag, modified: modified, reactions: reactions}
			)
		);
	}

	function processResponse(response) {
		id = $(response.context).data('id');
		cached = getDataFromCache(id);

		switch(response.status) {
			case 304:
			return cached.reactions;
			case 401:
				processQuotaExceeded(response);
			break;
			case 403:
				if(response.headers['x-ratelimit-remaining'] == 0) {
					processQuotaExceeded(response);
				}
			return cached.reactions;
			case 200:
				var reactions = {};
				if(response.response.length) {
					response.response.forEach(function(reaction) {
						reactions[reaction.content] = reactions[reaction.content] || [];
						reactions[reaction.content].push(reaction.user.login);
					});
				}
				putDataToCache(id, response.headers.etag, reactions, response.headers['last-modified'] || null);
			return reactions;
			default:
				GM_log(response);
			break;
		}
	}

	function processQuotaExceeded(response) {
		// Abort request and prevent future ones
		rq.abort();
		rq.maxParallel = 0;

		// Explain situation
		notification = {
			title: "API rate limit exceeded",
			text:  [
				"Quota will reset "+new Date(response.headers['x-ratelimit-reset'] * 1000).toLocaleTimeString()+".",
				"You can intercrease limit by providing personal access token."
			],
			prompt: "Authorize",
			highlight: true,
			timeout: 0,
			onclick: openAccessTokenPage
		};
		if(response.status == 401) {
			notification = $.extend(notification, {
				title: "Invalid access token",
				text: [
					"Access token is invalid and will be reseted.",
					"You can generate new token and reauthorize."
				],
				prompt: "Reauthorize"
			});
			GM_setValue('token', null);
		}
		showNotification(notification, response.headers['x-ratelimit-reset']);
		showMessage(notification);

		// Wait until quota reset and revert
		setTimeout(function() {
			processQuotaRenewed();
		}, response.headers['x-ratelimit-reset'] * 1000 - $.now());

		// Maybe token added?
		if(typeof GM_addValueChangeListener === 'function') {
			GM_addValueChangeListener('token', function() {
				processQuotaRenewed();
			});
		}
		var old_value = GM_getValue('token');
		var interval  = setInterval(function() {
			if(GM_getValue('token') != old_value) {
				processQuotaRenewed();
				clearInterval(interval);
			}
		}, 10000);
	}

	function processQuotaRenewed() {
		rq.maxParallel = 10;
		showMessage(null);
	}

	// From https://jsperf.com/parse-response-headers-from-xhr/3
	function parseResponseHeaders(headerStr) {
		var l = headerStr.length,
		p = -2,
		j = 0,
		headers = {},
		l, i, q, k, v;

		while ( (p = headerStr.indexOf( "\r\n", (i = p + 2) + 5 )) > i )
			(q = headerStr.indexOf( ":", i + 3 )) > i && q < p
			&& (headers[k = headerStr.slice( i, q ).toLowerCase()] = headerStr.slice( q + 2, p ))[0] === '"'
			&& (headers[k] = JSON.parse( headers[k] ));
			(q = headerStr.indexOf( ":", i + 3 )) > i && q < l
			&& (headers[k = headerStr.slice( i, q ).toLowerCase()] = headerStr.slice( q + 2 ))[0] === '"'
			&& (headers[k] = JSON.parse( headers[k] ))
		return headers;
	}

	var tags = [];
	function showNotification(notification, tag) {
		if(typeof notification === 'string') {
			notification = {
				text: notification
			};
		}
		if(typeof notification.text === 'object' && notification.text.length) {
			notification.text = notification.text.join("\n");
		}
		notification.title = notification.title || GM_info.script.name;

		if(typeof GM_notification === 'function') {
			if(tags.indexOf(tag) != -1) {
				return;
			}
			GM_notification(notification);
			if(tag) {
				tags.push(tag);
			}
		} else if ("Notification" in window) {
			if(Notification.permission === "granted") {
				var n = new Notification(notification.title, {
					body: notification.text,
					tag: tag,
				});
				if(notification.timeout !== 0) {
					setTimeout(n.close.bind(n), notification.timeout || 5000);
				}
				n.addEventListener('click', notification.onclick);
			} else {
				Notification.requestPermission(function (permission) {
					showNotification(notification, tag);
				});
			}
		} else {
			if(tags.indexOf(tag) != -1) {
				return;
			}
			alertText = [notification.title, notification.text].join("\n");
			if("onclick" in notification) {
				if(confirm(alertText)) {
					notification.onclick();
				}
			} else {
				alert(alertText);
			}
			if(tag) {
				tags.push(tag);
			}
		}
	}

	function showMessage(message) {
		if(typeof message === 'string') {
			message = {
				text: message
			};
		}
		if(typeof message?.text === 'object' && message.text.length) {
			message.text = message.text.join("</span><br /><span>");
		}

		$('#github-reactions-message').detach();
		if(message == null) {
			return;
		}
		$('body').append('<div id="github-reactions-message"></div>');
		$('#github-reactions-message').append(
			$('#ajax-error-message > svg').clone(),
			$('#ajax-error-message > button').clone(),
			'<strong>'+(message.title || GM_info.script.name)+':</strong> <span>'+message.text+'</span>',
			"\n"
		);
		if(typeof message.onclick === "function") {
			$('#github-reactions-message').append(
				'<a href="#">' + (message.prompt || 'Proceed') + '</a>'
			);
			$('#github-reactions-message a').click(message.onclick);
		}
		$('#github-reactions-message').addClass('flash flash-warn flash-banner');
	}

	function openAccessTokenPage() {
		GM_openInTab("https://github.com/settings/tokens/new#"+uuid, {
			active: true,
			insert: true
		});
	}

	function showReactions(issue, reactions) {
		if($.isEmptyObject(reactions)) {
			return;
		}
		var container = $(issue).find('.flex-shrink-0 ~ .d-flex > .reactions');
		if(container.length) {
			$(container).html('');
		} else {
			wrap = $(issue).find('.flex-shrink-0 ~ .d-flex.no-wrap').removeClass('no-wrap').addClass('flex-wrap');
			container = $('<span class="ml-2 flex-shrink-0 d-flex flex-row flex-justify-end pr-2 reactions" style="flex-basis: 100%;"></span>');
			wrap.append(container);
		}
		var emojis    = {
			"+1":       "👍",
			"-1":       "👎",
			"laugh":    "😄",
			"hooray":   "🎉",
			"confused": "😕",
			"heart":    "❤️",
			"rocket":   "🚀",
			"eyes":     "👀",
		};
		$.each(reactions, function(reaction, people) {
			const $reaction = $('<span>'+emojis[reaction]+'</span>')
			 	.addClass([
			 		'tooltipped',
			 		'tooltipped-sw',
			 		'tooltipped-multiline',
					'tooltipped-align-right-1',
					'mt-1',
					'social-reaction-summary-item',
					// 'btn-link',
					// 'no-underline',

			 	].join(' '))
			 	.attr('aria-label', people.join(', ')+' reacted with '+reaction+' emoji')
			 	.append('<span class="text-small text-bold">'+people.length+'</span>');
			if(people.includes(username)) {
				$reaction.addClass('user-has-reacted');
			}
			$reaction.appendTo(container);
		});
	}

	function processTokens() {
		if(window.location.hash == "#"+uuid) {
			window.sessionStorage.setItem('processingTokens', uuid);
			window.location.hash = "";
		}
		if(window.sessionStorage.getItem('processingTokens') == uuid) {
			$('#oauth_access_description').val(GM_info.script.name+' userscript in '+GM_info.scriptHandler);
			var counter = 0;
			$('.js-checkbox-scope').change(function() {
				if($(this).is(':checked')) {
					var messages = {
						0: {
							text: "We don't need any of those...",
							timeout: 0
						},
						3: "Nah, srsly, it's just simple quota extension...",
						6: "And for what, exactly?",
						9: "If you must...",
						12: "You're plaing with me, right?",
						15: "Nothing here, turn around.",
						18: "You're annoing. That's not funny.",
						21: "Don't you have anything better to do?",
						23: "I don't know, wath some movie, play a game, go outside, find girlfriend... ok, ok, back to Earth, just watch movie.",
						28: "Why you don't believe me? You have nothing to do here.",
						35: "Looking for porn or what??",
						40: "No pron here.",
						50: {
							title:   "Ok, ok, you won. Here, some pussy, have fun.",
							text:    "[click for cat]",
							onclick: function() {
								GM_openInTab('https://random.cat/');
							},
							timeout: 0
						}
					}
					if(messages[counter]) {
						showNotification(messages[counter], 'tokenDescription-'+counter);
					}
					counter = counter+1;
				}
			});

			if($('.access-token.new-token').length) {
				showNotification({
					text: 'Token generated, save it?',
					onclick: saveToken,
				});
				$('<a href="#" id="github-reactions-save-token-button">Use token in userscript</a>')
					.addClass([
						'Button',
						'Button--small',
						'Button--primary',
						'BtnGroup-item'
					].join(' '))
					.click(function(e) {
						e.preventDefault();
						e.stopPropagation();
						saveToken();
					})
					.prependTo('.access-token.new-token .listgroup-item .float-right');

				function saveToken() {
					GM_setValue('token', [
						username,
						$('.access-token.new-token code.token').text()
					].join(':'));
					$('#github-reactions-save-token-button').text('✓');
					showNotification('Token saved!');
					showMessage('Token saved, you can close the window.');
				}
			}
		}
	}



	if(!GM_getValue('hello', false)) {
		showNotification({
			title: 'Hello!',
			text:  'You have succesfully installed GitHub Reactions UserScript. 😊'
		}, 'hello');
		GM_setValue('hello', true);
	}


	// GM_log(GM_info);

	document.addEventListener("ghmo:container", process);

	process();

})(jQuery);