// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useCallback, useEffect, useState } from 'react'; import type { AvatarColorType } from '../types/Colors'; import type { AvatarDataType, DeleteAvatarFromDiskActionType, ReplaceAvatarActionType, SaveAvatarToDiskActionType, } from '../types/Avatar'; import { AvatarIconEditor } from './AvatarIconEditor'; import { AvatarModalButtons } from './AvatarModalButtons'; import { AvatarPreview } from './AvatarPreview'; import { AvatarTextEditor } from './AvatarTextEditor'; import { AvatarUploadButton } from './AvatarUploadButton'; import { BetterAvatar } from './BetterAvatar'; import type { LocalizerType } from '../types/Util'; import { avatarDataToBytes } from '../util/avatarDataToBytes'; import { createAvatarData } from '../util/createAvatarData'; import { isSameAvatarData } from '../util/isSameAvatarData'; import { missingCaseError } from '../util/missingCaseError'; export type PropsType = { avatarColor?: AvatarColorType; avatarPath?: string; avatarValue?: Uint8Array; conversationId?: string; conversationTitle?: string; deleteAvatarFromDisk: DeleteAvatarFromDiskActionType; i18n: LocalizerType; isGroup?: boolean; onCancel: () => unknown; onSave: (buffer: Uint8Array | undefined) => unknown; userAvatarData: ReadonlyArray; replaceAvatar: ReplaceAvatarActionType; saveAvatarToDisk: SaveAvatarToDiskActionType; }; enum EditMode { Main = 'Main', Custom = 'Custom', Text = 'Text', } export const AvatarEditor = ({ avatarColor, avatarPath, avatarValue, conversationId, conversationTitle, deleteAvatarFromDisk, i18n, isGroup, onCancel, onSave, userAvatarData, replaceAvatar, saveAvatarToDisk, }: PropsType): JSX.Element => { const [provisionalSelectedAvatar, setProvisionalSelectedAvatar] = useState< AvatarDataType | undefined >(); const [avatarPreview, setAvatarPreview] = useState( avatarValue ); const [initialAvatar, setInitialAvatar] = useState( avatarValue ); const [localAvatarData, setLocalAvatarData] = useState>( userAvatarData.slice() ); const [editMode, setEditMode] = useState(EditMode.Main); const getSelectedAvatar = useCallback( avatarToFind => localAvatarData.find(avatarData => isSameAvatarData(avatarData, avatarToFind) ), [localAvatarData] ); const selectedAvatar = getSelectedAvatar(provisionalSelectedAvatar); // Caching the Uint8Array produced into avatarData as buffer because // that function is a little expensive to run and so we don't flicker the UI. useEffect(() => { let shouldCancel = false; async function cacheAvatars() { const newAvatarData = await Promise.all( userAvatarData.map(async avatarData => { if (avatarData.buffer) { return avatarData; } const buffer = await avatarDataToBytes(avatarData); return { ...avatarData, buffer, }; }) ); if (!shouldCancel) { setLocalAvatarData(newAvatarData); } } cacheAvatars(); return () => { shouldCancel = true; }; }, [setLocalAvatarData, userAvatarData]); // This function optimistcally updates userAvatarData so we don't have to // wait for saveAvatarToDisk to finish before displaying something to the // user. As a bonus the component fully works in storybook! const updateAvatarDataList = useCallback( (newAvatarData?: AvatarDataType, staleAvatarData?: AvatarDataType) => { const existingAvatarData = staleAvatarData ? localAvatarData.filter(avatarData => avatarData !== staleAvatarData) : localAvatarData; if (newAvatarData) { setAvatarPreview(newAvatarData.buffer); setLocalAvatarData([newAvatarData, ...existingAvatarData]); setProvisionalSelectedAvatar(newAvatarData); } else { setLocalAvatarData(existingAvatarData); if (isSameAvatarData(selectedAvatar, staleAvatarData)) { setAvatarPreview(undefined); setProvisionalSelectedAvatar(undefined); } } }, [ localAvatarData, selectedAvatar, setAvatarPreview, setLocalAvatarData, setProvisionalSelectedAvatar, ] ); const handleAvatarLoaded = useCallback(avatarBuffer => { setAvatarPreview(avatarBuffer); setInitialAvatar(avatarBuffer); }, []); const hasChanges = initialAvatar !== avatarPreview; let content: JSX.Element | undefined; if (editMode === EditMode.Main) { content = ( <>
{ setAvatarPreview(undefined); setProvisionalSelectedAvatar(undefined); }} />
{ const avatarData = createAvatarData({ buffer: newAvatar, // This is so that the newly created avatar gets an X imagePath: 'TMP', }); saveAvatarToDisk(avatarData, conversationId); updateAvatarDataList(avatarData); }} />

{i18n('AvatarEditor--choose')}
{localAvatarData.map(avatarData => ( { if (isSameAvatarData(avatarData, selectedAvatar)) { if (avatarData.text) { setEditMode(EditMode.Text); } else if (avatarData.icon) { setEditMode(EditMode.Custom); } } else { setAvatarPreview(avatarBuffer); setProvisionalSelectedAvatar(avatarData); } }} onDelete={() => { updateAvatarDataList(undefined, avatarData); deleteAvatarFromDisk(avatarData, conversationId); }} /> ))}
{ if (selectedAvatar) { replaceAvatar(selectedAvatar, selectedAvatar, conversationId); } onSave(avatarPreview); }} /> ); } else if (editMode === EditMode.Text) { content = ( { setEditMode(EditMode.Main); if (selectedAvatar) { return; } // The selected avatar was cleared when we entered text mode so we // need to find if one is actually selected if it matches the current // preview. const actualAvatarSelected = localAvatarData.find( avatarData => avatarData.buffer === avatarPreview ); if (actualAvatarSelected) { setProvisionalSelectedAvatar(actualAvatarSelected); } }} onDone={(avatarBuffer, avatarData) => { const newAvatarData = { ...avatarData, buffer: avatarBuffer, }; updateAvatarDataList(newAvatarData, selectedAvatar); setEditMode(EditMode.Main); replaceAvatar(newAvatarData, selectedAvatar, conversationId); }} /> ); } else if (editMode === EditMode.Custom) { if (!selectedAvatar) { throw new Error('No selected avatar and editMode is custom'); } content = ( { if (avatarData) { updateAvatarDataList(avatarData, selectedAvatar); replaceAvatar(avatarData, selectedAvatar, conversationId); } setEditMode(EditMode.Main); }} /> ); } else { throw missingCaseError(editMode); } return
{content}
; };