// ==UserScript==
// @name GitHub Join Date
// @description Displays user's join date/time/age.
// @icon https://github.githubassets.com/favicons/favicon-dark.svg
// @version 1.3
// @author afkarxyz
// @namespace https://github.com/afkarxyz/userscripts/
// @supportURL https://github.com/afkarxyz/userscripts/issues
// @license MIT
// @match https://github.com/*
// @grant GM_xmlhttpRequest
// @connect api.codetabs.com
// @connect api.github.com
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
const ELEMENT_ID = 'userscript-join-date-display';
const CACHE_KEY = 'githubUserJoinDatesCache_v1';
const GITHUB_API_BASE = 'https://api.github.com/users/';
const FALLBACK_API_BASE = 'https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/users/';
let isProcessing = false;
let observerDebounceTimeout = null;
function readCache() {
try {
const cachedData = localStorage.getItem(CACHE_KEY);
return cachedData ? JSON.parse(cachedData) : {};
} catch (e) {
return {};
}
}
function writeCache(cacheData) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
} catch (e) {
}
}
function getRelativeTime(dateString) {
const joinDate = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.round((now - joinDate) / 1000);
const minute = 60, hour = 3600, day = 86400, month = 2592000, year = 31536000;
if (diffInSeconds < minute) return `less than a minute ago`;
if (diffInSeconds < hour) {
const m = Math.floor(diffInSeconds / minute);
return `${m} ${m === 1 ? 'minute' : 'minutes'} ago`;
}
if (diffInSeconds < day) {
const h = Math.floor(diffInSeconds / hour);
return `${h} ${h === 1 ? 'hour' : 'hours'} ago`;
}
if (diffInSeconds < month) {
const d = Math.floor(diffInSeconds / day);
return `${d} ${d === 1 ? 'day' : 'days'} ago`;
}
if (diffInSeconds < year) {
const mo = Math.floor(diffInSeconds / month);
return `${mo} ${mo === 1 ? 'month' : 'months'} ago`;
}
const y = Math.floor(diffInSeconds / year);
return `${y} ${y === 1 ? 'year' : 'years'} ago`;
}
function getAbbreviatedMonth(date) {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return months[date.getMonth()];
}
async function fetchFromGitHubApi(username) {
const apiUrl = `${GITHUB_API_BASE}${username}`;
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: apiUrl,
headers: {
'Accept': 'application/vnd.github.v3+json'
},
onload: function(response) {
if (response.status === 200) {
try {
const userData = JSON.parse(response.responseText);
const createdAt = userData.created_at;
if (createdAt) {
resolve({ success: true, data: createdAt });
} else {
resolve({ success: false, error: 'Missing creation date' });
}
} catch (e) {
resolve({ success: false, error: 'JSON parse error' });
}
} else {
resolve({
success: false,
error: `Status ${response.status}`,
useProxy: response.status === 403 || response.status === 429
});
}
},
onerror: function() {
resolve({ success: false, error: 'Network error', useProxy: true });
},
ontimeout: function() {
resolve({ success: false, error: 'Timeout', useProxy: true });
}
});
});
}
async function fetchFromProxyApi(username) {
const apiUrl = `${FALLBACK_API_BASE}${username}`;
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: apiUrl,
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
try {
const userData = JSON.parse(response.responseText);
const createdAt = userData.created_at;
if (createdAt) {
resolve({ success: true, data: createdAt });
} else {
resolve({ success: false, error: 'Missing creation date' });
}
} catch (e) {
resolve({ success: false, error: 'JSON parse error' });
}
} else {
resolve({ success: false, error: `Status ${response.status}` });
}
},
onerror: function() {
resolve({ success: false, error: 'Network error' });
},
ontimeout: function() {
resolve({ success: false, error: 'Timeout' });
}
});
});
}
async function getGitHubJoinDate(username) {
const directResult = await fetchFromGitHubApi(username);
if (directResult.success) {
return directResult.data;
}
if (directResult.useProxy) {
console.log('GitHub Join Date: Use Proxy');
const proxyResult = await fetchFromProxyApi(username);
if (proxyResult.success) {
return proxyResult.data;
}
}
return null;
}
function removeExistingElement() {
const existingElement = document.getElementById(ELEMENT_ID);
if (existingElement) {
existingElement.remove();
}
}
async function addOrUpdateJoinDateElement() {
if (document.getElementById(ELEMENT_ID) && !isProcessing) { return; }
if (isProcessing) { return; }
const pathParts = window.location.pathname.split('/').filter(part => part);
if (pathParts.length < 1 || pathParts.length > 2 || (pathParts.length === 2 && !['sponsors', 'followers', 'following'].includes(pathParts[1]))) {
removeExistingElement();
return;
}
const usernameElement = document.querySelector('.p-nickname.vcard-username') ||
document.querySelector('h1.h2.lh-condensed');
if (!usernameElement) {
removeExistingElement();
return;
}
const username = pathParts[0].toLowerCase();
isProcessing = true;
let joinElement = document.getElementById(ELEMENT_ID);
let createdAtISO = null;
let fromCache = false;
try {
const cache = readCache();
if (cache[username]) {
createdAtISO = cache[username];
fromCache = true;
}
if (!joinElement) {
joinElement = document.createElement('div');
joinElement.id = ELEMENT_ID;
joinElement.innerHTML = fromCache ? "..." : "Loading...";
joinElement.style.color = 'var(--color-fg-muted)';
joinElement.style.fontSize = '14px';
joinElement.style.fontWeight = 'normal';
if (usernameElement.classList.contains('h2')) {
joinElement.style.marginTop = '0px';
const colorFgMuted = usernameElement.nextElementSibling?.classList.contains('color-fg-muted') ?
usernameElement.nextElementSibling : null;
if (colorFgMuted) {
const innerDiv = colorFgMuted.querySelector('div') || colorFgMuted;
innerDiv.appendChild(joinElement);
} else {
usernameElement.insertAdjacentElement('afterend', joinElement);
}
} else {
joinElement.style.marginTop = '8px';
usernameElement.insertAdjacentElement('afterend', joinElement);
}
}
if (!fromCache) {
createdAtISO = await getGitHubJoinDate(username);
joinElement = document.getElementById(ELEMENT_ID);
if (!joinElement) { return; }
if (createdAtISO) {
const currentCache = readCache();
currentCache[username] = createdAtISO;
writeCache(currentCache);
} else {
removeExistingElement();
return;
}
}
if (createdAtISO && joinElement) {
const joinDate = new Date(createdAtISO);
const day = joinDate.getDate();
const month = getAbbreviatedMonth(joinDate);
const year = joinDate.getFullYear();
const hours = joinDate.getHours().toString().padStart(2, '0');
const minutes = joinDate.getMinutes().toString().padStart(2, '0');
const formattedTime = `${hours}:${minutes}`;
const relativeTimeString = getRelativeTime(createdAtISO);
joinElement.innerHTML = `<strong>Joined</strong> <span style="font-weight: normal;">${day} ${month} ${year} - ${formattedTime} (${relativeTimeString})</span>`;
} else if (!createdAtISO && joinElement) {
removeExistingElement();
}
} catch (error) {
removeExistingElement();
} finally {
isProcessing = false;
}
}
function handlePotentialPageChange() {
clearTimeout(observerDebounceTimeout);
observerDebounceTimeout = setTimeout(() => {
addOrUpdateJoinDateElement();
}, 600);
}
addOrUpdateJoinDateElement();
const observer = new MutationObserver((mutationsList) => {
let potentiallyRelevantChange = false;
for (const mutation of mutationsList) {
if (mutation.type === 'childList' && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)) {
const targetNode = mutation.target;
if (targetNode && (targetNode.matches?.('main, main *, .Layout-sidebar, .Layout-sidebar *, body'))) {
let onlySelfChange = false;
if ((mutation.addedNodes.length === 1 && mutation.addedNodes[0].id === ELEMENT_ID && mutation.removedNodes.length === 0) ||
(mutation.removedNodes.length === 1 && mutation.removedNodes[0].id === ELEMENT_ID && mutation.addedNodes.length === 0)) {
onlySelfChange = true;
}
if (!onlySelfChange) {
potentiallyRelevantChange = true;
break;
}
}
}
}
if(potentiallyRelevantChange) {
handlePotentialPageChange();
}
});
observer.observe(document.body, { childList: true, subtree: true });
})();