import { EventEmitter, Uri } from 'vscode'; import { GravatarDefaultStyle } from './config'; import { configuration } from './configuration'; import { Container } from './container'; import type { GitRevisionReference } from './git/models/reference'; import { getGitHubNoReplyAddressParts } from './git/remotes/github'; import type { StoredAvatar } from './storage'; import { debounce } from './system/function'; import { filterMap } from './system/iterable'; import { base64, equalsIgnoreCase, md5 } from './system/string'; import type { ContactPresenceStatus } from './vsls/vsls'; const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) const _onDidFetchAvatar = new EventEmitter<{ email: string }>(); _onDidFetchAvatar.event( debounce(() => { const avatars = avatarCache != null ? [ ...filterMap(avatarCache, ([key, avatar]) => avatar.uri != null ? ([ key, { uri: avatar.uri.toString(), timestamp: avatar.timestamp, }, ] as [string, StoredAvatar]) : undefined, ), ] : undefined; void Container.instance.storage.store('avatars', avatars); }, 1000), ); export namespace Avatars { export const onDidFetch = _onDidFetchAvatar.event; } interface Avatar { uri?: Uri; fallback?: Uri; timestamp: number; retries: number; } let avatarCache: Map | undefined; const avatarQueue = new Map>(); const missingGravatarHash = '00000000000000000000000000000000'; const presenceCache = new Map(); const millisecondsPerMinute = 60 * 1000; const millisecondsPerHour = 60 * 60 * 1000; const millisecondsPerDay = 24 * 60 * 60 * 1000; const retryDecay = [ millisecondsPerDay * 7, // First item is cache expiration (since retries will be 0) millisecondsPerMinute, millisecondsPerMinute * 5, millisecondsPerMinute * 10, millisecondsPerHour, millisecondsPerDay, millisecondsPerDay * 7, ]; export function getAvatarUri( email: string | undefined, repoPathOrCommit: string | GitRevisionReference | undefined, { defaultStyle, size = 16 }: { defaultStyle?: GravatarDefaultStyle; size?: number } = {}, ): Uri | Promise { ensureAvatarCache(avatarCache); // Double the size to avoid blurring on the retina screen size *= 2; if (!email) { const avatar = createOrUpdateAvatar( `${missingGravatarHash}:${size}`, undefined, size, missingGravatarHash, defaultStyle, ); return avatar.uri ?? avatar.fallback!; } const hash = md5(email.trim().toLowerCase(), 'hex'); const key = `${hash}:${size}`; const avatar = createOrUpdateAvatar(key, email, size, hash, defaultStyle); if (avatar.uri != null) return avatar.uri; let query = avatarQueue.get(key); if (query == null && repoPathOrCommit != null && hasAvatarExpired(avatar)) { query = getAvatarUriFromRemoteProvider(avatar, key, email, repoPathOrCommit, { size: size }).then( uri => uri ?? avatar.uri ?? avatar.fallback!, ); avatarQueue.set( key, query.finally(() => avatarQueue.delete(key)), ); } if (query != null) return query; return avatar.uri ?? avatar.fallback!; } function createOrUpdateAvatar( key: string, email: string | undefined, size: number, hash: string, defaultStyle?: GravatarDefaultStyle, ): Avatar { let avatar = avatarCache!.get(key); if (avatar == null) { avatar = { uri: email != null && email.length !== 0 ? getAvatarUriFromGitHubNoReplyAddress(email, size) : undefined, fallback: getAvatarUriFromGravatar(hash, size, defaultStyle), timestamp: 0, retries: 0, }; avatarCache!.set(key, avatar); } else if (avatar.fallback == null) { avatar.fallback = getAvatarUriFromGravatar(hash, size, defaultStyle); } return avatar; } function ensureAvatarCache(cache: Map | undefined): asserts cache is Map { if (cache == null) { const avatars: [string, Avatar][] | undefined = Container.instance.storage .get('avatars') ?.map<[string, Avatar]>(([key, avatar]) => [ key, { uri: Uri.parse(avatar.uri), timestamp: avatar.timestamp, retries: 0, }, ]); avatarCache = new Map(avatars); } } function hasAvatarExpired(avatar: Avatar) { return Date.now() >= avatar.timestamp + retryDecay[Math.min(avatar.retries, retryDecay.length - 1)]; } function getAvatarUriFromGravatar( hash: string, size: number, defaultStyle: GravatarDefaultStyle = GravatarDefaultStyle.Robot, ): Uri { return Uri.parse(`https://www.gravatar.com/avatar/${hash}?s=${size}&d=${defaultStyle}`); } function getAvatarUriFromGitHubNoReplyAddress(email: string, size: number = 16): Uri | undefined { const parts = getGitHubNoReplyAddressParts(email); if (parts == null || !equalsIgnoreCase(parts.authority, 'github.com')) return undefined; return Uri.parse( `https://avatars.githubusercontent.com/${parts.userId ? `u/${parts.userId}` : parts.login}?size=${size}`, ); } async function getAvatarUriFromRemoteProvider( avatar: Avatar, key: string, email: string, repoPathOrCommit: string | GitRevisionReference, { size = 16 }: { size?: number } = {}, ) { ensureAvatarCache(avatarCache); try { let account; if (configuration.get('integrations.enabled')) { // if (typeof repoPathOrCommit === 'string') { // const remote = await Container.instance.git.getRichRemoteProvider(repoPathOrCommit); // account = await remote?.provider.getAccountForEmail(email, { avatarSize: size }); // } else { if (typeof repoPathOrCommit !== 'string') { const remote = await Container.instance.git.getBestRemoteWithRichProvider(repoPathOrCommit.repoPath); account = await remote?.provider.getAccountForCommit(repoPathOrCommit.ref, { avatarSize: size }); } } if (account?.avatarUrl == null) { // If we have no account assume that won't change (without a reset), so set the timestamp to "never expire" avatar.uri = undefined; avatar.timestamp = maxSmallIntegerV8; avatar.retries = 0; return undefined; } avatar.uri = Uri.parse(account.avatarUrl); avatar.timestamp = Date.now(); avatar.retries = 0; if (account.email != null && equalsIgnoreCase(email, account.email)) { avatarCache.set(`${md5(account.email.trim().toLowerCase(), 'hex')}:${size}`, { ...avatar }); } _onDidFetchAvatar.fire({ email: email }); return avatar.uri; } catch { avatar.uri = undefined; avatar.timestamp = Date.now(); avatar.retries++; return undefined; } } const presenceStatusColorMap = new Map([ ['online', '#28ca42'], ['away', '#cecece'], ['busy', '#ca5628'], ['dnd', '#ca5628'], ['offline', '#cecece'], ]); export function getPresenceDataUri(status: ContactPresenceStatus) { let dataUri = presenceCache.get(status); if (dataUri == null) { const contents = base64(` `); dataUri = encodeURI(`data:image/svg+xml;base64,${contents}`); presenceCache.set(status, dataUri); } return dataUri; } export function resetAvatarCache(reset: 'all' | 'failed' | 'fallback') { switch (reset) { case 'all': void Container.instance.storage.delete('avatars'); avatarCache?.clear(); avatarQueue.clear(); break; case 'failed': for (const avatar of avatarCache?.values() ?? []) { // Reset failed requests if (avatar.uri == null) { avatar.timestamp = 0; avatar.retries = 0; } } break; case 'fallback': for (const avatar of avatarCache?.values() ?? []) { avatar.fallback = undefined; } break; } }