You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

318 lines
9.6 KiB

  1. import { EventEmitter, Uri } from 'vscode';
  2. import { md5 } from '@env/crypto';
  3. import { GravatarDefaultStyle } from './config';
  4. import { configuration } from './configuration';
  5. import { ContextKeys } from './constants';
  6. import { Container } from './container';
  7. import { getContext } from './context';
  8. import { getGitHubNoReplyAddressParts } from './git/remotes/github';
  9. import type { StoredAvatar } from './storage';
  10. import { debounce } from './system/function';
  11. import { filterMap } from './system/iterable';
  12. import { base64, equalsIgnoreCase } from './system/string';
  13. import type { ContactPresenceStatus } from './vsls/vsls';
  14. const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers)
  15. const _onDidFetchAvatar = new EventEmitter<{ email: string }>();
  16. _onDidFetchAvatar.event(
  17. debounce(() => {
  18. const avatars =
  19. avatarCache != null
  20. ? [
  21. ...filterMap(avatarCache, ([key, avatar]) =>
  22. avatar.uri != null
  23. ? ([
  24. key,
  25. {
  26. uri: avatar.uri.toString(),
  27. timestamp: avatar.timestamp,
  28. },
  29. ] as [string, StoredAvatar])
  30. : undefined,
  31. ),
  32. ]
  33. : undefined;
  34. void Container.instance.storage.store('avatars', avatars);
  35. }, 1000),
  36. );
  37. export namespace Avatars {
  38. export const onDidFetch = _onDidFetchAvatar.event;
  39. }
  40. interface Avatar {
  41. uri?: Uri;
  42. fallback?: Uri;
  43. timestamp: number;
  44. retries: number;
  45. }
  46. let avatarCache: Map<string, Avatar> | undefined;
  47. const avatarQueue = new Map<string, Promise<Uri>>();
  48. const missingGravatarHash = '00000000000000000000000000000000';
  49. const presenceCache = new Map<ContactPresenceStatus, string>();
  50. const millisecondsPerMinute = 60 * 1000;
  51. const millisecondsPerHour = 60 * 60 * 1000;
  52. const millisecondsPerDay = 24 * 60 * 60 * 1000;
  53. const retryDecay = [
  54. millisecondsPerDay * 7, // First item is cache expiration (since retries will be 0)
  55. millisecondsPerMinute,
  56. millisecondsPerMinute * 5,
  57. millisecondsPerMinute * 10,
  58. millisecondsPerHour,
  59. millisecondsPerDay,
  60. millisecondsPerDay * 7,
  61. ];
  62. export function getAvatarUri(
  63. email: string | undefined,
  64. repoPathOrCommit?: undefined,
  65. options?: { defaultStyle?: GravatarDefaultStyle; size?: number },
  66. ): Uri;
  67. export function getAvatarUri(
  68. email: string | undefined,
  69. repoPathOrCommit: string | { ref: string; repoPath: string },
  70. options?: { defaultStyle?: GravatarDefaultStyle; size?: number },
  71. ): Uri | Promise<Uri>;
  72. export function getAvatarUri(
  73. email: string | undefined,
  74. repoPathOrCommit: string | { ref: string; repoPath: string } | undefined,
  75. options?: { defaultStyle?: GravatarDefaultStyle; size?: number },
  76. ): Uri | Promise<Uri> {
  77. return getAvatarUriCore(email, repoPathOrCommit, options);
  78. }
  79. export function getCachedAvatarUri(email: string | undefined, options?: { size?: number }): Uri | undefined {
  80. return getAvatarUriCore(email, undefined, { ...options, cached: true });
  81. }
  82. function getAvatarUriCore(
  83. email: string | undefined,
  84. repoPathOrCommit: string | { ref: string; repoPath: string } | undefined,
  85. options?: { cached: true; defaultStyle?: GravatarDefaultStyle; size?: number },
  86. ): Uri | undefined;
  87. function getAvatarUriCore(
  88. email: string | undefined,
  89. repoPathOrCommit: string | { ref: string; repoPath: string } | undefined,
  90. options?: { defaultStyle?: GravatarDefaultStyle; size?: number },
  91. ): Uri | Promise<Uri>;
  92. function getAvatarUriCore(
  93. email: string | undefined,
  94. repoPathOrCommit: string | { ref: string; repoPath: string } | undefined,
  95. options?: { cached?: boolean; defaultStyle?: GravatarDefaultStyle; size?: number },
  96. ): Uri | Promise<Uri> | undefined {
  97. ensureAvatarCache(avatarCache);
  98. // Double the size to avoid blurring on the retina screen
  99. const size = (options?.size ?? 16) * 2;
  100. if (!email) {
  101. const avatar = createOrUpdateAvatar(
  102. `${missingGravatarHash}:${size}`,
  103. undefined,
  104. size,
  105. missingGravatarHash,
  106. options?.defaultStyle,
  107. );
  108. return avatar.uri ?? avatar.fallback!;
  109. }
  110. const hash = md5(email.trim().toLowerCase());
  111. const key = `${hash}:${size}`;
  112. const avatar = createOrUpdateAvatar(key, email, size, hash, options?.defaultStyle);
  113. if (avatar.uri != null) return avatar.uri;
  114. if (!options?.cached && repoPathOrCommit != null && getContext(ContextKeys.HasConnectedRemotes)) {
  115. let query = avatarQueue.get(key);
  116. if (query == null && hasAvatarExpired(avatar)) {
  117. query = getAvatarUriFromRemoteProvider(avatar, key, email, repoPathOrCommit, { size: size }).then(
  118. uri => uri ?? avatar.uri ?? avatar.fallback!,
  119. );
  120. avatarQueue.set(
  121. key,
  122. query.finally(() => avatarQueue.delete(key)),
  123. );
  124. }
  125. return query ?? avatar.fallback!;
  126. }
  127. return options?.cached ? avatar.uri : avatar.uri ?? avatar.fallback!;
  128. }
  129. function createOrUpdateAvatar(
  130. key: string,
  131. email: string | undefined,
  132. size: number,
  133. hash: string,
  134. defaultStyle?: GravatarDefaultStyle,
  135. ): Avatar {
  136. let avatar = avatarCache!.get(key);
  137. if (avatar == null) {
  138. avatar = {
  139. uri: email != null && email.length !== 0 ? getAvatarUriFromGitHubNoReplyAddress(email, size) : undefined,
  140. fallback: getAvatarUriFromGravatar(hash, size, defaultStyle),
  141. timestamp: 0,
  142. retries: 0,
  143. };
  144. avatarCache!.set(key, avatar);
  145. } else if (avatar.fallback == null) {
  146. avatar.fallback = getAvatarUriFromGravatar(hash, size, defaultStyle);
  147. }
  148. return avatar;
  149. }
  150. function ensureAvatarCache(cache: Map<string, Avatar> | undefined): asserts cache is Map<string, Avatar> {
  151. if (cache == null) {
  152. const avatars: [string, Avatar][] | undefined = Container.instance.storage
  153. .get('avatars')
  154. ?.map<[string, Avatar]>(([key, avatar]) => [
  155. key,
  156. {
  157. uri: Uri.parse(avatar.uri),
  158. timestamp: avatar.timestamp,
  159. retries: 0,
  160. },
  161. ]);
  162. avatarCache = new Map<string, Avatar>(avatars);
  163. }
  164. }
  165. function hasAvatarExpired(avatar: Avatar) {
  166. return Date.now() >= avatar.timestamp + retryDecay[Math.min(avatar.retries, retryDecay.length - 1)];
  167. }
  168. function getAvatarUriFromGravatar(hash: string, size: number, defaultStyle?: GravatarDefaultStyle): Uri {
  169. return Uri.parse(
  170. `https://www.gravatar.com/avatar/${hash}?s=${size}&d=${defaultStyle ?? getDefaultGravatarStyle()}`,
  171. );
  172. }
  173. export function getAvatarUriFromGravatarEmail(email: string, size: number, defaultStyle?: GravatarDefaultStyle): Uri {
  174. return getAvatarUriFromGravatar(md5(email.trim().toLowerCase()), size, defaultStyle);
  175. }
  176. function getAvatarUriFromGitHubNoReplyAddress(email: string, size: number = 16): Uri | undefined {
  177. const parts = getGitHubNoReplyAddressParts(email);
  178. if (parts == null || !equalsIgnoreCase(parts.authority, 'github.com')) return undefined;
  179. return Uri.parse(
  180. `https://avatars.githubusercontent.com/${parts.userId ? `u/${parts.userId}` : parts.login}?size=${size}`,
  181. );
  182. }
  183. async function getAvatarUriFromRemoteProvider(
  184. avatar: Avatar,
  185. key: string,
  186. email: string,
  187. repoPathOrCommit: string | { ref: string; repoPath: string },
  188. { size = 16 }: { size?: number } = {},
  189. ) {
  190. ensureAvatarCache(avatarCache);
  191. try {
  192. let account;
  193. // if (typeof repoPathOrCommit === 'string') {
  194. // const remote = await Container.instance.git.getRichRemoteProvider(repoPathOrCommit);
  195. // account = await remote?.provider.getAccountForEmail(email, { avatarSize: size });
  196. // } else {
  197. if (typeof repoPathOrCommit !== 'string') {
  198. const remote = await Container.instance.git.getBestRemoteWithRichProvider(repoPathOrCommit.repoPath);
  199. account = await remote?.provider.getAccountForCommit(repoPathOrCommit.ref, { avatarSize: size });
  200. }
  201. if (account?.avatarUrl == null) {
  202. // If we have no account assume that won't change (without a reset), so set the timestamp to "never expire"
  203. avatar.uri = undefined;
  204. avatar.timestamp = maxSmallIntegerV8;
  205. avatar.retries = 0;
  206. return undefined;
  207. }
  208. avatar.uri = Uri.parse(account.avatarUrl);
  209. avatar.timestamp = Date.now();
  210. avatar.retries = 0;
  211. if (account.email != null && equalsIgnoreCase(email, account.email)) {
  212. avatarCache.set(`${md5(account.email.trim().toLowerCase())}:${size}`, { ...avatar });
  213. }
  214. _onDidFetchAvatar.fire({ email: email });
  215. return avatar.uri;
  216. } catch {
  217. avatar.uri = undefined;
  218. avatar.timestamp = Date.now();
  219. avatar.retries++;
  220. return undefined;
  221. }
  222. }
  223. const presenceStatusColorMap = new Map<ContactPresenceStatus, string>([
  224. ['online', '#28ca42'],
  225. ['away', '#cecece'],
  226. ['busy', '#ca5628'],
  227. ['dnd', '#ca5628'],
  228. ['offline', '#cecece'],
  229. ]);
  230. export function getPresenceDataUri(status: ContactPresenceStatus) {
  231. let dataUri = presenceCache.get(status);
  232. if (dataUri == null) {
  233. const contents = base64(`<?xml version="1.0" encoding="utf-8"?>
  234. <svg xmlns="http://www.w3.org/2000/svg" width="4" height="16" viewBox="0 0 4 16">
  235. <circle cx="2" cy="14" r="2" fill="${presenceStatusColorMap.get(status)!}"/>
  236. </svg>`);
  237. dataUri = encodeURI(`data:image/svg+xml;base64,${contents}`);
  238. presenceCache.set(status, dataUri);
  239. }
  240. return dataUri;
  241. }
  242. export function resetAvatarCache(reset: 'all' | 'failed' | 'fallback') {
  243. switch (reset) {
  244. case 'all':
  245. void Container.instance.storage.delete('avatars');
  246. avatarCache?.clear();
  247. avatarQueue.clear();
  248. break;
  249. case 'failed':
  250. for (const avatar of avatarCache?.values() ?? []) {
  251. // Reset failed requests
  252. if (avatar.uri == null) {
  253. avatar.timestamp = 0;
  254. avatar.retries = 0;
  255. }
  256. }
  257. break;
  258. case 'fallback':
  259. for (const avatar of avatarCache?.values() ?? []) {
  260. avatar.fallback = undefined;
  261. }
  262. break;
  263. }
  264. }
  265. let defaultGravatarsStyle: GravatarDefaultStyle | undefined = undefined;
  266. function getDefaultGravatarStyle() {
  267. if (defaultGravatarsStyle == null) {
  268. defaultGravatarsStyle = configuration.get('defaultGravatarsStyle', undefined, GravatarDefaultStyle.Robot);
  269. }
  270. return defaultGravatarsStyle;
  271. }
  272. export function setDefaultGravatarsStyle(style: GravatarDefaultStyle) {
  273. defaultGravatarsStyle = style;
  274. resetAvatarCache('fallback');
  275. }