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
|
@ -6180,6 +6180,70 @@
|
||||||
"message": "About",
|
"message": "About",
|
||||||
"description": "Default text for about field"
|
"description": "Default text for about field"
|
||||||
},
|
},
|
||||||
|
"ProfileEditor--username": {
|
||||||
|
"message": "Username",
|
||||||
|
"description": "Default text for username field"
|
||||||
|
},
|
||||||
|
"ProfileEditor--username--placeholder": {
|
||||||
|
"message": "Enter a username",
|
||||||
|
"description": "Placeholder for the username field"
|
||||||
|
},
|
||||||
|
"ProfileEditor--username--helper": {
|
||||||
|
"message": "Usernames on Signal are optional. If you choose to create a username and make it searchable, other Signal users will be able to find you by this username and contact you without knowing your phone number.",
|
||||||
|
"description": "Shown on the edit username screen"
|
||||||
|
},
|
||||||
|
"ProfileEditor--username--check-characters": {
|
||||||
|
"message": "Usernames may only contain a-z, 0-9 and _",
|
||||||
|
"description": "Shown if user has attempted to use forbidden characters"
|
||||||
|
},
|
||||||
|
"ProfileEditor--username--check-starting-character": {
|
||||||
|
"message": "Usernames may not begin with a number.",
|
||||||
|
"description": "Shown if user has attempted to begin their username with a number"
|
||||||
|
},
|
||||||
|
"ProfileEditor--username--check-character-min": {
|
||||||
|
"message": "Usernames must have a least $min$ characters.",
|
||||||
|
"description": "Shown if user has attempted to enter a username with too few characters - currently min is 3",
|
||||||
|
"placeholders": {
|
||||||
|
"min": {
|
||||||
|
"content": "$1",
|
||||||
|
"example": "3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ProfileEditor--username--check-character-max": {
|
||||||
|
"message": "Usernames must have at most $max$ characters.",
|
||||||
|
"description": "Shown if user has attempted to enter a username with too many characters - currently min is 25",
|
||||||
|
"placeholders": {
|
||||||
|
"max": {
|
||||||
|
"content": "$1",
|
||||||
|
"example": "25"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ProfileEditor--username--check-username-taken": {
|
||||||
|
"message": "This username is taken.",
|
||||||
|
"description": "Shown if user has attempted to save a username which is not available"
|
||||||
|
},
|
||||||
|
"ProfileEditor--username--general-error": {
|
||||||
|
"message": "Your username couldn’t be saved. Check your connection and try again.",
|
||||||
|
"description": "Shown if something unknown has gone wrong with username save."
|
||||||
|
},
|
||||||
|
"ProfileEditor--username--delete-general-error": {
|
||||||
|
"message": "Your username couldn’t be removed. Check your connection and try again.",
|
||||||
|
"description": "Shown if something unknown has gone wrong with username delete."
|
||||||
|
},
|
||||||
|
"ProfileEditor--username--delete-username": {
|
||||||
|
"message": "Delete username",
|
||||||
|
"description": "Shown as aria label for trash icon next to username"
|
||||||
|
},
|
||||||
|
"ProfileEditor--username--confirm-delete-body": {
|
||||||
|
"message": "This will remove your username, allowing other users to claim it. Are you sure?",
|
||||||
|
"description": "Shown in dialog body if user has saved an empty string to delete their username"
|
||||||
|
},
|
||||||
|
"ProfileEditor--username--confirm-delete-button": {
|
||||||
|
"message": "Delete",
|
||||||
|
"description": "Shown in dialog button if user has saved an empty string to delete their username"
|
||||||
|
},
|
||||||
"ProfileEditor--about-placeholder": {
|
"ProfileEditor--about-placeholder": {
|
||||||
"message": "Write something about yourself...",
|
"message": "Write something about yourself...",
|
||||||
"description": "Placeholder text for about input field"
|
"description": "Placeholder text for about input field"
|
||||||
|
@ -6242,6 +6306,14 @@
|
||||||
"message": "About",
|
"message": "About",
|
||||||
"description": "Title for about editing"
|
"description": "Title for about editing"
|
||||||
},
|
},
|
||||||
|
"ProfileEditorModal--avatar": {
|
||||||
|
"message": "Your Avatar",
|
||||||
|
"description": "Title for profile avatar editing"
|
||||||
|
},
|
||||||
|
"ProfileEditorModal--username": {
|
||||||
|
"message": "Username",
|
||||||
|
"description": "Title for username editing"
|
||||||
|
},
|
||||||
"ProfileEditorModal--error": {
|
"ProfileEditorModal--error": {
|
||||||
"message": "Your profile could not be updated. Please try again.",
|
"message": "Your profile could not be updated. Please try again.",
|
||||||
"description": "Error message when something goes wrong updating your profile."
|
"description": "Error message when something goes wrong updating your profile."
|
||||||
|
|
|
@ -147,7 +147,7 @@
|
||||||
"react-sortable-hoc": "1.9.1",
|
"react-sortable-hoc": "1.9.1",
|
||||||
"react-virtualized": "9.21.0",
|
"react-virtualized": "9.21.0",
|
||||||
"read-last-lines": "1.8.0",
|
"read-last-lines": "1.8.0",
|
||||||
"redux": "4.0.1",
|
"redux": "4.0.2",
|
||||||
"redux-logger": "3.0.6",
|
"redux-logger": "3.0.6",
|
||||||
"redux-promise-middleware": "6.1.0",
|
"redux-promise-middleware": "6.1.0",
|
||||||
"redux-thunk": "2.3.0",
|
"redux-thunk": "2.3.0",
|
||||||
|
|
|
@ -33,6 +33,12 @@
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center center;
|
background-position: center center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include keyboard-mode {
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0 0 0 3px $color-ultramarine;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__group {
|
&__group {
|
||||||
|
|
|
@ -165,8 +165,12 @@
|
||||||
padding: none;
|
padding: none;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
@include mouse-mode {
|
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
|
.ConversationDetails-icon__icon::after {
|
||||||
|
@include keyboard-mode {
|
||||||
|
background-color: $color-ultramarine;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,6 +35,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--username {
|
||||||
|
&::after {
|
||||||
|
-webkit-mask: url(../images/icons/v2/at-24.svg) no-repeat center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&--bio {
|
&--bio {
|
||||||
&::after {
|
&::after {
|
||||||
-webkit-mask: url(../images/icons/v2/compose-outline-24.svg) no-repeat
|
-webkit-mask: url(../images/icons/v2/compose-outline-24.svg) no-repeat
|
||||||
|
@ -71,6 +77,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__error {
|
||||||
|
@include font-body-2;
|
||||||
|
margin: 16px 0;
|
||||||
|
|
||||||
|
color: $color-accent-red;
|
||||||
|
}
|
||||||
|
|
||||||
&__info {
|
&__info {
|
||||||
@include font-body-2;
|
@include font-body-2;
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
|
|
|
@ -25,6 +25,7 @@ export type ConfigKeyType =
|
||||||
| 'desktop.sendSenderKey3'
|
| 'desktop.sendSenderKey3'
|
||||||
| 'desktop.storage'
|
| 'desktop.storage'
|
||||||
| 'desktop.storageWrite3'
|
| 'desktop.storageWrite3'
|
||||||
|
| 'desktop.usernames'
|
||||||
| 'global.calling.maxGroupCallRingSize'
|
| 'global.calling.maxGroupCallRingSize'
|
||||||
| 'global.groupsv2.groupSizeHardLimit'
|
| 'global.groupsv2.groupSizeHardLimit'
|
||||||
| 'global.groupsv2.maxGroupSize';
|
| 'global.groupsv2.maxGroupSize';
|
||||||
|
@ -34,7 +35,9 @@ type ConfigValueType = {
|
||||||
enabledAt?: number;
|
enabledAt?: number;
|
||||||
value?: unknown;
|
value?: unknown;
|
||||||
};
|
};
|
||||||
export type ConfigMapType = { [key: string]: ConfigValueType };
|
export type ConfigMapType = {
|
||||||
|
[key in ConfigKeyType]?: ConfigValueType;
|
||||||
|
};
|
||||||
type ConfigListenerType = (value: ConfigValueType) => unknown;
|
type ConfigListenerType = (value: ConfigValueType) => unknown;
|
||||||
type ConfigListenersMapType = {
|
type ConfigListenersMapType = {
|
||||||
[key: string]: Array<ConfigListenerType>;
|
[key: string]: Array<ConfigListenerType>;
|
||||||
|
|
|
@ -86,6 +86,7 @@ import { getSendOptions } from './util/getSendOptions';
|
||||||
import { BackOff, FIBONACCI_TIMEOUTS } from './util/BackOff';
|
import { BackOff, FIBONACCI_TIMEOUTS } from './util/BackOff';
|
||||||
import { handleMessageSend } from './util/handleMessageSend';
|
import { handleMessageSend } from './util/handleMessageSend';
|
||||||
import { AppViewType } from './state/ducks/app';
|
import { AppViewType } from './state/ducks/app';
|
||||||
|
import { UsernameSaveState } from './state/ducks/conversationsEnums';
|
||||||
import { isIncoming } from './state/selectors/message';
|
import { isIncoming } from './state/selectors/message';
|
||||||
import { actionCreators } from './state/actions';
|
import { actionCreators } from './state/actions';
|
||||||
import { Deletes } from './messageModifiers/Deletes';
|
import { Deletes } from './messageModifiers/Deletes';
|
||||||
|
@ -950,6 +951,7 @@ export async function startApp(): Promise<void> {
|
||||||
selectedConversationPanelDepth: 0,
|
selectedConversationPanelDepth: 0,
|
||||||
selectedConversationTitle: '',
|
selectedConversationTitle: '',
|
||||||
showArchived: false,
|
showArchived: false,
|
||||||
|
usernameSaveState: UsernameSaveState.None,
|
||||||
},
|
},
|
||||||
emojis: getEmojiReducerState(),
|
emojis: getEmojiReducerState(),
|
||||||
items: window.storage.getItemsState(),
|
items: window.storage.getItemsState(),
|
||||||
|
|
|
@ -136,7 +136,18 @@ export const AvatarPreview = ({
|
||||||
|
|
||||||
const isLoading = imageStatus === ImageStatus.Loading;
|
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 = {
|
const componentStyle = {
|
||||||
...style,
|
...style,
|
||||||
};
|
};
|
||||||
|
|
|
@ -28,6 +28,7 @@ export type OwnProps = {
|
||||||
readonly title?: string | React.ReactNode;
|
readonly title?: string | React.ReactNode;
|
||||||
readonly theme?: Theme;
|
readonly theme?: Theme;
|
||||||
readonly hasXButton?: boolean;
|
readonly hasXButton?: boolean;
|
||||||
|
readonly cancelButtonVariant?: ButtonVariant;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Props = OwnProps;
|
export type Props = OwnProps;
|
||||||
|
@ -64,6 +65,7 @@ export const ConfirmationDialog = React.memo(
|
||||||
theme,
|
theme,
|
||||||
title,
|
title,
|
||||||
hasXButton,
|
hasXButton,
|
||||||
|
cancelButtonVariant,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { close, overlayStyles, modalStyles } = useAnimated(onClose, {
|
const { close, overlayStyles, modalStyles } = useAnimated(onClose, {
|
||||||
getFrom: () => ({ opacity: 0, transform: 'scale(0.25)' }),
|
getFrom: () => ({ opacity: 0, transform: 'scale(0.25)' }),
|
||||||
|
@ -104,7 +106,8 @@ export const ConfirmationDialog = React.memo(
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
ref={focusRef}
|
ref={focusRef}
|
||||||
variant={
|
variant={
|
||||||
hasActions ? ButtonVariant.Secondary : ButtonVariant.Primary
|
cancelButtonVariant ||
|
||||||
|
(hasActions ? ButtonVariant.Secondary : ButtonVariant.Primary)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{cancelText || i18n('confirmation-dialog--Cancel')}
|
{cancelText || i18n('confirmation-dialog--Cancel')}
|
||||||
|
|
|
@ -29,6 +29,7 @@ export type PropsType = {
|
||||||
maxByteCount?: number;
|
maxByteCount?: number;
|
||||||
moduleClassName?: string;
|
moduleClassName?: string;
|
||||||
onChange: (value: string) => unknown;
|
onChange: (value: string) => unknown;
|
||||||
|
onEnter?: () => unknown;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
whenToShowRemainingCount?: number;
|
whenToShowRemainingCount?: number;
|
||||||
|
@ -68,6 +69,7 @@ export const Input = forwardRef<
|
||||||
maxByteCount = 0,
|
maxByteCount = 0,
|
||||||
moduleClassName,
|
moduleClassName,
|
||||||
onChange,
|
onChange,
|
||||||
|
onEnter,
|
||||||
placeholder,
|
placeholder,
|
||||||
value = '',
|
value = '',
|
||||||
whenToShowRemainingCount = Infinity,
|
whenToShowRemainingCount = Infinity,
|
||||||
|
@ -99,7 +101,12 @@ export const Input = forwardRef<
|
||||||
}
|
}
|
||||||
}, [expandable]);
|
}, [expandable]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(() => {
|
const handleKeyDown = useCallback(
|
||||||
|
event => {
|
||||||
|
if (onEnter && event.key === 'Enter') {
|
||||||
|
onEnter();
|
||||||
|
}
|
||||||
|
|
||||||
const inputEl = innerRef.current;
|
const inputEl = innerRef.current;
|
||||||
if (!inputEl) {
|
if (!inputEl) {
|
||||||
return;
|
return;
|
||||||
|
@ -107,7 +114,9 @@ export const Input = forwardRef<
|
||||||
|
|
||||||
valueOnKeydownRef.current = inputEl.value;
|
valueOnKeydownRef.current = inputEl.value;
|
||||||
selectionStartOnKeydownRef.current = inputEl.selectionStart || 0;
|
selectionStartOnKeydownRef.current = inputEl.selectionStart || 0;
|
||||||
}, []);
|
},
|
||||||
|
[onEnter]
|
||||||
|
);
|
||||||
|
|
||||||
const handleChange = useCallback(() => {
|
const handleChange = useCallback(() => {
|
||||||
const inputEl = innerRef.current;
|
const inputEl = innerRef.current;
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { storiesOf } from '@storybook/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 { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
import type { PropsType } from './ProfileEditor';
|
import type { PropsType } from './ProfileEditor';
|
||||||
|
@ -16,6 +16,7 @@ import {
|
||||||
getLastName,
|
getLastName,
|
||||||
} from '../test-both/helpers/getDefaultConversation';
|
} from '../test-both/helpers/getDefaultConversation';
|
||||||
import { getRandomColor } from '../test-both/helpers/getRandomColor';
|
import { getRandomColor } from '../test-both/helpers/getRandomColor';
|
||||||
|
import { UsernameSaveState } from '../state/ducks/conversationsEnums';
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
@ -25,20 +26,34 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||||
aboutEmoji: overrideProps.aboutEmoji,
|
aboutEmoji: overrideProps.aboutEmoji,
|
||||||
aboutText: text('about', overrideProps.aboutText || ''),
|
aboutText: text('about', overrideProps.aboutText || ''),
|
||||||
avatarPath: overrideProps.avatarPath,
|
avatarPath: overrideProps.avatarPath,
|
||||||
|
clearUsernameSave: action('clearUsernameSave'),
|
||||||
conversationId: '123',
|
conversationId: '123',
|
||||||
color: overrideProps.color || getRandomColor(),
|
color: overrideProps.color || getRandomColor(),
|
||||||
deleteAvatarFromDisk: action('deleteAvatarFromDisk'),
|
deleteAvatarFromDisk: action('deleteAvatarFromDisk'),
|
||||||
familyName: overrideProps.familyName,
|
familyName: overrideProps.familyName,
|
||||||
firstName: text('firstName', overrideProps.firstName || getFirstName()),
|
firstName: text('firstName', overrideProps.firstName || getFirstName()),
|
||||||
i18n,
|
i18n,
|
||||||
|
isUsernameFlagEnabled: boolean(
|
||||||
|
'isUsernameFlagEnabled',
|
||||||
|
overrideProps.isUsernameFlagEnabled !== undefined
|
||||||
|
? overrideProps.isUsernameFlagEnabled
|
||||||
|
: false
|
||||||
|
),
|
||||||
onEditStateChanged: action('onEditStateChanged'),
|
onEditStateChanged: action('onEditStateChanged'),
|
||||||
onProfileChanged: action('onProfileChanged'),
|
onProfileChanged: action('onProfileChanged'),
|
||||||
onSetSkinTone: overrideProps.onSetSkinTone || action('onSetSkinTone'),
|
onSetSkinTone: overrideProps.onSetSkinTone || action('onSetSkinTone'),
|
||||||
recentEmojis: [],
|
recentEmojis: [],
|
||||||
replaceAvatar: action('replaceAvatar'),
|
replaceAvatar: action('replaceAvatar'),
|
||||||
saveAvatarToDisk: action('saveAvatarToDisk'),
|
saveAvatarToDisk: action('saveAvatarToDisk'),
|
||||||
|
saveUsername: action('saveUsername'),
|
||||||
skinTone: overrideProps.skinTone || 0,
|
skinTone: overrideProps.skinTone || 0,
|
||||||
userAvatarData: [],
|
userAvatarData: [],
|
||||||
|
username: overrideProps.username,
|
||||||
|
usernameSaveState: select(
|
||||||
|
'usernameSaveState',
|
||||||
|
Object.values(UsernameSaveState),
|
||||||
|
overrideProps.usernameSaveState || UsernameSaveState.None
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
stories.add('Full Set', () => {
|
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 React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import * as log from '../logging/log';
|
||||||
import type { AvatarColorType } from '../types/Colors';
|
import type { AvatarColorType } from '../types/Colors';
|
||||||
import { AvatarColors } from '../types/Colors';
|
import { AvatarColors } from '../types/Colors';
|
||||||
import type {
|
import type {
|
||||||
|
@ -21,18 +22,33 @@ import { EmojiButton } from './emoji/EmojiButton';
|
||||||
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||||
import { Input } from './Input';
|
import { Input } from './Input';
|
||||||
import { Intl } from './Intl';
|
import { Intl } from './Intl';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType, ReplacementValuesType } from '../types/Util';
|
||||||
import { Modal } from './Modal';
|
import { Modal } from './Modal';
|
||||||
import { PanelRow } from './conversation/conversation-details/PanelRow';
|
import { PanelRow } from './conversation/conversation-details/PanelRow';
|
||||||
import type { ProfileDataType } from '../state/ducks/conversations';
|
import type { ProfileDataType } from '../state/ducks/conversations';
|
||||||
import { getEmojiData, unifiedToEmoji } from './emoji/lib';
|
import { getEmojiData, unifiedToEmoji } from './emoji/lib';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
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 {
|
export enum EditState {
|
||||||
None = 'None',
|
None = 'None',
|
||||||
BetterAvatar = 'BetterAvatar',
|
BetterAvatar = 'BetterAvatar',
|
||||||
ProfileName = 'ProfileName',
|
ProfileName = 'ProfileName',
|
||||||
Bio = 'Bio',
|
Bio = 'Bio',
|
||||||
|
Username = 'Username',
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UsernameEditState {
|
||||||
|
Editing = 'Editing',
|
||||||
|
ConfirmingDelete = 'ConfirmingDelete',
|
||||||
|
ShowingErrorPopup = 'ShowingErrorPopup',
|
||||||
|
Saving = 'Saving',
|
||||||
}
|
}
|
||||||
|
|
||||||
type PropsExternalType = {
|
type PropsExternalType = {
|
||||||
|
@ -52,14 +68,22 @@ export type PropsDataType = {
|
||||||
familyName?: string;
|
familyName?: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
isUsernameFlagEnabled: boolean;
|
||||||
|
usernameSaveState: UsernameSaveState;
|
||||||
userAvatarData: Array<AvatarDataType>;
|
userAvatarData: Array<AvatarDataType>;
|
||||||
|
username?: string;
|
||||||
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
|
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
|
||||||
|
|
||||||
type PropsActionType = {
|
type PropsActionType = {
|
||||||
|
clearUsernameSave: () => unknown;
|
||||||
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
|
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
|
||||||
onSetSkinTone: (tone: number) => unknown;
|
onSetSkinTone: (tone: number) => unknown;
|
||||||
replaceAvatar: ReplaceAvatarActionType;
|
replaceAvatar: ReplaceAvatarActionType;
|
||||||
saveAvatarToDisk: SaveAvatarToDiskActionType;
|
saveAvatarToDisk: SaveAvatarToDiskActionType;
|
||||||
|
saveUsername: (options: {
|
||||||
|
username: string | undefined;
|
||||||
|
previousUsername: string | undefined;
|
||||||
|
}) => unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
|
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 = ({
|
export const ProfileEditor = ({
|
||||||
aboutEmoji,
|
aboutEmoji,
|
||||||
aboutText,
|
aboutText,
|
||||||
avatarPath,
|
avatarPath,
|
||||||
|
clearUsernameSave,
|
||||||
color,
|
color,
|
||||||
conversationId,
|
conversationId,
|
||||||
deleteAvatarFromDisk,
|
deleteAvatarFromDisk,
|
||||||
familyName,
|
familyName,
|
||||||
firstName,
|
firstName,
|
||||||
i18n,
|
i18n,
|
||||||
|
isUsernameFlagEnabled,
|
||||||
onEditStateChanged,
|
onEditStateChanged,
|
||||||
onProfileChanged,
|
onProfileChanged,
|
||||||
onSetSkinTone,
|
onSetSkinTone,
|
||||||
recentEmojis,
|
recentEmojis,
|
||||||
replaceAvatar,
|
replaceAvatar,
|
||||||
saveAvatarToDisk,
|
saveAvatarToDisk,
|
||||||
|
saveUsername,
|
||||||
skinTone,
|
skinTone,
|
||||||
userAvatarData,
|
userAvatarData,
|
||||||
|
username,
|
||||||
|
usernameSaveState,
|
||||||
}: PropsType): JSX.Element => {
|
}: PropsType): JSX.Element => {
|
||||||
const focusInputRef = useRef<HTMLInputElement | null>(null);
|
const focusInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [editState, setEditState] = useState<EditState>(EditState.None);
|
const [editState, setEditState] = useState<EditState>(EditState.None);
|
||||||
|
@ -127,6 +247,11 @@ export const ProfileEditor = ({
|
||||||
aboutEmoji,
|
aboutEmoji,
|
||||||
aboutText,
|
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>(
|
const [avatarBuffer, setAvatarBuffer] = useState<Uint8Array | undefined>(
|
||||||
undefined
|
undefined
|
||||||
|
@ -138,11 +263,13 @@ export const ProfileEditor = ({
|
||||||
firstName,
|
firstName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// To make AvatarEditor re-render less often
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
setEditState(EditState.None);
|
setEditState(EditState.None);
|
||||||
onEditStateChanged(EditState.None);
|
onEditStateChanged(EditState.None);
|
||||||
}, [setEditState, onEditStateChanged]);
|
}, [setEditState, onEditStateChanged]);
|
||||||
|
|
||||||
|
// To make EmojiButton re-render less often
|
||||||
const setAboutEmoji = useCallback(
|
const setAboutEmoji = useCallback(
|
||||||
(ev: EmojiPickDataType) => {
|
(ev: EmojiPickDataType) => {
|
||||||
const emojiData = getEmojiData(ev.shortName, skinTone);
|
const emojiData = getEmojiData(ev.shortName, skinTone);
|
||||||
|
@ -154,6 +281,7 @@ export const ProfileEditor = ({
|
||||||
[setStagedProfile, skinTone]
|
[setStagedProfile, skinTone]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// To make AvatarEditor re-render less often
|
||||||
const handleAvatarChanged = useCallback(
|
const handleAvatarChanged = useCallback(
|
||||||
(avatar: Uint8Array | undefined) => {
|
(avatar: Uint8Array | undefined) => {
|
||||||
setAvatarBuffer(avatar);
|
setAvatarBuffer(avatar);
|
||||||
|
@ -181,6 +309,92 @@ export const ProfileEditor = ({
|
||||||
onEditStateChanged(editState);
|
onEditStateChanged(editState);
|
||||||
}, [editState, onEditStateChanged]);
|
}, [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 => {
|
const handleAvatarLoaded = useCallback(avatar => {
|
||||||
setAvatarBuffer(avatar);
|
setAvatarBuffer(avatar);
|
||||||
}, []);
|
}, []);
|
||||||
|
@ -397,6 +611,60 @@ export const ProfileEditor = ({
|
||||||
</Modal.ButtonFooter>
|
</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) {
|
} else if (editState === EditState.None) {
|
||||||
content = (
|
content = (
|
||||||
<>
|
<>
|
||||||
|
@ -416,9 +684,7 @@ export const ProfileEditor = ({
|
||||||
width: 80,
|
width: 80,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<hr className="ProfileEditor__divider" />
|
<hr className="ProfileEditor__divider" />
|
||||||
|
|
||||||
<PanelRow
|
<PanelRow
|
||||||
className="ProfileEditor__row"
|
className="ProfileEditor__row"
|
||||||
icon={
|
icon={
|
||||||
|
@ -429,7 +695,40 @@ export const ProfileEditor = ({
|
||||||
setEditState(EditState.ProfileName);
|
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
|
<PanelRow
|
||||||
className="ProfileEditor__row"
|
className="ProfileEditor__row"
|
||||||
icon={
|
icon={
|
||||||
|
@ -446,9 +745,7 @@ export const ProfileEditor = ({
|
||||||
setEditState(EditState.Bio);
|
setEditState(EditState.Bio);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<hr className="ProfileEditor__divider" />
|
<hr className="ProfileEditor__divider" />
|
||||||
|
|
||||||
<div className="ProfileEditor__info">
|
<div className="ProfileEditor__info">
|
||||||
<Intl
|
<Intl
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
@ -474,6 +771,31 @@ export const ProfileEditor = ({
|
||||||
|
|
||||||
return (
|
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 && (
|
{confirmDiscardAction && (
|
||||||
<ConfirmDiscardDialog
|
<ConfirmDiscardDialog
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
|
|
@ -31,13 +31,17 @@ export const ProfileEditorModal = ({
|
||||||
toggleProfileEditorHasError,
|
toggleProfileEditorHasError,
|
||||||
...restProps
|
...restProps
|
||||||
}: PropsType): JSX.Element => {
|
}: PropsType): JSX.Element => {
|
||||||
const ModalTitles = {
|
const MODAL_TITLES_BY_EDIT_STATE: Record<EditState, string> = {
|
||||||
None: i18n('ProfileEditorModal--profile'),
|
[EditState.BetterAvatar]: i18n('ProfileEditorModal--avatar'),
|
||||||
ProfileName: i18n('ProfileEditorModal--name'),
|
[EditState.Bio]: i18n('ProfileEditorModal--about'),
|
||||||
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) {
|
if (hasError) {
|
||||||
return (
|
return (
|
||||||
|
@ -64,17 +68,9 @@ export const ProfileEditorModal = ({
|
||||||
{...restProps}
|
{...restProps}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onEditStateChanged={editState => {
|
onEditStateChanged={editState => {
|
||||||
if (editState === EditState.None) {
|
setModalTitle(MODAL_TITLES_BY_EDIT_STATE[editState]);
|
||||||
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);
|
|
||||||
}}
|
}}
|
||||||
|
onProfileChanged={myProfileChanged}
|
||||||
onSetSkinTone={onSetSkinTone}
|
onSetSkinTone={onSetSkinTone}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
@ -18,6 +18,7 @@ export type PropsType = {
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => unknown;
|
onClick: () => unknown;
|
||||||
};
|
};
|
||||||
|
style?: React.CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Toast = memo(
|
export const Toast = memo(
|
||||||
|
@ -27,6 +28,7 @@ export const Toast = memo(
|
||||||
className,
|
className,
|
||||||
disableCloseOnClick = false,
|
disableCloseOnClick = false,
|
||||||
onClose,
|
onClose,
|
||||||
|
style,
|
||||||
timeout = 8000,
|
timeout = 8000,
|
||||||
toastAction,
|
toastAction,
|
||||||
}: PropsType): JSX.Element | null => {
|
}: PropsType): JSX.Element | null => {
|
||||||
|
@ -77,6 +79,7 @@ export const Toast = memo(
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
style={style}
|
||||||
>
|
>
|
||||||
<div className="Toast__content">{children}</div>
|
<div className="Toast__content">{children}</div>
|
||||||
{toastAction && (
|
{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 => {
|
}: PropsType): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<Toast onClose={onClose}>
|
<Toast onClose={onClose}>
|
||||||
{i18n('fileSizeWarning')}
|
{i18n('fileSizeWarning')} {limit}
|
||||||
{limit}
|
|
||||||
{units}
|
{units}
|
||||||
</Toast>
|
</Toast>
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { Spinner } from '../../Spinner';
|
||||||
import { bemGenerator } from './util';
|
import { bemGenerator } from './util';
|
||||||
|
|
||||||
export enum IconType {
|
export enum IconType {
|
||||||
|
@ -19,6 +20,7 @@ export enum IconType {
|
||||||
'notifications' = 'notifications',
|
'notifications' = 'notifications',
|
||||||
'reset' = 'reset',
|
'reset' = 'reset',
|
||||||
'share' = 'share',
|
'share' = 'share',
|
||||||
|
'spinner' = 'spinner',
|
||||||
'timer' = 'timer',
|
'timer' = 'timer',
|
||||||
'trash' = 'trash',
|
'trash' = 'trash',
|
||||||
'verify' = 'verify',
|
'verify' = 'verify',
|
||||||
|
@ -28,6 +30,7 @@ export type Props = {
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
icon: IconType;
|
icon: IconType;
|
||||||
|
fakeButton?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -37,10 +40,16 @@ export const ConversationDetailsIcon: React.ComponentType<Props> = ({
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
disabled,
|
disabled,
|
||||||
icon,
|
icon,
|
||||||
|
fakeButton,
|
||||||
onClick,
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
|
let content: React.ReactChild;
|
||||||
|
|
||||||
|
if (icon === IconType.spinner) {
|
||||||
|
content = <Spinner svgSize="small" size="24" />;
|
||||||
|
} else {
|
||||||
const iconClassName = bem('icon', icon);
|
const iconClassName = bem('icon', icon);
|
||||||
const content = (
|
content = (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
iconClassName,
|
iconClassName,
|
||||||
|
@ -48,6 +57,33 @@ export const ConversationDetailsIcon: React.ComponentType<Props> = ({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
if (onClick) {
|
||||||
return (
|
return (
|
||||||
|
@ -56,7 +92,11 @@ export const ConversationDetailsIcon: React.ComponentType<Props> = ({
|
||||||
className={bem('button')}
|
className={bem('button')}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</button>
|
</button>
|
||||||
|
|
1
ts/model-types.d.ts
vendored
1
ts/model-types.d.ts
vendored
|
@ -270,6 +270,7 @@ export type ConversationAttributesType = {
|
||||||
verified?: number;
|
verified?: number;
|
||||||
profileLastFetchedAt?: number;
|
profileLastFetchedAt?: number;
|
||||||
pendingUniversalTimer?: string;
|
pendingUniversalTimer?: string;
|
||||||
|
username?: string;
|
||||||
|
|
||||||
// Group-only
|
// Group-only
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
|
|
|
@ -1441,6 +1441,7 @@ export class ConversationModel extends window.Backbone
|
||||||
id: this.id,
|
id: this.id,
|
||||||
uuid: this.get('uuid'),
|
uuid: this.get('uuid'),
|
||||||
e164: this.get('e164'),
|
e164: this.get('e164'),
|
||||||
|
username: this.get('username'),
|
||||||
|
|
||||||
about: this.getAboutText(),
|
about: this.getAboutText(),
|
||||||
aboutText: this.get('about'),
|
aboutText: this.get('about'),
|
||||||
|
|
38
ts/services/writeUsername.ts
Normal file
38
ts/services/writeUsername.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import dataInterface from '../sql/Client';
|
||||||
|
import { handleMessageSend } from '../util/handleMessageSend';
|
||||||
|
|
||||||
|
export async function writeUsername({
|
||||||
|
username,
|
||||||
|
previousUsername,
|
||||||
|
}: {
|
||||||
|
username: string | undefined;
|
||||||
|
previousUsername: string | undefined;
|
||||||
|
}): Promise<void> {
|
||||||
|
const me = window.ConversationController.getOurConversationOrThrow();
|
||||||
|
await me.getProfiles();
|
||||||
|
|
||||||
|
if (me.get('username') !== previousUsername) {
|
||||||
|
throw new Error('Username has changed on another device');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username) {
|
||||||
|
await window.textsecure.messaging.putUsername(username);
|
||||||
|
} else {
|
||||||
|
await window.textsecure.messaging.deleteUsername();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update backbone, update DB, then tell linked devices about profile update
|
||||||
|
me.set({
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
|
dataInterface.updateConversation(me.attributes);
|
||||||
|
|
||||||
|
await handleMessageSend(
|
||||||
|
window.textsecure.messaging.sendFetchLocalProfileSyncMessage(),
|
||||||
|
{ messageIds: [], sendType: 'otherSync' }
|
||||||
|
);
|
||||||
|
}
|
|
@ -25,6 +25,7 @@ import * as universalExpireTimer from '../../util/universalExpireTimer';
|
||||||
import { trigger } from '../../shims/events';
|
import { trigger } from '../../shims/events';
|
||||||
import type { ToggleProfileEditorErrorActionType } from './globalModals';
|
import type { ToggleProfileEditorErrorActionType } from './globalModals';
|
||||||
import { TOGGLE_PROFILE_EDITOR_ERROR } from './globalModals';
|
import { TOGGLE_PROFILE_EDITOR_ERROR } from './globalModals';
|
||||||
|
import { isRecord } from '../../util/isRecord';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AvatarColorType,
|
AvatarColorType,
|
||||||
|
@ -50,15 +51,24 @@ import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelect
|
||||||
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
||||||
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
||||||
import { writeProfile } from '../../services/writeProfile';
|
import { writeProfile } from '../../services/writeProfile';
|
||||||
|
import { writeUsername } from '../../services/writeUsername';
|
||||||
import {
|
import {
|
||||||
getMe,
|
getMe,
|
||||||
getMessageIdsPendingBecauseOfVerification,
|
getMessageIdsPendingBecauseOfVerification,
|
||||||
|
getUsernameSaveState,
|
||||||
} from '../selectors/conversations';
|
} from '../selectors/conversations';
|
||||||
import type { AvatarDataType } from '../../types/Avatar';
|
import type { AvatarDataType } from '../../types/Avatar';
|
||||||
import { getDefaultAvatars } from '../../types/Avatar';
|
import { getDefaultAvatars } from '../../types/Avatar';
|
||||||
import { getAvatarData } from '../../util/getAvatarData';
|
import { getAvatarData } from '../../util/getAvatarData';
|
||||||
import { isSameAvatarData } from '../../util/isSameAvatarData';
|
import { isSameAvatarData } from '../../util/isSameAvatarData';
|
||||||
import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
|
import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
|
||||||
|
import {
|
||||||
|
UsernameSaveState,
|
||||||
|
ComposerStep,
|
||||||
|
OneTimeModalState,
|
||||||
|
} from './conversationsEnums';
|
||||||
|
import { showToast } from '../../util/showToast';
|
||||||
|
import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername';
|
||||||
|
|
||||||
import type { NoopActionType } from './noop';
|
import type { NoopActionType } from './noop';
|
||||||
|
|
||||||
|
@ -89,6 +99,7 @@ export type ConversationType = {
|
||||||
familyName?: string;
|
familyName?: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
profileName?: string;
|
profileName?: string;
|
||||||
|
username?: string;
|
||||||
about?: string;
|
about?: string;
|
||||||
aboutText?: string;
|
aboutText?: string;
|
||||||
aboutEmoji?: string;
|
aboutEmoji?: string;
|
||||||
|
@ -242,18 +253,6 @@ export type PreJoinConversationType = {
|
||||||
approvalRequired: boolean;
|
approvalRequired: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum ComposerStep {
|
|
||||||
StartDirectConversation = 'StartDirectConversation',
|
|
||||||
ChooseGroupMembers = 'ChooseGroupMembers',
|
|
||||||
SetGroupMetadata = 'SetGroupMetadata',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum OneTimeModalState {
|
|
||||||
NeverShown,
|
|
||||||
Showing,
|
|
||||||
Shown,
|
|
||||||
}
|
|
||||||
|
|
||||||
type ComposerGroupCreationState = {
|
type ComposerGroupCreationState = {
|
||||||
groupAvatar: undefined | Uint8Array;
|
groupAvatar: undefined | Uint8Array;
|
||||||
groupName: string;
|
groupName: string;
|
||||||
|
@ -308,6 +307,7 @@ export type ConversationsStateType = {
|
||||||
showArchived: boolean;
|
showArchived: boolean;
|
||||||
composer?: ComposerStateType;
|
composer?: ComposerStateType;
|
||||||
contactSpoofingReview?: ContactSpoofingReviewStateType;
|
contactSpoofingReview?: ContactSpoofingReviewStateType;
|
||||||
|
usernameSaveState: UsernameSaveState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Each key is a conversation ID. Each value is an array of message IDs stopped by that
|
* Each key is a conversation ID. Each value is an array of message IDs stopped by that
|
||||||
|
@ -363,6 +363,7 @@ const CUSTOM_COLOR_REMOVED = 'conversations/CUSTOM_COLOR_REMOVED';
|
||||||
const MESSAGE_STOPPED_BY_MISSING_VERIFICATION =
|
const MESSAGE_STOPPED_BY_MISSING_VERIFICATION =
|
||||||
'conversations/MESSAGE_STOPPED_BY_MISSING_VERIFICATION';
|
'conversations/MESSAGE_STOPPED_BY_MISSING_VERIFICATION';
|
||||||
const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
|
const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
|
||||||
|
const UPDATE_USERNAME_SAVE_STATE = 'conversations/UPDATE_USERNAME_SAVE_STATE';
|
||||||
|
|
||||||
type CancelMessagesPendingConversationVerificationActionType = {
|
type CancelMessagesPendingConversationVerificationActionType = {
|
||||||
type: typeof CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION;
|
type: typeof CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION;
|
||||||
|
@ -677,6 +678,12 @@ export type ToggleConversationInChooseMembersActionType = {
|
||||||
maxGroupSize: number;
|
maxGroupSize: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
type UpdateUsernameSaveStateActionType = {
|
||||||
|
type: typeof UPDATE_USERNAME_SAVE_STATE;
|
||||||
|
payload: {
|
||||||
|
newSaveState: UsernameSaveState;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type ReplaceAvatarsActionType = {
|
type ReplaceAvatarsActionType = {
|
||||||
type: typeof REPLACE_AVATARS;
|
type: typeof REPLACE_AVATARS;
|
||||||
|
@ -743,7 +750,8 @@ export type ConversationActionType =
|
||||||
| StartSettingGroupMetadataActionType
|
| StartSettingGroupMetadataActionType
|
||||||
| SwitchToAssociatedViewActionType
|
| SwitchToAssociatedViewActionType
|
||||||
| ToggleConversationInChooseMembersActionType
|
| ToggleConversationInChooseMembersActionType
|
||||||
| ToggleComposeEditingAvatarActionType;
|
| ToggleComposeEditingAvatarActionType
|
||||||
|
| UpdateUsernameSaveStateActionType;
|
||||||
|
|
||||||
// Action Creators
|
// Action Creators
|
||||||
|
|
||||||
|
@ -755,6 +763,7 @@ export const actions = {
|
||||||
clearInvitedUuidsForNewlyCreatedGroup,
|
clearInvitedUuidsForNewlyCreatedGroup,
|
||||||
clearSelectedMessage,
|
clearSelectedMessage,
|
||||||
clearUnreadMetrics,
|
clearUnreadMetrics,
|
||||||
|
clearUsernameSave,
|
||||||
closeCantAddContactToGroupModal,
|
closeCantAddContactToGroupModal,
|
||||||
closeContactSpoofingReview,
|
closeContactSpoofingReview,
|
||||||
closeMaximumGroupSizeModal,
|
closeMaximumGroupSizeModal,
|
||||||
|
@ -789,6 +798,7 @@ export const actions = {
|
||||||
reviewGroupMemberNameCollision,
|
reviewGroupMemberNameCollision,
|
||||||
reviewMessageRequestNameCollision,
|
reviewMessageRequestNameCollision,
|
||||||
saveAvatarToDisk,
|
saveAvatarToDisk,
|
||||||
|
saveUsername,
|
||||||
scrollToMessage,
|
scrollToMessage,
|
||||||
selectMessage,
|
selectMessage,
|
||||||
setComposeGroupAvatar,
|
setComposeGroupAvatar,
|
||||||
|
@ -963,6 +973,82 @@ function saveAvatarToDisk(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeUsernameSaveType(
|
||||||
|
newSaveState: UsernameSaveState
|
||||||
|
): UpdateUsernameSaveStateActionType {
|
||||||
|
return {
|
||||||
|
type: UPDATE_USERNAME_SAVE_STATE,
|
||||||
|
payload: {
|
||||||
|
newSaveState,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearUsernameSave(): UpdateUsernameSaveStateActionType {
|
||||||
|
return makeUsernameSaveType(UsernameSaveState.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveUsername({
|
||||||
|
username,
|
||||||
|
previousUsername,
|
||||||
|
}: {
|
||||||
|
username: string | undefined;
|
||||||
|
previousUsername: string | undefined;
|
||||||
|
}): ThunkAction<
|
||||||
|
void,
|
||||||
|
RootStateType,
|
||||||
|
unknown,
|
||||||
|
UpdateUsernameSaveStateActionType
|
||||||
|
> {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
|
||||||
|
const previousState = getUsernameSaveState(state);
|
||||||
|
if (previousState !== UsernameSaveState.None) {
|
||||||
|
log.error(
|
||||||
|
`saveUsername: Save requested, but previous state was ${previousState}`
|
||||||
|
);
|
||||||
|
dispatch(makeUsernameSaveType(UsernameSaveState.GeneralError));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
dispatch(makeUsernameSaveType(UsernameSaveState.Saving));
|
||||||
|
await writeUsername({ username, previousUsername });
|
||||||
|
|
||||||
|
// writeUsername above updates the backbone model which in turn updates
|
||||||
|
// redux through it's on:change event listener. Once we lose Backbone
|
||||||
|
// we'll need to manually sync these new changes.
|
||||||
|
dispatch(makeUsernameSaveType(UsernameSaveState.Success));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Check to see if we were deleting
|
||||||
|
if (!username) {
|
||||||
|
dispatch(makeUsernameSaveType(UsernameSaveState.DeleteFailed));
|
||||||
|
showToast(ToastFailedToDeleteUsername);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(error)) {
|
||||||
|
dispatch(makeUsernameSaveType(UsernameSaveState.GeneralError));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 409) {
|
||||||
|
dispatch(makeUsernameSaveType(UsernameSaveState.UsernameTakenError));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error.code === 400) {
|
||||||
|
dispatch(
|
||||||
|
makeUsernameSaveType(UsernameSaveState.UsernameMalformedError)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(makeUsernameSaveType(UsernameSaveState.GeneralError));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function myProfileChanged(
|
function myProfileChanged(
|
||||||
profileData: ProfileDataType,
|
profileData: ProfileDataType,
|
||||||
avatarBuffer?: Uint8Array
|
avatarBuffer?: Uint8Array
|
||||||
|
@ -1816,6 +1902,7 @@ export function getEmptyState(): ConversationsStateType {
|
||||||
showArchived: false,
|
showArchived: false,
|
||||||
selectedConversationTitle: '',
|
selectedConversationTitle: '',
|
||||||
selectedConversationPanelDepth: 0,
|
selectedConversationPanelDepth: 0,
|
||||||
|
usernameSaveState: UsernameSaveState.None,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3287,5 +3374,14 @@ export function reducer(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action.type === UPDATE_USERNAME_SAVE_STATE) {
|
||||||
|
const { newSaveState } = action.payload;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
usernameSaveState: newSaveState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
30
ts/state/ducks/conversationsEnums.ts
Normal file
30
ts/state/ducks/conversationsEnums.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
// We prevent circular loops between ducks and selectors/components with `import type`.
|
||||||
|
// For example, Selectors are used in action creators using thunk/getState, but those
|
||||||
|
// Selectors need types from the ducks. Selectors shouldn't use code from ducks.
|
||||||
|
//
|
||||||
|
// But enums can be used as types but also as code. So we keep them out of the ducks.
|
||||||
|
|
||||||
|
export enum UsernameSaveState {
|
||||||
|
None = 'None',
|
||||||
|
Saving = 'Saving',
|
||||||
|
UsernameTakenError = 'UsernameTakenError',
|
||||||
|
UsernameMalformedError = 'UsernameMalformedError',
|
||||||
|
GeneralError = 'GeneralError',
|
||||||
|
DeleteFailed = 'DeleteFailed',
|
||||||
|
Success = 'Success',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ComposerStep {
|
||||||
|
StartDirectConversation = 'StartDirectConversation',
|
||||||
|
ChooseGroupMembers = 'ChooseGroupMembers',
|
||||||
|
SetGroupMetadata = 'SetGroupMetadata',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum OneTimeModalState {
|
||||||
|
NeverShown,
|
||||||
|
Showing,
|
||||||
|
Shown,
|
||||||
|
}
|
|
@ -17,6 +17,7 @@ import { ConversationColors } from '../../types/Colors';
|
||||||
import { reloadSelectedConversation } from '../../shims/reloadSelectedConversation';
|
import { reloadSelectedConversation } from '../../shims/reloadSelectedConversation';
|
||||||
import type { StorageAccessType } from '../../types/Storage.d';
|
import type { StorageAccessType } from '../../types/Storage.d';
|
||||||
import { actions as conversationActions } from './conversations';
|
import { actions as conversationActions } from './conversations';
|
||||||
|
import type { ConfigMapType as RemoteConfigType } from '../../RemoteConfig';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
|
@ -25,6 +26,8 @@ export type ItemsStateType = {
|
||||||
|
|
||||||
readonly [key: string]: unknown;
|
readonly [key: string]: unknown;
|
||||||
|
|
||||||
|
readonly remoteConfig?: RemoteConfigType;
|
||||||
|
|
||||||
// This property should always be set and this is ensured in background.ts
|
// This property should always be set and this is ensured in background.ts
|
||||||
readonly defaultConversationColor?: DefaultConversationColorType;
|
readonly defaultConversationColor?: DefaultConversationColorType;
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { fromPairs, isNumber } from 'lodash';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ConversationLookupType,
|
ConversationLookupType,
|
||||||
ConversationMessageType,
|
ConversationMessageType,
|
||||||
|
@ -15,7 +16,8 @@ import type {
|
||||||
MessagesByConversationType,
|
MessagesByConversationType,
|
||||||
PreJoinConversationType,
|
PreJoinConversationType,
|
||||||
} from '../ducks/conversations';
|
} from '../ducks/conversations';
|
||||||
import { ComposerStep, OneTimeModalState } from '../ducks/conversations';
|
import type { UsernameSaveState } from '../ducks/conversationsEnums';
|
||||||
|
import { ComposerStep, OneTimeModalState } from '../ducks/conversationsEnums';
|
||||||
import { getOwn } from '../../util/getOwn';
|
import { getOwn } from '../../util/getOwn';
|
||||||
import { isNotNil } from '../../util/isNotNil';
|
import { isNotNil } from '../../util/isNotNil';
|
||||||
import { deconstructLookup } from '../../util/deconstructLookup';
|
import { deconstructLookup } from '../../util/deconstructLookup';
|
||||||
|
@ -161,6 +163,13 @@ export const getSelectedMessage = createSelector(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getUsernameSaveState = createSelector(
|
||||||
|
getConversations,
|
||||||
|
(state: ConversationsStateType): UsernameSaveState => {
|
||||||
|
return state.usernameSaveState;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const getShowArchived = createSelector(
|
export const getShowArchived = createSelector(
|
||||||
getConversations,
|
getConversations,
|
||||||
(state: ConversationsStateType): boolean => {
|
(state: ConversationsStateType): boolean => {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { createSelector } from 'reselect';
|
||||||
import { isInteger } from 'lodash';
|
import { isInteger } from 'lodash';
|
||||||
|
|
||||||
import { ITEM_NAME as UNIVERSAL_EXPIRE_TIMER_ITEM } from '../../util/universalExpireTimer';
|
import { ITEM_NAME as UNIVERSAL_EXPIRE_TIMER_ITEM } from '../../util/universalExpireTimer';
|
||||||
|
import type { ConfigMapType } from '../../RemoteConfig';
|
||||||
|
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
import type { ItemsStateType } from '../ducks/items';
|
import type { ItemsStateType } from '../ducks/items';
|
||||||
|
@ -35,6 +36,17 @@ export const getUniversalExpireTimer = createSelector(
|
||||||
(state: ItemsStateType): number => state[UNIVERSAL_EXPIRE_TIMER_ITEM] || 0
|
(state: ItemsStateType): number => state[UNIVERSAL_EXPIRE_TIMER_ITEM] || 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getRemoteConfig = createSelector(
|
||||||
|
getItems,
|
||||||
|
(state: ItemsStateType): ConfigMapType | undefined => state.remoteConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getUsernamesEnabled = createSelector(
|
||||||
|
getRemoteConfig,
|
||||||
|
(remoteConfig?: ConfigMapType): boolean =>
|
||||||
|
Boolean(remoteConfig?.['desktop.usernames']?.enabled)
|
||||||
|
);
|
||||||
|
|
||||||
export const getDefaultConversationColor = createSelector(
|
export const getDefaultConversationColor = createSelector(
|
||||||
getItems,
|
getItems,
|
||||||
(
|
(
|
||||||
|
|
|
@ -12,10 +12,8 @@ import { SmartSafetyNumberModal } from './SafetyNumberModal';
|
||||||
|
|
||||||
import { getIntl } from '../selectors/user';
|
import { getIntl } from '../selectors/user';
|
||||||
|
|
||||||
const FilteredSmartProfileEditorModal = SmartProfileEditorModal;
|
|
||||||
|
|
||||||
function renderProfileEditor(): JSX.Element {
|
function renderProfileEditor(): JSX.Element {
|
||||||
return <FilteredSmartProfileEditorModal />;
|
return <SmartProfileEditorModal />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderContactModal(): JSX.Element {
|
function renderContactModal(): JSX.Element {
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { LeftPane, LeftPaneMode } from '../../components/LeftPane';
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
import { missingCaseError } from '../../util/missingCaseError';
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
|
|
||||||
import { ComposerStep, OneTimeModalState } from '../ducks/conversations';
|
import { ComposerStep, OneTimeModalState } from '../ducks/conversationsEnums';
|
||||||
import {
|
import {
|
||||||
getIsSearchingInAConversation,
|
getIsSearchingInAConversation,
|
||||||
getQuery,
|
getQuery,
|
||||||
|
@ -53,8 +53,6 @@ import { SmartRelinkDialog } from './RelinkDialog';
|
||||||
import { SmartUpdateDialog } from './UpdateDialog';
|
import { SmartUpdateDialog } from './UpdateDialog';
|
||||||
import { SmartCaptchaDialog } from './CaptchaDialog';
|
import { SmartCaptchaDialog } from './CaptchaDialog';
|
||||||
|
|
||||||
const FilteredSmartMessageSearchResult = SmartMessageSearchResult;
|
|
||||||
|
|
||||||
function renderExpiredBuildDialog(
|
function renderExpiredBuildDialog(
|
||||||
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
||||||
): JSX.Element {
|
): JSX.Element {
|
||||||
|
@ -64,7 +62,7 @@ function renderMainHeader(): JSX.Element {
|
||||||
return <SmartMainHeader />;
|
return <SmartMainHeader />;
|
||||||
}
|
}
|
||||||
function renderMessageSearchResult(id: string): JSX.Element {
|
function renderMessageSearchResult(id: string): JSX.Element {
|
||||||
return <FilteredSmartMessageSearchResult id={id} />;
|
return <SmartMessageSearchResult id={id} />;
|
||||||
}
|
}
|
||||||
function renderNetworkStatus(
|
function renderNetworkStatus(
|
||||||
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
||||||
|
|
|
@ -8,8 +8,8 @@ import { ProfileEditorModal } from '../../components/ProfileEditorModal';
|
||||||
import type { PropsDataType } from '../../components/ProfileEditor';
|
import type { PropsDataType } from '../../components/ProfileEditor';
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
import { getIntl } from '../selectors/user';
|
import { getIntl } from '../selectors/user';
|
||||||
import { getEmojiSkinTone } from '../selectors/items';
|
import { getEmojiSkinTone, getUsernamesEnabled } from '../selectors/items';
|
||||||
import { getMe } from '../selectors/conversations';
|
import { getMe, getUsernameSaveState } from '../selectors/conversations';
|
||||||
import { selectRecentEmojis } from '../selectors/emojis';
|
import { selectRecentEmojis } from '../selectors/emojis';
|
||||||
|
|
||||||
function mapStateToProps(
|
function mapStateToProps(
|
||||||
|
@ -25,9 +25,11 @@ function mapStateToProps(
|
||||||
firstName,
|
firstName,
|
||||||
familyName,
|
familyName,
|
||||||
id: conversationId,
|
id: conversationId,
|
||||||
|
username,
|
||||||
} = getMe(state);
|
} = getMe(state);
|
||||||
const recentEmojis = selectRecentEmojis(state);
|
const recentEmojis = selectRecentEmojis(state);
|
||||||
const skinTone = getEmojiSkinTone(state);
|
const skinTone = getEmojiSkinTone(state);
|
||||||
|
const isUsernameFlagEnabled = getUsernamesEnabled(state);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
aboutEmoji,
|
aboutEmoji,
|
||||||
|
@ -39,9 +41,12 @@ function mapStateToProps(
|
||||||
firstName: String(firstName),
|
firstName: String(firstName),
|
||||||
hasError: state.globalModals.profileEditorHasError,
|
hasError: state.globalModals.profileEditorHasError,
|
||||||
i18n: getIntl(state),
|
i18n: getIntl(state),
|
||||||
|
isUsernameFlagEnabled,
|
||||||
recentEmojis,
|
recentEmojis,
|
||||||
skinTone,
|
skinTone,
|
||||||
userAvatarData,
|
userAvatarData,
|
||||||
|
username,
|
||||||
|
usernameSaveState: getUsernameSaveState(state),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,10 +27,8 @@ type ExternalProps = {
|
||||||
previousMessageId: undefined | string;
|
previousMessageId: undefined | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FilteredSmartContactName = SmartContactName;
|
|
||||||
|
|
||||||
function renderContact(conversationId: string): JSX.Element {
|
function renderContact(conversationId: string): JSX.Element {
|
||||||
return <FilteredSmartContactName conversationId={conversationId} />;
|
return <SmartContactName conversationId={conversationId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUniversalTimerNotification(): JSX.Element {
|
function renderUniversalTimerNotification(): JSX.Element {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { ComposerStep } from '../../state/ducks/conversations';
|
import { ComposerStep } from '../../state/ducks/conversationsEnums';
|
||||||
import { OneTimeModalState } from '../../groups/toggleSelectedContactForGroupAddition';
|
import { OneTimeModalState } from '../../groups/toggleSelectedContactForGroupAddition';
|
||||||
|
|
||||||
export const defaultStartDirectConversationComposerState = {
|
export const defaultStartDirectConversationComposerState = {
|
||||||
|
|
|
@ -3,15 +3,15 @@
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
import {
|
||||||
|
OneTimeModalState,
|
||||||
|
ComposerStep,
|
||||||
|
} from '../../../state/ducks/conversationsEnums';
|
||||||
import type {
|
import type {
|
||||||
ConversationLookupType,
|
ConversationLookupType,
|
||||||
ConversationType,
|
ConversationType,
|
||||||
} from '../../../state/ducks/conversations';
|
} from '../../../state/ducks/conversations';
|
||||||
import {
|
import { getEmptyState } from '../../../state/ducks/conversations';
|
||||||
OneTimeModalState,
|
|
||||||
ComposerStep,
|
|
||||||
getEmptyState,
|
|
||||||
} from '../../../state/ducks/conversations';
|
|
||||||
import {
|
import {
|
||||||
_getConversationComparator,
|
_getConversationComparator,
|
||||||
_getLeftPaneLists,
|
_getLeftPaneLists,
|
||||||
|
|
|
@ -8,6 +8,10 @@ import { times } from 'lodash';
|
||||||
import { set } from 'lodash/fp';
|
import { set } from 'lodash/fp';
|
||||||
import { reducer as rootReducer } from '../../../state/reducer';
|
import { reducer as rootReducer } from '../../../state/reducer';
|
||||||
import { noopAction } from '../../../state/ducks/noop';
|
import { noopAction } from '../../../state/ducks/noop';
|
||||||
|
import {
|
||||||
|
OneTimeModalState,
|
||||||
|
ComposerStep,
|
||||||
|
} from '../../../state/ducks/conversationsEnums';
|
||||||
import type {
|
import type {
|
||||||
ConversationMessageType,
|
ConversationMessageType,
|
||||||
ConversationType,
|
ConversationType,
|
||||||
|
@ -18,8 +22,6 @@ import type {
|
||||||
} from '../../../state/ducks/conversations';
|
} from '../../../state/ducks/conversations';
|
||||||
import {
|
import {
|
||||||
actions,
|
actions,
|
||||||
OneTimeModalState,
|
|
||||||
ComposerStep,
|
|
||||||
getConversationCallMode,
|
getConversationCallMode,
|
||||||
getEmptyState,
|
getEmptyState,
|
||||||
reducer,
|
reducer,
|
||||||
|
|
|
@ -2203,4 +2203,13 @@ export default class MessageSender {
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return this.server.uploadAvatar(requestHeaders, avatarData);
|
return this.server.uploadAvatar(requestHeaders, avatarData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async putUsername(
|
||||||
|
username: string
|
||||||
|
): Promise<ReturnType<WebAPIType['putUsername']>> {
|
||||||
|
return this.server.putUsername(username);
|
||||||
|
}
|
||||||
|
async deleteUsername(): Promise<ReturnType<WebAPIType['deleteUsername']>> {
|
||||||
|
return this.server.deleteUsername();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -494,6 +494,7 @@ const URL_CALLS = {
|
||||||
accounts: 'v1/accounts',
|
accounts: 'v1/accounts',
|
||||||
attachmentId: 'v2/attachments/form/upload',
|
attachmentId: 'v2/attachments/form/upload',
|
||||||
attestation: 'v1/attestation',
|
attestation: 'v1/attestation',
|
||||||
|
challenge: 'v1/challenge',
|
||||||
config: 'v1/config',
|
config: 'v1/config',
|
||||||
deliveryCert: 'v1/certificate/delivery',
|
deliveryCert: 'v1/certificate/delivery',
|
||||||
devices: 'v1/devices',
|
devices: 'v1/devices',
|
||||||
|
@ -520,8 +521,8 @@ const URL_CALLS = {
|
||||||
storageToken: 'v1/storage/auth',
|
storageToken: 'v1/storage/auth',
|
||||||
supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery',
|
supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery',
|
||||||
updateDeviceName: 'v1/accounts/name',
|
updateDeviceName: 'v1/accounts/name',
|
||||||
|
username: 'v1/accounts/username',
|
||||||
whoami: 'v1/accounts/whoami',
|
whoami: 'v1/accounts/whoami',
|
||||||
challenge: 'v1/challenge',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const WEBSOCKET_CALLS = new Set<keyof typeof URL_CALLS>([
|
const WEBSOCKET_CALLS = new Set<keyof typeof URL_CALLS>([
|
||||||
|
@ -719,6 +720,7 @@ export type WebAPIType = {
|
||||||
group: Proto.IGroup,
|
group: Proto.IGroup,
|
||||||
options: GroupCredentialsType
|
options: GroupCredentialsType
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
deleteUsername: () => Promise<void>;
|
||||||
getAttachment: (cdnKey: string, cdnNumber?: number) => Promise<Uint8Array>;
|
getAttachment: (cdnKey: string, cdnNumber?: number) => Promise<Uint8Array>;
|
||||||
getAvatar: (path: string) => Promise<Uint8Array>;
|
getAvatar: (path: string) => Promise<Uint8Array>;
|
||||||
getDevices: () => Promise<GetDevicesResultType>;
|
getDevices: () => Promise<GetDevicesResultType>;
|
||||||
|
@ -807,12 +809,13 @@ export type WebAPIType = {
|
||||||
putProfile: (
|
putProfile: (
|
||||||
jsonData: ProfileRequestDataType
|
jsonData: ProfileRequestDataType
|
||||||
) => Promise<UploadAvatarHeadersType | undefined>;
|
) => Promise<UploadAvatarHeadersType | undefined>;
|
||||||
registerCapabilities: (capabilities: CapabilitiesUploadType) => Promise<void>;
|
|
||||||
putStickers: (
|
putStickers: (
|
||||||
encryptedManifest: Uint8Array,
|
encryptedManifest: Uint8Array,
|
||||||
encryptedStickers: Array<Uint8Array>,
|
encryptedStickers: Array<Uint8Array>,
|
||||||
onProgress?: () => void
|
onProgress?: () => void
|
||||||
) => Promise<string>;
|
) => Promise<string>;
|
||||||
|
putUsername: (newUsername: string) => Promise<void>;
|
||||||
|
registerCapabilities: (capabilities: CapabilitiesUploadType) => Promise<void>;
|
||||||
registerKeys: (genKeys: KeysType) => Promise<void>;
|
registerKeys: (genKeys: KeysType) => Promise<void>;
|
||||||
registerSupportForUnauthenticatedDelivery: () => Promise<void>;
|
registerSupportForUnauthenticatedDelivery: () => Promise<void>;
|
||||||
reportMessage: (senderE164: string, serverGuid: string) => Promise<void>;
|
reportMessage: (senderE164: string, serverGuid: string) => Promise<void>;
|
||||||
|
@ -1014,6 +1017,7 @@ export function initialize({
|
||||||
logout,
|
logout,
|
||||||
confirmCode,
|
confirmCode,
|
||||||
createGroup,
|
createGroup,
|
||||||
|
deleteUsername,
|
||||||
fetchLinkPreviewImage,
|
fetchLinkPreviewImage,
|
||||||
fetchLinkPreviewMetadata,
|
fetchLinkPreviewMetadata,
|
||||||
getAttachment,
|
getAttachment,
|
||||||
|
@ -1047,6 +1051,7 @@ export function initialize({
|
||||||
putAttachment,
|
putAttachment,
|
||||||
putProfile,
|
putProfile,
|
||||||
putStickers,
|
putStickers,
|
||||||
|
putUsername,
|
||||||
registerCapabilities,
|
registerCapabilities,
|
||||||
registerKeys,
|
registerKeys,
|
||||||
registerSupportForUnauthenticatedDelivery,
|
registerSupportForUnauthenticatedDelivery,
|
||||||
|
@ -1399,6 +1404,20 @@ export function initialize({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteUsername() {
|
||||||
|
await _ajax({
|
||||||
|
call: 'username',
|
||||||
|
httpType: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function putUsername(newUsername: string) {
|
||||||
|
await _ajax({
|
||||||
|
call: 'username',
|
||||||
|
httpType: 'PUT',
|
||||||
|
urlParameters: `/${newUsername}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function reportMessage(
|
async function reportMessage(
|
||||||
senderE164: string,
|
senderE164: string,
|
||||||
serverGuid: string
|
serverGuid: string
|
||||||
|
|
|
@ -17,7 +17,7 @@ export type RenderTextCallbackType = (options: {
|
||||||
}) => JSX.Element | string;
|
}) => JSX.Element | string;
|
||||||
|
|
||||||
export type ReplacementValuesType = {
|
export type ReplacementValuesType = {
|
||||||
[key: string]: string | undefined;
|
[key: string]: string | number | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LocalizerType = {
|
export type LocalizerType = {
|
||||||
|
|
|
@ -169,6 +169,13 @@ export async function getProfile(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { username } = profile;
|
||||||
|
if (username) {
|
||||||
|
c.set({ username });
|
||||||
|
} else {
|
||||||
|
c.unset('username');
|
||||||
|
}
|
||||||
|
|
||||||
if (profile.about) {
|
if (profile.about) {
|
||||||
const key = c.get('profileKey');
|
const key = c.get('profileKey');
|
||||||
if (key) {
|
if (key) {
|
||||||
|
|
16
yarn.lock
16
yarn.lock
|
@ -15417,10 +15417,10 @@ redux-ts-utils@3.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
immer "^2.0.0"
|
immer "^2.0.0"
|
||||||
|
|
||||||
redux@4.0.1, redux@^4.0.0:
|
redux@4.0.2:
|
||||||
version "4.0.1"
|
version "4.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.1.tgz#436cae6cc40fbe4727689d7c8fae44808f1bfef5"
|
resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.2.tgz#597cc660a99f91412e31c96c3da10ed8ace0715d"
|
||||||
integrity sha512-R7bAtSkk7nY6O/OYMVR9RiBI+XghjF9rlbl5806HJbQph0LJVHZrU5oaO4q70eUKiqMRqm4y07KLTlMZ2BlVmg==
|
integrity sha512-oAiFLWYQhbpSvzjcVfgQ90MlZ0u6uDIHFK41Q0/BnCfjEg96SACzwUFwDVUKz/LP/SwJORGaFY8AM5wOB/zf0A==
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify "^1.4.0"
|
loose-envify "^1.4.0"
|
||||||
symbol-observable "^1.2.0"
|
symbol-observable "^1.2.0"
|
||||||
|
@ -15435,6 +15435,14 @@ redux@^3.6.0:
|
||||||
loose-envify "^1.1.0"
|
loose-envify "^1.1.0"
|
||||||
symbol-observable "^1.0.3"
|
symbol-observable "^1.0.3"
|
||||||
|
|
||||||
|
redux@^4.0.0:
|
||||||
|
version "4.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.1.tgz#436cae6cc40fbe4727689d7c8fae44808f1bfef5"
|
||||||
|
integrity sha512-R7bAtSkk7nY6O/OYMVR9RiBI+XghjF9rlbl5806HJbQph0LJVHZrU5oaO4q70eUKiqMRqm4y07KLTlMZ2BlVmg==
|
||||||
|
dependencies:
|
||||||
|
loose-envify "^1.4.0"
|
||||||
|
symbol-observable "^1.2.0"
|
||||||
|
|
||||||
ref-array-napi@1.2.2:
|
ref-array-napi@1.2.2:
|
||||||
version "1.2.2"
|
version "1.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/ref-array-napi/-/ref-array-napi-1.2.2.tgz#8b172b375aa04441860973c262ac8a557e3d368e"
|
resolved "https://registry.yarnpkg.com/ref-array-napi/-/ref-array-napi-1.2.2.tgz#8b172b375aa04441860973c262ac8a557e3d368e"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue