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.

262 lines
7.1 KiB

  1. 'use strict';
  2. import * as fs from 'fs';
  3. import { EventEmitter, Uri } from 'vscode';
  4. import { GravatarDefaultStyle } from './config';
  5. import { GlobalState } from './constants';
  6. import { Container } from './container';
  7. import { GitRevisionReference } from './git/git';
  8. import { Functions, Iterables, Strings } from './system';
  9. import { MillisecondsPerDay, MillisecondsPerHour, MillisecondsPerMinute } from './system/date';
  10. import { ContactPresenceStatus } from './vsls/vsls';
  11. const _onDidFetchAvatar = new EventEmitter<{ email: string }>();
  12. _onDidFetchAvatar.event(
  13. Functions.debounce(() => {
  14. const avatars =
  15. avatarCache != null
  16. ? [
  17. ...Iterables.filterMap(avatarCache, ([key, avatar]) =>
  18. avatar.uri != null
  19. ? [
  20. key,
  21. {
  22. uri: avatar.uri.toString(),
  23. timestamp: avatar.timestamp,
  24. },
  25. ]
  26. : undefined,
  27. ),
  28. ]
  29. : undefined;
  30. void Container.context.globalState.update(GlobalState.Avatars, avatars);
  31. }, 1000),
  32. );
  33. export namespace Avatars {
  34. export const onDidFetch = _onDidFetchAvatar.event;
  35. }
  36. interface Avatar {
  37. uri?: Uri;
  38. fallback?: Uri;
  39. timestamp: number;
  40. retries: number;
  41. }
  42. interface SerializedAvatar {
  43. uri: string;
  44. timestamp: 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 gitHubNoReplyAddressRegex = /^(?:(?<userId>\d+)\+)?(?<userName>[a-zA-Z\d-]{1,39})@users\.noreply\.github\.com$/;
  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: string | GitRevisionReference | undefined,
  63. { defaultStyle, size = 16 }: { defaultStyle?: GravatarDefaultStyle; size?: number } = {},
  64. ): Uri | Promise<Uri> {
  65. ensureAvatarCache(avatarCache);
  66. if (email == null || email.length === 0) {
  67. const avatar = createOrUpdateAvatar(
  68. `${missingGravatarHash}:${size}`,
  69. undefined,
  70. missingGravatarHash,
  71. size,
  72. defaultStyle,
  73. );
  74. return avatar.uri ?? avatar.fallback!;
  75. }
  76. const hash = Strings.md5(email.trim().toLowerCase(), 'hex');
  77. const key = `${hash}:${size}`;
  78. const avatar = createOrUpdateAvatar(
  79. key,
  80. getAvatarUriFromGitHubNoReplyAddress(email, size),
  81. hash,
  82. size,
  83. defaultStyle,
  84. );
  85. if (avatar.uri != null) return avatar.uri;
  86. let query = avatarQueue.get(key);
  87. if (query == null && repoPathOrCommit != null && hasAvatarExpired(avatar)) {
  88. query = getAvatarUriFromRemoteProvider(avatar, key, email, repoPathOrCommit, { size: size }).then(
  89. uri => uri ?? avatar.uri ?? avatar.fallback!,
  90. );
  91. avatarQueue.set(
  92. key,
  93. query.finally(() => avatarQueue.delete(key)),
  94. );
  95. }
  96. if (query != null) return query;
  97. return avatar.uri ?? avatar.fallback!;
  98. }
  99. function createOrUpdateAvatar(
  100. key: string,
  101. uri: Uri | undefined,
  102. hash: string,
  103. size: number,
  104. defaultStyle?: GravatarDefaultStyle,
  105. ): Avatar {
  106. let avatar = avatarCache!.get(key);
  107. if (avatar == null) {
  108. avatar = {
  109. uri: uri,
  110. fallback: getAvatarUriFromGravatar(hash, size, defaultStyle),
  111. timestamp: 0,
  112. retries: 0,
  113. };
  114. avatarCache!.set(key, avatar);
  115. } else if (avatar.fallback == null) {
  116. avatar.fallback = getAvatarUriFromGravatar(hash, size, defaultStyle);
  117. }
  118. return avatar;
  119. }
  120. function ensureAvatarCache(cache: Map<string, Avatar> | undefined): asserts cache is Map<string, Avatar> {
  121. if (cache == null) {
  122. const avatars: [string, Avatar][] | undefined = Container.context.globalState
  123. .get<[string, SerializedAvatar][]>(GlobalState.Avatars)
  124. ?.map<[string, Avatar]>(([key, avatar]) => [
  125. key,
  126. {
  127. uri: Uri.parse(avatar.uri),
  128. timestamp: avatar.timestamp,
  129. retries: 0,
  130. },
  131. ]);
  132. avatarCache = new Map<string, Avatar>(avatars);
  133. }
  134. }
  135. function hasAvatarExpired(avatar: Avatar) {
  136. return Date.now() >= avatar.timestamp + retryDecay[Math.min(avatar.retries, retryDecay.length - 1)];
  137. }
  138. function getAvatarUriFromGravatar(
  139. hash: string,
  140. size: number,
  141. defaultStyle: GravatarDefaultStyle = GravatarDefaultStyle.Robot,
  142. ): Uri {
  143. return Uri.parse(`https://www.gravatar.com/avatar/${hash}.jpg?s=${size}&d=${defaultStyle}`);
  144. }
  145. function getAvatarUriFromGitHubNoReplyAddress(email: string, size: number = 16): Uri | undefined {
  146. const match = gitHubNoReplyAddressRegex.exec(email);
  147. if (match == null) return undefined;
  148. const [, userId, userName] = match;
  149. return Uri.parse(`https://avatars.githubusercontent.com/${userId ? `u/${userId}` : userName}?size=${size}`);
  150. }
  151. async function getAvatarUriFromRemoteProvider(
  152. avatar: Avatar,
  153. key: string,
  154. email: string,
  155. repoPathOrCommit: string | GitRevisionReference,
  156. { size = 16 }: { size?: number } = {},
  157. ) {
  158. ensureAvatarCache(avatarCache);
  159. try {
  160. let account;
  161. if (Container.config.integrations.enabled) {
  162. // if (typeof repoPathOrCommit === 'string') {
  163. // const remote = await Container.git.getRichRemoteProvider(repoPathOrCommit);
  164. // account = await remote?.provider.getAccountForEmail(email, { avatarSize: size });
  165. // } else {
  166. if (typeof repoPathOrCommit !== 'string') {
  167. const remote = await Container.git.getRichRemoteProvider(repoPathOrCommit.repoPath);
  168. account = await remote?.provider.getAccountForCommit(repoPathOrCommit.ref, { avatarSize: size });
  169. }
  170. }
  171. if (account == null) {
  172. // If we have no account assume that won't change (without a reset), so set the timestamp to "never expire"
  173. avatar.uri = undefined;
  174. avatar.timestamp = Number.MAX_SAFE_INTEGER;
  175. avatar.retries = 0;
  176. return undefined;
  177. }
  178. avatar.uri = Uri.parse(account.avatarUrl);
  179. avatar.timestamp = Date.now();
  180. avatar.retries = 0;
  181. if (account.email != null && Strings.equalsIgnoreCase(email, account.email)) {
  182. avatarCache.set(`${Strings.md5(account.email.trim().toLowerCase(), 'hex')}:${size}`, { ...avatar });
  183. }
  184. _onDidFetchAvatar.fire({ email: email });
  185. return avatar.uri;
  186. } catch {
  187. avatar.uri = undefined;
  188. avatar.timestamp = Date.now();
  189. avatar.retries++;
  190. return undefined;
  191. }
  192. }
  193. export function getPresenceDataUri(status: ContactPresenceStatus) {
  194. let dataUri = presenceCache.get(status);
  195. if (dataUri == null) {
  196. const contents = fs
  197. .readFileSync(Container.context.asAbsolutePath(`images/dark/icon-presence-${status}.svg`))
  198. .toString('base64');
  199. dataUri = encodeURI(`data:image/svg+xml;base64,${contents}`);
  200. presenceCache.set(status, dataUri);
  201. }
  202. return dataUri;
  203. }
  204. export function resetAvatarCache(reset: 'all' | 'failed' | 'fallback') {
  205. switch (reset) {
  206. case 'all':
  207. void Container.context.globalState.update(GlobalState.Avatars, undefined);
  208. avatarCache?.clear();
  209. avatarQueue.clear();
  210. break;
  211. case 'failed':
  212. for (const avatar of avatarCache?.values() ?? []) {
  213. // Reset failed requests
  214. if (avatar.uri == null) {
  215. avatar.timestamp = 0;
  216. avatar.retries = 0;
  217. }
  218. }
  219. break;
  220. case 'fallback':
  221. for (const avatar of avatarCache?.values() ?? []) {
  222. avatar.fallback = undefined;
  223. }
  224. break;
  225. }
  226. }