From 94abe60af19500d11a29466797bda0ac994661e5 Mon Sep 17 00:00:00 2001 From: William Grant Date: Wed, 22 May 2024 21:05:39 +1000 Subject: [PATCH] feat: added SessionInput to Profile Modal layout and style improvements, disable ui during name change, improved keyboard navigation --- ts/components/dialog/EditProfileDialog.tsx | 368 +++++++++++------- .../dialog/EditProfilePictureModal.tsx | 3 +- ts/components/registration/utils/index.tsx | 21 +- 3 files changed, 238 insertions(+), 154 deletions(-) diff --git a/ts/components/dialog/EditProfileDialog.tsx b/ts/components/dialog/EditProfileDialog.tsx index 6af6d16ad..78e3f5d3c 100644 --- a/ts/components/dialog/EditProfileDialog.tsx +++ b/ts/components/dialog/EditProfileDialog.tsx @@ -1,16 +1,16 @@ -import { ChangeEvent, useState } from 'react'; +import { isEmpty } from 'lodash'; +import { useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { useKey } from 'react-use'; import styled from 'styled-components'; import { Avatar, AvatarSize } from '../avatar/Avatar'; -import { SyncUtils, ToastUtils, UserUtils } from '../../session/utils'; +import { SyncUtils, UserUtils } from '../../session/utils'; import { YourSessionIDPill, YourSessionIDSelectable } from '../basic/YourSessionIDPill'; import { useOurAvatarPath, useOurConversationUsername } from '../../hooks/useParamSelector'; import { ConversationTypeEnum } from '../../models/conversationAttributes'; -import { MAX_NAME_LENGTH_BYTES } from '../../session/constants'; import { getConversationController } from '../../session/conversations'; -import { sanitizeSessionUsername } from '../../session/utils/String'; import { editProfileModal, updateEditProfilePictureModel } from '../../state/ducks/modalDialog'; import { getTheme } from '../../state/selectors/theme'; import { getThemeValue } from '../../themes/globals'; @@ -19,14 +19,19 @@ import { SessionQRCode } from '../SessionQRCode'; import { SessionWrapperModal } from '../SessionWrapperModal'; import { Flex } from '../basic/Flex'; import { SessionButton } from '../basic/SessionButton'; -import { Spacer2XL, Spacer3XL, SpacerLG, SpacerSM, SpacerXL, SpacerXS } from '../basic/Text'; +import { Spacer2XL, Spacer3XL, SpacerLG, SpacerSM, SpacerXL } from '../basic/Text'; +import { CopyToClipboardButton } from '../buttons/CopyToClipboardButton'; import { SessionIconButton } from '../icon'; +import { SessionInput } from '../inputs'; import { SessionSpinner } from '../loading'; +import { sanitizeDisplayNameOrToast } from '../registration/utils'; const StyledEditProfileDialog = styled.div` .session-modal { width: 468px; .session-modal__body { + width: calc(100% - 80px); + margin: 0 auto; overflow: initial; } } @@ -57,25 +62,27 @@ const StyledEditProfileDialog = styled.div` } } } -`; -const StyledProfileName = styled(Flex)` input { - height: 38px; - border-radius: 5px; - text-align: center; - font-size: var(--font-size-md); + border: none; } +`; - &.uneditable { - p { - margin: 0; - padding: 0px var(--margins-lg) 0 var(--margins-sm); - } +// We center the name in the modal by offsetting the pencil icon +// we have a transparent border to match the dimensions of the SessionInput +const StyledProfileName = styled(Flex)` + margin-inline-start: calc((25px + var(--margins-sm)) * -1); + padding: 8px; + border: 1px solid var(--transparent-color); + p { + font-size: var(--font-size-xl); + line-height: 1.4; + margin: 0; + padding: 0px; + } - .session-icon-button { - padding: 0px; - } + .session-icon-button { + padding: 0px; } `; @@ -104,6 +111,7 @@ const QRView = ({ sessionID }: { sessionID: string }) => { logoHeight={40} logoIsSVG={true} theme={theme} + style={{ marginTop: '-1px' }} /> ); }; @@ -143,11 +151,11 @@ export const ProfileAvatar = (props: ProfileAvatarProps) => { type ProfileHeaderProps = ProfileAvatarProps & { onClick: () => void; - setMode: (mode: ProfileDialogModes) => void; + onQRClick: () => void; }; const ProfileHeader = (props: ProfileHeaderProps) => { - const { avatarPath, profileName, ourId, onClick, setMode } = props; + const { avatarPath, profileName, ourId, onClick, onQRClick } = props; return (
@@ -159,13 +167,7 @@ const ProfileHeader = (props: ProfileHeaderProps) => { onClick={onClick} data-testid="image-upload-section" /> -
{ - setMode('qr'); - }} - role="button" - > +
@@ -181,14 +183,21 @@ export const EditProfileDialog = () => { const _profileName = useOurConversationUsername() || ''; const [profileName, setProfileName] = useState(_profileName); const [updatedProfileName, setUpdateProfileName] = useState(profileName); + const [profileNameError, setProfileNameError] = useState(undefined); + + const copyButtonRef = useRef(null); + const inputRef = useRef(null); + const avatarPath = useOurAvatarPath() || ''; const ourId = UserUtils.getOurPubKeyStrFromCache(); const [mode, setMode] = useState('default'); const [loading, setLoading] = useState(false); - const closeDialog = () => { - window.removeEventListener('keyup', handleOnKeyUp); + const closeDialog = (event?: any) => { + if (event?.key || loading) { + return; + } window.inboxStore?.dispatch(editProfileModal(null)); }; @@ -199,6 +208,9 @@ export const EditProfileDialog = () => { iconType: 'chevron', iconRotation: 90, onClick: () => { + if (loading) { + return; + } setMode('default'); }, }, @@ -206,48 +218,21 @@ export const EditProfileDialog = () => { : undefined; const onClickOK = async () => { - /** - * Tidy the profile name input text and save the new profile name and avatar - */ - try { - const newName = profileName ? profileName.trim() : ''; - - if (newName.length === 0 || newName.length > MAX_NAME_LENGTH_BYTES) { - return; - } - - // this throw if the length in bytes is too long - const sanitizedName = sanitizeSessionUsername(newName); - const trimName = sanitizedName.trim(); - - setUpdateProfileName(trimName); - setLoading(true); - - await updateDisplayName(newName); - setMode('default'); - setUpdateProfileName(profileName); - setLoading(false); - } catch (e) { - ToastUtils.pushToastError('nameTooLong', window.i18n('displayNameTooLong')); + if (isEmpty(profileName) || !isEmpty(profileNameError)) { + return; } - }; - const handleOnKeyUp = (event: any) => { - switch (event.key) { - case 'Enter': - if (mode === 'edit') { - void onClickOK(); - } - break; - case 'Esc': - case 'Escape': - closeDialog(); - break; - default: - } + setLoading(true); + await updateDisplayName(profileName); + setUpdateProfileName(profileName); + setMode('default'); + setLoading(false); }; const handleProfileHeaderClick = () => { + if (loading) { + return; + } closeDialog(); dispatch( updateEditProfilePictureModel({ @@ -258,124 +243,210 @@ export const EditProfileDialog = () => { ); }; - const onNameEdited = (event: ChangeEvent) => { - const displayName = event.target.value; - try { - const newName = sanitizeSessionUsername(displayName); - setProfileName(newName); - } catch (e) { - setProfileName(displayName); - ToastUtils.pushToastError('nameTooLong', window.i18n('displayNameTooLong')); + useKey( + (event: KeyboardEvent) => { + return event.key === 'c'; + }, + () => { + if (loading) { + return; + } + switch (mode) { + case 'default': + case 'qr': + if (copyButtonRef.current !== null) { + copyButtonRef.current.click(); + } + break; + case 'edit': + default: + } } - }; + ); + + useKey( + (event: KeyboardEvent) => { + return event.key === 'v'; + }, + () => { + if (loading) { + return; + } + switch (mode) { + case 'default': + setMode('qr'); + break; + case 'qr': + setMode('default'); + break; + case 'edit': + default: + } + } + ); + + useKey( + (event: KeyboardEvent) => { + return event.key === 'Enter'; + }, + () => { + if (loading) { + return; + } + switch (mode) { + case 'default': + setMode('edit'); + break; + case 'edit': + void onClickOK(); + break; + case 'qr': + default: + } + } + ); + + useKey( + (event: KeyboardEvent) => { + return event.key === 'Backspace'; + }, + () => { + if (loading) { + return; + } + switch (mode) { + case 'edit': + case 'qr': + if (inputRef.current !== null && document.activeElement === inputRef.current) { + return; + } + setMode('default'); + if (mode === 'edit') { + setProfileNameError(undefined); + setProfileName(updatedProfileName); + } + break; + case 'default': + default: + } + } + ); + + useKey( + (event: KeyboardEvent) => { + return event.key === 'Esc' || event.key === 'Escape'; + }, + () => { + if (loading) { + return; + } + if (mode === 'edit') { + setMode('default'); + setProfileNameError(undefined); + setProfileName(updatedProfileName); + } else { + window.inboxStore?.dispatch(editProfileModal(null)); + } + } + ); return ( - /* The
element has a child element that allows keyboard interaction - We use edit-profile-default class to prevent the qr icon on the avatar from clipping - */ - + - {mode === 'qr' && ( + {mode === 'qr' ? ( + + ) : ( <> - - - - )} - {mode === 'default' && ( - <> - + { + if (loading) { + return; + } + setMode('qr'); + }} /> - - - { - setMode('edit'); - }} - dataTestId="edit-profile-icon" - /> -

{updatedProfileName || profileName}

-
- )} - {mode === 'edit' && ( - <> - + + {mode === 'default' && ( + + { + if (loading) { + return; + } + setMode('edit'); + }} + dataTestId="edit-profile-icon" /> - - {/* TODO swap with new session input */} - - - + +

{updatedProfileName || profileName}

+
+ )} + + {mode === 'edit' && ( + { + const sanitizedName = sanitizeDisplayNameOrToast(name, setProfileNameError); + setProfileName(sanitizedName); + }} + editable={!loading} + tabIndex={0} + required={true} + error={profileNameError} + textSize={'xl'} + centerText={true} + inputRef={inputRef} + inputDataTestId="profile-name-input" + /> )} + {mode !== 'qr' ? : } + - - - - - + + {!loading ? : null} {mode === 'default' || mode === 'qr' ? ( - { - window.clipboard.writeText(ourId); - ToastUtils.pushCopiedToClipBoard(); - }} + {mode === 'default' ? ( @@ -398,7 +469,8 @@ export const EditProfileDialog = () => { /> ) )} - + + {!loading ? : null}
diff --git a/ts/components/dialog/EditProfilePictureModal.tsx b/ts/components/dialog/EditProfilePictureModal.tsx index b1b5fc74a..f7f8a1632 100644 --- a/ts/components/dialog/EditProfilePictureModal.tsx +++ b/ts/components/dialog/EditProfilePictureModal.tsx @@ -4,6 +4,7 @@ import styled from 'styled-components'; import { clearOurAvatar, uploadOurAvatar } from '../../interactions/conversationInteractions'; import { ToastUtils } from '../../session/utils'; import { editProfileModal, updateEditProfilePictureModel } from '../../state/ducks/modalDialog'; +import type { EditProfilePictureModalProps } from '../../types/ReduxTypes'; import { pickFileForAvatar } from '../../types/attachments/VisualAttachment'; import { SessionWrapperModal } from '../SessionWrapperModal'; import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton'; @@ -11,7 +12,6 @@ import { SpacerLG } from '../basic/Text'; import { SessionIconButton } from '../icon'; import { SessionSpinner } from '../loading'; import { ProfileAvatar } from './EditProfileDialog'; -import type { EditProfilePictureModalProps } from '../../types/ReduxTypes'; const StyledAvatarContainer = styled.div` cursor: pointer; @@ -111,6 +111,7 @@ export const EditProfilePictureModal = (props: EditProfilePictureModalProps) => title={window.i18n('setDisplayPicture')} onClose={closeDialog} showHeader={true} + headerReverse={true} showExitIcon={true} >
AnyAction, - dispatch: Dispatch + // can be a useState or redux function + setDisplayNameError: (error: string | undefined) => any, + dispatch?: Dispatch ) { try { const sanitizedName = sanitizeSessionUsername(displayName); const trimName = sanitizedName.trim(); - dispatch(setDisplayNameError(!trimName ? window.i18n('displayNameEmpty') : undefined)); + const errorString = !trimName ? window.i18n('displayNameEmpty') : undefined; + if (dispatch) { + dispatch(setDisplayNameError(errorString)); + } else { + setDisplayNameError(errorString); + } + return sanitizedName; } catch (e) { - dispatch(setDisplayNameError(window.i18n('displayNameErrorDescriptionShorter'))); + if (dispatch) { + dispatch(setDisplayNameError(window.i18n('displayNameErrorDescriptionShorter'))); + } else { + setDisplayNameError(window.i18n('displayNameErrorDescriptionShorter')); + } return displayName; } }