baseAvatar.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  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 ? `2px solid ${p.theme.background}` : '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<React.ComponentProps<typeof Tooltip>, 'children' | 'title'>;
  97. uploadId?: string | null | undefined;
  98. };
  99. type Props = BaseProps;
  100. type State = {
  101. hasLoaded: boolean;
  102. loadError: boolean;
  103. showBackupAvatar: boolean;
  104. };
  105. class BaseAvatar extends Component<Props, State> {
  106. static defaultProps = defaultProps;
  107. constructor(props: Props) {
  108. super(props);
  109. this.state = {
  110. showBackupAvatar: false,
  111. hasLoaded: props.type !== 'upload',
  112. loadError: false,
  113. };
  114. }
  115. getRemoteImageSize = () => {
  116. const {remoteImageSize, size} = this.props;
  117. // Try to make sure remote image size is >= requested size
  118. // If requested size > allowed size then use the largest allowed size
  119. const allowed =
  120. size &&
  121. (ALLOWED_SIZES.find(allowedSize => allowedSize >= size) ||
  122. ALLOWED_SIZES[ALLOWED_SIZES.length - 1]);
  123. return remoteImageSize || allowed || DEFAULT_GRAVATAR_SIZE;
  124. };
  125. buildUploadUrl = () => {
  126. const {uploadPath, uploadId} = this.props;
  127. return `/${uploadPath || 'avatar'}/${uploadId}/?${qs.stringify({
  128. s: DEFAULT_REMOTE_SIZE,
  129. })}`;
  130. };
  131. handleLoad = () => {
  132. this.setState({showBackupAvatar: false, hasLoaded: true});
  133. };
  134. handleError = () => {
  135. this.setState({showBackupAvatar: true, loadError: true, hasLoaded: true});
  136. };
  137. renderImg = () => {
  138. if (this.state.loadError) {
  139. return null;
  140. }
  141. const {type, round, gravatarId, suggested} = this.props;
  142. const eventProps = {
  143. onError: this.handleError,
  144. onLoad: this.handleLoad,
  145. };
  146. if (type === 'gravatar') {
  147. return (
  148. <Gravatar
  149. placeholder={this.props.default}
  150. gravatarId={gravatarId}
  151. round={round}
  152. remoteSize={DEFAULT_REMOTE_SIZE}
  153. suggested={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. />
  166. );
  167. }
  168. if (type === 'background') {
  169. return this.renderBackgroundAvatar();
  170. }
  171. return this.renderLetterAvatar();
  172. };
  173. renderLetterAvatar() {
  174. const {title, letterId, round, suggested} = this.props;
  175. return (
  176. <LetterAvatar
  177. round={round}
  178. displayName={title}
  179. identifier={letterId}
  180. suggested={suggested}
  181. />
  182. );
  183. }
  184. renderBackgroundAvatar() {
  185. const {round, suggested} = this.props;
  186. return <BackgroundAvatar round={round} suggested={suggested} />;
  187. }
  188. renderBackupAvatar() {
  189. const {backupAvatar} = this.props;
  190. return backupAvatar ?? this.renderLetterAvatar();
  191. }
  192. render() {
  193. const {
  194. className,
  195. style,
  196. round,
  197. hasTooltip,
  198. size,
  199. suggested,
  200. tooltip,
  201. tooltipOptions,
  202. forwardedRef,
  203. type,
  204. ...props
  205. } = this.props;
  206. let sizeStyle = {};
  207. if (size) {
  208. sizeStyle = {
  209. width: `${size}px`,
  210. height: `${size}px`,
  211. };
  212. }
  213. return (
  214. <Tooltip title={tooltip} disabled={!hasTooltip} {...tooltipOptions}>
  215. <StyledBaseAvatar
  216. data-test-id={`${type}-avatar`}
  217. ref={forwardedRef}
  218. loaded={this.state.hasLoaded}
  219. className={classNames('avatar', className)}
  220. round={!!round}
  221. suggested={!!suggested}
  222. style={{
  223. ...sizeStyle,
  224. ...style,
  225. }}
  226. {...props}
  227. >
  228. {this.state.showBackupAvatar && this.renderBackupAvatar()}
  229. {this.renderImg()}
  230. </StyledBaseAvatar>
  231. </Tooltip>
  232. );
  233. }
  234. }
  235. export default BaseAvatar;
  236. const Image = styled('img')<ImageStyleProps>`
  237. ${imageStyle};
  238. `;