baseAvatar.tsx 5.9 KB

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