baseAvatar.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. import {Component} from 'react';
  2. import styled from '@emotion/styled';
  3. import classNames from 'classnames';
  4. import * as qs from 'query-string';
  5. import BackgroundAvatar from 'sentry/components/avatar/backgroundAvatar';
  6. import LetterAvatar from 'sentry/components/letterAvatar';
  7. import {Tooltip, TooltipProps} from 'sentry/components/tooltip';
  8. import {Avatar} from 'sentry/types';
  9. import Gravatar from './gravatar';
  10. import {imageStyle, ImageStyleProps} from './styles';
  11. const DEFAULT_GRAVATAR_SIZE = 64;
  12. const ALLOWED_SIZES = [20, 32, 36, 48, 52, 64, 80, 96, 120];
  13. const DEFAULT_REMOTE_SIZE = 120;
  14. // Note: Avatar will not always be a child of a flex layout, but this seems like a
  15. // sensible default.
  16. const StyledBaseAvatar = styled('span')<{
  17. loaded: boolean;
  18. round: boolean;
  19. suggested: boolean;
  20. }>`
  21. flex-shrink: 0;
  22. border-radius: ${p => (p.round ? '50%' : '3px')};
  23. border: ${p => (p.suggested ? `1px dashed ${p.theme.subText}` : 'none')};
  24. background-color: ${p => (p.suggested ? p.theme.background : 'none')};
  25. `;
  26. const defaultProps: DefaultProps = {
  27. // No default size to ease transition from CSS defined sizes
  28. // size: 64,
  29. style: {},
  30. /**
  31. * Enable to display tooltips.
  32. */
  33. hasTooltip: false,
  34. /**
  35. * The type of avatar being rendered.
  36. */
  37. type: 'letter_avatar',
  38. /**
  39. * Path to uploaded avatar (differs based on model type)
  40. */
  41. uploadPath: 'avatar',
  42. /**
  43. * Should avatar be round instead of a square
  44. */
  45. round: false,
  46. };
  47. type DefaultProps = {
  48. /**
  49. * Enable to display tooltips.
  50. */
  51. hasTooltip?: boolean;
  52. /**
  53. * Should avatar be round instead of a square
  54. */
  55. round?: boolean;
  56. style?: React.CSSProperties;
  57. suggested?: boolean;
  58. /**
  59. * The type of avatar being rendered.
  60. */
  61. type?: Avatar['avatarType'];
  62. /**
  63. * Path to uploaded avatar (differs based on model type)
  64. */
  65. uploadPath?:
  66. | 'avatar'
  67. | 'team-avatar'
  68. | 'organization-avatar'
  69. | 'project-avatar'
  70. | 'sentry-app-avatar'
  71. | 'doc-integration-avatar';
  72. };
  73. type BaseProps = DefaultProps & {
  74. backupAvatar?: React.ReactNode;
  75. className?: string;
  76. /**
  77. * Default gravatar to display
  78. */
  79. default?: string;
  80. forwardedRef?: React.Ref<HTMLSpanElement>;
  81. gravatarId?: string;
  82. letterId?: string;
  83. /**
  84. * This is the size of the remote image to request.
  85. */
  86. remoteImageSize?: (typeof ALLOWED_SIZES)[number];
  87. size?: number;
  88. title?: string;
  89. /**
  90. * The content for the tooltip. Requires hasTooltip to display
  91. */
  92. tooltip?: React.ReactNode;
  93. /**
  94. * Additional props for the tooltip
  95. */
  96. tooltipOptions?: Omit<TooltipProps, 'children' | 'title'>;
  97. /**
  98. * The region domain that organization avatars are on
  99. */
  100. uploadDomain?: string;
  101. /**
  102. * The uuid for the uploaded avatar.
  103. */
  104. uploadId?: string | null | undefined;
  105. };
  106. type Props = BaseProps;
  107. type State = {
  108. hasLoaded: boolean;
  109. loadError: boolean;
  110. showBackupAvatar: boolean;
  111. };
  112. class BaseAvatar extends Component<Props, State> {
  113. static defaultProps = defaultProps;
  114. constructor(props: Props) {
  115. super(props);
  116. this.state = {
  117. showBackupAvatar: false,
  118. hasLoaded: props.type !== 'upload',
  119. loadError: false,
  120. };
  121. }
  122. getRemoteImageSize() {
  123. const {remoteImageSize, size} = this.props;
  124. // Try to make sure remote image size is >= requested size
  125. // If requested size > allowed size then use the largest allowed size
  126. const allowed =
  127. size &&
  128. (ALLOWED_SIZES.find(allowedSize => allowedSize >= size) ||
  129. ALLOWED_SIZES[ALLOWED_SIZES.length - 1]);
  130. return remoteImageSize || allowed || DEFAULT_GRAVATAR_SIZE;
  131. }
  132. buildUploadUrl() {
  133. const {uploadDomain, uploadPath, uploadId} = this.props;
  134. return `${uploadDomain || ''}/${uploadPath || 'avatar'}/${uploadId}/?${qs.stringify({
  135. s: DEFAULT_REMOTE_SIZE,
  136. })}`;
  137. }
  138. handleLoad = () => {
  139. this.setState({showBackupAvatar: false, hasLoaded: true});
  140. };
  141. handleError = () => {
  142. this.setState({showBackupAvatar: true, loadError: true, hasLoaded: true});
  143. };
  144. renderImg() {
  145. if (this.state.loadError) {
  146. return null;
  147. }
  148. const {type, round, gravatarId, suggested} = this.props;
  149. const eventProps = {
  150. onError: this.handleError,
  151. onLoad: this.handleLoad,
  152. };
  153. if (type === 'gravatar') {
  154. return (
  155. <Gravatar
  156. placeholder={this.props.default}
  157. gravatarId={gravatarId}
  158. round={round}
  159. remoteSize={DEFAULT_REMOTE_SIZE}
  160. suggested={suggested}
  161. {...eventProps}
  162. />
  163. );
  164. }
  165. if (type === 'upload') {
  166. return (
  167. <Image
  168. round={round}
  169. src={this.buildUploadUrl()}
  170. {...eventProps}
  171. suggested={suggested}
  172. />
  173. );
  174. }
  175. if (type === 'background') {
  176. return this.renderBackgroundAvatar();
  177. }
  178. return this.renderLetterAvatar();
  179. }
  180. renderLetterAvatar() {
  181. const {title, letterId, round, suggested} = this.props;
  182. return (
  183. <LetterAvatar
  184. round={round}
  185. displayName={title}
  186. identifier={letterId}
  187. suggested={suggested}
  188. />
  189. );
  190. }
  191. renderBackgroundAvatar() {
  192. const {round, suggested} = this.props;
  193. return <BackgroundAvatar round={round} suggested={suggested} />;
  194. }
  195. renderBackupAvatar() {
  196. const {backupAvatar} = this.props;
  197. return backupAvatar ?? this.renderLetterAvatar();
  198. }
  199. render() {
  200. const {
  201. className,
  202. style,
  203. round,
  204. hasTooltip,
  205. size,
  206. suggested,
  207. tooltip,
  208. tooltipOptions,
  209. forwardedRef,
  210. type,
  211. ...props
  212. } = this.props;
  213. let sizeStyle = {};
  214. if (size) {
  215. sizeStyle = {
  216. width: `${size}px`,
  217. height: `${size}px`,
  218. };
  219. }
  220. return (
  221. <Tooltip title={tooltip} disabled={!hasTooltip} {...tooltipOptions}>
  222. <StyledBaseAvatar
  223. data-test-id={`${type}-avatar`}
  224. ref={forwardedRef}
  225. loaded={this.state.hasLoaded}
  226. className={classNames('avatar', className)}
  227. round={!!round}
  228. suggested={!!suggested}
  229. style={{
  230. ...sizeStyle,
  231. ...style,
  232. }}
  233. {...props}
  234. >
  235. {this.state.showBackupAvatar && this.renderBackupAvatar()}
  236. {this.renderImg()}
  237. </StyledBaseAvatar>
  238. </Tooltip>
  239. );
  240. }
  241. }
  242. export default BaseAvatar;
  243. const Image = styled('img')<ImageStyleProps>`
  244. ${imageStyle};
  245. `;