Usernames: Create/update/delete in profile editor
This commit is contained in:
parent
a9cb621eb6
commit
3190f95fac
38 changed files with 923 additions and 89 deletions
|
@ -136,7 +136,18 @@ export const AvatarPreview = ({
|
|||
|
||||
const isLoading = imageStatus === ImageStatus.Loading;
|
||||
|
||||
const clickProps = onClick ? { role: 'button', onClick, tabIndex: 0 } : {};
|
||||
const clickProps = onClick
|
||||
? {
|
||||
role: 'button',
|
||||
onClick,
|
||||
tabIndex: 0,
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
onClick();
|
||||
}
|
||||
},
|
||||
}
|
||||
: {};
|
||||
const componentStyle = {
|
||||
...style,
|
||||
};
|
||||
|
|
|
@ -28,6 +28,7 @@ export type OwnProps = {
|
|||
readonly title?: string | React.ReactNode;
|
||||
readonly theme?: Theme;
|
||||
readonly hasXButton?: boolean;
|
||||
readonly cancelButtonVariant?: ButtonVariant;
|
||||
};
|
||||
|
||||
export type Props = OwnProps;
|
||||
|
@ -64,6 +65,7 @@ export const ConfirmationDialog = React.memo(
|
|||
theme,
|
||||
title,
|
||||
hasXButton,
|
||||
cancelButtonVariant,
|
||||
}: Props) => {
|
||||
const { close, overlayStyles, modalStyles } = useAnimated(onClose, {
|
||||
getFrom: () => ({ opacity: 0, transform: 'scale(0.25)' }),
|
||||
|
@ -104,7 +106,8 @@ export const ConfirmationDialog = React.memo(
|
|||
onClick={handleCancel}
|
||||
ref={focusRef}
|
||||
variant={
|
||||
hasActions ? ButtonVariant.Secondary : ButtonVariant.Primary
|
||||
cancelButtonVariant ||
|
||||
(hasActions ? ButtonVariant.Secondary : ButtonVariant.Primary)
|
||||
}
|
||||
>
|
||||
{cancelText || i18n('confirmation-dialog--Cancel')}
|
||||
|
|
|
@ -29,6 +29,7 @@ export type PropsType = {
|
|||
maxByteCount?: number;
|
||||
moduleClassName?: string;
|
||||
onChange: (value: string) => unknown;
|
||||
onEnter?: () => unknown;
|
||||
placeholder: string;
|
||||
value?: string;
|
||||
whenToShowRemainingCount?: number;
|
||||
|
@ -68,6 +69,7 @@ export const Input = forwardRef<
|
|||
maxByteCount = 0,
|
||||
moduleClassName,
|
||||
onChange,
|
||||
onEnter,
|
||||
placeholder,
|
||||
value = '',
|
||||
whenToShowRemainingCount = Infinity,
|
||||
|
@ -99,15 +101,22 @@ export const Input = forwardRef<
|
|||
}
|
||||
}, [expandable]);
|
||||
|
||||
const handleKeyDown = useCallback(() => {
|
||||
const inputEl = innerRef.current;
|
||||
if (!inputEl) {
|
||||
return;
|
||||
}
|
||||
const handleKeyDown = useCallback(
|
||||
event => {
|
||||
if (onEnter && event.key === 'Enter') {
|
||||
onEnter();
|
||||
}
|
||||
|
||||
valueOnKeydownRef.current = inputEl.value;
|
||||
selectionStartOnKeydownRef.current = inputEl.selectionStart || 0;
|
||||
}, []);
|
||||
const inputEl = innerRef.current;
|
||||
if (!inputEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
valueOnKeydownRef.current = inputEl.value;
|
||||
selectionStartOnKeydownRef.current = inputEl.selectionStart || 0;
|
||||
},
|
||||
[onEnter]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(() => {
|
||||
const inputEl = innerRef.current;
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { text } from '@storybook/addon-knobs';
|
||||
import { text, boolean, select } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import type { PropsType } from './ProfileEditor';
|
||||
|
@ -16,6 +16,7 @@ import {
|
|||
getLastName,
|
||||
} from '../test-both/helpers/getDefaultConversation';
|
||||
import { getRandomColor } from '../test-both/helpers/getRandomColor';
|
||||
import { UsernameSaveState } from '../state/ducks/conversationsEnums';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -25,20 +26,34 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
aboutEmoji: overrideProps.aboutEmoji,
|
||||
aboutText: text('about', overrideProps.aboutText || ''),
|
||||
avatarPath: overrideProps.avatarPath,
|
||||
clearUsernameSave: action('clearUsernameSave'),
|
||||
conversationId: '123',
|
||||
color: overrideProps.color || getRandomColor(),
|
||||
deleteAvatarFromDisk: action('deleteAvatarFromDisk'),
|
||||
familyName: overrideProps.familyName,
|
||||
firstName: text('firstName', overrideProps.firstName || getFirstName()),
|
||||
i18n,
|
||||
isUsernameFlagEnabled: boolean(
|
||||
'isUsernameFlagEnabled',
|
||||
overrideProps.isUsernameFlagEnabled !== undefined
|
||||
? overrideProps.isUsernameFlagEnabled
|
||||
: false
|
||||
),
|
||||
onEditStateChanged: action('onEditStateChanged'),
|
||||
onProfileChanged: action('onProfileChanged'),
|
||||
onSetSkinTone: overrideProps.onSetSkinTone || action('onSetSkinTone'),
|
||||
recentEmojis: [],
|
||||
replaceAvatar: action('replaceAvatar'),
|
||||
saveAvatarToDisk: action('saveAvatarToDisk'),
|
||||
saveUsername: action('saveUsername'),
|
||||
skinTone: overrideProps.skinTone || 0,
|
||||
userAvatarData: [],
|
||||
username: overrideProps.username,
|
||||
usernameSaveState: select(
|
||||
'usernameSaveState',
|
||||
Object.values(UsernameSaveState),
|
||||
overrideProps.usernameSaveState || UsernameSaveState.None
|
||||
),
|
||||
});
|
||||
|
||||
stories.add('Full Set', () => {
|
||||
|
@ -74,3 +89,60 @@ stories.add('with Custom About', () => (
|
|||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
stories.add('with Username flag enabled', () => (
|
||||
<ProfileEditor
|
||||
{...createProps({
|
||||
isUsernameFlagEnabled: true,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
stories.add('with Username flag enabled and username', () => (
|
||||
<ProfileEditor
|
||||
{...createProps({
|
||||
isUsernameFlagEnabled: true,
|
||||
username: 'unicorn55',
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
stories.add('Username editing, saving', () => (
|
||||
<ProfileEditor
|
||||
{...createProps({
|
||||
isUsernameFlagEnabled: true,
|
||||
usernameSaveState: UsernameSaveState.Saving,
|
||||
username: 'unicorn55',
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
stories.add('Username editing, username taken', () => (
|
||||
<ProfileEditor
|
||||
{...createProps({
|
||||
isUsernameFlagEnabled: true,
|
||||
usernameSaveState: UsernameSaveState.UsernameTakenError,
|
||||
username: 'unicorn55',
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
stories.add('Username editing, username malformed', () => (
|
||||
<ProfileEditor
|
||||
{...createProps({
|
||||
isUsernameFlagEnabled: true,
|
||||
usernameSaveState: UsernameSaveState.UsernameMalformedError,
|
||||
username: 'unicorn55',
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
stories.add('Username editing, general error', () => (
|
||||
<ProfileEditor
|
||||
{...createProps({
|
||||
isUsernameFlagEnabled: true,
|
||||
usernameSaveState: UsernameSaveState.GeneralError,
|
||||
username: 'unicorn55',
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import * as log from '../logging/log';
|
||||
import type { AvatarColorType } from '../types/Colors';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import type {
|
||||
|
@ -21,18 +22,33 @@ import { EmojiButton } from './emoji/EmojiButton';
|
|||
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||
import { Input } from './Input';
|
||||
import { Intl } from './Intl';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { LocalizerType, ReplacementValuesType } from '../types/Util';
|
||||
import { Modal } from './Modal';
|
||||
import { PanelRow } from './conversation/conversation-details/PanelRow';
|
||||
import type { ProfileDataType } from '../state/ducks/conversations';
|
||||
import { getEmojiData, unifiedToEmoji } from './emoji/lib';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import {
|
||||
ConversationDetailsIcon,
|
||||
IconType,
|
||||
} from './conversation/conversation-details/ConversationDetailsIcon';
|
||||
import { Spinner } from './Spinner';
|
||||
import { UsernameSaveState } from '../state/ducks/conversationsEnums';
|
||||
|
||||
export enum EditState {
|
||||
None = 'None',
|
||||
BetterAvatar = 'BetterAvatar',
|
||||
ProfileName = 'ProfileName',
|
||||
Bio = 'Bio',
|
||||
Username = 'Username',
|
||||
}
|
||||
|
||||
enum UsernameEditState {
|
||||
Editing = 'Editing',
|
||||
ConfirmingDelete = 'ConfirmingDelete',
|
||||
ShowingErrorPopup = 'ShowingErrorPopup',
|
||||
Saving = 'Saving',
|
||||
}
|
||||
|
||||
type PropsExternalType = {
|
||||
|
@ -52,14 +68,22 @@ export type PropsDataType = {
|
|||
familyName?: string;
|
||||
firstName: string;
|
||||
i18n: LocalizerType;
|
||||
isUsernameFlagEnabled: boolean;
|
||||
usernameSaveState: UsernameSaveState;
|
||||
userAvatarData: Array<AvatarDataType>;
|
||||
username?: string;
|
||||
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
|
||||
|
||||
type PropsActionType = {
|
||||
clearUsernameSave: () => unknown;
|
||||
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
|
||||
onSetSkinTone: (tone: number) => unknown;
|
||||
replaceAvatar: ReplaceAvatarActionType;
|
||||
saveAvatarToDisk: SaveAvatarToDiskActionType;
|
||||
saveUsername: (options: {
|
||||
username: string | undefined;
|
||||
previousUsername: string | undefined;
|
||||
}) => unknown;
|
||||
};
|
||||
|
||||
export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
|
||||
|
@ -92,24 +116,120 @@ const DEFAULT_BIOS: Array<DefaultBio> = [
|
|||
},
|
||||
];
|
||||
|
||||
function getUsernameInvalidKey(
|
||||
username: string | undefined
|
||||
): { key: string; replacements?: ReplacementValuesType } | undefined {
|
||||
if (!username) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const min = 3;
|
||||
if (username.length < min) {
|
||||
return {
|
||||
key: 'ProfileEditor--username--check-character-min',
|
||||
replacements: { min },
|
||||
};
|
||||
}
|
||||
|
||||
if (!/^[0-9a-z_]+$/.test(username)) {
|
||||
return { key: 'ProfileEditor--username--check-characters' };
|
||||
}
|
||||
if (/^[0-9]/.test(username)) {
|
||||
return { key: 'ProfileEditor--username--check-starting-character' };
|
||||
}
|
||||
|
||||
const max = 25;
|
||||
if (username.length > max) {
|
||||
return {
|
||||
key: 'ProfileEditor--username--check-character-max',
|
||||
replacements: { max },
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mapSaveStateToEditState({
|
||||
clearUsernameSave,
|
||||
i18n,
|
||||
setEditState,
|
||||
setUsernameEditState,
|
||||
setUsernameError,
|
||||
usernameSaveState,
|
||||
}: {
|
||||
clearUsernameSave: () => unknown;
|
||||
i18n: LocalizerType;
|
||||
setEditState: (state: EditState) => unknown;
|
||||
setUsernameEditState: (state: UsernameEditState) => unknown;
|
||||
setUsernameError: (errorText: string) => unknown;
|
||||
usernameSaveState: UsernameSaveState;
|
||||
}): void {
|
||||
if (usernameSaveState === UsernameSaveState.None) {
|
||||
return;
|
||||
}
|
||||
if (usernameSaveState === UsernameSaveState.Saving) {
|
||||
setUsernameEditState(UsernameEditState.Saving);
|
||||
return;
|
||||
}
|
||||
|
||||
clearUsernameSave();
|
||||
|
||||
if (usernameSaveState === UsernameSaveState.Success) {
|
||||
setEditState(EditState.None);
|
||||
setUsernameEditState(UsernameEditState.Editing);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (usernameSaveState === UsernameSaveState.UsernameMalformedError) {
|
||||
setUsernameEditState(UsernameEditState.Editing);
|
||||
setUsernameError(i18n('ProfileEditor--username--check-characters'));
|
||||
return;
|
||||
}
|
||||
if (usernameSaveState === UsernameSaveState.UsernameTakenError) {
|
||||
setUsernameEditState(UsernameEditState.Editing);
|
||||
setUsernameError(i18n('ProfileEditor--username--check-username-taken'));
|
||||
return;
|
||||
}
|
||||
if (usernameSaveState === UsernameSaveState.GeneralError) {
|
||||
setUsernameEditState(UsernameEditState.ShowingErrorPopup);
|
||||
return;
|
||||
}
|
||||
if (usernameSaveState === UsernameSaveState.DeleteFailed) {
|
||||
setUsernameEditState(UsernameEditState.Editing);
|
||||
return;
|
||||
}
|
||||
|
||||
const state: never = usernameSaveState;
|
||||
log.error(
|
||||
`ProfileEditor: useEffect username didn't handle usernameSaveState '${state})'`
|
||||
);
|
||||
setEditState(EditState.None);
|
||||
}
|
||||
|
||||
export const ProfileEditor = ({
|
||||
aboutEmoji,
|
||||
aboutText,
|
||||
avatarPath,
|
||||
clearUsernameSave,
|
||||
color,
|
||||
conversationId,
|
||||
deleteAvatarFromDisk,
|
||||
familyName,
|
||||
firstName,
|
||||
i18n,
|
||||
isUsernameFlagEnabled,
|
||||
onEditStateChanged,
|
||||
onProfileChanged,
|
||||
onSetSkinTone,
|
||||
recentEmojis,
|
||||
replaceAvatar,
|
||||
saveAvatarToDisk,
|
||||
saveUsername,
|
||||
skinTone,
|
||||
userAvatarData,
|
||||
username,
|
||||
usernameSaveState,
|
||||
}: PropsType): JSX.Element => {
|
||||
const focusInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [editState, setEditState] = useState<EditState>(EditState.None);
|
||||
|
@ -127,6 +247,11 @@ export const ProfileEditor = ({
|
|||
aboutEmoji,
|
||||
aboutText,
|
||||
});
|
||||
const [newUsername, setNewUsername] = useState<string | undefined>(username);
|
||||
const [usernameError, setUsernameError] = useState<string | undefined>();
|
||||
const [usernameEditState, setUsernameEditState] = useState<UsernameEditState>(
|
||||
UsernameEditState.Editing
|
||||
);
|
||||
|
||||
const [avatarBuffer, setAvatarBuffer] = useState<Uint8Array | undefined>(
|
||||
undefined
|
||||
|
@ -138,11 +263,13 @@ export const ProfileEditor = ({
|
|||
firstName,
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
@ -154,6 +281,7 @@ export const ProfileEditor = ({
|
|||
[setStagedProfile, skinTone]
|
||||
);
|
||||
|
||||
// To make AvatarEditor re-render less often
|
||||
const handleAvatarChanged = useCallback(
|
||||
(avatar: Uint8Array | undefined) => {
|
||||
setAvatarBuffer(avatar);
|
||||
|
@ -181,6 +309,92 @@ export const ProfileEditor = ({
|
|||
onEditStateChanged(editState);
|
||||
}, [editState, onEditStateChanged]);
|
||||
|
||||
// If there's some in-process username save, or just an unacknowledged save
|
||||
// completion/error, we clear it out on mount, and then again on unmount.
|
||||
useEffect(() => {
|
||||
clearUsernameSave();
|
||||
|
||||
return () => {
|
||||
clearUsernameSave();
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
mapSaveStateToEditState({
|
||||
clearUsernameSave,
|
||||
i18n,
|
||||
setEditState,
|
||||
setUsernameEditState,
|
||||
setUsernameError,
|
||||
usernameSaveState,
|
||||
});
|
||||
}, [
|
||||
clearUsernameSave,
|
||||
i18n,
|
||||
setEditState,
|
||||
setUsernameEditState,
|
||||
setUsernameError,
|
||||
usernameSaveState,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Whenever the user makes a change, we'll get rid of the red error text
|
||||
setUsernameError(undefined);
|
||||
|
||||
// And then we'll check the validity of that new username
|
||||
const timeout = setTimeout(() => {
|
||||
const key = getUsernameInvalidKey(newUsername);
|
||||
if (key) {
|
||||
setUsernameError(i18n(key.key, key.replacements));
|
||||
}
|
||||
}, 1000);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [newUsername, i18n, setUsernameError]);
|
||||
|
||||
const isCurrentlySaving = usernameEditState === UsernameEditState.Saving;
|
||||
const shouldDisableUsernameSave = Boolean(
|
||||
newUsername === username ||
|
||||
!newUsername ||
|
||||
usernameError ||
|
||||
isCurrentlySaving
|
||||
);
|
||||
|
||||
const checkThenSaveUsername = () => {
|
||||
if (isCurrentlySaving) {
|
||||
log.error('checkThenSaveUsername: Already saving! Returning early');
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldDisableUsernameSave) {
|
||||
return;
|
||||
}
|
||||
|
||||
const invalidKey = getUsernameInvalidKey(newUsername);
|
||||
if (invalidKey) {
|
||||
setUsernameError(i18n(invalidKey.key, invalidKey.replacements));
|
||||
return;
|
||||
}
|
||||
|
||||
setUsernameError(undefined);
|
||||
setUsernameEditState(UsernameEditState.Saving);
|
||||
saveUsername({ username: newUsername, previousUsername: username });
|
||||
};
|
||||
|
||||
const deleteUsername = () => {
|
||||
if (isCurrentlySaving) {
|
||||
log.error('deleteUsername: Already saving! Returning early');
|
||||
return;
|
||||
}
|
||||
|
||||
setNewUsername(undefined);
|
||||
setUsernameError(undefined);
|
||||
setUsernameEditState(UsernameEditState.Saving);
|
||||
saveUsername({ username: undefined, previousUsername: username });
|
||||
};
|
||||
|
||||
// To make AvatarEditor re-render less often
|
||||
const handleAvatarLoaded = useCallback(avatar => {
|
||||
setAvatarBuffer(avatar);
|
||||
}, []);
|
||||
|
@ -397,6 +611,60 @@ export const ProfileEditor = ({
|
|||
</Modal.ButtonFooter>
|
||||
</>
|
||||
);
|
||||
} else if (editState === EditState.Username) {
|
||||
content = (
|
||||
<>
|
||||
<Input
|
||||
i18n={i18n}
|
||||
disabled={isCurrentlySaving}
|
||||
onChange={changedUsername => {
|
||||
setUsernameError(undefined);
|
||||
setNewUsername(changedUsername);
|
||||
}}
|
||||
onEnter={checkThenSaveUsername}
|
||||
placeholder={i18n('ProfileEditor--username--placeholder')}
|
||||
ref={focusInputRef}
|
||||
value={newUsername}
|
||||
/>
|
||||
|
||||
<div className="ProfileEditor__error">{usernameError}</div>
|
||||
<div className="ProfileEditor__info">
|
||||
<Intl i18n={i18n} id="ProfileEditor--username--helper" />
|
||||
</div>
|
||||
|
||||
<Modal.ButtonFooter>
|
||||
<Button
|
||||
disabled={isCurrentlySaving}
|
||||
onClick={() => {
|
||||
const handleCancel = () => {
|
||||
handleBack();
|
||||
setNewUsername(username);
|
||||
};
|
||||
|
||||
const hasChanges = newUsername !== username;
|
||||
if (hasChanges) {
|
||||
setConfirmDiscardAction(() => handleCancel);
|
||||
} else {
|
||||
handleCancel();
|
||||
}
|
||||
}}
|
||||
variant={ButtonVariant.Secondary}
|
||||
>
|
||||
{i18n('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={shouldDisableUsernameSave}
|
||||
onClick={checkThenSaveUsername}
|
||||
>
|
||||
{isCurrentlySaving ? (
|
||||
<Spinner size="20px" svgSize="small" direction="on-avatar" />
|
||||
) : (
|
||||
i18n('save')
|
||||
)}
|
||||
</Button>
|
||||
</Modal.ButtonFooter>
|
||||
</>
|
||||
);
|
||||
} else if (editState === EditState.None) {
|
||||
content = (
|
||||
<>
|
||||
|
@ -416,9 +684,7 @@ export const ProfileEditor = ({
|
|||
width: 80,
|
||||
}}
|
||||
/>
|
||||
|
||||
<hr className="ProfileEditor__divider" />
|
||||
|
||||
<PanelRow
|
||||
className="ProfileEditor__row"
|
||||
icon={
|
||||
|
@ -429,7 +695,40 @@ export const ProfileEditor = ({
|
|||
setEditState(EditState.ProfileName);
|
||||
}}
|
||||
/>
|
||||
|
||||
{isUsernameFlagEnabled ? (
|
||||
<PanelRow
|
||||
className="ProfileEditor__row"
|
||||
icon={
|
||||
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--username" />
|
||||
}
|
||||
label={username || i18n('ProfileEditor--username')}
|
||||
onClick={
|
||||
usernameEditState !== UsernameEditState.Saving
|
||||
? () => {
|
||||
setNewUsername(username);
|
||||
setEditState(EditState.Username);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
actions={
|
||||
username ? (
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('ProfileEditor--username--delete-username')}
|
||||
icon={
|
||||
usernameEditState === UsernameEditState.Saving
|
||||
? IconType.spinner
|
||||
: IconType.trash
|
||||
}
|
||||
disabled={usernameEditState === UsernameEditState.Saving}
|
||||
fakeButton
|
||||
onClick={() => {
|
||||
setUsernameEditState(UsernameEditState.ConfirmingDelete);
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<PanelRow
|
||||
className="ProfileEditor__row"
|
||||
icon={
|
||||
|
@ -446,9 +745,7 @@ export const ProfileEditor = ({
|
|||
setEditState(EditState.Bio);
|
||||
}}
|
||||
/>
|
||||
|
||||
<hr className="ProfileEditor__divider" />
|
||||
|
||||
<div className="ProfileEditor__info">
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
|
@ -474,6 +771,31 @@ export const ProfileEditor = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
{usernameEditState === UsernameEditState.ConfirmingDelete && (
|
||||
<ConfirmationDialog
|
||||
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>
|
||||
)}
|
||||
{usernameEditState === UsernameEditState.ShowingErrorPopup && (
|
||||
<ConfirmationDialog
|
||||
cancelText={i18n('ok')}
|
||||
cancelButtonVariant={ButtonVariant.Secondary}
|
||||
i18n={i18n}
|
||||
onClose={() => setUsernameEditState(UsernameEditState.Editing)}
|
||||
>
|
||||
{i18n('ProfileEditor--username--general-error')}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
{confirmDiscardAction && (
|
||||
<ConfirmDiscardDialog
|
||||
i18n={i18n}
|
||||
|
|
|
@ -31,13 +31,17 @@ export const ProfileEditorModal = ({
|
|||
toggleProfileEditorHasError,
|
||||
...restProps
|
||||
}: PropsType): JSX.Element => {
|
||||
const ModalTitles = {
|
||||
None: i18n('ProfileEditorModal--profile'),
|
||||
ProfileName: i18n('ProfileEditorModal--name'),
|
||||
Bio: i18n('ProfileEditorModal--about'),
|
||||
const MODAL_TITLES_BY_EDIT_STATE: Record<EditState, string> = {
|
||||
[EditState.BetterAvatar]: i18n('ProfileEditorModal--avatar'),
|
||||
[EditState.Bio]: i18n('ProfileEditorModal--about'),
|
||||
[EditState.None]: i18n('ProfileEditorModal--profile'),
|
||||
[EditState.ProfileName]: i18n('ProfileEditorModal--name'),
|
||||
[EditState.Username]: i18n('ProfileEditorModal--username'),
|
||||
};
|
||||
|
||||
const [modalTitle, setModalTitle] = useState(ModalTitles.None);
|
||||
const [modalTitle, setModalTitle] = useState(
|
||||
MODAL_TITLES_BY_EDIT_STATE[EditState.None]
|
||||
);
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
|
@ -64,17 +68,9 @@ export const ProfileEditorModal = ({
|
|||
{...restProps}
|
||||
i18n={i18n}
|
||||
onEditStateChanged={editState => {
|
||||
if (editState === EditState.None) {
|
||||
setModalTitle(ModalTitles.None);
|
||||
} else if (editState === EditState.ProfileName) {
|
||||
setModalTitle(ModalTitles.ProfileName);
|
||||
} else if (editState === EditState.Bio) {
|
||||
setModalTitle(ModalTitles.Bio);
|
||||
}
|
||||
}}
|
||||
onProfileChanged={(profileData, avatarBuffer) => {
|
||||
myProfileChanged(profileData, avatarBuffer);
|
||||
setModalTitle(MODAL_TITLES_BY_EDIT_STATE[editState]);
|
||||
}}
|
||||
onProfileChanged={myProfileChanged}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
/>
|
||||
</Modal>
|
||||
|
|
|
@ -18,6 +18,7 @@ export type PropsType = {
|
|||
label: string;
|
||||
onClick: () => unknown;
|
||||
};
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export const Toast = memo(
|
||||
|
@ -27,6 +28,7 @@ export const Toast = memo(
|
|||
className,
|
||||
disableCloseOnClick = false,
|
||||
onClose,
|
||||
style,
|
||||
timeout = 8000,
|
||||
toastAction,
|
||||
}: PropsType): JSX.Element | null => {
|
||||
|
@ -77,6 +79,7 @@ export const Toast = memo(
|
|||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={style}
|
||||
>
|
||||
<div className="Toast__content">{children}</div>
|
||||
{toastAction && (
|
||||
|
|
23
ts/components/ToastFailedToDeleteUsername.stories.tsx
Normal file
23
ts/components/ToastFailedToDeleteUsername.stories.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { ToastFailedToDeleteUsername } from './ToastFailedToDeleteUsername';
|
||||
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const defaultProps = {
|
||||
i18n,
|
||||
onClose: action('onClose'),
|
||||
};
|
||||
|
||||
const story = storiesOf('Components/ToastFailedToDeleteUsername', module);
|
||||
|
||||
story.add('ToastFailedToDeleteUsername', () => (
|
||||
<ToastFailedToDeleteUsername {...defaultProps} />
|
||||
));
|
22
ts/components/ToastFailedToDeleteUsername.tsx
Normal file
22
ts/components/ToastFailedToDeleteUsername.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { Toast } from './Toast';
|
||||
|
||||
type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
onClose: () => unknown;
|
||||
};
|
||||
|
||||
export const ToastFailedToDeleteUsername = ({
|
||||
i18n,
|
||||
onClose,
|
||||
}: PropsType): JSX.Element => {
|
||||
return (
|
||||
<Toast onClose={onClose} style={{ maxWidth: '280px' }}>
|
||||
{i18n('ProfileEditor--username--delete-general-error')}
|
||||
</Toast>
|
||||
);
|
||||
};
|
|
@ -23,8 +23,7 @@ export const ToastFileSize = ({
|
|||
}: PropsType): JSX.Element => {
|
||||
return (
|
||||
<Toast onClose={onClose}>
|
||||
{i18n('fileSizeWarning')}
|
||||
{limit}
|
||||
{i18n('fileSizeWarning')} {limit}
|
||||
{units}
|
||||
</Toast>
|
||||
);
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Spinner } from '../../Spinner';
|
||||
import { bemGenerator } from './util';
|
||||
|
||||
export enum IconType {
|
||||
|
@ -19,6 +20,7 @@ export enum IconType {
|
|||
'notifications' = 'notifications',
|
||||
'reset' = 'reset',
|
||||
'share' = 'share',
|
||||
'spinner' = 'spinner',
|
||||
'timer' = 'timer',
|
||||
'trash' = 'trash',
|
||||
'verify' = 'verify',
|
||||
|
@ -28,6 +30,7 @@ export type Props = {
|
|||
ariaLabel: string;
|
||||
disabled?: boolean;
|
||||
icon: IconType;
|
||||
fakeButton?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
|
@ -37,17 +40,50 @@ export const ConversationDetailsIcon: React.ComponentType<Props> = ({
|
|||
ariaLabel,
|
||||
disabled,
|
||||
icon,
|
||||
fakeButton,
|
||||
onClick,
|
||||
}) => {
|
||||
const iconClassName = bem('icon', icon);
|
||||
const content = (
|
||||
<div
|
||||
className={classNames(
|
||||
iconClassName,
|
||||
disabled && `${iconClassName}--disabled`
|
||||
)}
|
||||
/>
|
||||
);
|
||||
let content: React.ReactChild;
|
||||
|
||||
if (icon === IconType.spinner) {
|
||||
content = <Spinner svgSize="small" size="24" />;
|
||||
} else {
|
||||
const iconClassName = bem('icon', icon);
|
||||
content = (
|
||||
<div
|
||||
className={classNames(
|
||||
iconClassName,
|
||||
disabled && `${iconClassName}--disabled`
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// We need this because sometimes this component is inside other buttons
|
||||
if (onClick && fakeButton && !disabled) {
|
||||
return (
|
||||
<div
|
||||
aria-label={ariaLabel}
|
||||
role="button"
|
||||
className={bem('button')}
|
||||
tabIndex={0}
|
||||
onClick={(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
onKeyDown={(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
|
@ -56,7 +92,11 @@ export const ConversationDetailsIcon: React.ComponentType<Props> = ({
|
|||
className={bem('button')}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onClick={(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue