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.

316 lines
9.6 KiB

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