Usernames: Create/update/delete in profile editor

This commit is contained in:
Scott Nonnenberg 2021-11-01 12:13:35 -07:00 committed by GitHub
parent a9cb621eb6
commit 3190f95fac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 923 additions and 89 deletions

View file

@ -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 couldnt 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 couldnt 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."

View file

@ -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",

View file

@ -33,6 +33,12 @@
background-size: cover;
background-position: center center;
}
@include keyboard-mode {
&:focus {
box-shadow: 0 0 0 3px $color-ultramarine;
}
}
}
&__group {

View file

@ -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;
}
}
}
}

View file

@ -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;

View file

@ -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>;

View file

@ -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(),

View file

@ -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,
};

View file

@ -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')}

View file

@ -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;

View file

@ -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',
})}
/>
));

View file

@ -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}

View file

@ -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>

View file

@ -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 && (

View 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} />
));

View 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>
);
};

View file

@ -23,8 +23,7 @@ export const ToastFileSize = ({
}: PropsType): JSX.Element => {
return (
<Toast onClose={onClose}>
{i18n('fileSizeWarning')}
{limit}
{i18n('fileSizeWarning')} {limit}
{units}
</Toast>
);

View file

@ -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
View file

@ -270,6 +270,7 @@ export type ConversationAttributesType = {
verified?: number;
profileLastFetchedAt?: number;
pendingUniversalTimer?: string;
username?: string;
// Group-only
groupId?: string;

View file

@ -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'),

View 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' }
);
}

View file

@ -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;
}

View 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,
}

View file

@ -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;

View file

@ -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 => {

View file

@ -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,
(

View file

@ -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 {

View file

@ -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 }>

View file

@ -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),
};
}

View file

@ -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 {

View file

@ -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 = {

View file

@ -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,

View file

@ -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,

View file

@ -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();
}
}

View file

@ -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

View file

@ -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 = {

View file

@ -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) {

View file

@ -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"