Signal-Desktop/ts/components/AvatarPreview.tsx

203 lines
5.1 KiB
TypeScript

// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { CSSProperties } from 'react';
import React, { useEffect, useState } from 'react';
import { noop } from 'lodash';
import * as log from '../logging/log';
import type { LocalizerType } from '../types/Util';
import { Spinner } from './Spinner';
import type { AvatarColorType } from '../types/Colors';
import { AvatarColors } from '../types/Colors';
import { getInitials } from '../util/getInitials';
import { imagePathToBytes } from '../util/imagePathToBytes';
export type PropsType = {
avatarColor?: AvatarColorType;
avatarPath?: string;
avatarValue?: Uint8Array;
conversationTitle?: string;
i18n: LocalizerType;
isEditable?: boolean;
isGroup?: boolean;
onAvatarLoaded?: (avatarBuffer: Uint8Array) => unknown;
onClear?: () => unknown;
onClick?: () => unknown;
style?: CSSProperties;
};
enum ImageStatus {
Nothing = 'nothing',
Loading = 'loading',
HasImage = 'has-image',
}
export const AvatarPreview = ({
avatarColor = AvatarColors[0],
avatarPath,
avatarValue,
conversationTitle,
i18n,
isEditable,
isGroup,
onAvatarLoaded,
onClear,
onClick,
style = {},
}: PropsType): JSX.Element => {
const [avatarPreview, setAvatarPreview] = useState<Uint8Array | undefined>();
// Loads the initial avatarPath if one is provided, but only if we're in editable mode.
// If we're not editable, we assume that we either have an avatarPath or we show a
// default avatar.
useEffect(() => {
if (!isEditable) {
return;
}
if (!avatarPath) {
return noop;
}
let shouldCancel = false;
(async () => {
try {
const buffer = await imagePathToBytes(avatarPath);
if (shouldCancel) {
return;
}
setAvatarPreview(buffer);
onAvatarLoaded?.(buffer);
} catch (err) {
if (shouldCancel) {
return;
}
log.warn(
`Failed to convert image URL to array buffer. Error message: ${
err && err.message
}`
);
}
})();
return () => {
shouldCancel = true;
};
}, [avatarPath, onAvatarLoaded, isEditable]);
// Ensures that when avatarValue changes we generate new URLs
useEffect(() => {
if (avatarValue) {
setAvatarPreview(avatarValue);
} else {
setAvatarPreview(undefined);
}
}, [avatarValue]);
// Creates the object URL to render the Uint8Array image
const [objectUrl, setObjectUrl] = useState<undefined | string>();
useEffect(() => {
if (!avatarPreview) {
setObjectUrl(undefined);
return noop;
}
const url = URL.createObjectURL(new Blob([avatarPreview]));
setObjectUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}, [avatarPreview]);
let imageStatus: ImageStatus;
let encodedPath: string | undefined;
if (avatarValue && !objectUrl) {
imageStatus = ImageStatus.Loading;
} else if (objectUrl) {
encodedPath = objectUrl;
imageStatus = ImageStatus.HasImage;
} else if (avatarPath) {
encodedPath = encodeURI(avatarPath);
imageStatus = ImageStatus.HasImage;
} else {
imageStatus = ImageStatus.Nothing;
}
const isLoading = imageStatus === ImageStatus.Loading;
const clickProps = onClick
? {
role: 'button',
onClick,
tabIndex: 0,
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
onClick();
}
},
}
: {};
const componentStyle = {
...style,
};
if (onClick) {
componentStyle.cursor = 'pointer';
}
if (imageStatus === ImageStatus.Nothing) {
return (
<div className="AvatarPreview">
<div
className={`AvatarPreview__avatar BetterAvatarBubble--${avatarColor}`}
{...clickProps}
style={componentStyle}
>
{isGroup ? (
<div
className={`BetterAvatarBubble--${avatarColor}--icon AvatarPreview__group`}
/>
) : (
getInitials(conversationTitle)
)}
{isEditable && <div className="AvatarPreview__upload" />}
</div>
</div>
);
}
return (
<div className="AvatarPreview">
<div
className={`AvatarPreview__avatar AvatarPreview__avatar--${imageStatus}`}
{...clickProps}
style={
imageStatus === ImageStatus.HasImage && encodedPath
? {
...componentStyle,
backgroundImage: `url('${encodedPath}')`,
}
: componentStyle
}
>
{isLoading && (
<Spinner size="70px" svgSize="normal" direction="on-avatar" />
)}
{imageStatus === ImageStatus.HasImage && onClear && (
<button
aria-label={i18n('delete')}
className="AvatarPreview__clear"
onClick={onClear}
tabIndex={-1}
type="button"
/>
)}
{isEditable && <div className="AvatarPreview__upload" />}
</div>
</div>
);
};