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",
|
||||
"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": {
|
||||
"message": "Write something about yourself...",
|
||||
"description": "Placeholder text for about input field"
|
||||
|
@ -6242,6 +6306,14 @@
|
|||
"message": "About",
|
||||
"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": {
|
||||
"message": "Your profile could not be updated. Please try again.",
|
||||
"description": "Error message when something goes wrong updating your profile."
|
||||
|
|
|
@ -147,7 +147,7 @@
|
|||
"react-sortable-hoc": "1.9.1",
|
||||
"react-virtualized": "9.21.0",
|
||||
"read-last-lines": "1.8.0",
|
||||
"redux": "4.0.1",
|
||||
"redux": "4.0.2",
|
||||
"redux-logger": "3.0.6",
|
||||
"redux-promise-middleware": "6.1.0",
|
||||
"redux-thunk": "2.3.0",
|
||||
|
|
|
@ -33,6 +33,12 @@
|
|||
background-size: cover;
|
||||
background-position: center center;
|
||||
}
|
||||
|
||||
@include keyboard-mode {
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 3px $color-ultramarine;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__group {
|
||||
|
|
|
@ -165,8 +165,12 @@
|
|||
padding: none;
|
||||
|
||||
&: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 {
|
||||
&::after {
|
||||
-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 {
|
||||
@include font-body-2;
|
||||
margin: 16px 0;
|
||||
|
|
|
@ -25,6 +25,7 @@ export type ConfigKeyType =
|
|||
| 'desktop.sendSenderKey3'
|
||||
| 'desktop.storage'
|
||||
| 'desktop.storageWrite3'
|
||||
| 'desktop.usernames'
|
||||
| 'global.calling.maxGroupCallRingSize'
|
||||
| 'global.groupsv2.groupSizeHardLimit'
|
||||
| 'global.groupsv2.maxGroupSize';
|
||||
|
@ -34,7 +35,9 @@ type ConfigValueType = {
|
|||
enabledAt?: number;
|
||||
value?: unknown;
|
||||
};
|
||||
export type ConfigMapType = { [key: string]: ConfigValueType };
|
||||
export type ConfigMapType = {
|
||||
[key in ConfigKeyType]?: ConfigValueType;
|
||||
};
|
||||
type ConfigListenerType = (value: ConfigValueType) => unknown;
|
||||
type ConfigListenersMapType = {
|
||||
[key: string]: Array<ConfigListenerType>;
|
||||
|
|
|
@ -86,6 +86,7 @@ import { getSendOptions } from './util/getSendOptions';
|
|||
import { BackOff, FIBONACCI_TIMEOUTS } from './util/BackOff';
|
||||
import { handleMessageSend } from './util/handleMessageSend';
|
||||
import { AppViewType } from './state/ducks/app';
|
||||
import { UsernameSaveState } from './state/ducks/conversationsEnums';
|
||||
import { isIncoming } from './state/selectors/message';
|
||||
import { actionCreators } from './state/actions';
|
||||
import { Deletes } from './messageModifiers/Deletes';
|
||||
|
@ -950,6 +951,7 @@ export async function startApp(): Promise<void> {
|
|||
selectedConversationPanelDepth: 0,
|
||||
selectedConversationTitle: '',
|
||||
showArchived: false,
|
||||
usernameSaveState: UsernameSaveState.None,
|
||||
},
|
||||
emojis: getEmojiReducerState(),
|
||||
items: window.storage.getItemsState(),
|
||||
|
|
|
@ -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>
|
||||
|
|
1
ts/model-types.d.ts
vendored
1
ts/model-types.d.ts
vendored
|
@ -270,6 +270,7 @@ export type ConversationAttributesType = {
|
|||
verified?: number;
|
||||
profileLastFetchedAt?: number;
|
||||
pendingUniversalTimer?: string;
|
||||
username?: string;
|
||||
|
||||
// Group-only
|
||||
groupId?: string;
|
||||
|
|
|
@ -1441,6 +1441,7 @@ export class ConversationModel extends window.Backbone
|
|||
id: this.id,
|
||||
uuid: this.get('uuid'),
|
||||
e164: this.get('e164'),
|
||||
username: this.get('username'),
|
||||
|
||||
about: this.getAboutText(),
|
||||
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 type { ToggleProfileEditorErrorActionType } from './globalModals';
|
||||
import { TOGGLE_PROFILE_EDITOR_ERROR } from './globalModals';
|
||||
import { isRecord } from '../../util/isRecord';
|
||||
|
||||
import type {
|
||||
AvatarColorType,
|
||||
|
@ -50,15 +51,24 @@ import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelect
|
|||
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
||||
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
||||
import { writeProfile } from '../../services/writeProfile';
|
||||
import { writeUsername } from '../../services/writeUsername';
|
||||
import {
|
||||
getMe,
|
||||
getMessageIdsPendingBecauseOfVerification,
|
||||
getUsernameSaveState,
|
||||
} from '../selectors/conversations';
|
||||
import type { AvatarDataType } from '../../types/Avatar';
|
||||
import { getDefaultAvatars } from '../../types/Avatar';
|
||||
import { getAvatarData } from '../../util/getAvatarData';
|
||||
import { isSameAvatarData } from '../../util/isSameAvatarData';
|
||||
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';
|
||||
|
||||
|
@ -89,6 +99,7 @@ export type ConversationType = {
|
|||
familyName?: string;
|
||||
firstName?: string;
|
||||
profileName?: string;
|
||||
username?: string;
|
||||
about?: string;
|
||||
aboutText?: string;
|
||||
aboutEmoji?: string;
|
||||
|
@ -242,18 +253,6 @@ export type PreJoinConversationType = {
|
|||
approvalRequired: boolean;
|
||||
};
|
||||
|
||||
export enum ComposerStep {
|
||||
StartDirectConversation = 'StartDirectConversation',
|
||||
ChooseGroupMembers = 'ChooseGroupMembers',
|
||||
SetGroupMetadata = 'SetGroupMetadata',
|
||||
}
|
||||
|
||||
export enum OneTimeModalState {
|
||||
NeverShown,
|
||||
Showing,
|
||||
Shown,
|
||||
}
|
||||
|
||||
type ComposerGroupCreationState = {
|
||||
groupAvatar: undefined | Uint8Array;
|
||||
groupName: string;
|
||||
|
@ -308,6 +307,7 @@ export type ConversationsStateType = {
|
|||
showArchived: boolean;
|
||||
composer?: ComposerStateType;
|
||||
contactSpoofingReview?: ContactSpoofingReviewStateType;
|
||||
usernameSaveState: UsernameSaveState;
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
'conversations/MESSAGE_STOPPED_BY_MISSING_VERIFICATION';
|
||||
const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
|
||||
const UPDATE_USERNAME_SAVE_STATE = 'conversations/UPDATE_USERNAME_SAVE_STATE';
|
||||
|
||||
type CancelMessagesPendingConversationVerificationActionType = {
|
||||
type: typeof CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION;
|
||||
|
@ -677,6 +678,12 @@ export type ToggleConversationInChooseMembersActionType = {
|
|||
maxGroupSize: number;
|
||||
};
|
||||
};
|
||||
type UpdateUsernameSaveStateActionType = {
|
||||
type: typeof UPDATE_USERNAME_SAVE_STATE;
|
||||
payload: {
|
||||
newSaveState: UsernameSaveState;
|
||||
};
|
||||
};
|
||||
|
||||
type ReplaceAvatarsActionType = {
|
||||
type: typeof REPLACE_AVATARS;
|
||||
|
@ -743,7 +750,8 @@ export type ConversationActionType =
|
|||
| StartSettingGroupMetadataActionType
|
||||
| SwitchToAssociatedViewActionType
|
||||
| ToggleConversationInChooseMembersActionType
|
||||
| ToggleComposeEditingAvatarActionType;
|
||||
| ToggleComposeEditingAvatarActionType
|
||||
| UpdateUsernameSaveStateActionType;
|
||||
|
||||
// Action Creators
|
||||
|
||||
|
@ -755,6 +763,7 @@ export const actions = {
|
|||
clearInvitedUuidsForNewlyCreatedGroup,
|
||||
clearSelectedMessage,
|
||||
clearUnreadMetrics,
|
||||
clearUsernameSave,
|
||||
closeCantAddContactToGroupModal,
|
||||
closeContactSpoofingReview,
|
||||
closeMaximumGroupSizeModal,
|
||||
|
@ -789,6 +798,7 @@ export const actions = {
|
|||
reviewGroupMemberNameCollision,
|
||||
reviewMessageRequestNameCollision,
|
||||
saveAvatarToDisk,
|
||||
saveUsername,
|
||||
scrollToMessage,
|
||||
selectMessage,
|
||||
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(
|
||||
profileData: ProfileDataType,
|
||||
avatarBuffer?: Uint8Array
|
||||
|
@ -1816,6 +1902,7 @@ export function getEmptyState(): ConversationsStateType {
|
|||
showArchived: false,
|
||||
selectedConversationTitle: '',
|
||||
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;
|
||||
}
|
||||
|
|
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 type { StorageAccessType } from '../../types/Storage.d';
|
||||
import { actions as conversationActions } from './conversations';
|
||||
import type { ConfigMapType as RemoteConfigType } from '../../RemoteConfig';
|
||||
|
||||
// State
|
||||
|
||||
|
@ -25,6 +26,8 @@ export type ItemsStateType = {
|
|||
|
||||
readonly [key: string]: unknown;
|
||||
|
||||
readonly remoteConfig?: RemoteConfigType;
|
||||
|
||||
// This property should always be set and this is ensured in background.ts
|
||||
readonly defaultConversationColor?: DefaultConversationColorType;
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import { fromPairs, isNumber } from 'lodash';
|
|||
import { createSelector } from 'reselect';
|
||||
|
||||
import type { StateType } from '../reducer';
|
||||
|
||||
import type {
|
||||
ConversationLookupType,
|
||||
ConversationMessageType,
|
||||
|
@ -15,7 +16,8 @@ import type {
|
|||
MessagesByConversationType,
|
||||
PreJoinConversationType,
|
||||
} 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 { isNotNil } from '../../util/isNotNil';
|
||||
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(
|
||||
getConversations,
|
||||
(state: ConversationsStateType): boolean => {
|
||||
|
|
|
@ -5,6 +5,7 @@ import { createSelector } from 'reselect';
|
|||
import { isInteger } from 'lodash';
|
||||
|
||||
import { ITEM_NAME as UNIVERSAL_EXPIRE_TIMER_ITEM } from '../../util/universalExpireTimer';
|
||||
import type { ConfigMapType } from '../../RemoteConfig';
|
||||
|
||||
import type { StateType } from '../reducer';
|
||||
import type { ItemsStateType } from '../ducks/items';
|
||||
|
@ -35,6 +36,17 @@ export const getUniversalExpireTimer = createSelector(
|
|||
(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(
|
||||
getItems,
|
||||
(
|
||||
|
|
|
@ -12,10 +12,8 @@ import { SmartSafetyNumberModal } from './SafetyNumberModal';
|
|||
|
||||
import { getIntl } from '../selectors/user';
|
||||
|
||||
const FilteredSmartProfileEditorModal = SmartProfileEditorModal;
|
||||
|
||||
function renderProfileEditor(): JSX.Element {
|
||||
return <FilteredSmartProfileEditorModal />;
|
||||
return <SmartProfileEditorModal />;
|
||||
}
|
||||
|
||||
function renderContactModal(): JSX.Element {
|
||||
|
|
|
@ -10,7 +10,7 @@ import { LeftPane, LeftPaneMode } from '../../components/LeftPane';
|
|||
import type { StateType } from '../reducer';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
|
||||
import { ComposerStep, OneTimeModalState } from '../ducks/conversations';
|
||||
import { ComposerStep, OneTimeModalState } from '../ducks/conversationsEnums';
|
||||
import {
|
||||
getIsSearchingInAConversation,
|
||||
getQuery,
|
||||
|
@ -53,8 +53,6 @@ import { SmartRelinkDialog } from './RelinkDialog';
|
|||
import { SmartUpdateDialog } from './UpdateDialog';
|
||||
import { SmartCaptchaDialog } from './CaptchaDialog';
|
||||
|
||||
const FilteredSmartMessageSearchResult = SmartMessageSearchResult;
|
||||
|
||||
function renderExpiredBuildDialog(
|
||||
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
||||
): JSX.Element {
|
||||
|
@ -64,7 +62,7 @@ function renderMainHeader(): JSX.Element {
|
|||
return <SmartMainHeader />;
|
||||
}
|
||||
function renderMessageSearchResult(id: string): JSX.Element {
|
||||
return <FilteredSmartMessageSearchResult id={id} />;
|
||||
return <SmartMessageSearchResult id={id} />;
|
||||
}
|
||||
function renderNetworkStatus(
|
||||
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
||||
|
|
|
@ -8,8 +8,8 @@ import { ProfileEditorModal } from '../../components/ProfileEditorModal';
|
|||
import type { PropsDataType } from '../../components/ProfileEditor';
|
||||
import type { StateType } from '../reducer';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getEmojiSkinTone } from '../selectors/items';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
import { getEmojiSkinTone, getUsernamesEnabled } from '../selectors/items';
|
||||
import { getMe, getUsernameSaveState } from '../selectors/conversations';
|
||||
import { selectRecentEmojis } from '../selectors/emojis';
|
||||
|
||||
function mapStateToProps(
|
||||
|
@ -25,9 +25,11 @@ function mapStateToProps(
|
|||
firstName,
|
||||
familyName,
|
||||
id: conversationId,
|
||||
username,
|
||||
} = getMe(state);
|
||||
const recentEmojis = selectRecentEmojis(state);
|
||||
const skinTone = getEmojiSkinTone(state);
|
||||
const isUsernameFlagEnabled = getUsernamesEnabled(state);
|
||||
|
||||
return {
|
||||
aboutEmoji,
|
||||
|
@ -39,9 +41,12 @@ function mapStateToProps(
|
|||
firstName: String(firstName),
|
||||
hasError: state.globalModals.profileEditorHasError,
|
||||
i18n: getIntl(state),
|
||||
isUsernameFlagEnabled,
|
||||
recentEmojis,
|
||||
skinTone,
|
||||
userAvatarData,
|
||||
username,
|
||||
usernameSaveState: getUsernameSaveState(state),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -27,10 +27,8 @@ type ExternalProps = {
|
|||
previousMessageId: undefined | string;
|
||||
};
|
||||
|
||||
const FilteredSmartContactName = SmartContactName;
|
||||
|
||||
function renderContact(conversationId: string): JSX.Element {
|
||||
return <FilteredSmartContactName conversationId={conversationId} />;
|
||||
return <SmartContactName conversationId={conversationId} />;
|
||||
}
|
||||
|
||||
function renderUniversalTimerNotification(): JSX.Element {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { ComposerStep } from '../../state/ducks/conversations';
|
||||
import { ComposerStep } from '../../state/ducks/conversationsEnums';
|
||||
import { OneTimeModalState } from '../../groups/toggleSelectedContactForGroupAddition';
|
||||
|
||||
export const defaultStartDirectConversationComposerState = {
|
||||
|
|
|
@ -3,15 +3,15 @@
|
|||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import {
|
||||
OneTimeModalState,
|
||||
ComposerStep,
|
||||
} from '../../../state/ducks/conversationsEnums';
|
||||
import type {
|
||||
ConversationLookupType,
|
||||
ConversationType,
|
||||
} from '../../../state/ducks/conversations';
|
||||
import {
|
||||
OneTimeModalState,
|
||||
ComposerStep,
|
||||
getEmptyState,
|
||||
} from '../../../state/ducks/conversations';
|
||||
import { getEmptyState } from '../../../state/ducks/conversations';
|
||||
import {
|
||||
_getConversationComparator,
|
||||
_getLeftPaneLists,
|
||||
|
|
|
@ -8,6 +8,10 @@ import { times } from 'lodash';
|
|||
import { set } from 'lodash/fp';
|
||||
import { reducer as rootReducer } from '../../../state/reducer';
|
||||
import { noopAction } from '../../../state/ducks/noop';
|
||||
import {
|
||||
OneTimeModalState,
|
||||
ComposerStep,
|
||||
} from '../../../state/ducks/conversationsEnums';
|
||||
import type {
|
||||
ConversationMessageType,
|
||||
ConversationType,
|
||||
|
@ -18,8 +22,6 @@ import type {
|
|||
} from '../../../state/ducks/conversations';
|
||||
import {
|
||||
actions,
|
||||
OneTimeModalState,
|
||||
ComposerStep,
|
||||
getConversationCallMode,
|
||||
getEmptyState,
|
||||
reducer,
|
||||
|
|
|
@ -2203,4 +2203,13 @@ export default class MessageSender {
|
|||
): Promise<string> {
|
||||
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',
|
||||
attachmentId: 'v2/attachments/form/upload',
|
||||
attestation: 'v1/attestation',
|
||||
challenge: 'v1/challenge',
|
||||
config: 'v1/config',
|
||||
deliveryCert: 'v1/certificate/delivery',
|
||||
devices: 'v1/devices',
|
||||
|
@ -520,8 +521,8 @@ const URL_CALLS = {
|
|||
storageToken: 'v1/storage/auth',
|
||||
supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery',
|
||||
updateDeviceName: 'v1/accounts/name',
|
||||
username: 'v1/accounts/username',
|
||||
whoami: 'v1/accounts/whoami',
|
||||
challenge: 'v1/challenge',
|
||||
};
|
||||
|
||||
const WEBSOCKET_CALLS = new Set<keyof typeof URL_CALLS>([
|
||||
|
@ -719,6 +720,7 @@ export type WebAPIType = {
|
|||
group: Proto.IGroup,
|
||||
options: GroupCredentialsType
|
||||
) => Promise<void>;
|
||||
deleteUsername: () => Promise<void>;
|
||||
getAttachment: (cdnKey: string, cdnNumber?: number) => Promise<Uint8Array>;
|
||||
getAvatar: (path: string) => Promise<Uint8Array>;
|
||||
getDevices: () => Promise<GetDevicesResultType>;
|
||||
|
@ -807,12 +809,13 @@ export type WebAPIType = {
|
|||
putProfile: (
|
||||
jsonData: ProfileRequestDataType
|
||||
) => Promise<UploadAvatarHeadersType | undefined>;
|
||||
registerCapabilities: (capabilities: CapabilitiesUploadType) => Promise<void>;
|
||||
putStickers: (
|
||||
encryptedManifest: Uint8Array,
|
||||
encryptedStickers: Array<Uint8Array>,
|
||||
onProgress?: () => void
|
||||
) => Promise<string>;
|
||||
putUsername: (newUsername: string) => Promise<void>;
|
||||
registerCapabilities: (capabilities: CapabilitiesUploadType) => Promise<void>;
|
||||
registerKeys: (genKeys: KeysType) => Promise<void>;
|
||||
registerSupportForUnauthenticatedDelivery: () => Promise<void>;
|
||||
reportMessage: (senderE164: string, serverGuid: string) => Promise<void>;
|
||||
|
@ -1014,6 +1017,7 @@ export function initialize({
|
|||
logout,
|
||||
confirmCode,
|
||||
createGroup,
|
||||
deleteUsername,
|
||||
fetchLinkPreviewImage,
|
||||
fetchLinkPreviewMetadata,
|
||||
getAttachment,
|
||||
|
@ -1047,6 +1051,7 @@ export function initialize({
|
|||
putAttachment,
|
||||
putProfile,
|
||||
putStickers,
|
||||
putUsername,
|
||||
registerCapabilities,
|
||||
registerKeys,
|
||||
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(
|
||||
senderE164: string,
|
||||
serverGuid: string
|
||||
|
|
|
@ -17,7 +17,7 @@ export type RenderTextCallbackType = (options: {
|
|||
}) => JSX.Element | string;
|
||||
|
||||
export type ReplacementValuesType = {
|
||||
[key: string]: string | undefined;
|
||||
[key: string]: string | number | undefined;
|
||||
};
|
||||
|
||||
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) {
|
||||
const key = c.get('profileKey');
|
||||
if (key) {
|
||||
|
|
16
yarn.lock
16
yarn.lock
|
@ -15417,10 +15417,10 @@ redux-ts-utils@3.2.2:
|
|||
dependencies:
|
||||
immer "^2.0.0"
|
||||
|
||||
redux@4.0.1, 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==
|
||||
redux@4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.2.tgz#597cc660a99f91412e31c96c3da10ed8ace0715d"
|
||||
integrity sha512-oAiFLWYQhbpSvzjcVfgQ90MlZ0u6uDIHFK41Q0/BnCfjEg96SACzwUFwDVUKz/LP/SwJORGaFY8AM5wOB/zf0A==
|
||||
dependencies:
|
||||
loose-envify "^1.4.0"
|
||||
symbol-observable "^1.2.0"
|
||||
|
@ -15435,6 +15435,14 @@ redux@^3.6.0:
|
|||
loose-envify "^1.1.0"
|
||||
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:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/ref-array-napi/-/ref-array-napi-1.2.2.tgz#8b172b375aa04441860973c262ac8a557e3d368e"
|
||||
|
|
Loading…
Reference in a new issue