// 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 function AvatarPreview({ avatarColor = AvatarColors[0], avatarPath, avatarValue, conversationTitle, i18n, isEditable, isGroup, onAvatarLoaded, onClear, onClick, style = {}, }: PropsType): JSX.Element { const [avatarPreview, setAvatarPreview] = useState(); // 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; void (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(); 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) => { if (event.key === 'Enter' || event.key === ' ') { onClick(); } }, } : {}; const componentStyle = { ...style, }; if (onClick) { componentStyle.cursor = 'pointer'; } if (imageStatus === ImageStatus.Nothing) { return (
{isGroup ? (
) : ( getInitials(conversationTitle) )} {isEditable &&
}
); } return (
{isLoading && ( )} {imageStatus === ImageStatus.HasImage && onClear && (
); }