// Copyright 2021 Signal Messenger, LLC // 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 { AvatarDataType, AvatarUpdateType, DeleteAvatarFromDiskActionType, ReplaceAvatarActionType, SaveAvatarToDiskActionType, } from '../types/Avatar'; import { AvatarEditor } from './AvatarEditor'; import { AvatarPreview } from './AvatarPreview'; import { Button, ButtonVariant } from './Button'; import { ConfirmDiscardDialog } from './ConfirmDiscardDialog'; import { Emoji } from './emoji/Emoji'; import type { Props as EmojiButtonProps } from './emoji/EmojiButton'; import { EmojiButton, EmojiButtonVariant } from './emoji/EmojiButton'; import type { EmojiPickDataType } from './emoji/EmojiPicker'; import { Input } from './Input'; import { Intl } from './Intl'; import type { LocalizerType } from '../types/Util'; import { Modal } from './Modal'; import { PanelRow } from './conversation/conversation-details/PanelRow'; import type { ProfileDataType } from '../state/ducks/conversations'; import { UsernameEditState } from '../state/ducks/usernameEnums'; import { ToastType } from '../types/Toast'; import type { ShowToastActionCreatorType } from '../state/ducks/toast'; import { getEmojiData, unifiedToEmoji } from './emoji/lib'; import { assertDev } from '../util/assert'; import { missingCaseError } from '../util/missingCaseError'; import { ConfirmationDialog } from './ConfirmationDialog'; import { ContextMenu } from './ContextMenu'; import { UsernameOnboardingModalBody } from './UsernameOnboardingModalBody'; import { ConversationDetailsIcon, IconType, } from './conversation/conversation-details/ConversationDetailsIcon'; import { isWhitespace, trim } from '../util/whitespaceStringUtil'; import { generateUsernameLink } from '../util/sgnlHref'; import { Emojify } from './conversation/Emojify'; export enum EditState { None = 'None', BetterAvatar = 'BetterAvatar', ProfileName = 'ProfileName', Bio = 'Bio', Username = 'Username', UsernameOnboarding = 'UsernameOnboarding', } type PropsExternalType = { onEditStateChanged: (editState: EditState) => unknown; onProfileChanged: ( profileData: ProfileDataType, avatar: AvatarUpdateType ) => unknown; renderEditUsernameModalBody: (props: { onClose: () => void }) => JSX.Element; }; export type PropsDataType = { aboutEmoji?: string; aboutText?: string; profileAvatarPath?: string; color?: AvatarColorType; conversationId: string; familyName?: string; firstName: string; hasCompletedUsernameOnboarding: boolean; i18n: LocalizerType; isUsernameFlagEnabled: boolean; userAvatarData: ReadonlyArray; username?: string; usernameEditState: UsernameEditState; markCompletedUsernameOnboarding: () => void; } & Pick; type PropsActionType = { deleteAvatarFromDisk: DeleteAvatarFromDiskActionType; onSetSkinTone: (tone: number) => unknown; replaceAvatar: ReplaceAvatarActionType; saveAvatarToDisk: SaveAvatarToDiskActionType; setUsernameEditState: (editState: UsernameEditState) => void; deleteUsername: () => void; showToast: ShowToastActionCreatorType; openUsernameReservationModal: () => void; }; export type PropsType = PropsDataType & PropsActionType & PropsExternalType; type DefaultBio = { i18nLabel: string; shortName: string; }; function getDefaultBios(i18n: LocalizerType): Array { 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', }, ]; } export function ProfileEditor({ aboutEmoji, aboutText, color, conversationId, deleteAvatarFromDisk, deleteUsername, familyName, firstName, hasCompletedUsernameOnboarding, i18n, isUsernameFlagEnabled, markCompletedUsernameOnboarding, onEditStateChanged, onProfileChanged, onSetSkinTone, openUsernameReservationModal, profileAvatarPath, recentEmojis, renderEditUsernameModalBody, replaceAvatar, saveAvatarToDisk, setUsernameEditState, showToast, skinTone, userAvatarData, username, usernameEditState, }: PropsType): JSX.Element { const focusInputRef = useRef(null); const [editState, setEditState] = useState(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); const [avatarBuffer, setAvatarBuffer] = useState( undefined ); const [isLoadingAvatar, setIsLoadingAvatar] = useState( Boolean(profileAvatarPath) ); const [stagedProfile, setStagedProfile] = useState({ aboutEmoji, aboutText, familyName, firstName, }); // 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); onEditStateChanged(EditState.None); }, [setEditState, onEditStateChanged]); // To make EmojiButton re-render less often 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 const handleAvatarChanged = useCallback( (avatar: Uint8Array | undefined) => { // Do not display stale avatar from disk anymore. setStartingAvatarPath(undefined); setAvatarBuffer(avatar); setEditState(EditState.None); onProfileChanged( { ...stagedProfile, firstName: trim(stagedProfile.firstName), familyName: stagedProfile.familyName ? trim(stagedProfile.familyName) : undefined, }, { oldAvatar: oldAvatarBuffer, newAvatar: avatar } ); setOldAvatarBuffer(avatar); }, [onProfileChanged, stagedProfile, oldAvatarBuffer] ); 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]); 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] ); let content: JSX.Element; if (editState === EditState.BetterAvatar) { content = ( ); } else if (editState === EditState.ProfileName) { const shouldDisableSave = isLoadingAvatar || !stagedProfile.firstName || (stagedProfile.firstName === fullName.firstName && stagedProfile.familyName === fullName.familyName) || isWhitespace(stagedProfile.firstName); content = ( <> { setStagedProfile(profileData => ({ ...profileData, firstName: String(newFirstName), })); }} placeholder={i18n('icu:ProfileEditor--first-name')} ref={focusInputRef} value={stagedProfile.firstName} /> { setStagedProfile(profileData => ({ ...profileData, familyName: newFamilyName, })); }} placeholder={i18n('icu:ProfileEditor--last-name')} value={stagedProfile.familyName} /> ); } else if (editState === EditState.Bio) { const shouldDisableSave = isLoadingAvatar || (stagedProfile.aboutText === fullBio.aboutText && stagedProfile.aboutEmoji === fullBio.aboutEmoji); const defaultBios = getDefaultBios(i18n); content = ( <> } 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 => ( } label={defaultBio.i18nLabel} onClick={() => { const emojiData = getEmojiData(defaultBio.shortName, skinTone); setStagedProfile(profileData => ({ ...profileData, aboutEmoji: unifiedToEmoji(emojiData.unified), aboutText: defaultBio.i18nLabel, })); }} /> ))} ); } else if (editState === EditState.Username) { content = renderEditUsernameModalBody({ onClose: () => setEditState(EditState.None), }); } else if (editState === EditState.UsernameOnboarding) { content = ( { markCompletedUsernameOnboarding(); setEditState(EditState.Username); }} /> ); } else if (editState === EditState.None) { let maybeUsernameRow: JSX.Element | undefined; if (isUsernameFlagEnabled) { let actions: JSX.Element | undefined; if (usernameEditState === UsernameEditState.Deleting) { actions = ( ); } 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.CopiedUsername); }, }, { group: 'copy', icon: 'ProfileEditor__username-menu__copy-link-icon', label: i18n('icu:ProfileEditor--username--copy-link'), onClick: () => { assertDev( username !== undefined, 'Should not be visible without username' ); void 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('icu:ProfileEditor--username--delete'), onClick: () => { setUsernameEditState(UsernameEditState.ConfirmingDelete); }, }, ]; if (username) { actions = ( ); } } maybeUsernameRow = ( } label={username || i18n('icu:ProfileEditor--username')} info={username && generateUsernameLink(username, { short: true })} onClick={() => { openUsernameReservationModal(); if (username || hasCompletedUsernameOnboarding) { setEditState(EditState.Username); } else { setEditState(EditState.UsernameOnboarding); } }} actions={actions} /> ); } content = ( <> { setEditState(EditState.BetterAvatar); }} style={{ height: 80, width: 80, }} />
} label={} onClick={() => { setEditState(EditState.ProfileName); }} /> {maybeUsernameRow} ) : ( ) } label={ } onClick={() => { setEditState(EditState.Bio); }} />
( {parts} ), }} />
); } else { throw missingCaseError(editState); } return ( <> {usernameEditState === UsernameEditState.ConfirmingDelete && ( setUsernameEditState(UsernameEditState.Editing)} actions={[ { text: i18n('icu:ProfileEditor--username--confirm-delete-button'), style: 'negative', action: () => deleteUsername(), }, ]} > {i18n('icu:ProfileEditor--username--confirm-delete-body')} )} {confirmDiscardAction && ( setConfirmDiscardAction(undefined)} /> )}
{content}
); }