959 lines
29 KiB
TypeScript
959 lines
29 KiB
TypeScript
// Copyright 2021 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { useSpring, animated } from '@react-spring/web';
|
|
|
|
import type { MutableRefObject } from 'react';
|
|
|
|
import { AvatarColors } from '../types/Colors';
|
|
import { AvatarEditor } from './AvatarEditor';
|
|
import { AvatarPreview } from './AvatarPreview';
|
|
import { Button, ButtonVariant } from './Button';
|
|
import { EmojiButton, EmojiButtonVariant } from './emoji/EmojiButton';
|
|
import { Input } from './Input';
|
|
import { PanelRow } from './conversation/conversation-details/PanelRow';
|
|
import { UsernameEditState } from '../state/ducks/usernameEnums';
|
|
import { ToastType } from '../types/Toast';
|
|
import { getEmojiData, unifiedToEmoji } from './emoji/lib';
|
|
import { assertDev, strictAssert } from '../util/assert';
|
|
import { missingCaseError } from '../util/missingCaseError';
|
|
import { ConfirmationDialog } from './ConfirmationDialog';
|
|
import { ContextMenu } from './ContextMenu';
|
|
import { UsernameLinkEditor } from './UsernameLinkEditor';
|
|
import {
|
|
ConversationDetailsIcon,
|
|
IconType,
|
|
} from './conversation/conversation-details/ConversationDetailsIcon';
|
|
import { isWhitespace, trim } from '../util/whitespaceStringUtil';
|
|
import { UserText } from './UserText';
|
|
import { Tooltip, TooltipPlacement } from './Tooltip';
|
|
import { offsetDistanceModifier } from '../util/popperUtil';
|
|
import { useReducedMotion } from '../hooks/useReducedMotion';
|
|
import { FunStaticEmoji } from './fun/FunEmoji';
|
|
import {
|
|
EmojiSkinTone,
|
|
getEmojiParentKeyByEnglishShortName,
|
|
getEmojiVariantByKey,
|
|
getEmojiVariantByParentKeyAndSkinTone,
|
|
getEmojiVariantKeyByValue,
|
|
isEmojiEnglishShortName,
|
|
isEmojiVariantValue,
|
|
} from './fun/data/emojis';
|
|
import { FunEmojiPicker } from './fun/FunEmojiPicker';
|
|
import { FunEmojiPickerButton } from './fun/FunButton';
|
|
import { isFunPickerEnabled } from './fun/isFunPickerEnabled';
|
|
import { useFunEmojiLocalizer } from './fun/useFunEmojiLocalizer';
|
|
import { PreferencesContent } from './Preferences';
|
|
|
|
import type { AvatarColorType } from '../types/Colors';
|
|
import type {
|
|
AvatarDataType,
|
|
AvatarUpdateOptionsType,
|
|
DeleteAvatarFromDiskActionType,
|
|
ReplaceAvatarActionType,
|
|
SaveAvatarToDiskActionType,
|
|
} from '../types/Avatar';
|
|
import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
|
|
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
|
import type { LocalizerType } from '../types/Util';
|
|
import type {
|
|
ProfileDataType,
|
|
SaveAttachmentActionCreatorType,
|
|
} from '../state/ducks/conversations';
|
|
import type { UsernameLinkState } from '../state/ducks/usernameEnums';
|
|
import type { ShowToastAction } from '../state/ducks/toast';
|
|
import type { EmojiVariantKey } from './fun/data/emojis';
|
|
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis';
|
|
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
|
|
|
|
export enum EditState {
|
|
None = 'None',
|
|
BetterAvatar = 'BetterAvatar',
|
|
ProfileName = 'ProfileName',
|
|
Bio = 'Bio',
|
|
Username = 'Username',
|
|
UsernameLink = 'UsernameLink',
|
|
}
|
|
|
|
type PropsExternalType = {
|
|
onProfileChanged: (
|
|
profileData: ProfileDataType,
|
|
avatarUpdateOptions: AvatarUpdateOptionsType
|
|
) => unknown;
|
|
renderUsernameEditor: (props: { onClose: () => void }) => JSX.Element;
|
|
};
|
|
|
|
export type PropsDataType = {
|
|
aboutEmoji?: string;
|
|
aboutText?: string;
|
|
color?: AvatarColorType;
|
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
|
conversationId: string;
|
|
familyName?: string;
|
|
firstName: string;
|
|
hasCompletedUsernameLinkOnboarding: boolean;
|
|
i18n: LocalizerType;
|
|
editState: EditState;
|
|
profileAvatarUrl?: string;
|
|
userAvatarData: ReadonlyArray<AvatarDataType>;
|
|
username?: string;
|
|
usernameCorrupted: boolean;
|
|
usernameEditState: UsernameEditState;
|
|
usernameLink?: string;
|
|
usernameLinkColor?: number;
|
|
usernameLinkCorrupted: boolean;
|
|
usernameLinkState: UsernameLinkState;
|
|
} & Pick<EmojiButtonProps, 'recentEmojis' | 'emojiSkinToneDefault'>;
|
|
|
|
type PropsActionType = {
|
|
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
|
|
markCompletedUsernameLinkOnboarding: () => void;
|
|
onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void;
|
|
replaceAvatar: ReplaceAvatarActionType;
|
|
saveAttachment: SaveAttachmentActionCreatorType;
|
|
saveAvatarToDisk: SaveAvatarToDiskActionType;
|
|
setUsernameEditState: (editState: UsernameEditState) => void;
|
|
setUsernameLinkColor: (color: number) => void;
|
|
resetUsernameLink: () => void;
|
|
deleteUsername: () => void;
|
|
setEditState: (editState: EditState) => void;
|
|
showToast: ShowToastAction;
|
|
openUsernameReservationModal: () => void;
|
|
};
|
|
|
|
export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
|
|
|
|
type DefaultBio = {
|
|
i18nLabel: string;
|
|
shortName: string;
|
|
};
|
|
|
|
function getDefaultBios(i18n: LocalizerType): Array<DefaultBio> {
|
|
return [
|
|
{
|
|
i18nLabel: i18n('icu:Bio--speak-freely'),
|
|
shortName: 'wave',
|
|
},
|
|
{
|
|
i18nLabel: i18n('icu:Bio--encrypted'),
|
|
shortName: 'zipper_mouth_face',
|
|
},
|
|
{
|
|
i18nLabel: i18n('icu:Bio--free-to-chat'),
|
|
shortName: '+1',
|
|
},
|
|
{
|
|
i18nLabel: i18n('icu:Bio--coffee-lover'),
|
|
shortName: 'coffee',
|
|
},
|
|
{
|
|
i18nLabel: i18n('icu:Bio--taking-break'),
|
|
shortName: 'mobile_phone_off',
|
|
},
|
|
];
|
|
}
|
|
|
|
function BioEmoji(props: { emoji: EmojiVariantKey }) {
|
|
const emojiLocalizer = useFunEmojiLocalizer();
|
|
const emojiVariant = getEmojiVariantByKey(props.emoji);
|
|
return (
|
|
<FunStaticEmoji
|
|
role="img"
|
|
aria-label={emojiLocalizer.getLocaleShortName(props.emoji)}
|
|
emoji={emojiVariant}
|
|
size={24}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function ProfileEditor({
|
|
aboutEmoji,
|
|
aboutText,
|
|
color,
|
|
conversationId,
|
|
contentsRef,
|
|
deleteAvatarFromDisk,
|
|
deleteUsername,
|
|
familyName,
|
|
firstName,
|
|
hasCompletedUsernameLinkOnboarding,
|
|
i18n,
|
|
editState,
|
|
markCompletedUsernameLinkOnboarding,
|
|
onProfileChanged,
|
|
onEmojiSkinToneDefaultChange,
|
|
openUsernameReservationModal,
|
|
profileAvatarUrl,
|
|
recentEmojis,
|
|
renderUsernameEditor,
|
|
replaceAvatar,
|
|
resetUsernameLink,
|
|
saveAttachment,
|
|
saveAvatarToDisk,
|
|
setEditState,
|
|
setUsernameEditState,
|
|
setUsernameLinkColor,
|
|
showToast,
|
|
emojiSkinToneDefault,
|
|
userAvatarData,
|
|
username,
|
|
usernameCorrupted,
|
|
usernameEditState,
|
|
usernameLinkState,
|
|
usernameLinkColor,
|
|
usernameLink,
|
|
usernameLinkCorrupted,
|
|
}: PropsType): JSX.Element {
|
|
const focusInputRef = useRef<HTMLInputElement | null>(null);
|
|
const tryClose = useRef<() => void | undefined>();
|
|
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
|
|
i18n,
|
|
name: 'ProfileEditor',
|
|
tryClose,
|
|
});
|
|
|
|
const TITLES_BY_EDIT_STATE: Record<EditState, string | undefined> = {
|
|
[EditState.BetterAvatar]: i18n('icu:ProfileEditorModal--avatar'),
|
|
[EditState.Bio]: i18n('icu:ProfileEditorModal--about'),
|
|
[EditState.None]: i18n('icu:ProfileEditorModal--profile'),
|
|
[EditState.ProfileName]: i18n('icu:ProfileEditorModal--name'),
|
|
[EditState.Username]: i18n('icu:ProfileEditorModal--username'),
|
|
[EditState.UsernameLink]: i18n('icu:ProfileEditorModal--sharing'),
|
|
};
|
|
|
|
// 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 [startingAvatarUrl, setStartingAvatarUrl] = useState(profileAvatarUrl);
|
|
|
|
const [oldAvatarBuffer, setOldAvatarBuffer] = useState<
|
|
Uint8Array | undefined
|
|
>(undefined);
|
|
const [avatarBuffer, setAvatarBuffer] = useState<Uint8Array | undefined>(
|
|
undefined
|
|
);
|
|
const [stagedProfile, setStagedProfile] = useState<ProfileDataType>({
|
|
aboutEmoji,
|
|
aboutText,
|
|
familyName,
|
|
firstName,
|
|
});
|
|
const [isResettingUsername, setIsResettingUsername] = useState(false);
|
|
const [isResettingUsernameLink, setIsResettingUsernameLink] = useState(false);
|
|
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
|
|
|
|
const stagedAboutEmojiVariantKey = useMemo(() => {
|
|
if (
|
|
stagedProfile.aboutEmoji == null ||
|
|
!isEmojiVariantValue(stagedProfile.aboutEmoji)
|
|
) {
|
|
return null;
|
|
}
|
|
return getEmojiVariantKeyByValue(stagedProfile.aboutEmoji);
|
|
}, [stagedProfile.aboutEmoji]);
|
|
|
|
// Reset username edit state when leaving
|
|
useEffect(() => {
|
|
return () => {
|
|
setUsernameEditState(UsernameEditState.Editing);
|
|
};
|
|
}, [setUsernameEditState]);
|
|
|
|
// To make AvatarEditor re-render less often
|
|
const handleBack = useCallback(() => {
|
|
setEditState(EditState.None);
|
|
}, [setEditState]);
|
|
|
|
const handleEmojiPickerOpenChange = useCallback((open: boolean) => {
|
|
setEmojiPickerOpen(open);
|
|
}, []);
|
|
|
|
// To make EmojiButton re-render less often
|
|
const setAboutEmoji = useCallback(
|
|
(ev: EmojiPickDataType) => {
|
|
const emojiData = getEmojiData(
|
|
ev.shortName,
|
|
emojiSkinToneDefault ?? EmojiSkinTone.None
|
|
);
|
|
setStagedProfile(profileData => ({
|
|
...profileData,
|
|
aboutEmoji: unifiedToEmoji(emojiData.unified),
|
|
}));
|
|
},
|
|
[setStagedProfile, emojiSkinToneDefault]
|
|
);
|
|
|
|
const handleSelectEmoji = useCallback(
|
|
(emojiSelection: FunEmojiSelection) => {
|
|
const emojiVariant = getEmojiVariantByKey(emojiSelection.variantKey);
|
|
|
|
setStagedProfile(profileData => ({
|
|
...profileData,
|
|
aboutEmoji: emojiVariant.value,
|
|
}));
|
|
},
|
|
[setStagedProfile]
|
|
);
|
|
|
|
// To make AvatarEditor re-render less often
|
|
const handleAvatarChanged = useCallback(
|
|
(avatar: Uint8Array | undefined) => {
|
|
// Do not display stale avatar from disk anymore.
|
|
setStartingAvatarUrl(undefined);
|
|
|
|
setAvatarBuffer(avatar);
|
|
onProfileChanged(
|
|
{
|
|
...stagedProfile,
|
|
firstName: trim(stagedProfile.firstName),
|
|
familyName: stagedProfile.familyName
|
|
? trim(stagedProfile.familyName)
|
|
: undefined,
|
|
},
|
|
{
|
|
keepAvatar: false,
|
|
avatarUpdate: { oldAvatar: oldAvatarBuffer, newAvatar: avatar },
|
|
}
|
|
);
|
|
setOldAvatarBuffer(avatar);
|
|
handleBack();
|
|
},
|
|
[handleBack, oldAvatarBuffer, onProfileChanged, stagedProfile]
|
|
);
|
|
|
|
const getFullNameText = () => {
|
|
return [fullName.firstName, fullName.familyName].filter(Boolean).join(' ');
|
|
};
|
|
|
|
useEffect(() => {
|
|
const focusNode = focusInputRef.current;
|
|
if (!focusNode) {
|
|
return;
|
|
}
|
|
|
|
focusNode.focus();
|
|
focusNode.setSelectionRange(focusNode.value.length, focusNode.value.length);
|
|
}, [editState]);
|
|
|
|
// To make AvatarEditor re-render less often
|
|
const handleAvatarLoaded = useCallback(
|
|
(avatar: Uint8Array) => {
|
|
setAvatarBuffer(avatar);
|
|
setOldAvatarBuffer(avatar);
|
|
},
|
|
[setAvatarBuffer, setOldAvatarBuffer]
|
|
);
|
|
|
|
const onTryClose = useCallback(() => {
|
|
const hasNameChanges =
|
|
stagedProfile.familyName !== fullName.familyName ||
|
|
stagedProfile.firstName !== fullName.firstName;
|
|
const hasAboutChanges =
|
|
stagedProfile.aboutText !== fullBio.aboutText ||
|
|
stagedProfile.aboutEmoji !== fullBio.aboutEmoji;
|
|
const onDiscard = () => {
|
|
setStagedProfile(profileData => ({
|
|
...profileData,
|
|
...fullName,
|
|
...fullBio,
|
|
}));
|
|
};
|
|
|
|
confirmDiscardIf(hasNameChanges || hasAboutChanges, onDiscard);
|
|
}, [confirmDiscardIf, stagedProfile, fullName, fullBio, setStagedProfile]);
|
|
tryClose.current = onTryClose;
|
|
|
|
let content: JSX.Element;
|
|
|
|
if (editState === EditState.BetterAvatar) {
|
|
content = (
|
|
<AvatarEditor
|
|
avatarColor={color || AvatarColors[0]}
|
|
avatarUrl={startingAvatarUrl}
|
|
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 =
|
|
!stagedProfile.firstName ||
|
|
(stagedProfile.firstName === fullName.firstName &&
|
|
stagedProfile.familyName === fullName.familyName) ||
|
|
isWhitespace(stagedProfile.firstName);
|
|
|
|
content = (
|
|
<>
|
|
<Input
|
|
i18n={i18n}
|
|
maxLengthCount={26}
|
|
maxByteCount={128}
|
|
onChange={newFirstName => {
|
|
setStagedProfile(profileData => ({
|
|
...profileData,
|
|
firstName: String(newFirstName),
|
|
}));
|
|
}}
|
|
placeholder={i18n('icu:ProfileEditor--first-name')}
|
|
ref={focusInputRef}
|
|
value={stagedProfile.firstName}
|
|
/>
|
|
<Input
|
|
i18n={i18n}
|
|
maxLengthCount={26}
|
|
maxByteCount={128}
|
|
onChange={newFamilyName => {
|
|
setStagedProfile(profileData => ({
|
|
...profileData,
|
|
familyName: newFamilyName,
|
|
}));
|
|
}}
|
|
placeholder={i18n('icu:ProfileEditor--last-name')}
|
|
value={stagedProfile.familyName}
|
|
/>
|
|
<div className="ProfileEditor__button-footer">
|
|
<Button onClick={handleBack} variant={ButtonVariant.Secondary}>
|
|
{i18n('icu:cancel')}
|
|
</Button>
|
|
<Button
|
|
disabled={shouldDisableSave}
|
|
onClick={() => {
|
|
if (!stagedProfile.firstName) {
|
|
return;
|
|
}
|
|
setFullName({
|
|
firstName: stagedProfile.firstName,
|
|
familyName: stagedProfile.familyName,
|
|
});
|
|
|
|
onProfileChanged(stagedProfile, { keepAvatar: true });
|
|
|
|
// Delay navigation until setFullName resolves and we are no longer dirty
|
|
setTimeout(() => handleBack(), 500);
|
|
}}
|
|
>
|
|
{i18n('icu:save')}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
);
|
|
} else if (editState === EditState.Bio) {
|
|
const shouldDisableSave =
|
|
stagedProfile.aboutText === fullBio.aboutText &&
|
|
stagedProfile.aboutEmoji === fullBio.aboutEmoji;
|
|
|
|
const defaultBios = getDefaultBios(i18n);
|
|
|
|
content = (
|
|
<>
|
|
<Input
|
|
expandable
|
|
hasClearButton
|
|
i18n={i18n}
|
|
icon={
|
|
<div className="module-composition-area__button-cell">
|
|
{!isFunPickerEnabled() && (
|
|
<EmojiButton
|
|
variant={EmojiButtonVariant.ProfileEditor}
|
|
closeOnPick
|
|
emoji={stagedProfile.aboutEmoji}
|
|
i18n={i18n}
|
|
onPickEmoji={setAboutEmoji}
|
|
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
|
recentEmojis={recentEmojis}
|
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
|
/>
|
|
)}
|
|
{isFunPickerEnabled() && (
|
|
<FunEmojiPicker
|
|
open={emojiPickerOpen}
|
|
onOpenChange={handleEmojiPickerOpenChange}
|
|
placement="bottom"
|
|
onSelectEmoji={handleSelectEmoji}
|
|
closeOnSelect
|
|
>
|
|
<FunEmojiPickerButton
|
|
i18n={i18n}
|
|
selectedEmoji={stagedAboutEmojiVariantKey}
|
|
/>
|
|
</FunEmojiPicker>
|
|
)}
|
|
</div>
|
|
}
|
|
maxLengthCount={140}
|
|
maxByteCount={512}
|
|
moduleClassName="ProfileEditor__about-input"
|
|
onChange={value => {
|
|
if (value) {
|
|
setStagedProfile(profileData => ({
|
|
...profileData,
|
|
aboutEmoji: stagedProfile.aboutEmoji,
|
|
aboutText: value.replace(/(\r\n|\n|\r)/gm, ''),
|
|
}));
|
|
} else {
|
|
setStagedProfile(profileData => ({
|
|
...profileData,
|
|
aboutEmoji: undefined,
|
|
aboutText: '',
|
|
}));
|
|
}
|
|
}}
|
|
ref={focusInputRef}
|
|
placeholder={i18n('icu:ProfileEditor--about-placeholder')}
|
|
value={stagedProfile.aboutText}
|
|
whenToShowRemainingCount={40}
|
|
/>
|
|
|
|
{defaultBios.map(defaultBio => {
|
|
strictAssert(
|
|
isEmojiEnglishShortName(defaultBio.shortName),
|
|
'Must be valid english short name'
|
|
);
|
|
const emojiParentKey = getEmojiParentKeyByEnglishShortName(
|
|
defaultBio.shortName
|
|
);
|
|
const emojiVariant = getEmojiVariantByParentKeyAndSkinTone(
|
|
emojiParentKey,
|
|
emojiSkinToneDefault ?? EmojiSkinTone.None
|
|
);
|
|
|
|
return (
|
|
<PanelRow
|
|
className="ProfileEditor__row"
|
|
key={defaultBio.shortName}
|
|
icon={
|
|
<div className="ProfileEditor__icon--container">
|
|
<BioEmoji emoji={emojiVariant.key} />
|
|
</div>
|
|
}
|
|
label={defaultBio.i18nLabel}
|
|
onClick={() => {
|
|
const emojiData = getEmojiData(
|
|
defaultBio.shortName,
|
|
emojiSkinToneDefault ?? EmojiSkinTone.None
|
|
);
|
|
|
|
setStagedProfile(profileData => ({
|
|
...profileData,
|
|
aboutEmoji: unifiedToEmoji(emojiData.unified),
|
|
aboutText: defaultBio.i18nLabel,
|
|
}));
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
<div className="ProfileEditor__button-footer">
|
|
<Button onClick={handleBack} variant={ButtonVariant.Secondary}>
|
|
{i18n('icu:cancel')}
|
|
</Button>
|
|
<Button
|
|
disabled={shouldDisableSave}
|
|
onClick={() => {
|
|
setFullBio({
|
|
aboutEmoji: stagedProfile.aboutEmoji,
|
|
aboutText: stagedProfile.aboutText,
|
|
});
|
|
|
|
onProfileChanged(stagedProfile, { keepAvatar: true });
|
|
|
|
// Delay navigation until setFullBio resolves and we are no longer dirty
|
|
setTimeout(() => handleBack(), 500);
|
|
}}
|
|
>
|
|
{i18n('icu:save')}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
);
|
|
} else if (editState === EditState.Username) {
|
|
content = renderUsernameEditor({
|
|
onClose: handleBack,
|
|
});
|
|
} else if (editState === EditState.UsernameLink) {
|
|
content = (
|
|
<UsernameLinkEditor
|
|
i18n={i18n}
|
|
link={usernameLink}
|
|
username={username ?? ''}
|
|
colorId={usernameLinkColor}
|
|
usernameLinkCorrupted={usernameLinkCorrupted}
|
|
usernameLinkState={usernameLinkState}
|
|
setUsernameLinkColor={setUsernameLinkColor}
|
|
resetUsernameLink={resetUsernameLink}
|
|
saveAttachment={saveAttachment}
|
|
showToast={showToast}
|
|
onBack={() => setEditState(EditState.None)}
|
|
/>
|
|
);
|
|
} else if (editState === EditState.None) {
|
|
let actions: JSX.Element | undefined;
|
|
let alwaysShowActions = false;
|
|
|
|
if (usernameEditState === UsernameEditState.Deleting) {
|
|
actions = (
|
|
<ConversationDetailsIcon
|
|
ariaLabel={i18n('icu:ProfileEditor--username--deleting-username')}
|
|
icon={IconType.spinner}
|
|
disabled
|
|
fakeButton
|
|
/>
|
|
);
|
|
} else {
|
|
const menuOptions = [
|
|
{
|
|
group: 'copy',
|
|
icon: 'ProfileEditor__username-menu__copy-icon',
|
|
label: i18n('icu:ProfileEditor--username--copy'),
|
|
onClick: () => {
|
|
assertDev(
|
|
username !== undefined,
|
|
'Should not be visible without username'
|
|
);
|
|
void window.navigator.clipboard.writeText(username);
|
|
showToast({ toastType: ToastType.CopiedUsername });
|
|
},
|
|
},
|
|
{
|
|
// Different group to display a divider above it
|
|
group: 'delete',
|
|
|
|
icon: 'ProfileEditor__username-menu__trash-icon',
|
|
label: i18n('icu:ProfileEditor--username--delete'),
|
|
onClick: () => {
|
|
setUsernameEditState(UsernameEditState.ConfirmingDelete);
|
|
},
|
|
},
|
|
];
|
|
|
|
if (usernameCorrupted) {
|
|
actions = (
|
|
<i
|
|
className="ProfileEditor__error-icon"
|
|
title={i18n('icu:ProfileEditor__username__error-icon')}
|
|
/>
|
|
);
|
|
alwaysShowActions = true;
|
|
} else if (username) {
|
|
actions = (
|
|
<ContextMenu
|
|
i18n={i18n}
|
|
menuOptions={menuOptions}
|
|
popperOptions={{ placement: 'bottom', strategy: 'absolute' }}
|
|
moduleClassName="ProfileEditor__username-menu"
|
|
ariaLabel={i18n('icu:ProfileEditor--username--context-menu')}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
let maybeUsernameLinkRow: JSX.Element | undefined;
|
|
if (username && !usernameCorrupted) {
|
|
let linkActions: JSX.Element | undefined;
|
|
|
|
if (usernameLinkCorrupted) {
|
|
linkActions = (
|
|
<i
|
|
className="ProfileEditor__error-icon"
|
|
title={i18n('icu:ProfileEditor__username-link__error-icon')}
|
|
/>
|
|
);
|
|
}
|
|
|
|
maybeUsernameLinkRow = (
|
|
<PanelRow
|
|
className="ProfileEditor__row"
|
|
icon={
|
|
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--username-link" />
|
|
}
|
|
label={i18n('icu:ProfileEditor__username-link')}
|
|
onClick={() => {
|
|
markCompletedUsernameLinkOnboarding();
|
|
|
|
if (usernameLinkCorrupted) {
|
|
setIsResettingUsernameLink(true);
|
|
return;
|
|
}
|
|
|
|
setEditState(EditState.UsernameLink);
|
|
}}
|
|
alwaysShowActions
|
|
actions={linkActions}
|
|
/>
|
|
);
|
|
|
|
if (!hasCompletedUsernameLinkOnboarding && !usernameLink) {
|
|
maybeUsernameLinkRow = (
|
|
<UsernameLinkTooltip
|
|
handleClose={markCompletedUsernameLinkOnboarding}
|
|
i18n={i18n}
|
|
>
|
|
{maybeUsernameLinkRow}
|
|
</UsernameLinkTooltip>
|
|
);
|
|
}
|
|
}
|
|
|
|
const usernameRows = (
|
|
<>
|
|
<hr className="ProfileEditor__divider" />
|
|
<PanelRow
|
|
className="ProfileEditor__row"
|
|
icon={
|
|
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--username" />
|
|
}
|
|
label={
|
|
(!usernameCorrupted && username) ||
|
|
i18n('icu:ProfileEditor--username')
|
|
}
|
|
onClick={() => {
|
|
if (usernameCorrupted) {
|
|
setIsResettingUsername(true);
|
|
return;
|
|
}
|
|
|
|
openUsernameReservationModal();
|
|
setEditState(EditState.Username);
|
|
}}
|
|
alwaysShowActions={alwaysShowActions}
|
|
actions={actions}
|
|
/>
|
|
{maybeUsernameLinkRow}
|
|
<div className="ProfileEditor__info">
|
|
{username
|
|
? i18n('icu:ProfileEditor--info--pnp')
|
|
: i18n('icu:ProfileEditor--info--pnp--no-username')}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
content = (
|
|
<>
|
|
<AvatarPreview
|
|
avatarColor={color}
|
|
avatarUrl={startingAvatarUrl}
|
|
avatarValue={avatarBuffer}
|
|
conversationTitle={getFullNameText()}
|
|
i18n={i18n}
|
|
onAvatarLoaded={handleAvatarLoaded}
|
|
onClick={() => {
|
|
setEditState(EditState.BetterAvatar);
|
|
}}
|
|
style={{
|
|
height: 80,
|
|
width: 80,
|
|
}}
|
|
/>
|
|
<div className="ProfileEditor__EditPhotoContainer">
|
|
<Button
|
|
onClick={() => {
|
|
setEditState(EditState.BetterAvatar);
|
|
}}
|
|
variant={ButtonVariant.Secondary}
|
|
className="ProfileEditor__EditPhoto"
|
|
>
|
|
{i18n('icu:ProfileEditor--edit-photo')}
|
|
</Button>
|
|
</div>
|
|
<PanelRow
|
|
className="ProfileEditor__row"
|
|
icon={
|
|
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--name" />
|
|
}
|
|
label={<UserText text={getFullNameText()} />}
|
|
onClick={() => {
|
|
setEditState(EditState.ProfileName);
|
|
}}
|
|
/>
|
|
<PanelRow
|
|
className="ProfileEditor__row"
|
|
icon={
|
|
fullBio.aboutEmoji && isEmojiVariantValue(fullBio.aboutEmoji) ? (
|
|
<div className="ProfileEditor__icon--container">
|
|
<BioEmoji
|
|
emoji={getEmojiVariantKeyByValue(fullBio.aboutEmoji)}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--bio" />
|
|
)
|
|
}
|
|
label={
|
|
<UserText
|
|
text={fullBio.aboutText || i18n('icu:ProfileEditor--about')}
|
|
/>
|
|
}
|
|
onClick={() => {
|
|
setEditState(EditState.Bio);
|
|
}}
|
|
/>
|
|
<div className="ProfileEditor__info">
|
|
{i18n('icu:ProfileEditor--info--general')}
|
|
</div>
|
|
{usernameRows}
|
|
</>
|
|
);
|
|
} else {
|
|
throw missingCaseError(editState);
|
|
}
|
|
|
|
const backButton =
|
|
editState !== EditState.None ? (
|
|
<button
|
|
aria-label={i18n('icu:goBack')}
|
|
className="Preferences__back-icon"
|
|
onClick={handleBack}
|
|
type="button"
|
|
/>
|
|
) : undefined;
|
|
|
|
return (
|
|
<>
|
|
{usernameEditState === UsernameEditState.ConfirmingDelete && (
|
|
<ConfirmationDialog
|
|
dialogName="ProfileEditor.confirmDeleteUsername"
|
|
i18n={i18n}
|
|
onClose={() => setUsernameEditState(UsernameEditState.Editing)}
|
|
actions={[
|
|
{
|
|
text: i18n('icu:ProfileEditor--username--confirm-delete-button'),
|
|
style: 'negative',
|
|
action: () => deleteUsername(),
|
|
},
|
|
]}
|
|
>
|
|
{i18n('icu:ProfileEditor--username--confirm-delete-body-2', {
|
|
username: username ?? '',
|
|
})}
|
|
</ConfirmationDialog>
|
|
)}
|
|
|
|
{confirmDiscardModal}
|
|
|
|
{isResettingUsernameLink && (
|
|
<ConfirmationDialog
|
|
i18n={i18n}
|
|
dialogName="ProfileEditor__resettingUsername"
|
|
onClose={() => setIsResettingUsernameLink(false)}
|
|
cancelButtonVariant={ButtonVariant.Secondary}
|
|
cancelText={i18n('icu:cancel')}
|
|
actions={[
|
|
{
|
|
action: () => {
|
|
setIsResettingUsernameLink(false);
|
|
setEditState(EditState.UsernameLink);
|
|
},
|
|
style: 'affirmative',
|
|
text: i18n('icu:UsernameLinkModalBody__error__fix-now'),
|
|
},
|
|
]}
|
|
>
|
|
{i18n('icu:UsernameLinkModalBody__error__text')}
|
|
</ConfirmationDialog>
|
|
)}
|
|
|
|
{isResettingUsername && (
|
|
<ConfirmationDialog
|
|
dialogName="ProfileEditor.confirmResetUsername"
|
|
moduleClassName="ProfileEditor__reset-username-modal"
|
|
i18n={i18n}
|
|
onClose={() => setIsResettingUsername(false)}
|
|
actions={[
|
|
{
|
|
text: i18n('icu:ProfileEditor--username--corrupted--fix-button'),
|
|
style: 'affirmative',
|
|
action: () => {
|
|
openUsernameReservationModal();
|
|
setEditState(EditState.Username);
|
|
},
|
|
},
|
|
]}
|
|
>
|
|
{i18n('icu:ProfileEditor--username--corrupted--body')}
|
|
</ConfirmationDialog>
|
|
)}
|
|
|
|
<PreferencesContent
|
|
backButton={backButton}
|
|
contents={<div className="ProfileEditor">{content}</div>}
|
|
contentsRef={contentsRef}
|
|
title={TITLES_BY_EDIT_STATE[editState]}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function UsernameLinkTooltip({
|
|
handleClose,
|
|
children,
|
|
i18n,
|
|
}: {
|
|
handleClose: VoidFunction;
|
|
children: React.ReactNode;
|
|
i18n: LocalizerType;
|
|
}) {
|
|
const reducedMotion = useReducedMotion();
|
|
const animatedStyles = useSpring({
|
|
from: { opacity: 0, scale: reducedMotion ? 1 : 0.25 },
|
|
to: { opacity: 1, scale: 1 },
|
|
config: { mass: 1, tension: 280, friction: 25 },
|
|
delay: 200,
|
|
});
|
|
const tooltip = (
|
|
<animated.div
|
|
className="ProfileEditor__username-link__tooltip__container"
|
|
style={animatedStyles}
|
|
>
|
|
<div className="ProfileEditor__username-link__tooltip__icon" />
|
|
|
|
<div className="ProfileEditor__username-link__tooltip__content">
|
|
<h3>{i18n('icu:ProfileEditor__username-link__tooltip__title')}</h3>
|
|
<p>{i18n('icu:ProfileEditor__username-link__tooltip__body')}</p>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="ProfileEditor__username-link__tooltip__close"
|
|
onClick={handleClose}
|
|
aria-label={i18n('icu:close')}
|
|
/>
|
|
<div className="ProfileEditor__username-link__tooltip__arrow" />
|
|
</animated.div>
|
|
);
|
|
|
|
return (
|
|
<Tooltip
|
|
className="ProfileEditor__username-link__tooltip"
|
|
direction={TooltipPlacement.Bottom}
|
|
sticky
|
|
content={tooltip}
|
|
// By default tooltip has its distance modified, here we clear that
|
|
popperModifiers={[offsetDistanceModifier(0)]}
|
|
hideArrow
|
|
>
|
|
{children}
|
|
</Tooltip>
|
|
);
|
|
}
|