2021-01-14 18:07:05 +00:00
|
|
|
// Copyright 2018-2021 Signal Messenger, LLC
|
2020-10-30 20:34:04 +00:00
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
2021-10-26 19:15:33 +00:00
|
|
|
import type {
|
2021-11-18 20:01:53 +00:00
|
|
|
CSSProperties,
|
2021-04-21 16:32:23 +00:00
|
|
|
FunctionComponent,
|
2021-10-12 19:07:58 +00:00
|
|
|
MouseEvent,
|
|
|
|
ReactChild,
|
2021-04-21 16:32:23 +00:00
|
|
|
ReactNode,
|
|
|
|
} from 'react';
|
2021-10-26 19:15:33 +00:00
|
|
|
import React, { useEffect, useState } from 'react';
|
2018-09-27 00:23:17 +00:00
|
|
|
import classNames from 'classnames';
|
2021-04-27 13:20:17 +00:00
|
|
|
import { noop } from 'lodash';
|
2018-09-27 00:23:17 +00:00
|
|
|
|
2021-01-29 22:16:48 +00:00
|
|
|
import { Spinner } from './Spinner';
|
|
|
|
|
2018-09-27 00:23:17 +00:00
|
|
|
import { getInitials } from '../util/getInitials';
|
2021-10-26 19:15:33 +00:00
|
|
|
import type { LocalizerType } from '../types/Util';
|
2021-11-02 23:01:13 +00:00
|
|
|
import { ThemeType } from '../types/Util';
|
2021-10-26 19:15:33 +00:00
|
|
|
import type { AvatarColorType } from '../types/Colors';
|
2021-11-02 23:01:13 +00:00
|
|
|
import type { BadgeType } from '../badges/types';
|
2021-04-21 16:32:23 +00:00
|
|
|
import * as log from '../logging/log';
|
2021-04-27 13:20:17 +00:00
|
|
|
import { assert } from '../util/assert';
|
2021-04-30 19:40:25 +00:00
|
|
|
import { shouldBlurAvatar } from '../util/shouldBlurAvatar';
|
2021-11-02 23:01:13 +00:00
|
|
|
import { getBadgeImageFileLocalPath } from '../badges/getBadgeImageFileLocalPath';
|
2021-11-08 16:29:54 +00:00
|
|
|
import { isBadgeVisible } from '../badges/isBadgeVisible';
|
2021-11-02 23:01:13 +00:00
|
|
|
import { BadgeImageTheme } from '../badges/BadgeImageTheme';
|
2021-11-09 15:51:56 +00:00
|
|
|
import { shouldShowBadges } from '../badges/shouldShowBadges';
|
2021-04-27 13:20:17 +00:00
|
|
|
|
|
|
|
export enum AvatarBlur {
|
|
|
|
NoBlur,
|
|
|
|
BlurPicture,
|
|
|
|
BlurPictureWithClickToView,
|
|
|
|
}
|
2018-09-27 00:23:17 +00:00
|
|
|
|
2020-11-19 18:13:36 +00:00
|
|
|
export enum AvatarSize {
|
2021-11-01 18:43:02 +00:00
|
|
|
SIXTEEN = 16,
|
2020-11-19 18:13:36 +00:00
|
|
|
TWENTY_EIGHT = 28,
|
|
|
|
THIRTY_TWO = 32,
|
2021-07-20 19:56:50 +00:00
|
|
|
THIRTY_SIX = 36,
|
2021-10-12 23:59:08 +00:00
|
|
|
FORTY_EIGHT = 48,
|
2020-11-19 18:13:36 +00:00
|
|
|
FIFTY_TWO = 52,
|
|
|
|
EIGHTY = 80,
|
|
|
|
NINETY_SIX = 96,
|
|
|
|
ONE_HUNDRED_TWELVE = 112,
|
|
|
|
}
|
|
|
|
|
2021-11-09 20:34:47 +00:00
|
|
|
type BadgePlacementType = { size: number; bottom: number; right: number };
|
|
|
|
|
2020-05-27 21:37:06 +00:00
|
|
|
export type Props = {
|
2018-09-27 00:23:17 +00:00
|
|
|
avatarPath?: string;
|
2021-11-02 23:01:13 +00:00
|
|
|
badge?: BadgeType;
|
2021-04-27 13:20:17 +00:00
|
|
|
blur?: AvatarBlur;
|
2021-05-28 16:15:17 +00:00
|
|
|
color?: AvatarColorType;
|
2021-01-29 22:16:48 +00:00
|
|
|
loading?: boolean;
|
2020-03-26 21:47:35 +00:00
|
|
|
|
2021-05-07 22:21:10 +00:00
|
|
|
acceptedMessageRequest: boolean;
|
2018-09-27 00:23:17 +00:00
|
|
|
conversationType: 'group' | 'direct';
|
2021-05-07 22:21:10 +00:00
|
|
|
isMe: boolean;
|
2018-09-27 00:23:17 +00:00
|
|
|
name?: string;
|
2021-04-30 19:40:25 +00:00
|
|
|
noteToSelf?: boolean;
|
2018-09-27 00:23:17 +00:00
|
|
|
phoneNumber?: string;
|
|
|
|
profileName?: string;
|
2021-05-07 22:21:10 +00:00
|
|
|
sharedGroupNames: Array<string>;
|
2020-11-19 18:13:36 +00:00
|
|
|
size: AvatarSize;
|
2021-11-02 23:01:13 +00:00
|
|
|
theme?: ThemeType;
|
2021-04-30 19:40:25 +00:00
|
|
|
title: string;
|
|
|
|
unblurredAvatarPath?: string;
|
2021-11-12 01:17:29 +00:00
|
|
|
searchResult?: boolean;
|
2019-10-17 18:22:07 +00:00
|
|
|
|
2021-10-12 19:07:58 +00:00
|
|
|
onClick?: (event: MouseEvent<HTMLButtonElement>) => unknown;
|
2021-11-18 20:01:53 +00:00
|
|
|
onClickBadge?: (event: MouseEvent<HTMLButtonElement>) => unknown;
|
2019-10-17 18:22:07 +00:00
|
|
|
|
|
|
|
// Matches Popper's RefHandler type
|
2020-01-07 01:49:00 +00:00
|
|
|
innerRef?: React.Ref<HTMLDivElement>;
|
2019-10-17 18:22:07 +00:00
|
|
|
|
|
|
|
i18n: LocalizerType;
|
2020-05-27 21:37:06 +00:00
|
|
|
} & Pick<React.HTMLProps<HTMLDivElement>, 'className'>;
|
2018-09-27 00:23:17 +00:00
|
|
|
|
2021-11-09 20:34:47 +00:00
|
|
|
const BADGE_PLACEMENT_BY_SIZE = new Map<number, BadgePlacementType>([
|
|
|
|
[28, { size: 16, bottom: -4, right: -2 }],
|
|
|
|
[32, { size: 16, bottom: -4, right: -2 }],
|
|
|
|
[36, { size: 16, bottom: -3, right: 0 }],
|
|
|
|
[40, { size: 24, bottom: -6, right: -4 }],
|
|
|
|
[48, { size: 24, bottom: -6, right: -4 }],
|
|
|
|
[56, { size: 24, bottom: -6, right: 0 }],
|
|
|
|
[80, { size: 36, bottom: -8, right: 0 }],
|
|
|
|
[88, { size: 36, bottom: -4, right: 3 }],
|
|
|
|
]);
|
|
|
|
|
2021-04-30 19:40:25 +00:00
|
|
|
const getDefaultBlur = (
|
|
|
|
...args: Parameters<typeof shouldBlurAvatar>
|
|
|
|
): AvatarBlur =>
|
|
|
|
shouldBlurAvatar(...args) ? AvatarBlur.BlurPicture : AvatarBlur.NoBlur;
|
|
|
|
|
2021-04-21 16:32:23 +00:00
|
|
|
export const Avatar: FunctionComponent<Props> = ({
|
2021-04-30 19:40:25 +00:00
|
|
|
acceptedMessageRequest,
|
2021-04-21 16:32:23 +00:00
|
|
|
avatarPath,
|
2021-11-02 23:01:13 +00:00
|
|
|
badge,
|
2021-04-21 16:32:23 +00:00
|
|
|
className,
|
2021-10-14 00:13:13 +00:00
|
|
|
color = 'A200',
|
2021-04-21 16:32:23 +00:00
|
|
|
conversationType,
|
|
|
|
i18n,
|
2021-04-30 19:40:25 +00:00
|
|
|
isMe,
|
2021-04-21 16:32:23 +00:00
|
|
|
innerRef,
|
|
|
|
loading,
|
|
|
|
noteToSelf,
|
|
|
|
onClick,
|
2021-11-18 20:01:53 +00:00
|
|
|
onClickBadge,
|
2021-04-30 19:40:25 +00:00
|
|
|
sharedGroupNames,
|
2021-04-21 16:32:23 +00:00
|
|
|
size,
|
2021-11-02 23:01:13 +00:00
|
|
|
theme,
|
2021-04-21 16:32:23 +00:00
|
|
|
title,
|
2021-04-30 19:40:25 +00:00
|
|
|
unblurredAvatarPath,
|
2021-11-12 01:17:29 +00:00
|
|
|
searchResult,
|
2021-04-30 19:40:25 +00:00
|
|
|
blur = getDefaultBlur({
|
|
|
|
acceptedMessageRequest,
|
|
|
|
avatarPath,
|
|
|
|
isMe,
|
|
|
|
sharedGroupNames,
|
|
|
|
unblurredAvatarPath,
|
|
|
|
}),
|
2021-04-21 16:32:23 +00:00
|
|
|
}) => {
|
|
|
|
const [imageBroken, setImageBroken] = useState(false);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
setImageBroken(false);
|
|
|
|
}, [avatarPath]);
|
|
|
|
|
2021-04-27 13:20:17 +00:00
|
|
|
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]);
|
|
|
|
|
2021-04-21 16:32:23 +00:00
|
|
|
const initials = getInitials(title);
|
|
|
|
const hasImage = !noteToSelf && avatarPath && !imageBroken;
|
|
|
|
const shouldUseInitials =
|
|
|
|
!hasImage && conversationType === 'direct' && Boolean(initials);
|
|
|
|
|
2021-10-12 19:07:58 +00:00
|
|
|
let contentsChildren: ReactNode;
|
2021-04-21 16:32:23 +00:00
|
|
|
if (loading) {
|
|
|
|
const svgSize = size < 40 ? 'small' : 'normal';
|
2021-10-12 19:07:58 +00:00
|
|
|
contentsChildren = (
|
2021-04-21 16:32:23 +00:00
|
|
|
<div className="module-Avatar__spinner-container">
|
|
|
|
<Spinner
|
|
|
|
size={`${size - 8}px`}
|
|
|
|
svgSize={svgSize}
|
|
|
|
direction="on-avatar"
|
|
|
|
/>
|
|
|
|
</div>
|
2020-09-12 00:46:52 +00:00
|
|
|
);
|
2021-04-21 16:32:23 +00:00
|
|
|
} else if (hasImage) {
|
2021-04-27 13:20:17 +00:00
|
|
|
assert(avatarPath, 'avatarPath should be defined here');
|
2021-04-30 19:40:25 +00:00
|
|
|
|
2021-04-27 13:20:17 +00:00
|
|
|
assert(
|
|
|
|
blur !== AvatarBlur.BlurPictureWithClickToView || size >= 100,
|
|
|
|
'Rendering "click to view" for a small avatar. This may not render correctly'
|
|
|
|
);
|
|
|
|
|
|
|
|
const isBlurred =
|
|
|
|
blur === AvatarBlur.BlurPicture ||
|
|
|
|
blur === AvatarBlur.BlurPictureWithClickToView;
|
2021-10-12 19:07:58 +00:00
|
|
|
contentsChildren = (
|
2021-04-27 13:20:17 +00:00
|
|
|
<>
|
|
|
|
<div
|
|
|
|
className="module-Avatar__image"
|
|
|
|
style={{
|
|
|
|
backgroundImage: `url('${encodeURI(avatarPath)}')`,
|
|
|
|
...(isBlurred ? { filter: `blur(${Math.ceil(size / 2)}px)` } : {}),
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
{blur === AvatarBlur.BlurPictureWithClickToView && (
|
|
|
|
<div className="module-Avatar__click-to-view">{i18n('view')}</div>
|
|
|
|
)}
|
|
|
|
</>
|
2018-09-27 00:23:17 +00:00
|
|
|
);
|
2021-11-12 01:17:29 +00:00
|
|
|
} else if (searchResult) {
|
|
|
|
contentsChildren = (
|
|
|
|
<div
|
|
|
|
className={classNames(
|
|
|
|
'module-Avatar__icon',
|
|
|
|
'module-Avatar__icon--search-result'
|
|
|
|
)}
|
|
|
|
/>
|
|
|
|
);
|
2021-04-21 16:32:23 +00:00
|
|
|
} else if (noteToSelf) {
|
2021-10-12 19:07:58 +00:00
|
|
|
contentsChildren = (
|
2018-09-27 00:23:17 +00:00
|
|
|
<div
|
|
|
|
className={classNames(
|
2021-04-21 16:32:23 +00:00
|
|
|
'module-Avatar__icon',
|
|
|
|
'module-Avatar__icon--note-to-self'
|
2018-09-27 00:23:17 +00:00
|
|
|
)}
|
|
|
|
/>
|
|
|
|
);
|
2021-04-21 16:32:23 +00:00
|
|
|
} else if (shouldUseInitials) {
|
2021-10-12 19:07:58 +00:00
|
|
|
contentsChildren = (
|
2021-04-21 16:32:23 +00:00
|
|
|
<div
|
2021-04-27 13:20:17 +00:00
|
|
|
aria-hidden="true"
|
2021-04-21 16:32:23 +00:00
|
|
|
className="module-Avatar__label"
|
2021-08-06 21:35:25 +00:00
|
|
|
style={{ fontSize: Math.ceil(size * 0.45) }}
|
2021-04-21 16:32:23 +00:00
|
|
|
>
|
|
|
|
{initials}
|
2021-01-29 22:16:48 +00:00
|
|
|
</div>
|
|
|
|
);
|
2021-04-21 16:32:23 +00:00
|
|
|
} else {
|
2021-10-12 19:07:58 +00:00
|
|
|
contentsChildren = (
|
2018-09-27 00:23:17 +00:00
|
|
|
<div
|
|
|
|
className={classNames(
|
2021-04-21 16:32:23 +00:00
|
|
|
'module-Avatar__icon',
|
|
|
|
`module-Avatar__icon--${conversationType}`
|
2018-09-27 00:23:17 +00:00
|
|
|
)}
|
2021-04-21 16:32:23 +00:00
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-10-12 19:07:58 +00:00
|
|
|
let contents: ReactChild;
|
|
|
|
const contentsClassName = classNames(
|
|
|
|
'module-Avatar__contents',
|
|
|
|
`module-Avatar__contents--${color}`
|
|
|
|
);
|
2021-04-21 16:32:23 +00:00
|
|
|
if (onClick) {
|
|
|
|
contents = (
|
2021-10-12 19:07:58 +00:00
|
|
|
<button className={contentsClassName} type="button" onClick={onClick}>
|
|
|
|
{contentsChildren}
|
2021-04-21 16:32:23 +00:00
|
|
|
</button>
|
2018-09-27 00:23:17 +00:00
|
|
|
);
|
2021-10-12 19:07:58 +00:00
|
|
|
} else {
|
|
|
|
contents = <div className={contentsClassName}>{contentsChildren}</div>;
|
2018-09-27 00:23:17 +00:00
|
|
|
}
|
2021-04-21 16:32:23 +00:00
|
|
|
|
2021-11-02 23:01:13 +00:00
|
|
|
let badgeNode: ReactNode;
|
2021-11-09 15:51:56 +00:00
|
|
|
if (
|
|
|
|
badge &&
|
|
|
|
theme &&
|
|
|
|
!noteToSelf &&
|
|
|
|
isBadgeVisible(badge) &&
|
|
|
|
shouldShowBadges()
|
|
|
|
) {
|
2021-11-09 20:34:47 +00:00
|
|
|
const badgePlacement = _getBadgePlacement(size);
|
2021-11-02 23:01:13 +00:00
|
|
|
const badgeTheme =
|
|
|
|
theme === ThemeType.light ? BadgeImageTheme.Light : BadgeImageTheme.Dark;
|
|
|
|
const badgeImagePath = getBadgeImageFileLocalPath(
|
|
|
|
badge,
|
2021-11-09 20:34:47 +00:00
|
|
|
badgePlacement.size,
|
2021-11-02 23:01:13 +00:00
|
|
|
badgeTheme
|
|
|
|
);
|
|
|
|
if (badgeImagePath) {
|
2021-11-18 20:01:53 +00:00
|
|
|
const positionStyles: CSSProperties = {
|
|
|
|
width: badgePlacement.size,
|
|
|
|
height: badgePlacement.size,
|
|
|
|
bottom: badgePlacement.bottom,
|
|
|
|
right: badgePlacement.right,
|
|
|
|
};
|
|
|
|
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 23:01:13 +00:00
|
|
|
}
|
|
|
|
} else if (badge && !theme) {
|
|
|
|
log.error('<Avatar> requires a theme if a badge is provided');
|
|
|
|
}
|
|
|
|
|
2021-04-21 16:32:23 +00:00
|
|
|
return (
|
|
|
|
<div
|
2021-04-27 13:20:17 +00:00
|
|
|
aria-label={i18n('contactAvatarAlt', [title])}
|
2021-04-21 16:32:23 +00:00
|
|
|
className={classNames(
|
|
|
|
'module-Avatar',
|
|
|
|
hasImage ? 'module-Avatar--with-image' : 'module-Avatar--no-image',
|
|
|
|
className
|
|
|
|
)}
|
|
|
|
style={{
|
|
|
|
minWidth: size,
|
|
|
|
width: size,
|
|
|
|
height: size,
|
|
|
|
}}
|
|
|
|
ref={innerRef}
|
|
|
|
>
|
|
|
|
{contents}
|
2021-11-02 23:01:13 +00:00
|
|
|
{badgeNode}
|
2021-04-21 16:32:23 +00:00
|
|
|
</div>
|
|
|
|
);
|
|
|
|
};
|
2021-11-09 20:34:47 +00:00
|
|
|
|
|
|
|
// This is only exported for testing.
|
|
|
|
export function _getBadgePlacement(
|
|
|
|
avatarSize: number
|
|
|
|
): Readonly<BadgePlacementType> {
|
|
|
|
return (
|
|
|
|
BADGE_PLACEMENT_BY_SIZE.get(avatarSize) || {
|
|
|
|
size: Math.ceil(avatarSize * 0.425),
|
|
|
|
bottom: 0,
|
|
|
|
right: 0,
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|