// Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { AriaAttributes, CSSProperties, MouseEvent, ReactChild, ReactNode, } from 'react'; import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; import { noop } from 'lodash'; import type { AvatarColorType } from '../types/Colors'; import type { BadgeType } from '../badges/types'; import type { LocalizerType } from '../types/Util'; import * as log from '../logging/log'; import { BadgeImageTheme } from '../badges/BadgeImageTheme'; import { HasStories } from '../types/Stories'; import { Spinner } from './Spinner'; import { ThemeType } from '../types/Util'; import { assertDev } from '../util/assert'; import { getBadgeImageFileLocalPath } from '../badges/getBadgeImageFileLocalPath'; import { getInitials } from '../util/getInitials'; import { isBadgeVisible } from '../badges/isBadgeVisible'; import { shouldBlurAvatar } from '../util/shouldBlurAvatar'; import { SIGNAL_AVATAR_PATH } from '../types/SignalConversation'; export enum AvatarBlur { NoBlur, BlurPicture, BlurPictureWithClickToView, } export enum AvatarSize { TWENTY = 20, TWENTY_EIGHT = 28, THIRTY_TWO = 32, FORTY_EIGHT = 48, FIFTY_TWO = 52, EIGHTY = 80, } type BadgePlacementType = { bottom: number; right: number }; export type Props = { avatarPath?: string; blur?: AvatarBlur; color?: AvatarColorType; loading?: boolean; acceptedMessageRequest: boolean; conversationType: 'group' | 'direct'; isMe: boolean; noteToSelf?: boolean; phoneNumber?: string; profileName?: string; sharedGroupNames: ReadonlyArray; size: AvatarSize; title: string; unblurredAvatarPath?: string; searchResult?: boolean; storyRing?: HasStories; onClick?: (event: MouseEvent) => unknown; onClickBadge?: (event: MouseEvent) => unknown; // Matches Popper's RefHandler type innerRef?: React.Ref; i18n: LocalizerType; } & ( | { badge: undefined; theme?: ThemeType } | { badge: BadgeType; theme: ThemeType } ) & Pick, 'className'> & AriaAttributes; const BADGE_PLACEMENT_BY_SIZE = new Map([ [28, { bottom: -4, right: -2 }], [32, { bottom: -4, right: -2 }], [36, { bottom: -3, right: 0 }], [40, { bottom: -6, right: -4 }], [48, { bottom: -6, right: -4 }], [52, { bottom: -6, right: -2 }], [56, { bottom: -6, right: 0 }], [64, { bottom: -6, right: 0 }], [80, { bottom: -8, right: 0 }], [88, { bottom: -4, right: 3 }], [112, { bottom: -4, right: 3 }], ]); const getDefaultBlur = ( ...args: Parameters ): AvatarBlur => shouldBlurAvatar(...args) ? AvatarBlur.BlurPicture : AvatarBlur.NoBlur; export function Avatar({ acceptedMessageRequest, avatarPath, badge, className, color = 'A200', conversationType, i18n, isMe, innerRef, loading, noteToSelf, onClick, onClickBadge, sharedGroupNames, size, theme, title, unblurredAvatarPath, searchResult, storyRing, blur = getDefaultBlur({ acceptedMessageRequest, avatarPath, isMe, sharedGroupNames, unblurredAvatarPath, }), ...ariaProps }: Props): JSX.Element { const [imageBroken, setImageBroken] = useState(false); useEffect(() => { setImageBroken(false); }, [avatarPath]); useEffect(() => { if (!avatarPath) { return noop; } const image = new Image(); image.src = avatarPath; image.onerror = () => { log.warn('Avatar: Image failed to load; failing over to placeholder'); setImageBroken(true); }; return () => { image.onerror = noop; }; }, [avatarPath]); const initials = getInitials(title); const hasImage = !noteToSelf && avatarPath && !imageBroken; const shouldUseInitials = !hasImage && conversationType === 'direct' && Boolean(initials); let contentsChildren: ReactNode; if (loading) { const svgSize = size < 40 ? 'small' : 'normal'; contentsChildren = (
); } else if (hasImage) { assertDev(avatarPath, 'avatarPath should be defined here'); assertDev( blur !== AvatarBlur.BlurPictureWithClickToView || size >= AvatarSize.EIGHTY, 'Rendering "click to view" for a small avatar. This may not render correctly' ); const isBlurred = blur === AvatarBlur.BlurPicture || blur === AvatarBlur.BlurPictureWithClickToView; contentsChildren = ( <>
{blur === AvatarBlur.BlurPictureWithClickToView && (
{i18n('icu:view')}
)} ); } else if (searchResult) { contentsChildren = (
); } else if (noteToSelf) { contentsChildren = (
); } else if (shouldUseInitials) { contentsChildren = ( ); } else { contentsChildren = (
); } let contents: ReactChild; const contentsClassName = classNames( 'module-Avatar__contents', `module-Avatar__contents--${color}` ); if (onClick) { contents = ( ); } else { contents =
{contentsChildren}
; } let badgeNode: ReactNode; const badgeSize = _getBadgeSize(size); if (badge && theme && !noteToSelf && badgeSize && isBadgeVisible(badge)) { const badgePlacement = _getBadgePlacement(size); const badgeTheme = theme === ThemeType.light ? BadgeImageTheme.Light : BadgeImageTheme.Dark; const badgeImagePath = getBadgeImageFileLocalPath( badge, badgeSize, badgeTheme ); if (badgeImagePath) { const positionStyles: CSSProperties = { width: badgeSize, height: badgeSize, ...badgePlacement, }; if (onClickBadge) { badgeNode = (