feat: added SessionInput to Profile Modal

layout and style improvements, disable ui during name change, improved keyboard navigation
pull/3083/head
William Grant 11 months ago
parent 698eb147f0
commit 94abe60af1

@ -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 { useDispatch, useSelector } from 'react-redux';
import { useKey } from 'react-use';
import styled from 'styled-components'; import styled from 'styled-components';
import { Avatar, AvatarSize } from '../avatar/Avatar'; 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 { YourSessionIDPill, YourSessionIDSelectable } from '../basic/YourSessionIDPill';
import { useOurAvatarPath, useOurConversationUsername } from '../../hooks/useParamSelector'; import { useOurAvatarPath, useOurConversationUsername } from '../../hooks/useParamSelector';
import { ConversationTypeEnum } from '../../models/conversationAttributes'; import { ConversationTypeEnum } from '../../models/conversationAttributes';
import { MAX_NAME_LENGTH_BYTES } from '../../session/constants';
import { getConversationController } from '../../session/conversations'; import { getConversationController } from '../../session/conversations';
import { sanitizeSessionUsername } from '../../session/utils/String';
import { editProfileModal, updateEditProfilePictureModel } from '../../state/ducks/modalDialog'; import { editProfileModal, updateEditProfilePictureModel } from '../../state/ducks/modalDialog';
import { getTheme } from '../../state/selectors/theme'; import { getTheme } from '../../state/selectors/theme';
import { getThemeValue } from '../../themes/globals'; import { getThemeValue } from '../../themes/globals';
@ -19,14 +19,19 @@ import { SessionQRCode } from '../SessionQRCode';
import { SessionWrapperModal } from '../SessionWrapperModal'; import { SessionWrapperModal } from '../SessionWrapperModal';
import { Flex } from '../basic/Flex'; import { Flex } from '../basic/Flex';
import { SessionButton } from '../basic/SessionButton'; 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 { SessionIconButton } from '../icon';
import { SessionInput } from '../inputs';
import { SessionSpinner } from '../loading'; import { SessionSpinner } from '../loading';
import { sanitizeDisplayNameOrToast } from '../registration/utils';
const StyledEditProfileDialog = styled.div` const StyledEditProfileDialog = styled.div`
.session-modal { .session-modal {
width: 468px; width: 468px;
.session-modal__body { .session-modal__body {
width: calc(100% - 80px);
margin: 0 auto;
overflow: initial; overflow: initial;
} }
} }
@ -57,25 +62,27 @@ const StyledEditProfileDialog = styled.div`
} }
} }
} }
`;
const StyledProfileName = styled(Flex)`
input { input {
height: 38px; border: none;
border-radius: 5px;
text-align: center;
font-size: var(--font-size-md);
} }
`;
&.uneditable { // We center the name in the modal by offsetting the pencil icon
p { // we have a transparent border to match the dimensions of the SessionInput
margin: 0; const StyledProfileName = styled(Flex)`
padding: 0px var(--margins-lg) 0 var(--margins-sm); 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 { .session-icon-button {
padding: 0px; padding: 0px;
}
} }
`; `;
@ -104,6 +111,7 @@ const QRView = ({ sessionID }: { sessionID: string }) => {
logoHeight={40} logoHeight={40}
logoIsSVG={true} logoIsSVG={true}
theme={theme} theme={theme}
style={{ marginTop: '-1px' }}
/> />
); );
}; };
@ -143,11 +151,11 @@ export const ProfileAvatar = (props: ProfileAvatarProps) => {
type ProfileHeaderProps = ProfileAvatarProps & { type ProfileHeaderProps = ProfileAvatarProps & {
onClick: () => void; onClick: () => void;
setMode: (mode: ProfileDialogModes) => void; onQRClick: () => void;
}; };
const ProfileHeader = (props: ProfileHeaderProps) => { const ProfileHeader = (props: ProfileHeaderProps) => {
const { avatarPath, profileName, ourId, onClick, setMode } = props; const { avatarPath, profileName, ourId, onClick, onQRClick } = props;
return ( return (
<div className="avatar-center"> <div className="avatar-center">
@ -159,13 +167,7 @@ const ProfileHeader = (props: ProfileHeaderProps) => {
onClick={onClick} onClick={onClick}
data-testid="image-upload-section" data-testid="image-upload-section"
/> />
<div <div className="qr-view-button" onClick={onQRClick} role="button">
className="qr-view-button"
onClick={() => {
setMode('qr');
}}
role="button"
>
<SessionIconButton iconType="qr" iconSize={26} iconColor="var(--black-color)" /> <SessionIconButton iconType="qr" iconSize={26} iconColor="var(--black-color)" />
</div> </div>
</div> </div>
@ -181,14 +183,21 @@ export const EditProfileDialog = () => {
const _profileName = useOurConversationUsername() || ''; const _profileName = useOurConversationUsername() || '';
const [profileName, setProfileName] = useState(_profileName); const [profileName, setProfileName] = useState(_profileName);
const [updatedProfileName, setUpdateProfileName] = useState(profileName); const [updatedProfileName, setUpdateProfileName] = useState(profileName);
const [profileNameError, setProfileNameError] = useState<string | undefined>(undefined);
const copyButtonRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const avatarPath = useOurAvatarPath() || ''; const avatarPath = useOurAvatarPath() || '';
const ourId = UserUtils.getOurPubKeyStrFromCache(); const ourId = UserUtils.getOurPubKeyStrFromCache();
const [mode, setMode] = useState<ProfileDialogModes>('default'); const [mode, setMode] = useState<ProfileDialogModes>('default');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const closeDialog = () => { const closeDialog = (event?: any) => {
window.removeEventListener('keyup', handleOnKeyUp); if (event?.key || loading) {
return;
}
window.inboxStore?.dispatch(editProfileModal(null)); window.inboxStore?.dispatch(editProfileModal(null));
}; };
@ -199,6 +208,9 @@ export const EditProfileDialog = () => {
iconType: 'chevron', iconType: 'chevron',
iconRotation: 90, iconRotation: 90,
onClick: () => { onClick: () => {
if (loading) {
return;
}
setMode('default'); setMode('default');
}, },
}, },
@ -206,48 +218,21 @@ export const EditProfileDialog = () => {
: undefined; : undefined;
const onClickOK = async () => { const onClickOK = async () => {
/** if (isEmpty(profileName) || !isEmpty(profileNameError)) {
* Tidy the profile name input text and save the new profile name and avatar return;
*/
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'));
} }
};
const handleOnKeyUp = (event: any) => { setLoading(true);
switch (event.key) { await updateDisplayName(profileName);
case 'Enter': setUpdateProfileName(profileName);
if (mode === 'edit') { setMode('default');
void onClickOK(); setLoading(false);
}
break;
case 'Esc':
case 'Escape':
closeDialog();
break;
default:
}
}; };
const handleProfileHeaderClick = () => { const handleProfileHeaderClick = () => {
if (loading) {
return;
}
closeDialog(); closeDialog();
dispatch( dispatch(
updateEditProfilePictureModel({ updateEditProfilePictureModel({
@ -258,124 +243,210 @@ export const EditProfileDialog = () => {
); );
}; };
const onNameEdited = (event: ChangeEvent<HTMLInputElement>) => { useKey(
const displayName = event.target.value; (event: KeyboardEvent) => {
try { return event.key === 'c';
const newName = sanitizeSessionUsername(displayName); },
setProfileName(newName); () => {
} catch (e) { if (loading) {
setProfileName(displayName); return;
ToastUtils.pushToastError('nameTooLong', window.i18n('displayNameTooLong')); }
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 ( return (
/* The <div> element has a child <input> element that allows keyboard interaction <StyledEditProfileDialog className="edit-profile-dialog" data-testid="edit-profile-dialog">
We use edit-profile-default class to prevent the qr icon on the avatar from clipping
*/
<StyledEditProfileDialog
className="edit-profile-dialog"
data-testid="edit-profile-dialog"
onKeyUp={handleOnKeyUp}
>
<SessionWrapperModal <SessionWrapperModal
title={window.i18n('editProfileModalTitle')} title={window.i18n('editProfileModalTitle')}
onClose={closeDialog}
headerIconButtons={backButton} headerIconButtons={backButton}
headerReverse={true} headerReverse={true}
showExitIcon={true} showExitIcon={true}
onClose={closeDialog}
additionalClassName={mode === 'default' ? 'edit-profile-default' : undefined} additionalClassName={mode === 'default' ? 'edit-profile-default' : undefined}
> >
{mode === 'qr' && ( {mode === 'qr' ? (
<QRView sessionID={ourId} />
) : (
<> <>
<QRView sessionID={ourId} /> <SpacerXL />
<SpacerXS />
</>
)}
{mode === 'default' && (
<>
<SpacerSM />
<ProfileHeader <ProfileHeader
avatarPath={avatarPath} avatarPath={avatarPath}
profileName={profileName} profileName={profileName}
ourId={ourId} ourId={ourId}
onClick={handleProfileHeaderClick} onClick={handleProfileHeaderClick}
setMode={setMode} onQRClick={() => {
if (loading) {
return;
}
setMode('qr');
}}
/> />
<SpacerXL />
<StyledProfileName
container={true}
justifyContent="center"
alignItems="center"
className="uneditable"
>
<SessionIconButton
iconType="pencil"
iconSize="large"
onClick={() => {
setMode('edit');
}}
dataTestId="edit-profile-icon"
/>
<p data-testid="your-profile-name">{updatedProfileName || profileName}</p>
</StyledProfileName>
<Spacer3XL />
</> </>
)} )}
{mode === 'edit' && (
<> <SpacerLG />
<ProfileHeader
avatarPath={avatarPath} {mode === 'default' && (
profileName={profileName} <StyledProfileName container={true} justifyContent="center" alignItems="center">
ourId={ourId} <SessionIconButton
onClick={handleProfileHeaderClick} iconType="pencil"
setMode={setMode} iconSize="large"
onClick={() => {
if (loading) {
return;
}
setMode('edit');
}}
dataTestId="edit-profile-icon"
/> />
<StyledProfileName container={true} justifyContent="center" alignItems="center"> <SpacerSM />
{/* TODO swap with new session input */} <p data-testid="your-profile-name">{updatedProfileName || profileName}</p>
<input </StyledProfileName>
type="text" )}
value={profileName}
placeholder={window.i18n('displayName')} {mode === 'edit' && (
onChange={onNameEdited} <SessionInput
maxLength={MAX_NAME_LENGTH_BYTES} autoFocus={true}
tabIndex={0} disableOnBlurEvent={true}
required={true} type="text"
aria-required={true} placeholder={window.i18n('enterDisplayName')}
data-testid="profile-name-input" value={profileName}
/> onValueChanged={(name: string) => {
</StyledProfileName> 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' ? <Spacer3XL /> : <SpacerSM />}
<StyledSessionIdSection <StyledSessionIdSection
container={true} container={true}
flexDirection="column" flexDirection="column"
justifyContent="center" justifyContent="center"
alignItems="center" alignItems="center"
width="calc(100% - 80px)" width={'100%'}
> >
<SpacerLG />
<YourSessionIDPill /> <YourSessionIDPill />
<SpacerLG /> <SpacerLG />
<YourSessionIDSelectable /> <YourSessionIDSelectable />
<SessionSpinner loading={loading} height={'74px'} />
<SessionSpinner loading={loading} /> {!loading ? <Spacer2XL /> : null}
<Spacer2XL />
{mode === 'default' || mode === 'qr' ? ( {mode === 'default' || mode === 'qr' ? (
<Flex <Flex
container={true} container={true}
justifyContent={mode === 'default' ? 'space-between' : 'center'} justifyContent={mode === 'default' ? 'space-between' : 'center'}
alignItems="center" alignItems="center"
flexGap="var(--margins-lg)"
width={'100%'} width={'100%'}
> >
<SessionButton <CopyToClipboardButton
text={window.i18n('editMenuCopy')} copyContent={ourId}
onClick={() => { reference={copyButtonRef}
window.clipboard.writeText(ourId);
ToastUtils.pushCopiedToClipBoard();
}}
dataTestId="copy-button-profile-update" dataTestId="copy-button-profile-update"
/> />
{mode === 'default' ? ( {mode === 'default' ? (
@ -398,7 +469,8 @@ export const EditProfileDialog = () => {
/> />
) )
)} )}
<SpacerSM />
{!loading ? <SpacerSM /> : null}
</StyledSessionIdSection> </StyledSessionIdSection>
</SessionWrapperModal> </SessionWrapperModal>
</StyledEditProfileDialog> </StyledEditProfileDialog>

@ -4,6 +4,7 @@ import styled from 'styled-components';
import { clearOurAvatar, uploadOurAvatar } from '../../interactions/conversationInteractions'; import { clearOurAvatar, uploadOurAvatar } from '../../interactions/conversationInteractions';
import { ToastUtils } from '../../session/utils'; import { ToastUtils } from '../../session/utils';
import { editProfileModal, updateEditProfilePictureModel } from '../../state/ducks/modalDialog'; import { editProfileModal, updateEditProfilePictureModel } from '../../state/ducks/modalDialog';
import type { EditProfilePictureModalProps } from '../../types/ReduxTypes';
import { pickFileForAvatar } from '../../types/attachments/VisualAttachment'; import { pickFileForAvatar } from '../../types/attachments/VisualAttachment';
import { SessionWrapperModal } from '../SessionWrapperModal'; import { SessionWrapperModal } from '../SessionWrapperModal';
import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton'; import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
@ -11,7 +12,6 @@ import { SpacerLG } from '../basic/Text';
import { SessionIconButton } from '../icon'; import { SessionIconButton } from '../icon';
import { SessionSpinner } from '../loading'; import { SessionSpinner } from '../loading';
import { ProfileAvatar } from './EditProfileDialog'; import { ProfileAvatar } from './EditProfileDialog';
import type { EditProfilePictureModalProps } from '../../types/ReduxTypes';
const StyledAvatarContainer = styled.div` const StyledAvatarContainer = styled.div`
cursor: pointer; cursor: pointer;
@ -111,6 +111,7 @@ export const EditProfilePictureModal = (props: EditProfilePictureModalProps) =>
title={window.i18n('setDisplayPicture')} title={window.i18n('setDisplayPicture')}
onClose={closeDialog} onClose={closeDialog}
showHeader={true} showHeader={true}
headerReverse={true}
showExitIcon={true} showExitIcon={true}
> >
<div <div

@ -1,18 +1,29 @@
import { AnyAction, Dispatch } from '@reduxjs/toolkit'; import { Dispatch } from '@reduxjs/toolkit';
import { sanitizeSessionUsername } from '../../../session/utils/String'; import { sanitizeSessionUsername } from '../../../session/utils/String';
export function sanitizeDisplayNameOrToast( export function sanitizeDisplayNameOrToast(
displayName: string, displayName: string,
setDisplayNameError: (error: string | undefined) => AnyAction, // can be a useState or redux function
dispatch: Dispatch setDisplayNameError: (error: string | undefined) => any,
dispatch?: Dispatch
) { ) {
try { try {
const sanitizedName = sanitizeSessionUsername(displayName); const sanitizedName = sanitizeSessionUsername(displayName);
const trimName = sanitizedName.trim(); 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; return sanitizedName;
} catch (e) { } catch (e) {
dispatch(setDisplayNameError(window.i18n('displayNameErrorDescriptionShorter'))); if (dispatch) {
dispatch(setDisplayNameError(window.i18n('displayNameErrorDescriptionShorter')));
} else {
setDisplayNameError(window.i18n('displayNameErrorDescriptionShorter'));
}
return displayName; return displayName;
} }
} }

Loading…
Cancel
Save