signal-desktop/ts/components/ProfileEditor.tsx

670 lines
20 KiB
TypeScript
Raw Normal View History

2022-01-26 21:58:00 +00:00
// Copyright 2021-2022 Signal Messenger, LLC
2021-07-19 19:26:06 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { AvatarColorType } from '../types/Colors';
import { AvatarColors } from '../types/Colors';
import type {
2021-08-06 00:17:05 +00:00
AvatarDataType,
AvatarUpdateType,
2021-08-06 00:17:05 +00:00
DeleteAvatarFromDiskActionType,
ReplaceAvatarActionType,
SaveAvatarToDiskActionType,
} from '../types/Avatar';
import { AvatarEditor } from './AvatarEditor';
import { AvatarPreview } from './AvatarPreview';
2021-07-19 19:26:06 +00:00
import { Button, ButtonVariant } from './Button';
2021-08-06 00:17:05 +00:00
import { ConfirmDiscardDialog } from './ConfirmDiscardDialog';
2021-07-19 19:26:06 +00:00
import { Emoji } from './emoji/Emoji';
import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
2022-10-18 17:12:02 +00:00
import { EmojiButton, EmojiButtonVariant } from './emoji/EmojiButton';
import type { EmojiPickDataType } from './emoji/EmojiPicker';
2021-07-19 19:26:06 +00:00
import { Input } from './Input';
import { Intl } from './Intl';
2022-10-18 17:12:02 +00:00
import type { LocalizerType } from '../types/Util';
2021-08-06 00:17:05 +00:00
import { Modal } from './Modal';
2021-07-19 19:26:06 +00:00
import { PanelRow } from './conversation/conversation-details/PanelRow';
import type { ProfileDataType } from '../state/ducks/conversations';
2022-10-18 17:12:02 +00:00
import { UsernameEditState } from '../state/ducks/usernameEnums';
import { ToastType } from '../types/Toast';
2022-10-18 17:12:02 +00:00
import type { ShowToastActionCreatorType } from '../state/ducks/toast';
2021-07-19 19:26:06 +00:00
import { getEmojiData, unifiedToEmoji } from './emoji/lib';
2022-10-18 17:12:02 +00:00
import { assertDev } from '../util/assert';
2021-07-19 19:26:06 +00:00
import { missingCaseError } from '../util/missingCaseError';
import { ConfirmationDialog } from './ConfirmationDialog';
2022-10-18 17:12:02 +00:00
import { ContextMenu } from './ContextMenu';
import {
ConversationDetailsIcon,
IconType,
} from './conversation/conversation-details/ConversationDetailsIcon';
2022-01-26 21:58:00 +00:00
import { isWhitespace, trim } from '../util/whitespaceStringUtil';
2022-10-18 17:12:02 +00:00
import { generateUsernameLink } from '../util/sgnlHref';
import { Emojify } from './conversation/Emojify';
2021-07-19 19:26:06 +00:00
export enum EditState {
None = 'None',
2021-08-06 00:17:05 +00:00
BetterAvatar = 'BetterAvatar',
2021-07-19 19:26:06 +00:00
ProfileName = 'ProfileName',
Bio = 'Bio',
Username = 'Username',
}
2021-07-19 19:26:06 +00:00
type PropsExternalType = {
onEditStateChanged: (editState: EditState) => unknown;
onProfileChanged: (
profileData: ProfileDataType,
avatar: AvatarUpdateType
2021-07-19 19:26:06 +00:00
) => unknown;
2022-10-18 17:12:02 +00:00
renderEditUsernameModalBody: (props: { onClose: () => void }) => JSX.Element;
2021-07-19 19:26:06 +00:00
};
export type PropsDataType = {
aboutEmoji?: string;
aboutText?: string;
profileAvatarPath?: string;
2021-08-06 00:17:05 +00:00
color?: AvatarColorType;
conversationId: string;
2021-07-19 19:26:06 +00:00
familyName?: string;
firstName: string;
i18n: LocalizerType;
isUsernameFlagEnabled: boolean;
2021-08-06 00:17:05 +00:00
userAvatarData: Array<AvatarDataType>;
username?: string;
2022-10-18 17:12:02 +00:00
usernameEditState: UsernameEditState;
2021-07-19 19:26:06 +00:00
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
type PropsActionType = {
2021-08-06 00:17:05 +00:00
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
2021-07-19 19:26:06 +00:00
onSetSkinTone: (tone: number) => unknown;
2021-08-06 00:17:05 +00:00
replaceAvatar: ReplaceAvatarActionType;
saveAvatarToDisk: SaveAvatarToDiskActionType;
2022-10-18 17:12:02 +00:00
setUsernameEditState: (editState: UsernameEditState) => void;
deleteUsername: () => void;
showToast: ShowToastActionCreatorType;
openUsernameReservationModal: () => void;
2021-07-19 19:26:06 +00:00
};
export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
type DefaultBio = {
i18nLabel: string;
shortName: string;
};
const DEFAULT_BIOS: Array<DefaultBio> = [
{
i18nLabel: 'Bio--speak-freely',
shortName: 'wave',
},
{
i18nLabel: 'Bio--encrypted',
shortName: 'zipper_mouth_face',
},
{
i18nLabel: 'Bio--free-to-chat',
shortName: '+1',
},
{
i18nLabel: 'Bio--coffee-lover',
shortName: 'coffee',
},
{
i18nLabel: 'Bio--taking-break',
shortName: 'mobile_phone_off',
},
];
2022-11-18 00:45:19 +00:00
export function ProfileEditor({
2021-07-19 19:26:06 +00:00
aboutEmoji,
aboutText,
2021-08-06 00:17:05 +00:00
color,
conversationId,
deleteAvatarFromDisk,
2022-10-18 17:12:02 +00:00
deleteUsername,
2021-07-19 19:26:06 +00:00
familyName,
firstName,
i18n,
isUsernameFlagEnabled,
2021-07-19 19:26:06 +00:00
onEditStateChanged,
onProfileChanged,
onSetSkinTone,
2022-10-18 17:12:02 +00:00
openUsernameReservationModal,
profileAvatarPath,
2021-07-19 19:26:06 +00:00
recentEmojis,
2022-10-18 17:12:02 +00:00
renderEditUsernameModalBody,
2021-08-06 00:17:05 +00:00
replaceAvatar,
saveAvatarToDisk,
2022-10-18 17:12:02 +00:00
setUsernameEditState,
showToast,
2021-07-19 19:26:06 +00:00
skinTone,
2021-08-06 00:17:05 +00:00
userAvatarData,
username,
2022-10-18 17:12:02 +00:00
usernameEditState,
2022-11-18 00:45:19 +00:00
}: PropsType): JSX.Element {
2021-07-19 19:26:06 +00:00
const focusInputRef = useRef<HTMLInputElement | null>(null);
const [editState, setEditState] = useState<EditState>(EditState.None);
const [confirmDiscardAction, setConfirmDiscardAction] = useState<
(() => unknown) | undefined
>(undefined);
// This is here to avoid component re-render jitters in the time it takes
// redux to come back with the correct state
const [fullName, setFullName] = useState({
familyName,
firstName,
});
const [fullBio, setFullBio] = useState({
aboutEmoji,
aboutText,
});
const [startingAvatarPath, setStartingAvatarPath] =
useState(profileAvatarPath);
const [oldAvatarBuffer, setOldAvatarBuffer] = useState<
Uint8Array | undefined
>(undefined);
2021-09-24 00:49:05 +00:00
const [avatarBuffer, setAvatarBuffer] = useState<Uint8Array | undefined>(
2021-07-19 19:26:06 +00:00
undefined
);
const [isLoadingAvatar, setIsLoadingAvatar] = useState(
Boolean(profileAvatarPath)
);
2021-07-19 19:26:06 +00:00
const [stagedProfile, setStagedProfile] = useState<ProfileDataType>({
aboutEmoji,
aboutText,
familyName,
firstName,
});
2022-10-18 17:12:02 +00:00
// Reset username edit state when leaving
useEffect(() => {
return () => {
setUsernameEditState(UsernameEditState.Editing);
};
}, [setUsernameEditState]);
// To make AvatarEditor re-render less often
2021-07-19 19:26:06 +00:00
const handleBack = useCallback(() => {
setEditState(EditState.None);
onEditStateChanged(EditState.None);
}, [setEditState, onEditStateChanged]);
// To make EmojiButton re-render less often
2021-07-19 19:26:06 +00:00
const setAboutEmoji = useCallback(
(ev: EmojiPickDataType) => {
const emojiData = getEmojiData(ev.shortName, skinTone);
setStagedProfile(profileData => ({
...profileData,
aboutEmoji: unifiedToEmoji(emojiData.unified),
}));
},
[setStagedProfile, skinTone]
);
// To make AvatarEditor re-render less often
2021-07-19 19:26:06 +00:00
const handleAvatarChanged = useCallback(
2021-09-24 00:49:05 +00:00
(avatar: Uint8Array | undefined) => {
// Do not display stale avatar from disk anymore.
setStartingAvatarPath(undefined);
2021-08-06 00:17:05 +00:00
setAvatarBuffer(avatar);
setEditState(EditState.None);
2022-01-26 21:58:00 +00:00
onProfileChanged(
{
...stagedProfile,
firstName: trim(stagedProfile.firstName),
familyName: stagedProfile.familyName
? trim(stagedProfile.familyName)
: undefined,
},
{ oldAvatar: oldAvatarBuffer, newAvatar: avatar }
2022-01-26 21:58:00 +00:00
);
setOldAvatarBuffer(avatar);
2021-07-19 19:26:06 +00:00
},
[onProfileChanged, stagedProfile, oldAvatarBuffer]
2021-07-19 19:26:06 +00:00
);
2021-08-06 00:17:05 +00:00
const getFullNameText = () => {
return [fullName.firstName, fullName.familyName].filter(Boolean).join(' ');
};
2021-07-19 19:26:06 +00:00
useEffect(() => {
const focusNode = focusInputRef.current;
if (!focusNode) {
return;
}
focusNode.focus();
2021-10-01 19:27:34 +00:00
focusNode.setSelectionRange(focusNode.value.length, focusNode.value.length);
2021-07-19 19:26:06 +00:00
}, [editState]);
2021-08-06 00:17:05 +00:00
useEffect(() => {
onEditStateChanged(editState);
}, [editState, onEditStateChanged]);
// To make AvatarEditor re-render less often
const handleAvatarLoaded = useCallback(
avatar => {
setAvatarBuffer(avatar);
setOldAvatarBuffer(avatar);
setIsLoadingAvatar(false);
},
[setAvatarBuffer, setOldAvatarBuffer, setIsLoadingAvatar]
);
2021-08-06 00:17:05 +00:00
let content: JSX.Element;
if (editState === EditState.BetterAvatar) {
content = (
<AvatarEditor
avatarColor={color || AvatarColors[0]}
avatarPath={startingAvatarPath}
2021-08-06 00:17:05 +00:00
avatarValue={avatarBuffer}
conversationId={conversationId}
conversationTitle={getFullNameText()}
deleteAvatarFromDisk={deleteAvatarFromDisk}
i18n={i18n}
onCancel={handleBack}
onSave={handleAvatarChanged}
userAvatarData={userAvatarData}
replaceAvatar={replaceAvatar}
saveAvatarToDisk={saveAvatarToDisk}
/>
);
} else if (editState === EditState.ProfileName) {
const shouldDisableSave =
isLoadingAvatar ||
!stagedProfile.firstName ||
(stagedProfile.firstName === fullName.firstName &&
2022-01-26 21:58:00 +00:00
stagedProfile.familyName === fullName.familyName) ||
isWhitespace(stagedProfile.firstName);
2021-07-19 19:26:06 +00:00
content = (
<>
<Input
i18n={i18n}
maxLengthCount={26}
maxByteCount={128}
whenToShowRemainingCount={0}
2021-07-19 19:26:06 +00:00
onChange={newFirstName => {
setStagedProfile(profileData => ({
...profileData,
firstName: String(newFirstName),
}));
}}
placeholder={i18n('ProfileEditor--first-name')}
ref={focusInputRef}
value={stagedProfile.firstName}
/>
<Input
i18n={i18n}
maxLengthCount={26}
maxByteCount={128}
whenToShowRemainingCount={0}
2021-07-19 19:26:06 +00:00
onChange={newFamilyName => {
setStagedProfile(profileData => ({
...profileData,
familyName: newFamilyName,
}));
}}
placeholder={i18n('ProfileEditor--last-name')}
value={stagedProfile.familyName}
/>
2021-08-06 00:17:05 +00:00
<Modal.ButtonFooter>
2021-07-19 19:26:06 +00:00
<Button
onClick={() => {
const handleCancel = () => {
handleBack();
setStagedProfile(profileData => ({
...profileData,
familyName,
firstName,
}));
};
const hasChanges =
stagedProfile.familyName !== fullName.familyName ||
stagedProfile.firstName !== fullName.firstName;
if (hasChanges) {
setConfirmDiscardAction(() => handleCancel);
} else {
handleCancel();
}
}}
variant={ButtonVariant.Secondary}
>
{i18n('cancel')}
</Button>
<Button
disabled={shouldDisableSave}
2021-07-19 19:26:06 +00:00
onClick={() => {
if (!stagedProfile.firstName) {
return;
}
setFullName({
firstName: stagedProfile.firstName,
familyName: stagedProfile.familyName,
2021-07-19 19:26:06 +00:00
});
onProfileChanged(stagedProfile, {
oldAvatar: oldAvatarBuffer,
newAvatar: avatarBuffer,
});
2021-07-19 19:26:06 +00:00
handleBack();
}}
>
{i18n('save')}
</Button>
2021-08-06 00:17:05 +00:00
</Modal.ButtonFooter>
2021-07-19 19:26:06 +00:00
</>
);
} else if (editState === EditState.Bio) {
const shouldDisableSave =
isLoadingAvatar ||
(stagedProfile.aboutText === fullBio.aboutText &&
stagedProfile.aboutEmoji === fullBio.aboutEmoji);
2021-07-19 19:26:06 +00:00
content = (
<>
<Input
expandable
hasClearButton
i18n={i18n}
icon={
<div className="module-composition-area__button-cell">
<EmojiButton
2022-10-18 17:12:02 +00:00
variant={EmojiButtonVariant.ProfileEditor}
2021-07-19 19:26:06 +00:00
closeOnPick
emoji={stagedProfile.aboutEmoji}
i18n={i18n}
onPickEmoji={setAboutEmoji}
onSetSkinTone={onSetSkinTone}
recentEmojis={recentEmojis}
skinTone={skinTone}
/>
</div>
}
maxLengthCount={140}
maxByteCount={512}
2021-07-19 19:26:06 +00:00
moduleClassName="ProfileEditor__about-input"
onChange={value => {
if (value) {
setStagedProfile(profileData => ({
...profileData,
aboutEmoji: stagedProfile.aboutEmoji,
2021-10-01 19:27:34 +00:00
aboutText: value.replace(/(\r\n|\n|\r)/gm, ''),
2021-07-19 19:26:06 +00:00
}));
} else {
setStagedProfile(profileData => ({
...profileData,
aboutEmoji: undefined,
aboutText: '',
}));
}
}}
ref={focusInputRef}
placeholder={i18n('ProfileEditor--about-placeholder')}
value={stagedProfile.aboutText}
whenToShowRemainingCount={40}
/>
{DEFAULT_BIOS.map(defaultBio => (
<PanelRow
className="ProfileEditor__row"
key={defaultBio.shortName}
icon={
<div className="ProfileEditor__icon--container">
<Emoji shortName={defaultBio.shortName} size={24} />
</div>
}
label={i18n(defaultBio.i18nLabel)}
onClick={() => {
const emojiData = getEmojiData(defaultBio.shortName, skinTone);
setStagedProfile(profileData => ({
...profileData,
aboutEmoji: unifiedToEmoji(emojiData.unified),
aboutText: i18n(defaultBio.i18nLabel),
}));
}}
/>
))}
2021-08-06 00:17:05 +00:00
<Modal.ButtonFooter>
2021-07-19 19:26:06 +00:00
<Button
onClick={() => {
const handleCancel = () => {
handleBack();
setStagedProfile(profileData => ({
...profileData,
...fullBio,
}));
};
const hasChanges =
stagedProfile.aboutText !== fullBio.aboutText ||
stagedProfile.aboutEmoji !== fullBio.aboutEmoji;
if (hasChanges) {
setConfirmDiscardAction(() => handleCancel);
} else {
handleCancel();
}
}}
variant={ButtonVariant.Secondary}
>
{i18n('cancel')}
</Button>
<Button
disabled={shouldDisableSave}
2021-07-19 19:26:06 +00:00
onClick={() => {
setFullBio({
aboutEmoji: stagedProfile.aboutEmoji,
aboutText: stagedProfile.aboutText,
});
onProfileChanged(stagedProfile, {
oldAvatar: oldAvatarBuffer,
newAvatar: avatarBuffer,
});
2021-07-19 19:26:06 +00:00
handleBack();
}}
>
{i18n('save')}
</Button>
2021-08-06 00:17:05 +00:00
</Modal.ButtonFooter>
2021-07-19 19:26:06 +00:00
</>
);
} else if (editState === EditState.Username) {
2022-10-18 17:12:02 +00:00
content = renderEditUsernameModalBody({
onClose: () => setEditState(EditState.None),
});
} else if (editState === EditState.None) {
let maybeUsernameRow: JSX.Element | undefined;
if (isUsernameFlagEnabled) {
let actions: JSX.Element | undefined;
if (usernameEditState === UsernameEditState.Deleting) {
actions = (
<ConversationDetailsIcon
ariaLabel={i18n('ProfileEditor--username--deleting-username')}
icon={IconType.spinner}
disabled
fakeButton
/>
);
} else {
const menuOptions = [
{
group: 'copy',
icon: 'ProfileEditor__username-menu__copy-icon',
label: i18n('ProfileEditor--username--copy'),
onClick: () => {
assertDev(
username !== undefined,
'Should not be visible without username'
);
window.navigator.clipboard.writeText(username);
showToast(ToastType.CopiedUsername);
},
},
{
group: 'copy',
icon: 'ProfileEditor__username-menu__copy-link-icon',
label: i18n('ProfileEditor--username--copy-link'),
onClick: () => {
assertDev(
username !== undefined,
'Should not be visible without username'
);
window.navigator.clipboard.writeText(
generateUsernameLink(username)
);
showToast(ToastType.CopiedUsernameLink);
},
},
{
// Different group to display a divider above it
group: 'delete',
icon: 'ProfileEditor__username-menu__trash-icon',
label: i18n('ProfileEditor--username--delete'),
onClick: () => {
setUsernameEditState(UsernameEditState.ConfirmingDelete);
},
},
];
if (username) {
actions = (
<ContextMenu
i18n={i18n}
menuOptions={menuOptions}
popperOptions={{ placement: 'bottom', strategy: 'absolute' }}
moduleClassName="ProfileEditor__username-menu"
ariaLabel={i18n('ProfileEditor--username--context-menu')}
/>
);
}
}
maybeUsernameRow = (
<PanelRow
className="ProfileEditor__row"
icon={
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--username" />
}
label={username || i18n('ProfileEditor--username')}
info={username && generateUsernameLink(username, { short: true })}
onClick={() => {
openUsernameReservationModal();
setEditState(EditState.Username);
}}
2022-10-18 17:12:02 +00:00
actions={actions}
/>
2022-10-18 17:12:02 +00:00
);
}
2021-07-19 19:26:06 +00:00
content = (
<>
2021-08-06 00:17:05 +00:00
<AvatarPreview
avatarColor={color}
avatarPath={startingAvatarPath}
2021-08-06 00:17:05 +00:00
avatarValue={avatarBuffer}
conversationTitle={getFullNameText()}
2021-07-19 19:26:06 +00:00
i18n={i18n}
2021-08-06 21:35:25 +00:00
isEditable
2021-08-06 00:17:05 +00:00
onAvatarLoaded={handleAvatarLoaded}
onClick={() => {
setEditState(EditState.BetterAvatar);
}}
style={{
2021-08-06 21:35:25 +00:00
height: 80,
width: 80,
2021-07-19 19:26:06 +00:00
}}
/>
<hr className="ProfileEditor__divider" />
<PanelRow
className="ProfileEditor__row"
icon={
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--name" />
}
label={<Emojify text={getFullNameText()} />}
2021-07-19 19:26:06 +00:00
onClick={() => {
setEditState(EditState.ProfileName);
}}
/>
2022-10-18 17:12:02 +00:00
{maybeUsernameRow}
2021-07-19 19:26:06 +00:00
<PanelRow
className="ProfileEditor__row"
icon={
fullBio.aboutEmoji ? (
<div className="ProfileEditor__icon--container">
<Emoji emoji={fullBio.aboutEmoji} size={24} />
</div>
) : (
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--bio" />
)
}
label={
<Emojify text={fullBio.aboutText || i18n('ProfileEditor--about')} />
}
2021-07-19 19:26:06 +00:00
onClick={() => {
setEditState(EditState.Bio);
}}
/>
<hr className="ProfileEditor__divider" />
<div className="ProfileEditor__info">
<Intl
i18n={i18n}
id="icu:ProfileEditor--info"
2021-07-19 19:26:06 +00:00
components={{
learnMore: (
<a
href="https://support.signal.org/hc/en-us/articles/360007459591"
target="_blank"
rel="noreferrer"
>
{i18n('ProfileEditor--learnMore')}
</a>
),
}}
/>
</div>
</>
);
} else {
throw missingCaseError(editState);
}
return (
<>
{usernameEditState === UsernameEditState.ConfirmingDelete && (
<ConfirmationDialog
2022-09-27 20:24:21 +00:00
dialogName="ProfileEditor.confirmDeleteUsername"
i18n={i18n}
onClose={() => setUsernameEditState(UsernameEditState.Editing)}
actions={[
{
text: i18n('ProfileEditor--username--confirm-delete-button'),
style: 'negative',
action: () => deleteUsername(),
},
]}
>
{i18n('ProfileEditor--username--confirm-delete-body')}
</ConfirmationDialog>
)}
2022-10-18 17:12:02 +00:00
2021-07-19 19:26:06 +00:00
{confirmDiscardAction && (
2021-08-06 00:17:05 +00:00
<ConfirmDiscardDialog
2021-07-19 19:26:06 +00:00
i18n={i18n}
2021-08-06 00:17:05 +00:00
onDiscard={confirmDiscardAction}
2021-07-19 19:26:06 +00:00
onClose={() => setConfirmDiscardAction(undefined)}
2021-08-06 00:17:05 +00:00
/>
2021-07-19 19:26:06 +00:00
)}
<div className="ProfileEditor">{content}</div>
</>
);
2022-11-18 00:45:19 +00:00
}