NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name GitHub Reactions on lists // @namespace http://niewiarowski.it/ // @version 0.4.0 // @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 // @updateURL https://openuserjs.org/meta/marsjaninzmarsa/GitHub_Reactions_on_lists.meta.js // @downloadURL https://openuserjs.org/src/scripts/marsjaninzmarsa/GitHub_Reactions_on_lists.user.js // @homepageURL https://github.com/marsjaninzmarsa/userscripts/ // @supportURL https://github.com/marsjaninzmarsa/userscripts/issues // @match https://github.com/* // @grant GM_log // @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'); 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) { // GM_log(headers); GM_log(response); response.headers = parseResponseHeaders(response.responseHeaders); reactions = processResponse(response); GM_log(reactions); 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); // GM_log(response); 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 flex-row 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) { $('<span>'+emojis[reaction]+'</span>') .addClass([ 'float-right', 'tooltipped', 'tooltipped-sw', 'tooltipped-multiline', 'tooltipped-align-right-1', 'mt-1' ].join(' ')) .attr('aria-label', people.join(', ')+' reacted with '+reaction+' emoji') .append('<span class="text-small text-bold">'+people.length+'</span>') .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', [ $('meta[name=user-login]').attr('content'), $('.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);