2023-01-03 11:55:46 -08:00
|
|
|
// Copyright 2018 Signal Messenger, LLC
|
2020-10-30 15:34:04 -05:00
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
2023-05-04 19:41:17 -04:00
|
|
|
import type {
|
|
|
|
AriaAttributes,
|
|
|
|
CSSProperties,
|
|
|
|
MouseEvent,
|
|
|
|
ReactChild,
|
|
|
|
ReactNode,
|
|
|
|
} from 'react';
|
2021-10-26 14:15:33 -05:00
|
|
|
import React, { useEffect, useState } from 'react';
|
2018-09-26 17:23:17 -07:00
|
|
|
import classNames from 'classnames';
|
2021-04-27 08:20:17 -05:00
|
|
|
import { noop } from 'lodash';
|
2018-09-26 17:23:17 -07:00
|
|
|
|
2023-08-08 17:53:06 -07:00
|
|
|
import { filterDOMProps } from '@react-aria/utils';
|
2021-10-26 14:15:33 -05:00
|
|
|
import type { AvatarColorType } from '../types/Colors';
|
2021-11-02 18:01:13 -05:00
|
|
|
import type { BadgeType } from '../badges/types';
|
2022-07-21 20:44:35 -04:00
|
|
|
import type { LocalizerType } from '../types/Util';
|
2021-04-21 11:32:23 -05:00
|
|
|
import * as log from '../logging/log';
|
2022-07-21 20:44:35 -04:00
|
|
|
import { BadgeImageTheme } from '../badges/BadgeImageTheme';
|
|
|
|
import { HasStories } from '../types/Stories';
|
|
|
|
import { Spinner } from './Spinner';
|
|
|
|
import { ThemeType } from '../types/Util';
|
2022-09-15 12:17:15 -07:00
|
|
|
import { assertDev } from '../util/assert';
|
2021-11-02 18:01:13 -05:00
|
|
|
import { getBadgeImageFileLocalPath } from '../badges/getBadgeImageFileLocalPath';
|
2022-07-21 20:44:35 -04:00
|
|
|
import { getInitials } from '../util/getInitials';
|
2021-11-08 10:29:54 -06:00
|
|
|
import { isBadgeVisible } from '../badges/isBadgeVisible';
|
2022-07-21 20:44:35 -04:00
|
|
|
import { shouldBlurAvatar } from '../util/shouldBlurAvatar';
|
2022-11-09 16:11:45 -05:00
|
|
|
import { SIGNAL_AVATAR_PATH } from '../types/SignalConversation';
|
2021-04-27 08:20:17 -05:00
|
|
|
|
|
|
|
export enum AvatarBlur {
|
|
|
|
NoBlur,
|
|
|
|
BlurPicture,
|
|
|
|
BlurPictureWithClickToView,
|
|
|
|
}
|
2018-09-26 17:23:17 -07:00
|
|
|
|
2020-11-19 13:13:36 -05:00
|
|
|
export enum AvatarSize {
|
2022-12-09 13:37:45 -07:00
|
|
|
TWENTY = 20,
|
2024-02-05 18:13:13 -08:00
|
|
|
TWENTY_FOUR = 24,
|
2020-11-19 13:13:36 -05:00
|
|
|
TWENTY_EIGHT = 28,
|
2024-06-10 14:12:45 -07:00
|
|
|
THIRTY = 30,
|
2020-11-19 13:13:36 -05:00
|
|
|
THIRTY_TWO = 32,
|
2023-08-10 15:16:51 -07:00
|
|
|
THIRTY_SIX = 36,
|
2023-10-31 12:32:56 -07:00
|
|
|
FORTY = 40,
|
2021-10-12 18:59:08 -05:00
|
|
|
FORTY_EIGHT = 48,
|
2023-03-20 12:58:07 -07:00
|
|
|
FIFTY_TWO = 52,
|
2024-05-22 09:24:27 -07:00
|
|
|
SIXTY_FOUR = 64,
|
2020-11-19 13:13:36 -05:00
|
|
|
EIGHTY = 80,
|
2023-10-31 12:32:56 -07:00
|
|
|
NINETY_SIX = 96,
|
2024-02-05 18:13:13 -08:00
|
|
|
TWO_HUNDRED_SIXTEEN = 216,
|
2020-11-19 13:13:36 -05:00
|
|
|
}
|
|
|
|
|
2021-11-20 09:41:48 -06:00
|
|
|
type BadgePlacementType = { bottom: number; right: number };
|
2021-11-09 14:34:47 -06:00
|
|
|
|
2020-05-27 17:37:06 -04:00
|
|
|
export type Props = {
|
2024-07-11 12:44:09 -07:00
|
|
|
avatarUrl?: string;
|
2021-04-27 08:20:17 -05:00
|
|
|
blur?: AvatarBlur;
|
2021-05-28 12:15:17 -04:00
|
|
|
color?: AvatarColorType;
|
2021-01-29 14:16:48 -08:00
|
|
|
loading?: boolean;
|
2020-03-26 14:47:35 -07:00
|
|
|
|
2021-05-07 17:21:10 -05:00
|
|
|
acceptedMessageRequest: boolean;
|
2024-02-22 13:19:50 -08:00
|
|
|
conversationType: 'group' | 'direct' | 'callLink';
|
2021-05-07 17:21:10 -05:00
|
|
|
isMe: boolean;
|
2021-04-30 14:40:25 -05:00
|
|
|
noteToSelf?: boolean;
|
2018-09-26 17:23:17 -07:00
|
|
|
phoneNumber?: string;
|
|
|
|
profileName?: string;
|
2022-12-21 16:07:02 -08:00
|
|
|
sharedGroupNames: ReadonlyArray<string>;
|
2020-11-19 13:13:36 -05:00
|
|
|
size: AvatarSize;
|
2021-04-30 14:40:25 -05:00
|
|
|
title: string;
|
2024-07-11 12:44:09 -07:00
|
|
|
unblurredAvatarUrl?: string;
|
2021-11-11 17:17:29 -08:00
|
|
|
searchResult?: boolean;
|
2022-07-21 20:44:35 -04:00
|
|
|
storyRing?: HasStories;
|
2019-10-17 11:22:07 -07:00
|
|
|
|
2021-10-12 14:07:58 -05:00
|
|
|
onClick?: (event: MouseEvent<HTMLButtonElement>) => unknown;
|
2021-11-18 14:01:53 -06:00
|
|
|
onClickBadge?: (event: MouseEvent<HTMLButtonElement>) => unknown;
|
2019-10-17 11:22:07 -07:00
|
|
|
|
|
|
|
// Matches Popper's RefHandler type
|
2020-01-06 20:49:00 -05:00
|
|
|
innerRef?: React.Ref<HTMLDivElement>;
|
2019-10-17 11:22:07 -07:00
|
|
|
|
|
|
|
i18n: LocalizerType;
|
2021-12-01 11:24:00 -06:00
|
|
|
} & (
|
|
|
|
| { badge: undefined; theme?: ThemeType }
|
|
|
|
| { badge: BadgeType; theme: ThemeType }
|
|
|
|
) &
|
2023-05-04 19:41:17 -04:00
|
|
|
Pick<React.HTMLProps<HTMLDivElement>, 'className'> &
|
|
|
|
AriaAttributes;
|
2018-09-26 17:23:17 -07:00
|
|
|
|
2021-11-09 14:34:47 -06:00
|
|
|
const BADGE_PLACEMENT_BY_SIZE = new Map<number, BadgePlacementType>([
|
2021-11-20 09:41:48 -06:00
|
|
|
[28, { bottom: -4, right: -2 }],
|
2024-06-10 14:12:45 -07:00
|
|
|
[30, { bottom: -4, right: -2 }],
|
2021-11-20 09:41:48 -06:00
|
|
|
[32, { bottom: -4, right: -2 }],
|
|
|
|
[36, { bottom: -3, right: 0 }],
|
|
|
|
[40, { bottom: -6, right: -4 }],
|
|
|
|
[48, { bottom: -6, right: -4 }],
|
2022-01-24 15:59:55 -06:00
|
|
|
[52, { bottom: -6, right: -2 }],
|
2021-11-20 09:41:48 -06:00
|
|
|
[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 }],
|
2021-11-09 14:34:47 -06:00
|
|
|
]);
|
|
|
|
|
2021-04-30 14:40:25 -05:00
|
|
|
const getDefaultBlur = (
|
|
|
|
...args: Parameters<typeof shouldBlurAvatar>
|
|
|
|
): AvatarBlur =>
|
|
|
|
shouldBlurAvatar(...args) ? AvatarBlur.BlurPicture : AvatarBlur.NoBlur;
|
|
|
|
|
2022-11-17 16:45:19 -08:00
|
|
|
export function Avatar({
|
2021-04-30 14:40:25 -05:00
|
|
|
acceptedMessageRequest,
|
2024-07-11 12:44:09 -07:00
|
|
|
avatarUrl,
|
2021-11-02 18:01:13 -05:00
|
|
|
badge,
|
2021-04-21 11:32:23 -05:00
|
|
|
className,
|
2021-10-13 19:13:13 -05:00
|
|
|
color = 'A200',
|
2021-04-21 11:32:23 -05:00
|
|
|
conversationType,
|
|
|
|
i18n,
|
2021-04-30 14:40:25 -05:00
|
|
|
isMe,
|
2021-04-21 11:32:23 -05:00
|
|
|
innerRef,
|
|
|
|
loading,
|
|
|
|
noteToSelf,
|
|
|
|
onClick,
|
2021-11-18 14:01:53 -06:00
|
|
|
onClickBadge,
|
2021-04-30 14:40:25 -05:00
|
|
|
sharedGroupNames,
|
2021-04-21 11:32:23 -05:00
|
|
|
size,
|
2021-11-02 18:01:13 -05:00
|
|
|
theme,
|
2021-04-21 11:32:23 -05:00
|
|
|
title,
|
2024-07-11 12:44:09 -07:00
|
|
|
unblurredAvatarUrl,
|
2021-11-11 17:17:29 -08:00
|
|
|
searchResult,
|
2022-03-04 16:14:52 -05:00
|
|
|
storyRing,
|
2021-04-30 14:40:25 -05:00
|
|
|
blur = getDefaultBlur({
|
|
|
|
acceptedMessageRequest,
|
2024-07-11 12:44:09 -07:00
|
|
|
avatarUrl,
|
2021-04-30 14:40:25 -05:00
|
|
|
isMe,
|
|
|
|
sharedGroupNames,
|
2024-07-11 12:44:09 -07:00
|
|
|
unblurredAvatarUrl,
|
2021-04-30 14:40:25 -05:00
|
|
|
}),
|
2023-05-04 19:41:17 -04:00
|
|
|
...ariaProps
|
2022-11-17 16:45:19 -08:00
|
|
|
}: Props): JSX.Element {
|
2021-04-21 11:32:23 -05:00
|
|
|
const [imageBroken, setImageBroken] = useState(false);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
setImageBroken(false);
|
2024-07-11 12:44:09 -07:00
|
|
|
}, [avatarUrl]);
|
2021-04-21 11:32:23 -05:00
|
|
|
|
2021-04-27 08:20:17 -05:00
|
|
|
useEffect(() => {
|
2024-07-11 12:44:09 -07:00
|
|
|
if (!avatarUrl) {
|
2021-04-27 08:20:17 -05:00
|
|
|
return noop;
|
|
|
|
}
|
|
|
|
|
|
|
|
const image = new Image();
|
2024-07-11 12:44:09 -07:00
|
|
|
image.src = avatarUrl;
|
2021-04-27 08:20:17 -05:00
|
|
|
image.onerror = () => {
|
|
|
|
log.warn('Avatar: Image failed to load; failing over to placeholder');
|
|
|
|
setImageBroken(true);
|
|
|
|
};
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
image.onerror = noop;
|
|
|
|
};
|
2024-07-11 12:44:09 -07:00
|
|
|
}, [avatarUrl]);
|
2021-04-27 08:20:17 -05:00
|
|
|
|
2021-04-21 11:32:23 -05:00
|
|
|
const initials = getInitials(title);
|
2024-07-11 12:44:09 -07:00
|
|
|
const hasImage = !noteToSelf && avatarUrl && !imageBroken;
|
2021-04-21 11:32:23 -05:00
|
|
|
const shouldUseInitials =
|
2024-06-10 14:12:45 -07:00
|
|
|
!hasImage &&
|
|
|
|
conversationType === 'direct' &&
|
|
|
|
Boolean(initials) &&
|
|
|
|
title !== i18n('icu:unknownContact');
|
2021-04-21 11:32:23 -05:00
|
|
|
|
2021-10-12 14:07:58 -05:00
|
|
|
let contentsChildren: ReactNode;
|
2021-04-21 11:32:23 -05:00
|
|
|
if (loading) {
|
|
|
|
const svgSize = size < 40 ? 'small' : 'normal';
|
2021-10-12 14:07:58 -05:00
|
|
|
contentsChildren = (
|
2021-04-21 11:32:23 -05:00
|
|
|
<div className="module-Avatar__spinner-container">
|
|
|
|
<Spinner
|
|
|
|
size={`${size - 8}px`}
|
|
|
|
svgSize={svgSize}
|
|
|
|
direction="on-avatar"
|
|
|
|
/>
|
|
|
|
</div>
|
2020-09-11 17:46:52 -07:00
|
|
|
);
|
2021-04-21 11:32:23 -05:00
|
|
|
} else if (hasImage) {
|
2024-07-11 12:44:09 -07:00
|
|
|
assertDev(avatarUrl, 'avatarUrl should be defined here');
|
2021-04-30 14:40:25 -05:00
|
|
|
|
2022-09-15 12:17:15 -07:00
|
|
|
assertDev(
|
2022-12-14 12:48:11 -07:00
|
|
|
blur !== AvatarBlur.BlurPictureWithClickToView ||
|
|
|
|
size >= AvatarSize.EIGHTY,
|
2021-04-27 08:20:17 -05:00
|
|
|
'Rendering "click to view" for a small avatar. This may not render correctly'
|
|
|
|
);
|
|
|
|
|
|
|
|
const isBlurred =
|
|
|
|
blur === AvatarBlur.BlurPicture ||
|
|
|
|
blur === AvatarBlur.BlurPictureWithClickToView;
|
2021-10-12 14:07:58 -05:00
|
|
|
contentsChildren = (
|
2021-04-27 08:20:17 -05:00
|
|
|
<>
|
|
|
|
<div
|
|
|
|
className="module-Avatar__image"
|
|
|
|
style={{
|
2024-07-11 12:44:09 -07:00
|
|
|
backgroundImage: `url('${avatarUrl}')`,
|
2021-04-27 08:20:17 -05:00
|
|
|
...(isBlurred ? { filter: `blur(${Math.ceil(size / 2)}px)` } : {}),
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
{blur === AvatarBlur.BlurPictureWithClickToView && (
|
2023-03-29 17:03:25 -07:00
|
|
|
<div className="module-Avatar__click-to-view">{i18n('icu:view')}</div>
|
2021-04-27 08:20:17 -05:00
|
|
|
)}
|
|
|
|
</>
|
2018-09-26 17:23:17 -07:00
|
|
|
);
|
2021-11-11 17:17:29 -08:00
|
|
|
} else if (searchResult) {
|
|
|
|
contentsChildren = (
|
|
|
|
<div
|
|
|
|
className={classNames(
|
|
|
|
'module-Avatar__icon',
|
|
|
|
'module-Avatar__icon--search-result'
|
|
|
|
)}
|
|
|
|
/>
|
|
|
|
);
|
2021-04-21 11:32:23 -05:00
|
|
|
} else if (noteToSelf) {
|
2021-10-12 14:07:58 -05:00
|
|
|
contentsChildren = (
|
2018-09-26 17:23:17 -07:00
|
|
|
<div
|
|
|
|
className={classNames(
|
2021-04-21 11:32:23 -05:00
|
|
|
'module-Avatar__icon',
|
|
|
|
'module-Avatar__icon--note-to-self'
|
2018-09-26 17:23:17 -07:00
|
|
|
)}
|
|
|
|
/>
|
|
|
|
);
|
2021-04-21 11:32:23 -05:00
|
|
|
} else if (shouldUseInitials) {
|
2021-10-12 14:07:58 -05:00
|
|
|
contentsChildren = (
|
2021-04-21 11:32:23 -05:00
|
|
|
<div
|
2021-04-27 08:20:17 -05:00
|
|
|
aria-hidden="true"
|
2021-04-21 11:32:23 -05:00
|
|
|
className="module-Avatar__label"
|
2021-08-06 17:35:25 -04:00
|
|
|
style={{ fontSize: Math.ceil(size * 0.45) }}
|
2021-04-21 11:32:23 -05:00
|
|
|
>
|
|
|
|
{initials}
|
2021-01-29 14:16:48 -08:00
|
|
|
</div>
|
|
|
|
);
|
2021-04-21 11:32:23 -05:00
|
|
|
} else {
|
2021-10-12 14:07:58 -05:00
|
|
|
contentsChildren = (
|
2018-09-26 17:23:17 -07:00
|
|
|
<div
|
|
|
|
className={classNames(
|
2021-04-21 11:32:23 -05:00
|
|
|
'module-Avatar__icon',
|
|
|
|
`module-Avatar__icon--${conversationType}`
|
2018-09-26 17:23:17 -07:00
|
|
|
)}
|
2021-04-21 11:32:23 -05:00
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-10-12 14:07:58 -05:00
|
|
|
let contents: ReactChild;
|
|
|
|
const contentsClassName = classNames(
|
|
|
|
'module-Avatar__contents',
|
|
|
|
`module-Avatar__contents--${color}`
|
|
|
|
);
|
2021-04-21 11:32:23 -05:00
|
|
|
if (onClick) {
|
|
|
|
contents = (
|
2023-05-04 19:41:17 -04:00
|
|
|
<button
|
2023-08-08 17:53:06 -07:00
|
|
|
{...filterDOMProps(ariaProps)}
|
2023-05-04 19:41:17 -04:00
|
|
|
className={contentsClassName}
|
|
|
|
type="button"
|
|
|
|
onClick={onClick}
|
|
|
|
>
|
2021-10-12 14:07:58 -05:00
|
|
|
{contentsChildren}
|
2021-04-21 11:32:23 -05:00
|
|
|
</button>
|
2018-09-26 17:23:17 -07:00
|
|
|
);
|
2021-10-12 14:07:58 -05:00
|
|
|
} else {
|
|
|
|
contents = <div className={contentsClassName}>{contentsChildren}</div>;
|
2018-09-26 17:23:17 -07:00
|
|
|
}
|
2021-04-21 11:32:23 -05:00
|
|
|
|
2021-11-02 18:01:13 -05:00
|
|
|
let badgeNode: ReactNode;
|
2021-11-20 09:41:48 -06:00
|
|
|
const badgeSize = _getBadgeSize(size);
|
2022-10-24 13:46:36 -07:00
|
|
|
if (badge && theme && !noteToSelf && badgeSize && isBadgeVisible(badge)) {
|
2021-11-09 14:34:47 -06:00
|
|
|
const badgePlacement = _getBadgePlacement(size);
|
2021-11-02 18:01:13 -05:00
|
|
|
const badgeTheme =
|
|
|
|
theme === ThemeType.light ? BadgeImageTheme.Light : BadgeImageTheme.Dark;
|
|
|
|
const badgeImagePath = getBadgeImageFileLocalPath(
|
|
|
|
badge,
|
2021-11-20 09:41:48 -06:00
|
|
|
badgeSize,
|
2021-11-02 18:01:13 -05:00
|
|
|
badgeTheme
|
|
|
|
);
|
|
|
|
if (badgeImagePath) {
|
2021-11-18 14:01:53 -06:00
|
|
|
const positionStyles: CSSProperties = {
|
2021-11-20 09:41:48 -06:00
|
|
|
width: badgeSize,
|
|
|
|
height: badgeSize,
|
|
|
|
...badgePlacement,
|
2021-11-18 14:01:53 -06:00
|
|
|
};
|
|
|
|
if (onClickBadge) {
|
|
|
|
badgeNode = (
|
|
|
|
<button
|
|
|
|
aria-label={badge.name}
|
|
|
|
className="module-Avatar__badge module-Avatar__badge--button"
|
|
|
|
onClick={onClickBadge}
|
|
|
|
style={{
|
|
|
|
backgroundImage: `url('${encodeURI(badgeImagePath)}')`,
|
|
|
|
...positionStyles,
|
|
|
|
}}
|
|
|
|
type="button"
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
badgeNode = (
|
|
|
|
<img
|
|
|
|
alt={badge.name}
|
|
|
|
className="module-Avatar__badge module-Avatar__badge--static"
|
|
|
|
src={badgeImagePath}
|
|
|
|
style={positionStyles}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
2021-11-02 18:01:13 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-04-21 11:32:23 -05:00
|
|
|
return (
|
|
|
|
<div
|
2023-03-29 17:03:25 -07:00
|
|
|
aria-label={i18n('icu:contactAvatarAlt', {
|
2023-03-27 16:37:39 -07:00
|
|
|
name: title,
|
|
|
|
})}
|
2021-04-21 11:32:23 -05:00
|
|
|
className={classNames(
|
|
|
|
'module-Avatar',
|
2022-07-21 20:44:35 -04:00
|
|
|
Boolean(storyRing) && 'module-Avatar--with-story',
|
|
|
|
storyRing === HasStories.Unread && 'module-Avatar--with-story--unread',
|
2022-11-08 21:38:19 -05:00
|
|
|
className,
|
2024-07-11 12:44:09 -07:00
|
|
|
avatarUrl === SIGNAL_AVATAR_PATH
|
2022-11-08 21:38:19 -05:00
|
|
|
? 'module-Avatar--signal-official'
|
|
|
|
: undefined
|
2021-04-21 11:32:23 -05:00
|
|
|
)}
|
|
|
|
style={{
|
|
|
|
minWidth: size,
|
|
|
|
width: size,
|
|
|
|
height: size,
|
|
|
|
}}
|
|
|
|
ref={innerRef}
|
|
|
|
>
|
|
|
|
{contents}
|
2021-11-02 18:01:13 -05:00
|
|
|
{badgeNode}
|
2021-04-21 11:32:23 -05:00
|
|
|
</div>
|
|
|
|
);
|
2022-11-17 16:45:19 -08:00
|
|
|
}
|
2021-11-09 14:34:47 -06:00
|
|
|
|
2021-11-20 09:41:48 -06:00
|
|
|
// This is only exported for testing.
|
|
|
|
export function _getBadgeSize(avatarSize: number): undefined | number {
|
|
|
|
if (avatarSize < 24) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
if (avatarSize <= 36) {
|
|
|
|
return 16;
|
|
|
|
}
|
|
|
|
if (avatarSize <= 64) {
|
|
|
|
return 24;
|
|
|
|
}
|
|
|
|
if (avatarSize <= 112) {
|
|
|
|
return 36;
|
|
|
|
}
|
|
|
|
return Math.round(avatarSize * 0.4);
|
|
|
|
}
|
|
|
|
|
2021-11-09 14:34:47 -06:00
|
|
|
// This is only exported for testing.
|
|
|
|
export function _getBadgePlacement(
|
|
|
|
avatarSize: number
|
|
|
|
): Readonly<BadgePlacementType> {
|
2021-11-20 09:41:48 -06:00
|
|
|
return BADGE_PLACEMENT_BY_SIZE.get(avatarSize) || { bottom: 0, right: 0 };
|
2021-11-09 14:34:47 -06:00
|
|
|
}
|