GitHub Reactions on lists

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

// ==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);