Avatar defaults and colors
This commit is contained in:
parent
a001882d58
commit
12d2b1bf7c
140 changed files with 4212 additions and 1084 deletions
298
ts/components/AvatarEditor.tsx
Normal file
298
ts/components/AvatarEditor.tsx
Normal file
|
@ -0,0 +1,298 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { AvatarColorType } from '../types/Colors';
|
||||
import {
|
||||
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 { LocalizerType } from '../types/Util';
|
||||
import { avatarDataToArrayBuffer } from '../util/avatarDataToArrayBuffer';
|
||||
import { createAvatarData } from '../util/createAvatarData';
|
||||
import { isSameAvatarData } from '../util/isSameAvatarData';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
|
||||
export type PropsType = {
|
||||
avatarColor?: AvatarColorType;
|
||||
avatarPath?: string;
|
||||
avatarValue?: ArrayBuffer;
|
||||
conversationId?: string;
|
||||
conversationTitle?: string;
|
||||
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
|
||||
i18n: LocalizerType;
|
||||
isGroup?: boolean;
|
||||
onCancel: () => unknown;
|
||||
onSave: (buffer: ArrayBuffer | undefined) => unknown;
|
||||
userAvatarData: ReadonlyArray<AvatarDataType>;
|
||||
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<ArrayBuffer | undefined>(
|
||||
avatarValue
|
||||
);
|
||||
const [initialAvatar, setInitialAvatar] = useState<ArrayBuffer | undefined>(
|
||||
avatarValue
|
||||
);
|
||||
const [localAvatarData, setLocalAvatarData] = useState<Array<AvatarDataType>>(
|
||||
userAvatarData.slice()
|
||||
);
|
||||
|
||||
const [editMode, setEditMode] = useState<EditMode>(EditMode.Main);
|
||||
|
||||
const getSelectedAvatar = useCallback(
|
||||
avatarToFind =>
|
||||
localAvatarData.find(avatarData =>
|
||||
isSameAvatarData(avatarData, avatarToFind)
|
||||
),
|
||||
[localAvatarData]
|
||||
);
|
||||
|
||||
const selectedAvatar = getSelectedAvatar(provisionalSelectedAvatar);
|
||||
|
||||
// Caching the ArrayBuffer 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 avatarDataToArrayBuffer(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 = (
|
||||
<>
|
||||
<div className="AvatarEditor__preview">
|
||||
<AvatarPreview
|
||||
avatarColor={avatarColor}
|
||||
avatarPath={avatarPath}
|
||||
avatarValue={avatarPreview}
|
||||
conversationTitle={conversationTitle}
|
||||
i18n={i18n}
|
||||
isGroup={isGroup}
|
||||
onAvatarLoaded={handleAvatarLoaded}
|
||||
onClear={() => {
|
||||
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>;
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue