/* eslint-disable no-await-in-loop */ import './user-local-time.css'; import React from 'dom-chef'; import {CachedFunction} from 'webext-storage-cache'; import delay from 'delay'; import select from 'select-dom'; import {ClockIcon} from '@primer/octicons-react'; import features from '../feature-manager.js'; import observe from '../helpers/selector-observer.js'; import api from '../github-helpers/api.js'; import {getUsername} from '../github-helpers/index.js'; type Commit = { url: string; sha: string; }; async function loadCommitPatch(commitUrl: string): Promise { const {textContent} = await api.v3(commitUrl, { json: false, headers: { Accept: 'application/vnd.github.v3.patch', }, }); return textContent; } const lastCommitDate = new CachedFunction('last-commit', { async updater(login: string): Promise { for await (const page of api.v3paginated(`/users/${login}/events`)) { for (const event of page as any) { if (event.type !== 'PushEvent') { continue; } // Start from the latest commit, which is the last one in the list for (const commit of event.payload.commits.reverse() as Commit[]) { const response = await api.v3(commit.url, {ignoreHTTPStatus: true}); // Commits might not exist anymore even if they are listed in the events // This can happen if the repository was deleted so we can also skip all other commits if (response.httpStatus === 404) { break; } if (!response.ok) { throw await api.getError(response); } // `response.author` only appears if GitHub can match the email to a GitHub user if (response.author?.id !== event.actor.id) { continue; } const patch = await loadCommitPatch(commit.url); // The patch of merge commits doesn't include the commit sha so the date might be from another user if (patch.startsWith(`From ${commit.sha} `)) { return /^Date: (.*)$/m.exec(patch)?.[1] ?? false; } } } } return false; }, maxAge: {days: 10}, staleWhileRevalidate: {days: 20}, }); function parseOffset(date: string): number { const [, hourString, minuteString] = (/([-+]\d\d)(\d\d)$/).exec(date) ?? []; const hours = Number.parseInt(hourString, 10); const minutes = Number.parseInt(minuteString, 10); return (hours * 60) + (hours < 0 ? -minutes : minutes); } async function display({ datePromise, placeholder, container, }: { datePromise: Promise; placeholder: JSX.Element; container: JSX.Element; }): Promise { const date = await datePromise; if (!date) { placeholder.textContent = 'Timezone unknown'; container.title = 'Timezone couldn’t be determined from their last commits'; return; } const userTime = new Date(); userTime.setMinutes(parseOffset(date) + userTime.getTimezoneOffset() + userTime.getMinutes()); const timeFormatter = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: 'numeric', weekday: userTime.getDay() === new Date().getDay() ? undefined : 'long', }); placeholder.textContent = timeFormatter.format(userTime); container.title = `Timezone guessed from their last commit: ${date}`; } async function insertUserLocalTime(hovercardContainer: Element): Promise { const hovercard = hovercardContainer.closest('div.Popover-message')!; if (!select.exists('[data-hydro-view*="user-hovercard-hover"]', hovercard)) { // It's not the hovercard type we expect return; } if (select.exists('profile-timezone', hovercard)) { // Native time already present return; } const login = select('a.Link--primary', hovercard)?.pathname.slice(1); if (!login || login === getUsername()) { return; } const datePromise = lastCommitDate.get(login); const race = await Promise.race([delay(300), datePromise]); if (race === false) { // The timezone was undeterminable and this resolved "immediately" (or was cached), so don't add the icon at all return; } const placeholder = Guessing local time…; const container = (
{placeholder}
); // Adding the time element might change the height of the hovercard and thus break its positioning const hovercardHeight = hovercard.offsetHeight; // Only remove the space reserved via CSS when the element is actually inserted in the hovercard #4527 hovercardContainer.classList.add('rgh-user-local-time-added'); hovercardContainer.append(container); if (hovercard.matches('.Popover-message--bottom-right, .Popover-message--bottom-left')) { const diff = hovercard.offsetHeight - hovercardHeight; if (diff > 0) { const parent = hovercard.parentElement!; const top = Number.parseInt(parent.style.top, 10); parent.style.top = `${top - diff}px`; } } void display({datePromise, placeholder, container}); } const selector = [ '.js-hovercard-content .Popover-message div.d-flex.mt-3.overflow-hidden > div.d-flex', '.js-hovercard-content .Popover-message div.d-flex.mt-3 > div.overflow-hidden.ml-3', // GHE 2022/06/24 ]; function init(signal: AbortSignal): void { observe(selector, insertUserLocalTime, {signal}); } void features.add(import.meta.url, { init, }); /* Test URLs: 1. Open https://github.com/sindresorhus/np/releases/tag/v8.0.4 2. Hover over the username "sindresorhus" in the sidebar 3. Notice his local time in the hovercard */