signal-desktop/ts/components/AvatarEditor.tsx

302 lines
9.3 KiB
TypeScript
Raw Normal View History

2021-08-06 00:17:05 +00:00
// 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 {
2021-08-06 00:17:05 +00:00
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';
2021-09-24 00:49:05 +00:00
import { avatarDataToBytes } from '../util/avatarDataToBytes';
2021-08-06 00:17:05 +00:00
import { createAvatarData } from '../util/createAvatarData';
import { isSameAvatarData } from '../util/isSameAvatarData';
import { missingCaseError } from '../util/missingCaseError';
export type PropsType = {
avatarColor?: AvatarColorType;
avatarPath?: string;
2021-09-24 00:49:05 +00:00
avatarValue?: Uint8Array;
2021-08-06 00:17:05 +00:00
conversationId?: string;
conversationTitle?: string;
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
i18n: LocalizerType;
isGroup?: boolean;
onCancel: () => unknown;
2021-09-24 00:49:05 +00:00
onSave: (buffer: Uint8Array | undefined) => unknown;
2021-08-06 00:17:05 +00:00
userAvatarData: ReadonlyArray<AvatarDataType>;
replaceAvatar: ReplaceAvatarActionType;
saveAvatarToDisk: SaveAvatarToDiskActionType;
};
enum EditMode {
Main = 'Main',
Custom = 'Custom',
Text = 'Text',
}
2022-11-18 00:45:19 +00:00
export function AvatarEditor({
2021-08-06 00:17:05 +00:00
avatarColor,
avatarPath,
avatarValue,
conversationId,
conversationTitle,
deleteAvatarFromDisk,
i18n,
isGroup,
onCancel,
onSave,
userAvatarData,
replaceAvatar,
saveAvatarToDisk,
2022-11-18 00:45:19 +00:00
}: PropsType): JSX.Element {
2021-08-06 00:17:05 +00:00
const [provisionalSelectedAvatar, setProvisionalSelectedAvatar] = useState<
AvatarDataType | undefined
>();
2021-09-24 00:49:05 +00:00
const [avatarPreview, setAvatarPreview] = useState<Uint8Array | undefined>(
2021-08-06 00:17:05 +00:00
avatarValue
);
2021-09-24 00:49:05 +00:00
const [initialAvatar, setInitialAvatar] = useState<Uint8Array | undefined>(
2021-08-06 00:17:05 +00:00
avatarValue
);
const [localAvatarData, setLocalAvatarData] = useState<Array<AvatarDataType>>(
userAvatarData.slice()
);
2022-02-24 23:57:16 +00:00
const [pendingClear, setPendingClear] = useState(false);
2021-08-06 00:17:05 +00:00
const [editMode, setEditMode] = useState<EditMode>(EditMode.Main);
const getSelectedAvatar = useCallback(
avatarToFind =>
localAvatarData.find(avatarData =>
isSameAvatarData(avatarData, avatarToFind)
),
[localAvatarData]
);
const selectedAvatar = getSelectedAvatar(provisionalSelectedAvatar);
2021-09-24 00:49:05 +00:00
// Caching the Uint8Array produced into avatarData as buffer because
2021-08-06 00:17:05 +00:00
// 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;
}
2021-09-24 00:49:05 +00:00
const buffer = await avatarDataToBytes(avatarData);
2021-08-06 00:17:05 +00:00
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);
}, []);
2022-02-24 23:57:16 +00:00
const hasChanges =
initialAvatar !== avatarPreview || Boolean(pendingClear && avatarPath);
2021-08-06 00:17:05 +00:00
let content: JSX.Element | undefined;
if (editMode === EditMode.Main) {
content = (
<>
<div className="AvatarEditor__preview">
<AvatarPreview
avatarColor={avatarColor}
2022-02-24 23:57:16 +00:00
avatarPath={pendingClear ? undefined : avatarPath}
2021-08-06 00:17:05 +00:00
avatarValue={avatarPreview}
conversationTitle={conversationTitle}
i18n={i18n}
isGroup={isGroup}
onAvatarLoaded={handleAvatarLoaded}
onClear={() => {
2022-02-24 23:57:16 +00:00
setPendingClear(true);
2021-08-06 00:17:05 +00:00
setAvatarPreview(undefined);
setProvisionalSelectedAvatar(undefined);
}}
/>
<div className="AvatarEditor__top-buttons">
<AvatarUploadButton
className="AvatarEditor__button AvatarEditor__button--photo"
i18n={i18n}
onChange={newAvatar => {
const avatarData = createAvatarData({
buffer: newAvatar,
// This is so that the newly created avatar gets an X
imagePath: 'TMP',
});
saveAvatarToDisk(avatarData, conversationId);
updateAvatarDataList(avatarData);
}}
/>
<button
className="AvatarEditor__button AvatarEditor__button--text"
onClick={() => {
setProvisionalSelectedAvatar(undefined);
setEditMode(EditMode.Text);
}}
type="button"
>
{i18n('text')}
</button>
</div>
</div>
<hr className="AvatarEditor__divider" />
<div className="AvatarEditor__avatar-selector-title">
{i18n('AvatarEditor--choose')}
</div>
<div className="AvatarEditor__avatars">
{localAvatarData.map(avatarData => (
<BetterAvatar
avatarData={avatarData}
key={avatarData.id}
i18n={i18n}
isSelected={isSameAvatarData(avatarData, selectedAvatar)}
onClick={avatarBuffer => {
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);
}}
/>
))}
</div>
<AvatarModalButtons
hasChanges={hasChanges}
i18n={i18n}
onCancel={onCancel}
onSave={() => {
if (selectedAvatar) {
replaceAvatar(selectedAvatar, selectedAvatar, conversationId);
}
onSave(avatarPreview);
}}
/>
</>
);
} else if (editMode === EditMode.Text) {
content = (
<AvatarTextEditor
avatarData={selectedAvatar}
i18n={i18n}
onCancel={() => {
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 = (
<AvatarIconEditor
avatarData={selectedAvatar}
i18n={i18n}
onClose={avatarData => {
if (avatarData) {
updateAvatarDataList(avatarData, selectedAvatar);
replaceAvatar(avatarData, selectedAvatar, conversationId);
}
setEditMode(EditMode.Main);
}}
/>
);
} else {
throw missingCaseError(editMode);
}
return <div className="AvatarEditor">{content}</div>;
2022-11-18 00:45:19 +00:00
}