// ==UserScript== // @name Stack Exchange Pronoun Assistant // @namespace https://github.com/Glorfindel83/ // @description Displays users' pronouns (mentioned in their profiles) // @author Glorfindel // @author ArtOfCode // @contributor wizzwizz4 // @updateURL https://raw.githubusercontent.com/Glorfindel83/SE-Userscripts/master/pronoun-assistant/pronoun-assistant.user.js // @downloadURL https://raw.githubusercontent.com/Glorfindel83/SE-Userscripts/master/pronoun-assistant/pronoun-assistant.user.js // @supportURL https://stackapps.com/questions/8440/pronoun-assistant // @version 2.11 // @match *://chat.stackexchange.com/rooms/* // @match *://chat.stackoverflow.com/rooms/* // @match *://chat.meta.stackexchange.com/rooms/* // @match *://*.stackexchange.com/questions/* // @match *://*.stackoverflow.com/questions/* // @match *://*.superuser.com/questions/* // @match *://*.serverfault.com/questions/* // @match *://*.askubuntu.com/questions/* // @match *://*.stackapps.com/questions/* // @match *://*.mathoverflow.net/questions/* // @exclude *://*.stackexchange.com/questions/ask // @exclude *://*.stackoverflow.com/questions/ask // @exclude *://*.superuser.com/questions/ask // @exclude *://*.serverfault.com/questions/ask // @exclude *://*.askubuntu.com/questions/ask // @exclude *://*.stackapps.com/questions/ask // @exclude *://*.mathoverflow.net/questions/ask // @grant GM_addStyle // @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js // @require https://gist.github.com/raw/2625891/waitForKeyElements.js // @require https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js // ==/UserScript== /* global $, waitForKeyElements */ GM_addStyle(` .tiny-signature { display: inline-flex; flex-direction: row-reverse; align-items: center; width: 100%; } .username { height: unset !important; } .pronouns, .pronouns a { color: #777; } .pronouns { word-break: keep-all; } .pronouns a:hover { text-decoration: underline; } `) // List of pronouns to look out for let allPronouns = [ "him", "his", "she", "her?", // that covers 'he' as well "they", "them", "their", "ze", "hir", "zir", "xey?", "xem", "xyr", "faer?" ].join("|"); let pronounListRegex = new RegExp('\\b((' + allPronouns + ')(\\s*/\\s*(' + allPronouns + '))+)\\b', 'i'); let myPronounIsRegex = /(https?:\/\/)?(my\.)?pronoun\.is\/([\w/]+)/i; let explicitPronounsRegex = /pronouns:\s*([^.\n)\]}<]*)(\.|\n|\)|]|}|<|$)/im; let unlikelyCombinations = ["her/his", "her/him", "he/she"]; // Keys: user IDs // Values: either a list of DOM elements (specifically, the anchors to chat profiles) // or a string with pronouns. var users = {}; // Keys: user IDs (Q&A only) // Values: the users' 'about me' values. var profiles = {}; // If we're on a Q&A site, also cache all changes to the `users` object to save on API calls if (location.hostname.indexOf("chat") === -1) { const localStorageData = localStorage.stackPronounAssistant_users; let cached = localStorageData ? JSON.parse(localStorage.stackPronounAssistant_users) : {}; if (Object.keys(cached).length > 0 && typeof cached[Object.keys(cached)[0]] === "string") { // v2.4 and before had no cache expiry, users[userId] was a string. // Currently, users[userId] is an array containing a string and an expiry. // If we have cached data but it's in <= 2.4 format, delete it - we'll regenerate it instead. delete localStorage.stackPronounAssistant_users; cached = {}; } const userData = {}; Object.keys(cached).forEach(k => {userData[parseInt(k, 10)] = cached[k]}); users = new Proxy(userData, { get: (obj, prop) => { const data = obj[prop]; if (!data) { return null; } else { const [pronouns, expiry] = data; if (expiry < Date.now()) { return null; } else { return pronouns; } } }, set: (obj, prop, value) => { obj[prop] = [value, Date.now() + 86400 * 1000]; // 24h expiry localStorage.stackPronounAssistant_users = JSON.stringify(userData); } }); } // Adds pronoun information to a user's 'signature' in chat. function showPronounsForChat($element, pronouns) { if (pronouns == "") { return; } addPronounsToChatSignatures($element, pronouns); // After clicking the signature (to show the chat profile popup), *sometimes* // (the exact conditions are unclear - it happens in The Bridge but not in the Teachers' Lounge) // the signature gets rerendered. In that case, we need to readd the pronouns. $element.on("DOMSubtreeModified", function() { if ($(this).find(".pronouns").length == 0) { $(this).off("DOMSubtreeModified"); addPronounsToChatSignatures($(this), pronouns); } }); } function addPronounsToChatSignatures($element, pronouns) { // The element might contain both a tiny and a full signature $element.find("div.username").each(function (index, usernameElement) { usernameElement.innerHTML = '' + usernameElement.innerHTML + '
' + ' ' + pronouns + ''; }); } // Determines pronoun information and adds it to a user card (under a post) or author information (after a comment) function decorate($element) { const link = $element.attr("href"); const userId = parseInt(link.split("/users/")[1]); if (!users[userId]) { // No pronouns calculated yet, we need to calculate and store them. users[userId] = getPronouns(profiles[userId], true); showPronouns($element, users[userId]); } else { // We already have the pronouns, we can just use them. showPronouns($element, users[userId]); } }; // Adds pronoun information to a user card or author information function showPronouns($element, pronouns) { // For comments, it looks better when added to the parent of the user link if ($element.hasClass("comment-user")) { $element = $element.parent(); } // Anything to show, or already shown? if (pronouns == "" || $element.siblings(".pronouns").length != 0) { return; } // Make sure the pronouns don't end up between the username and the diamond // or staff/mod labels do { let $nextElement = $element.next(); if (!$nextElement.hasClass("mod-flair") && !$nextElement.hasClass("s-badge")) break; $element = $nextElement; } while (true); $element.after($(' ' + pronouns + '')); } // Check text (obtained from the user's 'about me' in their chat profile or Q&A profile) for pronoun indicators function getPronouns(aboutMe, allowPronounIslandLinks) { // Link to Pronoun Island, e.g. // http://my.pronoun.is/she var match = myPronounIsRegex.exec(aboutMe); myPronounIsRegex.lastIndex = 0; if (match != null) { return allowPronounIslandLinks ? '' + match[0] + '' : match[3]; } // Explicit pronouns specification, e.g. // Pronouns: he/him. // The end is indicated by a dot, a newline, or simply the end of the text. match = explicitPronounsRegex.exec(aboutMe); explicitPronounsRegex.lastIndex = 0; if (match != null) { return match[1]; } // Check for pronouns (see above for the list) joined by a forward slash, e.g. // she/her // (he/him/his) // they / them match = pronounListRegex.exec(aboutMe); pronounListRegex.lastIndex = 0; if (match != null) { // Check for unlikely combinations, cf. https://stackapps.com/a/8567/34061 let pronouns = match[1].split(/\s*\/\s*/); pronouns.sort(); if (!unlikelyCombinations.includes(pronouns.join("/"))) { return match[1]; } } // No pronouns found return ""; } // Chat signatures waitForKeyElements("a.signature", function(jNode) { let userID = jNode.attr("href").split("/users/")[1]; if (!users[userID]) { users[userID] = []; users[userID].push(jNode); // Read chat profile $.get("https://" + location.host + "/users/thumbs/" + userID + "?showUsage=true", function(data) { let pronouns = data.user_message == null ? "" : getPronouns(data.user_message, false); users[userID].forEach(function (element) { showPronounsForChat(element, pronouns); }); users[userID] = pronouns; }); } else if (typeof users[userID] == 'string') { // We already have the pronouns, we can just use them. showPronounsForChat(jNode, users[userID]); } else { // User appearing multiple times, their profile is already being fetched. users[userID].push(jNode); } }); // Selector for Q&A sites const selector = "div.user-details > a, a.comment-user"; // Q&A site user cards & comment usernames (async () => { const sleep = async (ms) => { return new Promise(resolve => { setTimeout(resolve, ms); }); }; const userIds = []; const $userElements = $(selector); // Grab all the user IDs out of a page first. We'll go back over them later to add pronouns in. $userElements.each(function() { const link = $(this).attr("href"); if (!link.startsWith("/users/")) { // not a user card, but a community wiki post return; } const userId = parseInt(link.split("/users/")[1]); if (userIds.indexOf(userId) === -1) { userIds.push(userId); } }); // Split the list into 100-item pages and grab profiles for each page. // This works because splice modifies the userIds array in-place, removing elements from the front and // returning them into the `page` variable. When we've used them all, the array will be empty. while (userIds.length > 0) { const page = userIds.splice(0, 100); const resp = await fetch("https://api.stackexchange.com/2.2/users/" + page.join(';') + "?site=" + location.hostname + "&key=L8n*CvRX3ZsedRQicxnjIA((&filter=!23IboywNfWUzv_nydJbn*&pagesize=100"); const data = await resp.json(); if (typeof data.items !== 'undefined') { data.items.forEach(i => { profiles[i.user_id] = i.about_me; }); } if (data.backoff) { // Respect backoffs, not just pronouns. await sleep(data.backoff * 1000); } } $userElements.each(function() { decorate($(this)); }); // Make sure new answers / comments receive the same treatment waitForKeyElements(selector, function(jNode) { decorate($(jNode)); }); })();