// ==UserScript==
// @name GitHub Join Date
// @description Displays user's join date/time/age.
// @icon https://github.githubassets.com/favicons/favicon-dark.svg
// @version 1.4
// @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.cors.lol
// @connect api.allorigins.win
// @connect everyorigin.jwvbremen.nl
// @connect api.github.com
// @run-at document-idle
// ==/UserScript==
;(() => {
const ELEMENT_ID = "userscript-join-date-display"
const CACHE_KEY = "githubUserJoinDatesCache_v1"
let isProcessing = false
let observerDebounceTimeout = null
const proxyServices = [
{
name: "Direct GitHub API",
url: "https://api.github.com/users/",
parseResponse: (response) => {
return JSON.parse(response)
},
},
{
name: "CodeTabs Proxy",
url: "https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/users/",
parseResponse: (response) => {
return JSON.parse(response)
},
},
{
name: "CORS.lol Proxy",
url: "https://api.cors.lol/?url=https://api.github.com/users/",
parseResponse: (response) => {
return JSON.parse(response)
},
},
{
name: "AllOrigins Proxy",
url: "https://api.allorigins.win/get?url=https://api.github.com/users/",
parseResponse: (response) => {
const parsed = JSON.parse(response)
return JSON.parse(parsed.contents)
},
},
{
name: "EveryOrigin Proxy",
url: "https://everyorigin.jwvbremen.nl/api/get?url=https://api.github.com/users/",
parseResponse: (response) => {
const parsed = JSON.parse(response)
return JSON.parse(parsed.html)
},
},
]
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) {
// Storage error - continue without caching
}
}
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 fetchFromApi(proxyService, username) {
const apiUrl = `${proxyService.url}${username}`
return new Promise((resolve) => {
if (typeof GM_xmlhttpRequest === "undefined") {
console.error("GM_xmlhttpRequest is not defined. Make sure your userscript manager supports it.")
resolve({ success: false, error: "GM_xmlhttpRequest is not defined" })
return
}
GM_xmlhttpRequest({
method: "GET",
url: apiUrl,
headers: {
Accept: "application/vnd.github.v3+json",
},
onload: (response) => {
if (response.responseText.includes("limit") && response.responseText.includes("API")) {
resolve({
success: false,
error: "Rate limit exceeded",
isRateLimit: true,
})
return
}
if (response.status >= 200 && response.status < 300) {
try {
const userData = proxyService.parseResponse(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: () => {
resolve({ success: false, error: "Network error" })
},
ontimeout: () => {
resolve({ success: false, error: "Timeout" })
},
})
})
}
async function getGitHubJoinDate(username) {
for (let i = 0; i < proxyServices.length; i++) {
const proxyService = proxyServices[i]
const result = await fetchFromApi(proxyService, username)
if (result.success) {
return result.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 })
})()