baseAvatar.tsx 6.2 KB

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