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.

268 lines
7.5 KiB

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