Github User Info

Show inline user information on avatar hover.

// ==UserScript==
// @name             Github User Info
// @id               Github_User_Info@https://github.com/jerone/UserScripts
// @namespace        https://github.com/jerone/UserScripts
// @description      Show inline user information on avatar hover.
// @author           jerone
// @copyright        2015+, jerone (https://github.com/jerone)
// @license          CC-BY-NC-SA-4.0; https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode
// @license          GPL-3.0-or-later; http://www.gnu.org/licenses/gpl-3.0.txt
// @homepage         https://github.com/jerone/UserScripts/tree/master/Github_User_Info
// @homepageURL      https://github.com/jerone/UserScripts/tree/master/Github_User_Info
// @supportURL       https://github.com/jerone/UserScripts/issues
// @contributionURL  https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=VCYMHWQ7ZMBKW
// @icon             https://github.githubassets.com/pinned-octocat.svg
// @version          0.4.1
// @grant            GM_xmlhttpRequest
// @grant            GM_setValue
// @grant            GM_getValue
// @grant            unsafeWindow
// @run-at           document-end
// @include          https://github.com/*
// @include          https://gist.github.com/*
// ==/UserScript==

// cSpell:ignore leaderboard, vcard, transform
/* eslint security/detect-object-injection: "off" */

(function () {
	function proxy(fn) {
		return function proxyScope() {
			var that = this;
			return function proxyEvent(e) {
				var args = that.slice(0); // clone
				args.unshift(e); // prepend event
				fn.apply(this, args);
			};
		}.call([].slice.call(arguments, 1));
	}

	var _timer;

	var userMenu = document.createElement("div");
	userMenu.style =
		"display: none;" +
		"background-color: #F5F5F5;" +
		"border-radius: 3px;" +
		"border: 1px solid #DDDDDD;" +
		"box-shadow: 0 0 10px rgba(0, 0, 1, 0.1);" +
		"font-size: 11px;" +
		"padding: 10px;" +
		"position: absolute;" +
		"width: 335px;" +
		"z-index: 99;";
	userMenu.classList.add("GithubUserInfo");
	userMenu.addEventListener("mouseleave", function mouseleave() {
		// console.log('GithubUserInfo:userMenu', 'mouseleave');
		window.clearTimeout(_timer);
		userMenu.style.display = "none";
	});
	document.body.appendChild(userMenu);

	var userAvatar = document.createElement("a");
	userAvatar.style =
		"width: 100px;" +
		"height: 100px;" +
		"float: left;" +
		"margin-bottom: 10px;";
	userMenu.appendChild(userAvatar);
	var userAvatarImg = document.createElement("img");
	userAvatarImg.style =
		"border-radius: 3px;" +
		"transition-property: height, width;" +
		"transition-duration: 0.5s;";
	userAvatar.appendChild(userAvatarImg);

	var userInfo = document.createElement("div");
	userInfo.style = "width: 100%;" + "padding-left: 102px;";
	userMenu.appendChild(userInfo);

	var userName = document.createElement("div");
	userName.style =
		"padding-left: 24px;" +
		"white-space: nowrap;" +
		"overflow: hidden;" +
		"text-overflow: ellipsis;" +
		"font-weight: bold;";
	userInfo.appendChild(userName);

	var userCompany = document.createElement("div");
	userCompany.style =
		"display: none;" +
		"white-space: nowrap;" +
		"overflow: hidden;" +
		"text-overflow: ellipsis;";
	userInfo.appendChild(userCompany);
	var userCompanyIcon = document.createElement("span");
	userCompanyIcon.classList.add("octicon", "octicon-organization");
	userCompanyIcon.style =
		"width: 24px;" + "text-align: center;" + "color: #CCC;";
	userCompany.appendChild(userCompanyIcon);
	var userCompanyText = document.createElement("span");
	userCompany.appendChild(userCompanyText);
	var userCompanyAdmin = document.createElement("span");
	userCompanyAdmin.style =
		"display: none;" +
		"margin-left: 5px;" +
		"position: relative;" +
		"top: -1px;" +
		"padding: 2px 5px;" +
		"font-size: 10px;" +
		"font-weight: bold;" +
		"color: #FFF;" +
		"text-transform: uppercase;" +
		"background-color: #4183C4;" +
		"border-radius: 3px;";
	userCompanyAdmin.appendChild(document.createTextNode("Staff"));
	userCompany.appendChild(userCompanyAdmin);

	var userLocation = document.createElement("div");
	userLocation.style =
		"display: none;" +
		"white-space: nowrap;" +
		"overflow: hidden;" +
		"text-overflow: ellipsis;";
	userInfo.appendChild(userLocation);
	var userLocationIcon = document.createElement("span");
	userLocationIcon.classList.add("octicon", "octicon-location");
	userLocationIcon.style =
		"width: 24px;" + "text-align: center;" + "color: #CCC;";
	userLocation.appendChild(userLocationIcon);
	var userLocationText = document.createElement("a");
	userLocationText.setAttribute("target", "_blank");
	userLocation.appendChild(userLocationText);

	var userMail = document.createElement("div");
	userMail.style =
		"display: none;" +
		"white-space: nowrap;" +
		"overflow: hidden;" +
		"text-overflow: ellipsis;";
	userInfo.appendChild(userMail);
	var userMailIcon = document.createElement("span");
	userMailIcon.classList.add("octicon", "octicon-mail");
	userMailIcon.style =
		"width: 24px;" + "text-align: center;" + "color: #CCC;";
	userMail.appendChild(userMailIcon);
	var userMailText = document.createElement("a");
	userMail.appendChild(userMailText);

	var userLink = document.createElement("div");
	userLink.style =
		"display: none;" +
		"white-space: nowrap;" +
		"overflow: hidden;" +
		"text-overflow: ellipsis;";
	userInfo.appendChild(userLink);
	var userLinkIcon = document.createElement("span");
	userLinkIcon.classList.add("octicon", "octicon-link");
	userLinkIcon.style =
		"width: 24px;" + "text-align: center;" + "color: #CCC;";
	userLink.appendChild(userLinkIcon);
	var userLinkText = document.createElement("a");
	userLinkText.setAttribute("target", "_blank");
	userLink.appendChild(userLinkText);

	var userJoined = document.createElement("div");
	userJoined.style =
		"display: none;" +
		"white-space: nowrap;" +
		"overflow: hidden;" +
		"text-overflow: ellipsis;";
	userInfo.appendChild(userJoined);
	var userJoinedIcon = document.createElement("span");
	userJoinedIcon.classList.add("octicon", "octicon-clock");
	userJoinedIcon.style =
		"width: 24px;" + "text-align: center;" + "color: #CCC;";
	userJoined.appendChild(userJoinedIcon);
	userJoined.appendChild(document.createTextNode("Joined "));
	var userJoinedText = unsafeWindow.document.createElement("relative-time"); // https://github.com/github/time-elements
	userJoinedText.setAttribute("day", "numeric");
	userJoinedText.setAttribute("month", "short");
	userJoinedText.setAttribute("year", "numeric");
	userJoined.appendChild(userJoinedText);

	var userCounts = document.createElement("div");
	userCounts.style =
		"border-top: 1px solid #EEE;" +
		"clear: left;" +
		"display: flex;" +
		"justify-content: space-around;" +
		"text-align: center;" +
		"white-space: nowrap;";
	userMenu.appendChild(userCounts);

	var userFollowers = document.createElement("a");
	userFollowers.style = "display: none;" + "text-decoration: none;";
	userFollowers.classList.add("vcard-stat");
	userFollowers.setAttribute("target", "_blank");
	userFollowers.setAttribute("title", "Followers");
	userCounts.appendChild(userFollowers);
	var userFollowersCount = document.createElement("strong");
	userFollowersCount.style = "display: block;" + "font-size: 28px;";
	userFollowers.appendChild(userFollowersCount);
	var userFollowersText = document.createElement("span");
	userFollowersText.appendChild(document.createTextNode("Followers"));
	userFollowersText.classList.add("text-muted");
	userFollowers.appendChild(userFollowersText);

	var userFollowing = document.createElement("a");
	userFollowing.style = "display: none;" + "text-decoration: none;";
	userFollowing.classList.add("vcard-stat");
	userFollowing.setAttribute("target", "_blank");
	userFollowing.setAttribute("title", "Following");
	userCounts.appendChild(userFollowing);
	var userFollowingCount = document.createElement("strong");
	userFollowingCount.style = "display: block;" + "font-size: 28px;";
	userFollowing.appendChild(userFollowingCount);
	var userFollowingText = document.createElement("span");
	userFollowingText.appendChild(document.createTextNode("Following"));
	userFollowingText.classList.add("text-muted");
	userFollowing.appendChild(userFollowingText);

	var userRepos = document.createElement("a");
	userRepos.style = "display: none;" + "text-decoration: none;";
	userRepos.classList.add("vcard-stat");
	userRepos.setAttribute("target", "_blank");
	userRepos.setAttribute("title", "Public repositories");
	userCounts.appendChild(userRepos);
	var userReposCount = document.createElement("strong");
	userReposCount.style = "display: block;" + "font-size: 28px;";
	userRepos.appendChild(userReposCount);
	var userReposText = document.createElement("span");
	userReposText.appendChild(document.createTextNode("Repos"));
	userReposText.classList.add("text-muted");
	userRepos.appendChild(userReposText);

	var userOrgs = document.createElement("a");
	userOrgs.style = "display: none;" + "text-decoration: none;";
	userOrgs.classList.add("vcard-stat");
	userOrgs.setAttribute("target", "_blank");
	userOrgs.setAttribute("title", "Public organizations");
	userCounts.appendChild(userOrgs);
	var userOrgsCount = document.createElement("strong");
	userOrgsCount.style = "display: block;" + "font-size: 28px;";
	userOrgs.appendChild(userOrgsCount);
	var userOrgsText = document.createElement("span");
	userOrgsText.appendChild(document.createTextNode("Orgs"));
	userOrgsText.classList.add("text-muted");
	userOrgs.appendChild(userOrgsText);

	var userMembers = document.createElement("a");
	userMembers.style = "display: none;" + "text-decoration: none;";
	userMembers.classList.add("vcard-stat");
	userMembers.setAttribute("target", "_blank");
	userMembers.setAttribute("title", "Public members");
	userCounts.appendChild(userMembers);
	var userMembersCount = document.createElement("strong");
	userMembersCount.style = "display: block;" + "font-size: 28px;";
	userMembers.appendChild(userMembersCount);
	var userMembersText = document.createElement("span");
	userMembersText.appendChild(document.createTextNode("Members"));
	userMembersText.classList.add("text-muted");
	userMembers.appendChild(userMembersText);

	var userGists = document.createElement("a");
	userGists.style = "display: none;" + "text-decoration: none;";
	userGists.classList.add("vcard-stat");
	userGists.setAttribute("target", "_blank");
	userGists.setAttribute("title", "Public gists");
	userCounts.appendChild(userGists);
	var userGistsCount = document.createElement("strong");
	userGistsCount.style = "display: block;" + "font-size: 28px;";
	userGists.appendChild(userGistsCount);
	var userGistsText = document.createElement("span");
	userGistsText.appendChild(document.createTextNode("Gists"));
	userGistsText.classList.add("text-muted");
	userGists.appendChild(userGistsText);

	var UPDATE_INTERVAL_DAYS = 7;

	function getData(elm) {
		var username;
		if (elm.getAttribute("alt")) {
			username = elm.getAttribute("alt").replace("@", "");
		} else if (elm.parentNode.parentNode.querySelector(".author")) {
			username = elm.parentNode.parentNode
				.querySelector(".author")
				.textContent.trim();
		} else {
			return;
		}

		var rect = elm.getBoundingClientRect();
		var position = {
			top: rect.top + window.scrollY,
			left: rect.left + window.scrollX,
		};
		var avatarSize = {
			height: elm.height,
			width: elm.width,
		};

		var usersString = GM_getValue("users", "{}");
		var users = JSON.parse(usersString);
		if (users[username]) {
			var date = new Date(users[username].checked_at),
				now = new Date(),
				upDate = new Date(
					now.setDate(now.getDate() - UPDATE_INTERVAL_DAYS),
				);
			if (date > upDate) {
				var data = users[username].data;
				// console.log('GithubUserInfo:getData', 'CACHED', data);
				fillData(defaultData(data), position, avatarSize);
			} else {
				// console.log('GithubUserInfo:getData', 'AJAX - OUTDATED', username, date, upDate);
				fetchData(username, position, avatarSize);
			}
		} else {
			// console.log('GithubUserInfo:getData', 'AJAX - NON-EXISTING', username);
			fetchData(username, position, avatarSize);
		}
	}

	function fetchData(username, position, avatarSize) {
		// console.log('GithubUserInfo:fetchData', username);
		GM_xmlhttpRequest({
			method: "GET",
			url: "https://api.github.com/users/" + username,
			onload: proxy(parseUserData, position, avatarSize),
		});
	}

	function parseUserData(response, position, avatarSize) {
		var dataParsed = parseRawData(response.responseText);
		if (!dataParsed) {
			return;
		}
		var data = defaultData(normalizeData(dataParsed));
		// console.log('GithubUserInfo:parseUserData', data.username);

		GM_xmlhttpRequest({
			method: "GET",
			url: "https://api.github.com/users/" + data.username + "/orgs",
			onload: proxy(parseOrgsData, position, avatarSize, data),
		});
	}

	function parseOrgsData(response, position, avatarSize, data) {
		var dataParsed = parseRawData(response.responseText);
		if (!dataParsed) {
			return;
		}
		data.orgs = dataParsed.length;
		// console.log('GithubUserInfo:parseOrgsData', data.username, data.orgs);

		switch (data.type) {
			case "Organization": {
				GM_xmlhttpRequest({
					method: "GET",
					url:
						"https://api.github.com/orgs/" +
						data.username +
						"/members",
					onload: proxy(parseMembersData, position, avatarSize, data),
				});
				break;
			}
			default: {
				fillData(data, position, avatarSize);
				setData(data, data.username);
				break;
			}
		}
	}

	function parseMembersData(response, position, avatarSize, data) {
		var dataParsed = parseRawData(response.responseText);
		if (!dataParsed) {
			return;
		}
		data.members = dataParsed.length;
		// console.log('GithubUserInfo:parseMembersData', data.username, data.members);

		fillData(data, position, avatarSize);
		setData(data, data.username);
	}

	function parseRawData(data) {
		data = JSON.parse(data);
		if (
			data.message &&
			data.message.startsWith("API rate limit exceeded")
		) {
			console.warn(
				"GithubUserInfo:parseRawData",
				"API RATE LIMIT EXCEEDED",
			);
			return;
		}
		return data;
	}

	function normalizeData(data) {
		return {
			username: data.login,
			avatar: data.avatar_url,
			type: data.type,
			name: data.name,
			company: data.company,
			blog: data.blog,
			location: data.location,
			mail: data.email,
			repos: data.public_repos,
			gists: data.public_gists,
			followers: data.followers,
			following: data.following,
			created_at: data.created_at,
			admin: !!data.site_admin,
		};
	}

	function defaultData(data) {
		return {
			username: data.username,
			avatar: data.avatar,
			type: data.type,
			name: data.name || data.username,
			company: data.admin ? "GitHub" : data.company || "",
			blog: data.blog || "",
			location: data.location || "",
			mail: data.mail || "",
			repos: data.repos || 0,
			gists: data.gists || 0,
			followers: data.followers || 0,
			following: data.following || 0,
			created_at: data.created_at,
			admin: data.admin || false,
			orgs: data.orgs || 0,
			members: data.members || 0,
		};
	}

	function setData(data, username) {
		// console.log('GithubUserInfo:setData', username, data);
		var usersString = GM_getValue("users", "{}");
		var users = JSON.parse(usersString);
		users[username] = {
			checked_at: new Date().toJSON(),
			data: data,
		};
		GM_setValue("users", JSON.stringify(users));
	}

	function fillData(data, position, avatarSize) {
		// console.log('GithubUserInfo:fillData', data, position, avatarSize);

		userAvatar.setAttribute("href", "https://github.com/" + data.username);
		userAvatarImg.style.height = avatarSize.height + "px";
		userAvatarImg.style.width = avatarSize.width + "px";
		userAvatarImg.addEventListener("load", function () {
			userMenu.style.top = Math.max(position.top - 10 - 1, 2) + "px";
			userMenu.style.left = Math.max(position.left - 10 - 1, 2) + "px";
			userMenu.style.display = "block";
			window.setTimeout(function avatarAnimationTimeout() {
				userAvatarImg.style.height = "100px";
				userAvatarImg.style.width = "100px";
			}, 50);
		});
		userAvatarImg.setAttribute("src", "");
		userAvatarImg.setAttribute("src", data.avatar);

		userName.setAttribute("title", data.username);
		userName.textContent = data.name;

		if (hasValue(data.company, userCompany)) {
			userCompanyText.textContent = data.company;
			userCompanyAdmin.style.display = data.admin ? "inline" : "none";
		}
		if (hasValue(data.location, userLocation)) {
			userLocationText.setAttribute(
				"href",
				"https://maps.google.com/maps?q=" +
					encodeURIComponent(data.location),
			);
			userLocationText.textContent = data.location;
		}
		if (hasValue(data.mail, userMail)) {
			userMailText.setAttribute("href", "mailto:" + data.mail);
			userMailText.textContent = data.mail;
		}
		if (hasValue(data.blog, userLink)) {
			userLinkText.setAttribute("href", data.blog);
			userLinkText.textContent = data.blog;
		}
		if (hasValue(data.created_at, userJoined)) {
			userJoinedText.setAttribute("datetime", data.created_at);
		}

		var userCountsHasValue = false;
		if (hasValue(data.followers, userFollowers)) {
			userCountsHasValue = true;
			userFollowers.setAttribute(
				"href",
				"https://github.com/" + data.username + "/followers",
			);
			userFollowersCount.textContent = data.followers;
		}
		if (hasValue(data.following, userFollowing)) {
			userCountsHasValue = true;
			userFollowing.setAttribute(
				"href",
				"https://github.com/" + data.username + "/following",
			);
			userFollowingCount.textContent = data.following;
		}
		if (hasValue(true, userRepos)) {
			// Always show repos count, as long another count is shown too
			userRepos.setAttribute(
				"href",
				"https://github.com/" + data.username + "?tab=repositories",
			);
			userReposCount.textContent = data.repos;
		}
		if (hasValue(data.orgs, userOrgs)) {
			userCountsHasValue = true;
			userOrgs.setAttribute(
				"href",
				"https://github.com/" + data.username,
			);
			userOrgsCount.textContent = data.orgs;
		}
		if (hasValue(data.members, userMembers)) {
			userCountsHasValue = true;
			userMembers.setAttribute(
				"href",
				"https://github.com/orgs/" + data.username + "/people",
			);
			userMembersCount.textContent =
				data.members === 30 ? "30+" : data.members;
		}
		if (hasValue(data.gists, userGists)) {
			userCountsHasValue = true;
			userGists.setAttribute(
				"href",
				"https://gist.github.com/" + data.username,
			);
			userGistsCount.textContent = data.gists;
		}
		userCounts.style.display = userCountsHasValue ? "flex" : "none";

		//if (data.type === 'Organization' || data.type === 'User') {}
	}

	function hasValue(property, elm) {
		elm.style.display = property ? "block" : "none";
		return !!property;
	}

	function init() {
		var avatars = document.querySelectorAll(
			[
				'.avatar[alt^="@"]', // Logged-in user & commits author & issue participant & users organization & organization member
				'.avatar-child[alt^="@"]', // Authored committed users
				'.gravatar[alt^="@"]', // Following & followers page
				'.timeline-comment-avatar[alt^="@"]', // GitHub comments author
				'.commits img[alt^="@"]', // Commits on user activity tab
				'.leaderboard-gravatar[alt^="@"]', // Trending developer: https://github.com/trending/developers
				".gist-author img", // Gist author
				".gist .js-discussion .timeline-comment-avatar", // Gist comments author
			].join(","),
		);
		Array.prototype.forEach.call(avatars, function avatarsForEach(avatar) {
			avatar.addEventListener("mouseenter", function mouseenter() {
				// console.log('GithubUserInfo:avatar', 'mouseenter');
				_timer = window.setTimeout(
					function mouseenterTimer() {
						// console.log('GithubUserInfo:avatar', 'timeout');
						getData(this);
					}.bind(this),
					500,
				);
			});
			avatar.addEventListener("mouseleave", function mouseleave() {
				// console.log('GithubUserInfo:avatar', 'mouseleave');
				window.clearTimeout(_timer);
			});
		});
	}

	// Init
	init();

	// Pjax
	document.addEventListener("pjax:end", init);
})();