Discriminator in username
This commit is contained in:
parent
58f0012f14
commit
00f82a6d39
54 changed files with 2706 additions and 892 deletions
|
@ -4939,17 +4939,13 @@
|
|||
"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 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--title": {
|
||||
"message": "Choose your username",
|
||||
"description": "Title text for username modal"
|
||||
},
|
||||
"ProfileEditor--username--check-characters": {
|
||||
"message": "Usernames may only contain a-z, 0-9 and _",
|
||||
"description": "Shown if user has attempted to use forbidden characters"
|
||||
"description": "Shown if user has attempted to use forbidden characters in username"
|
||||
},
|
||||
"ProfileEditor--username--check-starting-character": {
|
||||
"message": "Usernames may not begin with a number.",
|
||||
|
@ -4963,6 +4959,10 @@
|
|||
"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"
|
||||
},
|
||||
"ProfileEditor--username--unavailable": {
|
||||
"message": "This username is not available",
|
||||
"description": "Shown if the username is not available for registration"
|
||||
},
|
||||
"ProfileEditor--username--check-username-taken": {
|
||||
"message": "This username is taken.",
|
||||
"description": "Shown if user has attempted to save a username which is not available"
|
||||
|
@ -4975,6 +4975,18 @@
|
|||
"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--copied-username": {
|
||||
"message": "Copied username",
|
||||
"description": "Shown when username is copied to clipboard."
|
||||
},
|
||||
"ProfileEditor--username--copied-username-link": {
|
||||
"message": "Copied link",
|
||||
"description": "Shown when username link is copied to clipboard."
|
||||
},
|
||||
"ProfileEditor--username--deleting-username": {
|
||||
"message": "Deleting username",
|
||||
"description": "Shown as aria label for spinner icon next to username"
|
||||
},
|
||||
"ProfileEditor--username--delete-username": {
|
||||
"message": "Delete username",
|
||||
"description": "Shown as aria label for trash icon next to username"
|
||||
|
@ -4987,6 +4999,22 @@
|
|||
"message": "Delete",
|
||||
"description": "Shown in dialog button if user has saved an empty string to delete their username"
|
||||
},
|
||||
"ProfileEditor--username--context-menu": {
|
||||
"message": "Copy or delete username",
|
||||
"description": "Shown as aria label for context menu next to username"
|
||||
},
|
||||
"ProfileEditor--username--copy": {
|
||||
"message": "Copy username",
|
||||
"description": "Shown as a button in context menu next to username. The action of the button is to put username into the clipboard."
|
||||
},
|
||||
"ProfileEditor--username--copy-link": {
|
||||
"message": "Copy link",
|
||||
"description": "Shown as a button in context menu next to username. The action of the button is to put a username link into the clipboard."
|
||||
},
|
||||
"ProfileEditor--username--delete": {
|
||||
"message": "Delete",
|
||||
"description": "Shown as a button in context menu next to username. The action of the button is to open a confirmation dialog for deleting username."
|
||||
},
|
||||
"ProfileEditor--about-placeholder": {
|
||||
"message": "Write something about yourself...",
|
||||
"description": "Placeholder text for about input field"
|
||||
|
@ -5011,6 +5039,10 @@
|
|||
"message": "Learn More",
|
||||
"description": "Text that links to a support article"
|
||||
},
|
||||
"ProfileEditor--learnMore": {
|
||||
"message": "Learn More",
|
||||
"description": "Text that links to a support article"
|
||||
},
|
||||
"Bio--speak-freely": {
|
||||
"message": "Speak Freely",
|
||||
"description": "A default bio option"
|
||||
|
@ -5875,6 +5907,26 @@
|
|||
"message": "Context menu",
|
||||
"description": "Default aria-label for the context menu buttons"
|
||||
},
|
||||
"EditUsernameModalBody__username-placeholder": {
|
||||
"message": "Username",
|
||||
"description": "Placeholder for the username field"
|
||||
},
|
||||
"EditUsernameModalBody__username-helper": {
|
||||
"message": "Usernames let others message you without needing your phone number. They are paired with a set of digits to help keep your address private.",
|
||||
"description": "Shown on the edit username screen"
|
||||
},
|
||||
"EditUsernameModalBody__learn-more": {
|
||||
"message": "Learn More",
|
||||
"description": "Text that open a popup with information about discriminator in username"
|
||||
},
|
||||
"EditUsernameModalBody__learn-more__title": {
|
||||
"message": "What is this number?",
|
||||
"description": "Title of the popup with information about discriminator in username"
|
||||
},
|
||||
"EditUsernameModalBody__learn-more__body": {
|
||||
"message": "These digits help keep your username private so you can avoid unwanted messages. Share you’re username with only the people and groups you’d like to chat with. If you change usernames you’ll get a new set of digits.",
|
||||
"description": "Body of the popup with information about discriminator in username"
|
||||
},
|
||||
"WhatsNew__modal-title": {
|
||||
"message": "What's New",
|
||||
"description": "Title for the whats new modal"
|
||||
|
|
1
images/icons/v2/hashtag-24.svg
Normal file
1
images/icons/v2/hashtag-24.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M17.5 7.917v-1.25h-3.467l.772-3.75h-1.276l-.772 3.75H8.616l.772-3.75H8.113l-.773 3.75H3.333v1.25h3.75l-.815 3.958H2.5v1.25h3.51l-.815 3.958h1.276l.815-3.958h4.14l-.814 3.958h1.275l.816-3.958h3.964v-1.25H12.96l.815-3.958H17.5Zm-5.816 3.958h-4.14l.814-3.958H12.5l-.816 3.958Z" fill="#1B1B1D"/></svg>
|
After Width: | Height: | Size: 381 B |
|
@ -6649,6 +6649,21 @@ button.module-image__border-overlay:focus {
|
|||
}
|
||||
}
|
||||
|
||||
&--profile-editor::after {
|
||||
@include light-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/add-emoji-outline-24.svg',
|
||||
$color-gray-75
|
||||
);
|
||||
}
|
||||
@include dark-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/add-emoji-outline-24.svg',
|
||||
$color-gray-15
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&--has-emoji {
|
||||
opacity: 1;
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ $color-accent-yellow: #ffd624;
|
|||
|
||||
$color-white: #ffffff;
|
||||
$color-gray-02: #f6f6f6;
|
||||
$color-gray-04: #f0f0f0;
|
||||
$color-gray-05: #e9e9e9;
|
||||
$color-gray-15: #dedede;
|
||||
$color-gray-20: #c6c6c6;
|
||||
|
|
|
@ -98,4 +98,19 @@
|
|||
&__popper--single-item &__option {
|
||||
padding: 12px 6px;
|
||||
}
|
||||
|
||||
&__divider {
|
||||
border-style: solid;
|
||||
border-width: 0 0 1px 0;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
|
||||
@include light-theme {
|
||||
border-color: $color-gray-15;
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
border-color: $color-gray-65;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
128
stylesheets/components/EditUsernameModalBody.scss
Normal file
128
stylesheets/components/EditUsernameModalBody.scss
Normal file
|
@ -0,0 +1,128 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
.EditUsernameModalBody {
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
&__large-at {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 32px;
|
||||
|
||||
margin-top: 21px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@include light-theme {
|
||||
background-color: $color-gray-04;
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
background-color: $color-gray-65;
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
-webkit-mask-size: 100%;
|
||||
content: '';
|
||||
|
||||
@include light-theme {
|
||||
background-color: $color-gray-75;
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
background-color: $color-gray-15;
|
||||
}
|
||||
|
||||
-webkit-mask: url(../images/icons/v2/at-24.svg) no-repeat center;
|
||||
}
|
||||
}
|
||||
|
||||
&__preview {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__divider {
|
||||
width: 2px;
|
||||
height: 20px;
|
||||
margin: 0 12px;
|
||||
|
||||
@include light-theme {
|
||||
background-color: $color-gray-20;
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
background-color: $color-gray-45;
|
||||
}
|
||||
}
|
||||
|
||||
&__discriminator {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&__error {
|
||||
@include font-body-2;
|
||||
margin: 16px 0;
|
||||
|
||||
color: $color-accent-red;
|
||||
}
|
||||
|
||||
&__info {
|
||||
@include font-body-2;
|
||||
margin: 16px 0;
|
||||
|
||||
@include light-theme {
|
||||
color: $color-gray-60;
|
||||
}
|
||||
@include dark-theme {
|
||||
color: $color-gray-25;
|
||||
}
|
||||
|
||||
// To account for missing error section - 16px previous margin, 34px for
|
||||
// 16px margin of error plus 18px line height.
|
||||
&--no-error {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
&__learn-more-button {
|
||||
@include button-reset;
|
||||
color: $color-accent-blue;
|
||||
}
|
||||
|
||||
&__learn-more {
|
||||
&__title.module-Modal__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__hashtag {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 12px;
|
||||
|
||||
-webkit-mask-size: 100%;
|
||||
content: '';
|
||||
|
||||
@include light-theme {
|
||||
background-color: $color-gray-75;
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
background-color: $color-gray-15;
|
||||
}
|
||||
|
||||
-webkit-mask: url(../images/icons/v2/hashtag-24.svg) no-repeat center;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,9 +8,14 @@
|
|||
border-style: solid;
|
||||
border-width: 2px;
|
||||
margin: 16px 0;
|
||||
padding: 8px 12px;
|
||||
padding: 2px 16px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
&--expandable {
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
@include light-theme {
|
||||
background: $color-white;
|
||||
border-color: $color-gray-15;
|
||||
|
@ -52,10 +57,8 @@
|
|||
&__icon {
|
||||
font-size: 24px;
|
||||
height: 32px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 32px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
|
@ -70,8 +73,8 @@
|
|||
height: 280px;
|
||||
}
|
||||
|
||||
&--with-icon {
|
||||
padding-left: 28px;
|
||||
&--expandable {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&:placeholder {
|
||||
|
@ -94,12 +97,10 @@
|
|||
&__controls {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: 22px;
|
||||
justify-content: flex-end;
|
||||
margin: 8px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
&__clear-icon {
|
||||
|
@ -119,10 +120,11 @@
|
|||
color: $color-gray-45;
|
||||
|
||||
&--large {
|
||||
bottom: 0;
|
||||
margin: 12px 24px 12px 12px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
|
||||
margin: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// Copyright 2021-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
.ProfileEditor {
|
||||
|
@ -7,9 +7,9 @@
|
|||
align-items: center;
|
||||
display: flex;
|
||||
font-size: 24px;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
|
@ -49,16 +49,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__about-input {
|
||||
&__icon {
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
&__input--with-icon {
|
||||
padding-left: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
&__row {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
@ -77,13 +67,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
@include font-body-2;
|
||||
margin: 16px 0;
|
||||
|
||||
color: $color-accent-red;
|
||||
}
|
||||
|
||||
&__info {
|
||||
@include font-body-2;
|
||||
margin: 16px 0;
|
||||
|
@ -99,11 +82,65 @@
|
|||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
// To account for missing error section - 16px previous margin, 34px for
|
||||
// 16px margin of error plus 18px line height.
|
||||
&--no-error {
|
||||
margin-bottom: 50px;
|
||||
&__username-menu {
|
||||
&__button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 4px;
|
||||
|
||||
@include dark-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/chevron-down-20.svg',
|
||||
$color-white
|
||||
);
|
||||
}
|
||||
@include light-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/chevron-down-20.svg',
|
||||
$color-black
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&__copy-icon {
|
||||
@include dark-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/copy-outline-24.svg',
|
||||
$color-white
|
||||
);
|
||||
}
|
||||
@include light-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/copy-outline-24.svg',
|
||||
$color-black
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&__copy-link-icon {
|
||||
@include dark-theme {
|
||||
@include color-svg('../images/icons/v2/link-24.svg', $color-white);
|
||||
}
|
||||
@include light-theme {
|
||||
@include color-svg('../images/icons/v2/link-24.svg', $color-black);
|
||||
}
|
||||
}
|
||||
|
||||
&__trash-icon {
|
||||
@include dark-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/trash-outline-24.svg',
|
||||
$color-white
|
||||
);
|
||||
}
|
||||
@include light-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/trash-outline-24.svg',
|
||||
$color-black
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -68,6 +68,7 @@
|
|||
@import './components/DisappearingTimeDialog.scss';
|
||||
@import './components/DisappearingTimerSelect.scss';
|
||||
@import './components/EditConversationAttributesModal.scss';
|
||||
@import './components/EditUsernameModalBody.scss';
|
||||
@import './components/ForwardMessageModal.scss';
|
||||
@import './components/GradientDial.scss';
|
||||
@import './components/GroupDescription.scss';
|
||||
|
|
|
@ -35,7 +35,9 @@ export type ConfigKeyType =
|
|||
| 'global.attachments.maxBytes'
|
||||
| 'global.calling.maxGroupCallRingSize'
|
||||
| 'global.groupsv2.groupSizeHardLimit'
|
||||
| 'global.groupsv2.maxGroupSize';
|
||||
| 'global.groupsv2.maxGroupSize'
|
||||
| 'global.nicknames.max'
|
||||
| 'global.nicknames.min';
|
||||
|
||||
type ConfigValueType = {
|
||||
name: ConfigKeyType;
|
||||
|
|
|
@ -1117,6 +1117,7 @@ export async function startApp(): Promise<void> {
|
|||
toast: bindActionCreators(actionCreators.toast, store.dispatch),
|
||||
updates: bindActionCreators(actionCreators.updates, store.dispatch),
|
||||
user: bindActionCreators(actionCreators.user, store.dispatch),
|
||||
username: bindActionCreators(actionCreators.username, store.dispatch),
|
||||
};
|
||||
|
||||
const {
|
||||
|
|
|
@ -15,20 +15,21 @@ import { getClassNamesFor } from '../util/getClassNamesFor';
|
|||
import { themeClassName } from '../util/theme';
|
||||
import { handleOutsideClick } from '../util/handleOutsideClick';
|
||||
|
||||
export type ContextMenuOptionType<T> = {
|
||||
readonly description?: string;
|
||||
readonly icon?: string;
|
||||
readonly label: string;
|
||||
readonly onClick: (value?: T) => unknown;
|
||||
readonly value?: T;
|
||||
};
|
||||
export type ContextMenuOptionType<T> = Readonly<{
|
||||
description?: string;
|
||||
icon?: string;
|
||||
label: string;
|
||||
group?: string;
|
||||
onClick: (value?: T) => unknown;
|
||||
value?: T;
|
||||
}>;
|
||||
|
||||
type RenderButtonProps = {
|
||||
type RenderButtonProps = Readonly<{
|
||||
openMenu: (() => void) | ((ev: React.MouseEvent) => void);
|
||||
onKeyDown: (ev: KeyboardEvent) => void;
|
||||
isMenuShowing: boolean;
|
||||
ref: React.Ref<HTMLButtonElement> | null;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type PropsType<T> = Readonly<{
|
||||
ariaLabel?: string;
|
||||
|
@ -176,6 +177,73 @@ export function ContextMenu<T>({
|
|||
|
||||
const getClassName = getClassNamesFor('ContextMenu', moduleClassName);
|
||||
|
||||
const optionElements = new Array<JSX.Element>();
|
||||
|
||||
for (const [index, option] of menuOptions.entries()) {
|
||||
const previous = menuOptions[index - 1];
|
||||
|
||||
const needsDivider = previous && previous.group !== option.group;
|
||||
|
||||
if (needsDivider) {
|
||||
optionElements.push(
|
||||
<div
|
||||
className={getClassName('__divider')}
|
||||
key={`${option.label}-divider`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-loop-func
|
||||
const onElementClick = (ev: React.MouseEvent): void => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
option.onClick(option.value);
|
||||
setIsMenuShowing(false);
|
||||
|
||||
closeCurrentOpenContextMenu = undefined;
|
||||
};
|
||||
|
||||
optionElements.push(
|
||||
<button
|
||||
aria-label={option.label}
|
||||
className={classNames(
|
||||
getClassName('__option'),
|
||||
focusedIndex === index ? getClassName('__option--focused') : undefined
|
||||
)}
|
||||
key={option.label}
|
||||
type="button"
|
||||
onClick={onElementClick}
|
||||
>
|
||||
<div className={getClassName('__option--container')}>
|
||||
{option.icon && (
|
||||
<div
|
||||
className={classNames(
|
||||
getClassName('__option--icon'),
|
||||
option.icon
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className={getClassName('__option--title')}>
|
||||
{option.label}
|
||||
</div>
|
||||
{option.description && (
|
||||
<div className={getClassName('__option--description')}>
|
||||
{option.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{typeof value !== 'undefined' &&
|
||||
typeof option.value !== 'undefined' &&
|
||||
value === option.value ? (
|
||||
<div className={getClassName('__option--selected')} />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
let buttonNode: ReactNode;
|
||||
if (typeof children === 'function') {
|
||||
buttonNode = (children as (props: RenderButtonProps) => JSX.Element)({
|
||||
|
@ -230,50 +298,7 @@ export function ContextMenu<T>({
|
|||
{...attributes.popper}
|
||||
>
|
||||
{title && <div className={getClassName('__title')}>{title}</div>}
|
||||
{menuOptions.map((option, index) => (
|
||||
<button
|
||||
aria-label={option.label}
|
||||
className={classNames(
|
||||
getClassName('__option'),
|
||||
focusedIndex === index
|
||||
? getClassName('__option--focused')
|
||||
: undefined
|
||||
)}
|
||||
key={option.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
option.onClick(option.value);
|
||||
setIsMenuShowing(false);
|
||||
closeCurrentOpenContextMenu = undefined;
|
||||
}}
|
||||
>
|
||||
<div className={getClassName('__option--container')}>
|
||||
{option.icon && (
|
||||
<div
|
||||
className={classNames(
|
||||
getClassName('__option--icon'),
|
||||
option.icon
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className={getClassName('__option--title')}>
|
||||
{option.label}
|
||||
</div>
|
||||
{option.description && (
|
||||
<div className={getClassName('__option--description')}>
|
||||
{option.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{typeof value !== 'undefined' &&
|
||||
typeof option.value !== 'undefined' &&
|
||||
value === option.value ? (
|
||||
<div className={getClassName('__option--selected')} />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
{optionElements}
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
|
|
161
ts/components/EditUsernameModalBody.stories.tsx
Normal file
161
ts/components/EditUsernameModalBody.stories.tsx
Normal file
|
@ -0,0 +1,161 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import type { Meta, Story } from '@storybook/react';
|
||||
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import type { UsernameReservationType } from '../types/Username';
|
||||
|
||||
import type { PropsType } from './EditUsernameModalBody';
|
||||
import { EditUsernameModalBody } from './EditUsernameModalBody';
|
||||
import {
|
||||
UsernameReservationState as State,
|
||||
UsernameReservationError,
|
||||
} from '../state/ducks/usernameEnums';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const DEFAULT_RESERVATION: UsernameReservationType = {
|
||||
username: 'reserved.56',
|
||||
previousUsername: undefined,
|
||||
reservationToken: 'unused token',
|
||||
};
|
||||
|
||||
export default {
|
||||
component: EditUsernameModalBody,
|
||||
title: 'Components/EditUsernameModalBody',
|
||||
argTypes: {
|
||||
currentUsername: {
|
||||
type: { name: 'string', required: false },
|
||||
defaultValue: undefined,
|
||||
},
|
||||
state: {
|
||||
control: { type: 'radio' },
|
||||
defaultValue: State.Open,
|
||||
options: {
|
||||
Open: State.Open,
|
||||
Closed: State.Closed,
|
||||
Reserving: State.Reserving,
|
||||
Confirming: State.Confirming,
|
||||
},
|
||||
},
|
||||
error: {
|
||||
control: { type: 'radio' },
|
||||
defaultValue: undefined,
|
||||
options: {
|
||||
None: undefined,
|
||||
NotEnoughCharacters: UsernameReservationError.NotEnoughCharacters,
|
||||
TooManyCharacters: UsernameReservationError.TooManyCharacters,
|
||||
CheckStartingCharacter: UsernameReservationError.CheckStartingCharacter,
|
||||
CheckCharacters: UsernameReservationError.CheckCharacters,
|
||||
UsernameNotAvailable: UsernameReservationError.UsernameNotAvailable,
|
||||
General: UsernameReservationError.General,
|
||||
},
|
||||
},
|
||||
discriminator: {
|
||||
type: { name: 'string', required: false },
|
||||
defaultValue: undefined,
|
||||
},
|
||||
i18n: {
|
||||
defaultValue: i18n,
|
||||
},
|
||||
onClose: { action: true },
|
||||
onError: { action: true },
|
||||
setUsernameReservationError: { action: true },
|
||||
reserveUsername: { action: true },
|
||||
confirmUsername: { action: true },
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
type ArgsType = PropsType & {
|
||||
discriminator?: string;
|
||||
reservation?: UsernameReservationType;
|
||||
};
|
||||
|
||||
const Template: Story<ArgsType> = args => {
|
||||
let { reservation } = args;
|
||||
if (!reservation && args.discriminator) {
|
||||
reservation = {
|
||||
username: `reserved.${args.discriminator}`,
|
||||
previousUsername: undefined,
|
||||
reservationToken: 'unused token',
|
||||
};
|
||||
}
|
||||
return <EditUsernameModalBody {...args} reservation={reservation} />;
|
||||
};
|
||||
|
||||
export const WithoutUsername = Template.bind({});
|
||||
WithoutUsername.args = {};
|
||||
WithoutUsername.story = {
|
||||
name: 'without current username',
|
||||
};
|
||||
|
||||
export const WithUsername = Template.bind({});
|
||||
WithUsername.args = {};
|
||||
WithUsername.story = {
|
||||
name: 'with current username',
|
||||
args: {
|
||||
currentUsername: 'signaluser.12',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithReservation = Template.bind({});
|
||||
WithReservation.args = {};
|
||||
WithReservation.story = {
|
||||
name: 'with reservation',
|
||||
args: {
|
||||
currentUsername: 'reserved',
|
||||
reservation: DEFAULT_RESERVATION,
|
||||
},
|
||||
};
|
||||
|
||||
export const UsernameEditingConfirming = Template.bind({});
|
||||
UsernameEditingConfirming.args = {
|
||||
state: State.Confirming,
|
||||
currentUsername: 'signaluser.12',
|
||||
};
|
||||
UsernameEditingConfirming.story = {
|
||||
name: 'Username editing, Confirming',
|
||||
};
|
||||
|
||||
export const UsernameEditingUsernameTaken = Template.bind({});
|
||||
UsernameEditingUsernameTaken.args = {
|
||||
state: State.Open,
|
||||
error: UsernameReservationError.UsernameNotAvailable,
|
||||
currentUsername: 'signaluser.12',
|
||||
};
|
||||
UsernameEditingUsernameTaken.story = {
|
||||
name: 'Username editing, username taken',
|
||||
};
|
||||
|
||||
export const UsernameEditingUsernameWrongCharacters = Template.bind({});
|
||||
UsernameEditingUsernameWrongCharacters.args = {
|
||||
state: State.Open,
|
||||
error: UsernameReservationError.CheckCharacters,
|
||||
currentUsername: 'signaluser.12',
|
||||
};
|
||||
UsernameEditingUsernameWrongCharacters.story = {
|
||||
name: 'Username editing, Wrong Characters',
|
||||
};
|
||||
|
||||
export const UsernameEditingUsernameTooShort = Template.bind({});
|
||||
UsernameEditingUsernameTooShort.args = {
|
||||
state: State.Open,
|
||||
error: UsernameReservationError.NotEnoughCharacters,
|
||||
currentUsername: 'sig',
|
||||
};
|
||||
UsernameEditingUsernameTooShort.story = {
|
||||
name: 'Username editing, username too short',
|
||||
};
|
||||
|
||||
export const UsernameEditingGeneralError = Template.bind({});
|
||||
UsernameEditingGeneralError.args = {
|
||||
state: State.Open,
|
||||
error: UsernameReservationError.General,
|
||||
currentUsername: 'signaluser.12',
|
||||
};
|
||||
UsernameEditingGeneralError.story = {
|
||||
name: 'Username editing, general error',
|
||||
};
|
270
ts/components/EditUsernameModalBody.tsx
Normal file
270
ts/components/EditUsernameModalBody.tsx
Normal file
|
@ -0,0 +1,270 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { UsernameReservationType } from '../types/Username';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import {
|
||||
getNickname,
|
||||
getDiscriminator,
|
||||
getMinNickname,
|
||||
getMaxNickname,
|
||||
} from '../util/Username';
|
||||
import {
|
||||
UsernameReservationState,
|
||||
UsernameReservationError,
|
||||
} from '../state/ducks/usernameEnums';
|
||||
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import { Input } from './Input';
|
||||
import { Spinner } from './Spinner';
|
||||
import { Modal } from './Modal';
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
|
||||
export type PropsDataType = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
currentUsername?: string;
|
||||
reservation?: UsernameReservationType;
|
||||
error?: UsernameReservationError;
|
||||
state: UsernameReservationState;
|
||||
}>;
|
||||
|
||||
export type ActionPropsDataType = Readonly<{
|
||||
setUsernameReservationError(
|
||||
error: UsernameReservationError | undefined
|
||||
): void;
|
||||
reserveUsername(nickname: string | undefined): void;
|
||||
confirmUsername(): void;
|
||||
}>;
|
||||
|
||||
export type ExternalPropsDataType = Readonly<{
|
||||
onClose(): void;
|
||||
}>;
|
||||
|
||||
export type PropsType = PropsDataType &
|
||||
ActionPropsDataType &
|
||||
ExternalPropsDataType;
|
||||
|
||||
export const EditUsernameModalBody = ({
|
||||
i18n,
|
||||
currentUsername,
|
||||
reserveUsername,
|
||||
confirmUsername,
|
||||
reservation,
|
||||
setUsernameReservationError,
|
||||
error,
|
||||
state,
|
||||
onClose,
|
||||
}: PropsType): JSX.Element => {
|
||||
const currentNickname = useMemo(() => {
|
||||
if (!currentUsername) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return getNickname(currentUsername);
|
||||
}, [currentUsername]);
|
||||
|
||||
const isReserving = state === UsernameReservationState.Reserving;
|
||||
const isConfirming = state === UsernameReservationState.Confirming;
|
||||
const canSave = !isReserving && !isConfirming && reservation !== undefined;
|
||||
|
||||
const [hasEverChanged, setHasEverChanged] = useState(false);
|
||||
const [nickname, setNickname] = useState(currentNickname);
|
||||
const [isLearnMoreVisible, setIsLearnMoreVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state === UsernameReservationState.Closed) {
|
||||
onClose();
|
||||
}
|
||||
}, [state, onClose]);
|
||||
|
||||
const discriminator = useMemo(() => {
|
||||
if (reservation !== undefined) {
|
||||
// New discriminator
|
||||
return getDiscriminator(reservation.username);
|
||||
}
|
||||
|
||||
// User never changed the nickname - return discriminator from the current
|
||||
// username.
|
||||
if (!hasEverChanged && currentUsername) {
|
||||
return getDiscriminator(currentUsername);
|
||||
}
|
||||
|
||||
// No reservation, different nickname - no discriminator
|
||||
return undefined;
|
||||
}, [reservation, hasEverChanged, currentUsername]);
|
||||
|
||||
const errorString = useMemo(() => {
|
||||
if (!error) {
|
||||
return undefined;
|
||||
}
|
||||
if (error === UsernameReservationError.NotEnoughCharacters) {
|
||||
return i18n('ProfileEditor--username--check-character-min', {
|
||||
min: getMinNickname(),
|
||||
});
|
||||
}
|
||||
if (error === UsernameReservationError.TooManyCharacters) {
|
||||
return i18n('ProfileEditor--username--check-character-max', {
|
||||
max: getMaxNickname(),
|
||||
});
|
||||
}
|
||||
if (error === UsernameReservationError.CheckStartingCharacter) {
|
||||
return i18n('ProfileEditor--username--check-starting-character');
|
||||
}
|
||||
if (error === UsernameReservationError.CheckCharacters) {
|
||||
return i18n('ProfileEditor--username--check-characters');
|
||||
}
|
||||
if (error === UsernameReservationError.UsernameNotAvailable) {
|
||||
return i18n('ProfileEditor--username--unavailable');
|
||||
}
|
||||
// Displayed through confirmation modal below
|
||||
if (error === UsernameReservationError.General) {
|
||||
return;
|
||||
}
|
||||
throw missingCaseError(error);
|
||||
}, [error, i18n]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial effect run
|
||||
if (!hasEverChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
reserveUsername(nickname);
|
||||
}, [hasEverChanged, nickname, reserveUsername]);
|
||||
|
||||
const onChange = useCallback((newNickname: string) => {
|
||||
setHasEverChanged(true);
|
||||
setNickname(newNickname);
|
||||
}, []);
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
confirmUsername();
|
||||
}, [confirmUsername]);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const onLearnMore = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
setIsLearnMoreVisible(true);
|
||||
}, []);
|
||||
|
||||
let title = i18n('ProfileEditor--username--title');
|
||||
if (nickname && discriminator) {
|
||||
title = `${nickname}${discriminator}`;
|
||||
}
|
||||
|
||||
const learnMoreTitle = (
|
||||
<>
|
||||
<i className="EditUsernameModalBody__learn-more__hashtag" />
|
||||
{i18n('EditUsernameModalBody__learn-more__title')}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="EditUsernameModalBody__header">
|
||||
<div className="EditUsernameModalBody__header__large-at" />
|
||||
|
||||
<div className="EditUsernameModalBody__header__preview">{title}</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
moduleClassName="Edit"
|
||||
i18n={i18n}
|
||||
disableSpellcheck
|
||||
disabled={isConfirming}
|
||||
onChange={onChange}
|
||||
onEnter={onSave}
|
||||
placeholder={i18n('EditUsernameModalBody__username-placeholder')}
|
||||
value={nickname}
|
||||
>
|
||||
{isReserving && <Spinner size="16px" svgSize="small" />}
|
||||
{discriminator && (
|
||||
<>
|
||||
<div className="EditUsernameModalBody__divider" />
|
||||
<div className="EditUsernameModalBody__discriminator">
|
||||
{discriminator}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Input>
|
||||
|
||||
{errorString && (
|
||||
<div className="EditUsernameModalBody__error">{errorString}</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
'EditUsernameModalBody__info',
|
||||
!errorString ? 'EditUsernameModalBody__info--no-error' : undefined
|
||||
)}
|
||||
>
|
||||
{i18n('EditUsernameModalBody__username-helper')}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="EditUsernameModalBody__learn-more-button"
|
||||
onClick={onLearnMore}
|
||||
>
|
||||
{i18n('EditUsernameModalBody__learn-more')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Modal.ButtonFooter>
|
||||
<Button
|
||||
disabled={isConfirming}
|
||||
onClick={onCancel}
|
||||
variant={ButtonVariant.Secondary}
|
||||
>
|
||||
{i18n('cancel')}
|
||||
</Button>
|
||||
<Button disabled={!canSave} onClick={onSave}>
|
||||
{isConfirming ? (
|
||||
<Spinner size="20px" svgSize="small" direction="on-avatar" />
|
||||
) : (
|
||||
i18n('save')
|
||||
)}
|
||||
</Button>
|
||||
</Modal.ButtonFooter>
|
||||
|
||||
{isLearnMoreVisible && (
|
||||
<Modal
|
||||
modalName="EditUsernamModalBody.LearnMore"
|
||||
moduleClassName="EditUsernameModalBody__learn-more"
|
||||
i18n={i18n}
|
||||
onClose={() => setIsLearnMoreVisible(false)}
|
||||
title={learnMoreTitle}
|
||||
>
|
||||
{i18n('EditUsernameModalBody__learn-more__body')}
|
||||
|
||||
<Modal.ButtonFooter>
|
||||
<Button
|
||||
onClick={() => setIsLearnMoreVisible(false)}
|
||||
variant={ButtonVariant.Secondary}
|
||||
>
|
||||
{i18n('ok')}
|
||||
</Button>
|
||||
</Modal.ButtonFooter>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{error === UsernameReservationError.General && (
|
||||
<ConfirmationDialog
|
||||
dialogName="EditUsernameModalBody.generalError"
|
||||
cancelText={i18n('ok')}
|
||||
cancelButtonVariant={ButtonVariant.Secondary}
|
||||
i18n={i18n}
|
||||
onClose={() => setUsernameReservationError(undefined)}
|
||||
>
|
||||
{i18n('ProfileEditor--username--general-error')}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -34,6 +34,7 @@ export type PropsType = {
|
|||
placeholder: string;
|
||||
value?: string;
|
||||
whenToShowRemainingCount?: number;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -75,6 +76,7 @@ export const Input = forwardRef<
|
|||
placeholder,
|
||||
value = '',
|
||||
whenToShowRemainingCount = Infinity,
|
||||
children,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
|
@ -201,7 +203,8 @@ export const Input = forwardRef<
|
|||
className: classNames(
|
||||
getClassName('__input'),
|
||||
icon && getClassName('__input--with-icon'),
|
||||
isLarge && getClassName('__input--large')
|
||||
isLarge && getClassName('__input--large'),
|
||||
expandable && getClassName('__input--expandable')
|
||||
),
|
||||
disabled: Boolean(disabled),
|
||||
spellCheck: !disableSpellcheck,
|
||||
|
@ -238,15 +241,21 @@ export const Input = forwardRef<
|
|||
<div
|
||||
className={classNames(
|
||||
getClassName('__container'),
|
||||
expandable && getClassName('__container--expandable'),
|
||||
disabled && getClassName('__container--disabled')
|
||||
)}
|
||||
>
|
||||
{icon ? <div className={getClassName('__icon')}>{icon}</div> : null}
|
||||
{expandable ? <textarea {...inputProps} /> : <input {...inputProps} />}
|
||||
{expandable ? (
|
||||
<textarea rows={1} {...inputProps} />
|
||||
) : (
|
||||
<input {...inputProps} />
|
||||
)}
|
||||
{isLarge ? (
|
||||
<>
|
||||
<div className={getClassName('__controls')}>
|
||||
{clearButtonElement}
|
||||
{children}
|
||||
</div>
|
||||
<div className={getClassName('__remaining-count--large')}>
|
||||
{lengthCountElement}
|
||||
|
@ -256,6 +265,7 @@ export const Input = forwardRef<
|
|||
<div className={getClassName('__controls')}>
|
||||
{lengthCountElement}
|
||||
{clearButtonElement}
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -2,14 +2,19 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Meta, Story } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import React, { useState } from 'react';
|
||||
import casual from 'casual';
|
||||
|
||||
import type { PropsType } from './ProfileEditor';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { ProfileEditor } from './ProfileEditor';
|
||||
import { EditUsernameModalBody } from './EditUsernameModalBody';
|
||||
import {
|
||||
UsernameEditState,
|
||||
UsernameReservationState,
|
||||
} from '../state/ducks/usernameEnums';
|
||||
import { UUID } from '../types/UUID';
|
||||
import { UsernameSaveState } from '../state/ducks/conversationsEnums';
|
||||
import { getRandomColor } from '../test-both/helpers/getRandomColor';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
|
||||
|
@ -28,7 +33,6 @@ export default {
|
|||
profileAvatarPath: {
|
||||
defaultValue: undefined,
|
||||
},
|
||||
clearUsernameSave: { action: true },
|
||||
conversationId: {
|
||||
defaultValue: UUID.generate().toString(),
|
||||
},
|
||||
|
@ -49,15 +53,27 @@ export default {
|
|||
control: { type: 'checkbox' },
|
||||
defaultValue: false,
|
||||
},
|
||||
usernameEditState: {
|
||||
control: { type: 'radio' },
|
||||
defaultValue: UsernameEditState.Editing,
|
||||
options: {
|
||||
Editing: UsernameEditState.Editing,
|
||||
ConfirmingDelete: UsernameEditState.ConfirmingDelete,
|
||||
Deleting: UsernameEditState.Deleting,
|
||||
},
|
||||
},
|
||||
onEditStateChanged: { action: true },
|
||||
onProfileChanged: { action: true },
|
||||
onSetSkinTone: { action: true },
|
||||
showToast: { action: true },
|
||||
recentEmojis: {
|
||||
defaultValue: [],
|
||||
},
|
||||
replaceAvatar: { action: true },
|
||||
saveAvatarToDisk: { action: true },
|
||||
saveUsername: { action: true },
|
||||
openUsernameReservationModal: { action: true },
|
||||
setUsernameEditState: { action: true },
|
||||
deleteUsername: { action: true },
|
||||
skinTone: {
|
||||
defaultValue: 0,
|
||||
},
|
||||
|
@ -65,29 +81,37 @@ export default {
|
|||
defaultValue: [],
|
||||
},
|
||||
username: {
|
||||
defaultValue: casual.username,
|
||||
},
|
||||
usernameSaveState: {
|
||||
control: { type: 'radio' },
|
||||
defaultValue: UsernameSaveState.None,
|
||||
options: {
|
||||
None: UsernameSaveState.None,
|
||||
Saving: UsernameSaveState.Saving,
|
||||
UsernameTakenError: UsernameSaveState.UsernameTakenError,
|
||||
UsernameMalformedError: UsernameSaveState.UsernameMalformedError,
|
||||
GeneralError: UsernameSaveState.GeneralError,
|
||||
DeleteFailed: UsernameSaveState.DeleteFailed,
|
||||
Success: UsernameSaveState.Success,
|
||||
},
|
||||
defaultValue: undefined,
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
function renderEditUsernameModalBody(props: {
|
||||
onClose: () => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<EditUsernameModalBody
|
||||
i18n={i18n}
|
||||
state={UsernameReservationState.Open}
|
||||
error={undefined}
|
||||
setUsernameReservationError={action('setUsernameReservationError')}
|
||||
reserveUsername={action('reserveUsername')}
|
||||
confirmUsername={action('confirmUsername')}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Template: Story<PropsType> = args => {
|
||||
const [skinTone, setSkinTone] = useState(0);
|
||||
|
||||
return (
|
||||
<ProfileEditor {...args} skinTone={skinTone} onSetSkinTone={setSkinTone} />
|
||||
<ProfileEditor
|
||||
{...args}
|
||||
skinTone={skinTone}
|
||||
onSetSkinTone={setSkinTone}
|
||||
renderEditUsernameModalBody={renderEditUsernameModalBody}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -128,48 +152,22 @@ WithUsernameFlagEnabled.story = {
|
|||
export const WithUsernameFlagEnabledAndUsername = Template.bind({});
|
||||
WithUsernameFlagEnabledAndUsername.args = {
|
||||
isUsernameFlagEnabled: true,
|
||||
username: casual.username,
|
||||
username: 'signaluser.123',
|
||||
};
|
||||
WithUsernameFlagEnabledAndUsername.story = {
|
||||
name: 'with Username flag enabled and username',
|
||||
};
|
||||
|
||||
export const UsernameEditingSaving = Template.bind({});
|
||||
UsernameEditingSaving.args = {
|
||||
export const DeletingUsername = Template.bind({});
|
||||
DeletingUsername.args = {
|
||||
isUsernameFlagEnabled: true,
|
||||
usernameSaveState: UsernameSaveState.Saving,
|
||||
username: casual.username,
|
||||
};
|
||||
UsernameEditingSaving.story = {
|
||||
name: 'Username editing, saving',
|
||||
username: 'signaluser.123',
|
||||
usernameEditState: UsernameEditState.Deleting,
|
||||
};
|
||||
|
||||
export const UsernameEditingUsernameTaken = Template.bind({});
|
||||
UsernameEditingUsernameTaken.args = {
|
||||
export const ConfirmingDelete = Template.bind({});
|
||||
ConfirmingDelete.args = {
|
||||
isUsernameFlagEnabled: true,
|
||||
usernameSaveState: UsernameSaveState.UsernameTakenError,
|
||||
username: casual.username,
|
||||
};
|
||||
UsernameEditingUsernameTaken.story = {
|
||||
name: 'Username editing, username taken',
|
||||
};
|
||||
|
||||
export const UsernameEditingUsernameMalformed = Template.bind({});
|
||||
UsernameEditingUsernameMalformed.args = {
|
||||
isUsernameFlagEnabled: true,
|
||||
usernameSaveState: UsernameSaveState.UsernameMalformedError,
|
||||
username: casual.username,
|
||||
};
|
||||
UsernameEditingUsernameMalformed.story = {
|
||||
name: 'Username editing, username malformed',
|
||||
};
|
||||
|
||||
export const UsernameEditingGeneralError = Template.bind({});
|
||||
UsernameEditingGeneralError.args = {
|
||||
isUsernameFlagEnabled: true,
|
||||
usernameSaveState: UsernameSaveState.GeneralError,
|
||||
username: casual.username,
|
||||
};
|
||||
UsernameEditingGeneralError.story = {
|
||||
name: 'Username editing, general error',
|
||||
username: 'signaluser.123',
|
||||
usernameEditState: UsernameEditState.ConfirmingDelete,
|
||||
};
|
||||
|
|
|
@ -2,9 +2,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import * as log from '../logging/log';
|
||||
import type { AvatarColorType } from '../types/Colors';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import type {
|
||||
|
@ -20,25 +18,28 @@ import { Button, ButtonVariant } from './Button';
|
|||
import { ConfirmDiscardDialog } from './ConfirmDiscardDialog';
|
||||
import { Emoji } from './emoji/Emoji';
|
||||
import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
|
||||
import { EmojiButton } from './emoji/EmojiButton';
|
||||
import { EmojiButton, EmojiButtonVariant } from './emoji/EmojiButton';
|
||||
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||
import { Input } from './Input';
|
||||
import { Intl } from './Intl';
|
||||
import type { LocalizerType, ReplacementValuesType } from '../types/Util';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { Modal } from './Modal';
|
||||
import { PanelRow } from './conversation/conversation-details/PanelRow';
|
||||
import type { ProfileDataType } from '../state/ducks/conversations';
|
||||
import { UsernameEditState } from '../state/ducks/usernameEnums';
|
||||
import { ToastType } from '../state/ducks/toast';
|
||||
import type { ShowToastActionCreatorType } from '../state/ducks/toast';
|
||||
import { getEmojiData, unifiedToEmoji } from './emoji/lib';
|
||||
import { assertDev } from '../util/assert';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import {
|
||||
ConversationDetailsIcon,
|
||||
IconType,
|
||||
} from './conversation/conversation-details/ConversationDetailsIcon';
|
||||
import { Spinner } from './Spinner';
|
||||
import { UsernameSaveState } from '../state/ducks/conversationsEnums';
|
||||
import { MAX_USERNAME, MIN_USERNAME } from '../types/Username';
|
||||
import { isWhitespace, trim } from '../util/whitespaceStringUtil';
|
||||
import { generateUsernameLink } from '../util/sgnlHref';
|
||||
import { Emojify } from './conversation/Emojify';
|
||||
|
||||
export enum EditState {
|
||||
|
@ -49,19 +50,13 @@ export enum EditState {
|
|||
Username = 'Username',
|
||||
}
|
||||
|
||||
enum UsernameEditState {
|
||||
Editing = 'Editing',
|
||||
ConfirmingDelete = 'ConfirmingDelete',
|
||||
ShowingErrorPopup = 'ShowingErrorPopup',
|
||||
Saving = 'Saving',
|
||||
}
|
||||
|
||||
type PropsExternalType = {
|
||||
onEditStateChanged: (editState: EditState) => unknown;
|
||||
onProfileChanged: (
|
||||
profileData: ProfileDataType,
|
||||
avatar: AvatarUpdateType
|
||||
) => unknown;
|
||||
renderEditUsernameModalBody: (props: { onClose: () => void }) => JSX.Element;
|
||||
};
|
||||
|
||||
export type PropsDataType = {
|
||||
|
@ -74,21 +69,20 @@ export type PropsDataType = {
|
|||
firstName: string;
|
||||
i18n: LocalizerType;
|
||||
isUsernameFlagEnabled: boolean;
|
||||
usernameSaveState: UsernameSaveState;
|
||||
userAvatarData: Array<AvatarDataType>;
|
||||
username?: string;
|
||||
usernameEditState: UsernameEditState;
|
||||
} & 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;
|
||||
setUsernameEditState: (editState: UsernameEditState) => void;
|
||||
deleteUsername: () => void;
|
||||
showToast: ShowToastActionCreatorType;
|
||||
openUsernameReservationModal: () => void;
|
||||
};
|
||||
|
||||
export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
|
||||
|
@ -121,103 +115,13 @@ const DEFAULT_BIOS: Array<DefaultBio> = [
|
|||
},
|
||||
];
|
||||
|
||||
function getUsernameInvalidKey(
|
||||
username: string | undefined
|
||||
): { key: string; replacements?: ReplacementValuesType } | undefined {
|
||||
if (!username) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (username.length < MIN_USERNAME) {
|
||||
return {
|
||||
key: 'ProfileEditor--username--check-character-min',
|
||||
replacements: { min: MIN_USERNAME },
|
||||
};
|
||||
}
|
||||
|
||||
if (!/^[0-9a-z_]+$/.test(username)) {
|
||||
return { key: 'ProfileEditor--username--check-characters' };
|
||||
}
|
||||
if (!/^[a-z_]/.test(username)) {
|
||||
return { key: 'ProfileEditor--username--check-starting-character' };
|
||||
}
|
||||
|
||||
if (username.length > MAX_USERNAME) {
|
||||
return {
|
||||
key: 'ProfileEditor--username--check-character-max',
|
||||
replacements: { max: MAX_USERNAME },
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
profileAvatarPath,
|
||||
clearUsernameSave,
|
||||
color,
|
||||
conversationId,
|
||||
deleteAvatarFromDisk,
|
||||
deleteUsername,
|
||||
familyName,
|
||||
firstName,
|
||||
i18n,
|
||||
|
@ -225,14 +129,18 @@ export const ProfileEditor = ({
|
|||
onEditStateChanged,
|
||||
onProfileChanged,
|
||||
onSetSkinTone,
|
||||
openUsernameReservationModal,
|
||||
profileAvatarPath,
|
||||
recentEmojis,
|
||||
renderEditUsernameModalBody,
|
||||
replaceAvatar,
|
||||
saveAvatarToDisk,
|
||||
saveUsername,
|
||||
setUsernameEditState,
|
||||
showToast,
|
||||
skinTone,
|
||||
userAvatarData,
|
||||
username,
|
||||
usernameSaveState,
|
||||
usernameEditState,
|
||||
}: PropsType): JSX.Element => {
|
||||
const focusInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [editState, setEditState] = useState<EditState>(EditState.None);
|
||||
|
@ -250,12 +158,6 @@ 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 [startingAvatarPath, setStartingAvatarPath] =
|
||||
useState(profileAvatarPath);
|
||||
|
||||
|
@ -275,6 +177,13 @@ export const ProfileEditor = ({
|
|||
firstName,
|
||||
});
|
||||
|
||||
// Reset username edit state when leaving
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setUsernameEditState(UsernameEditState.Editing);
|
||||
};
|
||||
}, [setUsernameEditState]);
|
||||
|
||||
// To make AvatarEditor re-render less often
|
||||
const handleBack = useCallback(() => {
|
||||
setEditState(EditState.None);
|
||||
|
@ -334,91 +243,6 @@ 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 => {
|
||||
|
@ -550,6 +374,7 @@ export const ProfileEditor = ({
|
|||
icon={
|
||||
<div className="module-composition-area__button-cell">
|
||||
<EmojiButton
|
||||
variant={EmojiButtonVariant.ProfileEditor}
|
||||
closeOnPick
|
||||
emoji={stagedProfile.aboutEmoji}
|
||||
i18n={i18n}
|
||||
|
@ -651,68 +476,95 @@ export const ProfileEditor = ({
|
|||
</>
|
||||
);
|
||||
} else if (editState === EditState.Username) {
|
||||
content = (
|
||||
<>
|
||||
<Input
|
||||
i18n={i18n}
|
||||
disabled={isCurrentlySaving}
|
||||
disableSpellcheck
|
||||
onChange={changedUsername => {
|
||||
setUsernameError(undefined);
|
||||
setNewUsername(changedUsername);
|
||||
}}
|
||||
onEnter={checkThenSaveUsername}
|
||||
placeholder={i18n('ProfileEditor--username--placeholder')}
|
||||
ref={focusInputRef}
|
||||
value={newUsername}
|
||||
/>
|
||||
|
||||
{usernameError && (
|
||||
<div className="ProfileEditor__error">{usernameError}</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
'ProfileEditor__info',
|
||||
!usernameError ? 'ProfileEditor__info--no-error' : undefined
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
content = renderEditUsernameModalBody({
|
||||
onClose: () => setEditState(EditState.None),
|
||||
});
|
||||
} else if (editState === EditState.None) {
|
||||
let maybeUsernameRow: JSX.Element | undefined;
|
||||
if (isUsernameFlagEnabled) {
|
||||
let actions: JSX.Element | undefined;
|
||||
|
||||
if (usernameEditState === UsernameEditState.Deleting) {
|
||||
actions = (
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('ProfileEditor--username--deleting-username')}
|
||||
icon={IconType.spinner}
|
||||
disabled
|
||||
fakeButton
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const menuOptions = [
|
||||
{
|
||||
group: 'copy',
|
||||
icon: 'ProfileEditor__username-menu__copy-icon',
|
||||
label: i18n('ProfileEditor--username--copy'),
|
||||
onClick: () => {
|
||||
assertDev(
|
||||
username !== undefined,
|
||||
'Should not be visible without username'
|
||||
);
|
||||
window.navigator.clipboard.writeText(username);
|
||||
showToast(ToastType.CopiedUsername);
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'copy',
|
||||
icon: 'ProfileEditor__username-menu__copy-link-icon',
|
||||
label: i18n('ProfileEditor--username--copy-link'),
|
||||
onClick: () => {
|
||||
assertDev(
|
||||
username !== undefined,
|
||||
'Should not be visible without username'
|
||||
);
|
||||
window.navigator.clipboard.writeText(
|
||||
generateUsernameLink(username)
|
||||
);
|
||||
showToast(ToastType.CopiedUsernameLink);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Different group to display a divider above it
|
||||
group: 'delete',
|
||||
|
||||
icon: 'ProfileEditor__username-menu__trash-icon',
|
||||
label: i18n('ProfileEditor--username--delete'),
|
||||
onClick: () => {
|
||||
setUsernameEditState(UsernameEditState.ConfirmingDelete);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (username) {
|
||||
actions = (
|
||||
<ContextMenu
|
||||
i18n={i18n}
|
||||
menuOptions={menuOptions}
|
||||
popperOptions={{ placement: 'bottom', strategy: 'absolute' }}
|
||||
moduleClassName="ProfileEditor__username-menu"
|
||||
ariaLabel={i18n('ProfileEditor--username--context-menu')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
maybeUsernameRow = (
|
||||
<PanelRow
|
||||
className="ProfileEditor__row"
|
||||
icon={
|
||||
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--username" />
|
||||
}
|
||||
label={username || i18n('ProfileEditor--username')}
|
||||
info={username && generateUsernameLink(username, { short: true })}
|
||||
onClick={() => {
|
||||
openUsernameReservationModal();
|
||||
setEditState(EditState.Username);
|
||||
}}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
content = (
|
||||
<>
|
||||
<AvatarPreview
|
||||
|
@ -742,40 +594,7 @@ 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}
|
||||
{maybeUsernameRow}
|
||||
<PanelRow
|
||||
className="ProfileEditor__row"
|
||||
icon={
|
||||
|
@ -836,17 +655,7 @@ export const ProfileEditor = ({
|
|||
{i18n('ProfileEditor--username--confirm-delete-body')}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
{usernameEditState === UsernameEditState.ShowingErrorPopup && (
|
||||
<ConfirmationDialog
|
||||
dialogName="ProfileEditor.usernameError"
|
||||
cancelText={i18n('ok')}
|
||||
cancelButtonVariant={ButtonVariant.Secondary}
|
||||
i18n={i18n}
|
||||
onClose={() => setUsernameEditState(UsernameEditState.Editing)}
|
||||
>
|
||||
{i18n('ProfileEditor--username--general-error')}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
|
||||
{confirmDiscardAction && (
|
||||
<ConfirmDiscardDialog
|
||||
i18n={i18n}
|
||||
|
|
|
@ -11,7 +11,7 @@ import type { AvatarUpdateType } from '../types/Avatar';
|
|||
|
||||
export type PropsDataType = {
|
||||
hasError: boolean;
|
||||
};
|
||||
} & Pick<ProfileEditorPropsType, 'renderEditUsernameModalBody'>;
|
||||
|
||||
type PropsType = {
|
||||
myProfileChanged: (
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from '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'),
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'Components/ToastFailedToDeleteUsername',
|
||||
};
|
||||
|
||||
export const _ToastFailedToDeleteUsername = (): JSX.Element => (
|
||||
<ToastFailedToDeleteUsername {...defaultProps} />
|
||||
);
|
||||
|
||||
_ToastFailedToDeleteUsername.story = {
|
||||
name: 'ToastFailedToDeleteUsername',
|
||||
};
|
|
@ -1,22 +0,0 @@
|
|||
// 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>
|
||||
);
|
||||
};
|
|
@ -7,7 +7,7 @@ import { SECOND } from '../util/durations';
|
|||
import { Toast } from './Toast';
|
||||
import { ToastMessageBodyTooLong } from './ToastMessageBodyTooLong';
|
||||
import { ToastType } from '../state/ducks/toast';
|
||||
import { strictAssert } from '../util/assert';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
|
||||
export type PropsType = {
|
||||
hideToast: () => unknown;
|
||||
|
@ -25,7 +25,12 @@ export const ToastManager = ({
|
|||
i18n,
|
||||
toast,
|
||||
}: PropsType): JSX.Element | null => {
|
||||
if (toast?.toastType === ToastType.Error) {
|
||||
if (toast === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { toastType } = toast;
|
||||
if (toastType === ToastType.Error) {
|
||||
return (
|
||||
<Toast
|
||||
autoDismissDisabled
|
||||
|
@ -40,11 +45,11 @@ export const ToastManager = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (toast?.toastType === ToastType.MessageBodyTooLong) {
|
||||
if (toastType === ToastType.MessageBodyTooLong) {
|
||||
return <ToastMessageBodyTooLong i18n={i18n} onClose={hideToast} />;
|
||||
}
|
||||
|
||||
if (toast?.toastType === ToastType.StoryReact) {
|
||||
if (toastType === ToastType.StoryReact) {
|
||||
return (
|
||||
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
|
||||
{i18n('Stories__toast--sending-reaction')}
|
||||
|
@ -52,7 +57,7 @@ export const ToastManager = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (toast?.toastType === ToastType.StoryReply) {
|
||||
if (toastType === ToastType.StoryReply) {
|
||||
return (
|
||||
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
|
||||
{i18n('Stories__toast--sending-reply')}
|
||||
|
@ -60,7 +65,7 @@ export const ToastManager = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (toast?.toastType === ToastType.StoryMuted) {
|
||||
if (toastType === ToastType.StoryMuted) {
|
||||
return (
|
||||
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
|
||||
{i18n('Stories__toast--hasNoSound')}
|
||||
|
@ -68,7 +73,7 @@ export const ToastManager = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (toast?.toastType === ToastType.StoryVideoTooLong) {
|
||||
if (toastType === ToastType.StoryVideoTooLong) {
|
||||
return (
|
||||
<Toast onClose={hideToast}>
|
||||
{i18n('StoryCreator__error--video-too-long')}
|
||||
|
@ -76,7 +81,7 @@ export const ToastManager = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (toast?.toastType === ToastType.StoryVideoUnsupported) {
|
||||
if (toastType === ToastType.StoryVideoUnsupported) {
|
||||
return (
|
||||
<Toast onClose={hideToast}>
|
||||
{i18n('StoryCreator__error--video-unsupported')}
|
||||
|
@ -84,7 +89,7 @@ export const ToastManager = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (toast?.toastType === ToastType.StoryVideoError) {
|
||||
if (toastType === ToastType.StoryVideoError) {
|
||||
return (
|
||||
<Toast onClose={hideToast}>
|
||||
{i18n('StoryCreator__error--video-error')}
|
||||
|
@ -92,7 +97,7 @@ export const ToastManager = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (toast?.toastType === ToastType.AddingUserToGroup) {
|
||||
if (toastType === ToastType.AddingUserToGroup) {
|
||||
return (
|
||||
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
|
||||
{i18n(
|
||||
|
@ -103,7 +108,7 @@ export const ToastManager = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (toast?.toastType === ToastType.UserAddedToGroup) {
|
||||
if (toastType === ToastType.UserAddedToGroup) {
|
||||
return (
|
||||
<Toast onClose={hideToast}>
|
||||
{i18n(
|
||||
|
@ -114,10 +119,29 @@ export const ToastManager = ({
|
|||
);
|
||||
}
|
||||
|
||||
strictAssert(
|
||||
toast === undefined,
|
||||
`Unhandled toast of type: ${toast?.toastType}`
|
||||
if (toastType === ToastType.FailedToDeleteUsername) {
|
||||
return (
|
||||
<Toast onClose={hideToast}>
|
||||
{i18n('ProfileEditor--username--delete-general-error')}
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
if (toastType === ToastType.CopiedUsername) {
|
||||
return (
|
||||
<Toast onClose={hideToast} timeout={3 * SECOND}>
|
||||
{i18n('ProfileEditor--username--copied-username')}
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
if (toastType === ToastType.CopiedUsernameLink) {
|
||||
return (
|
||||
<Toast onClose={hideToast} timeout={3 * SECOND}>
|
||||
{i18n('ProfileEditor--username--copied-username-link')}
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
throw missingCaseError(toastType);
|
||||
};
|
||||
|
|
|
@ -14,7 +14,7 @@ import type { MeasuredComponentProps } from 'react-measure';
|
|||
import Measure from 'react-measure';
|
||||
|
||||
import type { LocalizerType, ThemeType } from '../../../../types/Util';
|
||||
import { getUsernameFromSearch } from '../../../../types/Username';
|
||||
import { getUsernameFromSearch } from '../../../../util/Username';
|
||||
import { refMerger } from '../../../../util/refMerger';
|
||||
import { useRestoreFocus } from '../../../../hooks/useRestoreFocus';
|
||||
import { missingCaseError } from '../../../../util/missingCaseError';
|
||||
|
@ -94,21 +94,10 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
|
|||
|
||||
const phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode);
|
||||
|
||||
let isPhoneNumberChecked = false;
|
||||
if (phoneNumber) {
|
||||
isPhoneNumberChecked =
|
||||
phoneNumber.isValid &&
|
||||
selectedContacts.some(contact => contact.e164 === phoneNumber.e164);
|
||||
}
|
||||
|
||||
const isPhoneNumberVisible =
|
||||
phoneNumber &&
|
||||
candidateContacts.every(contact => contact.e164 !== phoneNumber.e164);
|
||||
|
||||
let username: string | undefined;
|
||||
let isUsernameChecked = false;
|
||||
let isUsernameVisible = false;
|
||||
if (!phoneNumber && isUsernamesEnabled) {
|
||||
if (isUsernamesEnabled) {
|
||||
username = getUsernameFromSearch(searchTerm);
|
||||
|
||||
isUsernameChecked = selectedContacts.some(
|
||||
|
@ -120,6 +109,17 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
|
|||
candidateContacts.every(contact => contact.username !== username);
|
||||
}
|
||||
|
||||
let isPhoneNumberChecked = false;
|
||||
if (!username && phoneNumber) {
|
||||
isPhoneNumberChecked =
|
||||
phoneNumber.isValid &&
|
||||
selectedContacts.some(contact => contact.e164 === phoneNumber.e164);
|
||||
}
|
||||
|
||||
const isPhoneNumberVisible =
|
||||
phoneNumber &&
|
||||
candidateContacts.every(contact => contact.e164 !== phoneNumber.e164);
|
||||
|
||||
const inputRef = useRef<null | HTMLInputElement>(null);
|
||||
|
||||
const numberOfContactsAlreadyInGroup = conversationIdsAlreadyInGroup.size;
|
||||
|
|
|
@ -15,6 +15,11 @@ import { useRefMerger } from '../../hooks/useRefMerger';
|
|||
import { handleOutsideClick } from '../../util/handleOutsideClick';
|
||||
import * as KeyboardLayout from '../../services/keyboardLayout';
|
||||
|
||||
export enum EmojiButtonVariant {
|
||||
Normal,
|
||||
ProfileEditor,
|
||||
}
|
||||
|
||||
export type OwnProps = Readonly<{
|
||||
className?: string;
|
||||
closeOnPick?: boolean;
|
||||
|
@ -22,6 +27,7 @@ export type OwnProps = Readonly<{
|
|||
i18n: LocalizerType;
|
||||
onClose?: () => unknown;
|
||||
emojiButtonApi?: MutableRefObject<EmojiButtonAPI | undefined>;
|
||||
variant?: EmojiButtonVariant;
|
||||
}>;
|
||||
|
||||
export type Props = OwnProps &
|
||||
|
@ -47,6 +53,7 @@ export const EmojiButton = React.memo(
|
|||
skinTone,
|
||||
onSetSkinTone,
|
||||
recentEmojis,
|
||||
variant = EmojiButtonVariant.Normal,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [popperRoot, setPopperRoot] = React.useState<HTMLElement | null>(
|
||||
|
@ -154,6 +161,8 @@ export const EmojiButton = React.memo(
|
|||
'module-emoji-button__button': true,
|
||||
'module-emoji-button__button--active': open,
|
||||
'module-emoji-button__button--has-emoji': Boolean(emoji),
|
||||
'module-emoji-button__button--profile-editor':
|
||||
variant === EmojiButtonVariant.ProfileEditor,
|
||||
})}
|
||||
aria-label={i18n('EmojiButton__label')}
|
||||
>
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
} from '../AddGroupMemberErrorDialog';
|
||||
import { Button } from '../Button';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import { getUsernameFromSearch } from '../../types/Username';
|
||||
import { getUsernameFromSearch } from '../../util/Username';
|
||||
import type { ParsedE164Type } from '../../util/libphonenumberInstance';
|
||||
import { parseAndFormatPhoneNumber } from '../../util/libphonenumberInstance';
|
||||
import type { UUIDFetchStateType } from '../../util/uuidFetchState';
|
||||
|
@ -85,22 +85,7 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
|
|||
isShowingRecommendedGroupSizeModal;
|
||||
this.searchTerm = searchTerm;
|
||||
|
||||
const phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode);
|
||||
if (phoneNumber) {
|
||||
this.isPhoneNumberChecked =
|
||||
phoneNumber.isValid &&
|
||||
selectedContacts.some(contact => contact.e164 === phoneNumber.e164);
|
||||
|
||||
const isVisible = this.candidateContacts.every(
|
||||
contact => contact.e164 !== phoneNumber.e164
|
||||
);
|
||||
if (isVisible) {
|
||||
this.phoneNumber = phoneNumber;
|
||||
}
|
||||
} else {
|
||||
this.isPhoneNumberChecked = false;
|
||||
}
|
||||
if (!this.phoneNumber && isUsernamesEnabled) {
|
||||
if (isUsernamesEnabled) {
|
||||
const username = getUsernameFromSearch(searchTerm);
|
||||
const isVisible = this.candidateContacts.every(
|
||||
contact => contact.username !== username
|
||||
|
@ -116,6 +101,22 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
|
|||
} else {
|
||||
this.isUsernameChecked = false;
|
||||
}
|
||||
|
||||
const phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode);
|
||||
if (!this.username && phoneNumber) {
|
||||
this.isPhoneNumberChecked =
|
||||
phoneNumber.isValid &&
|
||||
selectedContacts.some(contact => contact.e164 === phoneNumber.e164);
|
||||
|
||||
const isVisible = this.candidateContacts.every(
|
||||
contact => contact.e164 !== phoneNumber.e164
|
||||
);
|
||||
if (isVisible) {
|
||||
this.phoneNumber = phoneNumber;
|
||||
}
|
||||
} else {
|
||||
this.isPhoneNumberChecked = false;
|
||||
}
|
||||
this.selectedContacts = selectedContacts;
|
||||
|
||||
this.selectedConversationIdsSet = new Set(
|
||||
|
|
|
@ -14,7 +14,7 @@ import type { LocalizerType } from '../../types/Util';
|
|||
import type { ParsedE164Type } from '../../util/libphonenumberInstance';
|
||||
import { parseAndFormatPhoneNumber } from '../../util/libphonenumberInstance';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { getUsernameFromSearch } from '../../types/Username';
|
||||
import { getUsernameFromSearch } from '../../util/Username';
|
||||
import type { UUIDFetchStateType } from '../../util/uuidFetchState';
|
||||
import {
|
||||
isFetchingByUsername,
|
||||
|
@ -66,18 +66,9 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
|
|||
this.composeContacts = composeContacts;
|
||||
this.composeGroups = composeGroups;
|
||||
this.searchTerm = searchTerm;
|
||||
this.phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode);
|
||||
if (this.phoneNumber) {
|
||||
const { phoneNumber } = this;
|
||||
this.isPhoneNumberVisible = this.composeContacts.every(
|
||||
contact => contact.e164 !== phoneNumber.e164
|
||||
);
|
||||
} else {
|
||||
this.isPhoneNumberVisible = false;
|
||||
}
|
||||
this.uuidFetchState = uuidFetchState;
|
||||
|
||||
if (isUsernamesEnabled && !this.phoneNumber) {
|
||||
if (isUsernamesEnabled) {
|
||||
this.username = getUsernameFromSearch(this.searchTerm);
|
||||
this.isUsernameVisible =
|
||||
isUsernamesEnabled &&
|
||||
|
@ -88,6 +79,16 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
|
|||
} else {
|
||||
this.isUsernameVisible = false;
|
||||
}
|
||||
|
||||
const phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode);
|
||||
if (!this.username && phoneNumber) {
|
||||
this.phoneNumber = phoneNumber;
|
||||
this.isPhoneNumberVisible = this.composeContacts.every(
|
||||
contact => contact.e164 !== phoneNumber.e164
|
||||
);
|
||||
} else {
|
||||
this.isPhoneNumberVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
override getHeaderContents({
|
||||
|
|
172
ts/services/username.ts
Normal file
172
ts/services/username.ts
Normal file
|
@ -0,0 +1,172 @@
|
|||
// Copyright 2021-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
|
||||
import dataInterface from '../sql/Client';
|
||||
import { updateOurUsernameAndPni } from '../util/updateOurUsernameAndPni';
|
||||
import { sleep } from '../util/sleep';
|
||||
import type { UsernameReservationType } from '../types/Username';
|
||||
import { ReserveUsernameError } from '../types/Username';
|
||||
import * as Errors from '../types/errors';
|
||||
import * as log from '../logging/log';
|
||||
import MessageSender from '../textsecure/SendMessage';
|
||||
import { HTTPError } from '../textsecure/Errors';
|
||||
import { findRetryAfterTimeFromError } from '../jobs/helpers/findRetryAfterTimeFromError';
|
||||
|
||||
export type WriteUsernameOptionsType = Readonly<
|
||||
| {
|
||||
reservation: UsernameReservationType;
|
||||
}
|
||||
| {
|
||||
username: undefined;
|
||||
previousUsername: string | undefined;
|
||||
reservation?: undefined;
|
||||
}
|
||||
>;
|
||||
|
||||
export type ReserveUsernameOptionsType = Readonly<{
|
||||
nickname: string;
|
||||
previousUsername: string | undefined;
|
||||
abortSignal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
export type ReserveUsernameResultType = Readonly<
|
||||
| {
|
||||
ok: true;
|
||||
reservation: UsernameReservationType;
|
||||
error?: void;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reservation?: void;
|
||||
error: ReserveUsernameError;
|
||||
}
|
||||
>;
|
||||
|
||||
export async function reserveUsername(
|
||||
options: ReserveUsernameOptionsType
|
||||
): Promise<ReserveUsernameResultType> {
|
||||
const { server } = window.textsecure;
|
||||
if (!server) {
|
||||
throw new Error('server interface is not available!');
|
||||
}
|
||||
|
||||
const { nickname, previousUsername, abortSignal } = options;
|
||||
|
||||
const me = window.ConversationController.getOurConversationOrThrow();
|
||||
await updateOurUsernameAndPni();
|
||||
|
||||
if (me.get('username') !== previousUsername) {
|
||||
throw new Error('reserveUsername: Username has changed on another device');
|
||||
}
|
||||
|
||||
try {
|
||||
const { username, reservationToken } = await server.reserveUsername({
|
||||
nickname,
|
||||
abortSignal,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
reservation: { previousUsername, username, reservationToken },
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPError) {
|
||||
if (error.code === 422) {
|
||||
return { ok: false, error: ReserveUsernameError.Unprocessable };
|
||||
}
|
||||
if (error.code === 409) {
|
||||
return { ok: false, error: ReserveUsernameError.Conflict };
|
||||
}
|
||||
if (error.code === 413) {
|
||||
const time = findRetryAfterTimeFromError(error);
|
||||
log.warn(`reserveUsername: got 413, waiting ${time}ms`);
|
||||
await sleep(time, abortSignal);
|
||||
|
||||
return reserveUsername(options);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUsernameAndSyncProfile(
|
||||
username: string | undefined
|
||||
): Promise<void> {
|
||||
const me = window.ConversationController.getOurConversationOrThrow();
|
||||
|
||||
// Update backbone, update DB, then tell linked devices about profile update
|
||||
me.set({ username });
|
||||
dataInterface.updateConversation(me.attributes);
|
||||
|
||||
try {
|
||||
await singleProtoJobQueue.add(
|
||||
MessageSender.getFetchLocalProfileSyncMessage()
|
||||
);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'updateUsernameAndSyncProfile: Failed to queue sync message',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmUsername(
|
||||
reservation: UsernameReservationType,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const { server } = window.textsecure;
|
||||
if (!server) {
|
||||
throw new Error('server interface is not available!');
|
||||
}
|
||||
|
||||
const { previousUsername, username, reservationToken } = reservation;
|
||||
|
||||
const me = window.ConversationController.getOurConversationOrThrow();
|
||||
await updateOurUsernameAndPni();
|
||||
|
||||
if (me.get('username') !== previousUsername) {
|
||||
throw new Error('Username has changed on another device');
|
||||
}
|
||||
|
||||
try {
|
||||
await server.confirmUsername({
|
||||
usernameToConfirm: username,
|
||||
reservationToken,
|
||||
abortSignal,
|
||||
});
|
||||
|
||||
await updateUsernameAndSyncProfile(username);
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPError) {
|
||||
if (error.code === 413) {
|
||||
const time = findRetryAfterTimeFromError(error);
|
||||
log.warn(`confirmUsername: got 413, waiting ${time}ms`);
|
||||
await sleep(time, abortSignal);
|
||||
|
||||
return confirmUsername(reservation, abortSignal);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUsername(
|
||||
previousUsername: string,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const { server } = window.textsecure;
|
||||
if (!server) {
|
||||
throw new Error('server interface is not available!');
|
||||
}
|
||||
|
||||
const me = window.ConversationController.getOurConversationOrThrow();
|
||||
await updateOurUsernameAndPni();
|
||||
|
||||
if (me.get('username') !== previousUsername) {
|
||||
throw new Error('Username has changed on another device');
|
||||
}
|
||||
|
||||
await server.deleteUsername(abortSignal);
|
||||
await updateUsernameAndSyncProfile(undefined);
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
// Copyright 2021-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
|
||||
import dataInterface from '../sql/Client';
|
||||
import { updateOurUsernameAndPni } from '../util/updateOurUsernameAndPni';
|
||||
import * as Errors from '../types/errors';
|
||||
import * as log from '../logging/log';
|
||||
import MessageSender from '../textsecure/SendMessage';
|
||||
|
||||
export async function writeUsername({
|
||||
username,
|
||||
previousUsername,
|
||||
}: {
|
||||
username: string | undefined;
|
||||
previousUsername: string | undefined;
|
||||
}): Promise<void> {
|
||||
const { messaging } = window.textsecure;
|
||||
if (!messaging) {
|
||||
throw new Error('messaging interface is not available!');
|
||||
}
|
||||
|
||||
const me = window.ConversationController.getOurConversationOrThrow();
|
||||
await updateOurUsernameAndPni();
|
||||
|
||||
if (me.get('username') !== previousUsername) {
|
||||
throw new Error('Username has changed on another device');
|
||||
}
|
||||
|
||||
if (username) {
|
||||
await messaging.putUsername(username);
|
||||
} else {
|
||||
await messaging.deleteUsername();
|
||||
}
|
||||
|
||||
// Update backbone, update DB, then tell linked devices about profile update
|
||||
me.set({
|
||||
username,
|
||||
});
|
||||
|
||||
dataInterface.updateConversation(me.attributes);
|
||||
|
||||
try {
|
||||
await singleProtoJobQueue.add(
|
||||
MessageSender.getFetchLocalProfileSyncMessage()
|
||||
);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'writeUsername: Failed to queue sync message',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -24,6 +24,7 @@ import { actions as storyDistributionLists } from './ducks/storyDistributionList
|
|||
import { actions as toast } from './ducks/toast';
|
||||
import { actions as updates } from './ducks/updates';
|
||||
import { actions as user } from './ducks/user';
|
||||
import { actions as username } from './ducks/username';
|
||||
import type { ReduxActions } from './types';
|
||||
|
||||
export const actionCreators: ReduxActions = {
|
||||
|
@ -50,6 +51,7 @@ export const actionCreators: ReduxActions = {
|
|||
toast,
|
||||
updates,
|
||||
user,
|
||||
username,
|
||||
};
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
|
@ -76,4 +78,5 @@ export const mapDispatchToProps = {
|
|||
...toast,
|
||||
...updates,
|
||||
...user,
|
||||
...username,
|
||||
};
|
||||
|
|
|
@ -29,7 +29,6 @@ import {
|
|||
SHOW_SEND_ANYWAY_DIALOG,
|
||||
TOGGLE_PROFILE_EDITOR_ERROR,
|
||||
} from './globalModals';
|
||||
import { isRecord } from '../../util/isRecord';
|
||||
import type {
|
||||
UUIDFetchStateKeyType,
|
||||
UUIDFetchStateType,
|
||||
|
@ -59,12 +58,10 @@ 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 {
|
||||
getConversationUuidsStoppingSend,
|
||||
getConversationIdsStoppedForVerification,
|
||||
getMe,
|
||||
getUsernameSaveState,
|
||||
} from '../selectors/conversations';
|
||||
import type { AvatarDataType, AvatarUpdateType } from '../../types/Avatar';
|
||||
import { getDefaultAvatars } from '../../types/Avatar';
|
||||
|
@ -75,11 +72,8 @@ import {
|
|||
ComposerStep,
|
||||
ConversationVerificationState,
|
||||
OneTimeModalState,
|
||||
UsernameSaveState,
|
||||
} from './conversationsEnums';
|
||||
import { markViewed as messageUpdaterMarkViewed } from '../../services/MessageUpdater';
|
||||
import { showToast } from '../../util/showToast';
|
||||
import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername';
|
||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||
|
||||
import type { NoopActionType } from './noop';
|
||||
|
@ -354,7 +348,6 @@ export type ConversationsStateType = {
|
|||
showArchived: boolean;
|
||||
composer?: ComposerStateType;
|
||||
contactSpoofingReview?: ContactSpoofingReviewStateType;
|
||||
usernameSaveState: UsernameSaveState;
|
||||
|
||||
/**
|
||||
* Each key is a conversation ID. Each value is a value representing the state of
|
||||
|
@ -413,7 +406,6 @@ const CONVERSATION_STOPPED_BY_MISSING_VERIFICATION =
|
|||
'conversations/CONVERSATION_STOPPED_BY_MISSING_VERIFICATION';
|
||||
const DISCARD_MESSAGES = 'conversations/DISCARD_MESSAGES';
|
||||
const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
|
||||
const UPDATE_USERNAME_SAVE_STATE = 'conversations/UPDATE_USERNAME_SAVE_STATE';
|
||||
export const SELECTED_CONVERSATION_CHANGED =
|
||||
'conversations/SELECTED_CONVERSATION_CHANGED';
|
||||
|
||||
|
@ -739,12 +731,6 @@ export type ToggleConversationInChooseMembersActionType = {
|
|||
maxGroupSize: number;
|
||||
};
|
||||
};
|
||||
type UpdateUsernameSaveStateActionType = {
|
||||
type: typeof UPDATE_USERNAME_SAVE_STATE;
|
||||
payload: {
|
||||
newSaveState: UsernameSaveState;
|
||||
};
|
||||
};
|
||||
|
||||
type ReplaceAvatarsActionType = {
|
||||
type: typeof REPLACE_AVATARS;
|
||||
|
@ -753,6 +739,7 @@ type ReplaceAvatarsActionType = {
|
|||
avatars: Array<AvatarDataType>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ConversationActionType =
|
||||
| CancelVerificationDataByConversationActionType
|
||||
| ClearCancelledVerificationActionType
|
||||
|
@ -811,8 +798,7 @@ export type ConversationActionType =
|
|||
| StartComposingActionType
|
||||
| StartSettingGroupMetadataActionType
|
||||
| ToggleConversationInChooseMembersActionType
|
||||
| ToggleComposeEditingAvatarActionType
|
||||
| UpdateUsernameSaveStateActionType;
|
||||
| ToggleComposeEditingAvatarActionType;
|
||||
|
||||
// Action Creators
|
||||
|
||||
|
@ -825,7 +811,6 @@ export const actions = {
|
|||
clearInvitedUuidsForNewlyCreatedGroup,
|
||||
clearSelectedMessage,
|
||||
clearUnreadMetrics,
|
||||
clearUsernameSave,
|
||||
closeContactSpoofingReview,
|
||||
closeMaximumGroupSizeModal,
|
||||
closeRecommendedGroupSizeModal,
|
||||
|
@ -859,7 +844,6 @@ export const actions = {
|
|||
reviewGroupMemberNameCollision,
|
||||
reviewMessageRequestNameCollision,
|
||||
saveAvatarToDisk,
|
||||
saveUsername,
|
||||
scrollToMessage,
|
||||
selectMessage,
|
||||
setAccessControlAddFromInviteLinkSetting,
|
||||
|
@ -1165,82 +1149,6 @@ 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,
|
||||
avatar: AvatarUpdateType
|
||||
|
@ -2214,7 +2122,6 @@ export function getEmptyState(): ConversationsStateType {
|
|||
showArchived: false,
|
||||
selectedConversationTitle: '',
|
||||
selectedConversationPanelDepth: 0,
|
||||
usernameSaveState: UsernameSaveState.None,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -3784,14 +3691,5 @@ export function reducer(
|
|||
};
|
||||
}
|
||||
|
||||
if (action.type === UPDATE_USERNAME_SAVE_STATE) {
|
||||
const { newSaveState } = action.payload;
|
||||
|
||||
return {
|
||||
...state,
|
||||
usernameSaveState: newSaveState,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -7,16 +7,6 @@
|
|||
//
|
||||
// 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',
|
||||
|
|
|
@ -6,6 +6,7 @@ import { showToast } from '../../util/showToast';
|
|||
import * as Errors from '../../types/errors';
|
||||
import { ToastLinkCopied } from '../../components/ToastLinkCopied';
|
||||
import { ToastDebugLogError } from '../../components/ToastDebugLogError';
|
||||
import type { PromiseAction } from '../util';
|
||||
|
||||
// State
|
||||
|
||||
|
@ -25,24 +26,6 @@ type SetCrashReportCountActionType = {
|
|||
payload: number;
|
||||
};
|
||||
|
||||
type PromiseAction<Type extends string, Payload = void> =
|
||||
| {
|
||||
type: Type;
|
||||
payload: Promise<Payload>;
|
||||
}
|
||||
| {
|
||||
type: `${Type}_PENDING`;
|
||||
}
|
||||
| {
|
||||
type: `${Type}_FULFILLED`;
|
||||
payload: Payload;
|
||||
}
|
||||
| {
|
||||
type: `${Type}_REJECTED`;
|
||||
error: true;
|
||||
payload: Error;
|
||||
};
|
||||
|
||||
type CrashReportsActionType =
|
||||
| SetCrashReportCountActionType
|
||||
| PromiseAction<typeof UPLOAD>
|
||||
|
|
|
@ -15,6 +15,9 @@ export enum ToastType {
|
|||
StoryVideoUnsupported = 'StoryVideoUnsupported',
|
||||
AddingUserToGroup = 'AddingUserToGroup',
|
||||
UserAddedToGroup = 'UserAddedToGroup',
|
||||
FailedToDeleteUsername = 'FailedToDeleteUsername',
|
||||
CopiedUsername = 'CopiedUsername',
|
||||
CopiedUsernameLink = 'CopiedUsernameLink',
|
||||
}
|
||||
|
||||
// State
|
||||
|
@ -58,7 +61,10 @@ export type ShowToastActionCreatorType = (
|
|||
parameters?: ReplacementValuesType
|
||||
) => ShowToastActionType;
|
||||
|
||||
const showToast: ShowToastActionCreatorType = (toastType, parameters) => {
|
||||
export const showToast: ShowToastActionCreatorType = (
|
||||
toastType,
|
||||
parameters
|
||||
) => {
|
||||
return {
|
||||
type: SHOW_TOAST,
|
||||
payload: {
|
||||
|
|
489
ts/state/ducks/username.ts
Normal file
489
ts/state/ducks/username.ts
Normal file
|
@ -0,0 +1,489 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ThunkAction } from 'redux-thunk';
|
||||
|
||||
import type { UsernameReservationType } from '../../types/Username';
|
||||
import { ReserveUsernameError } from '../../types/Username';
|
||||
import * as usernameServices from '../../services/username';
|
||||
import type { ReserveUsernameResultType } from '../../services/username';
|
||||
import {
|
||||
isValidNickname,
|
||||
getMinNickname,
|
||||
getMaxNickname,
|
||||
} from '../../util/Username';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { sleep } from '../../util/sleep';
|
||||
import { assertDev } from '../../util/assert';
|
||||
import type { StateType as RootStateType } from '../reducer';
|
||||
import type { PromiseAction } from '../util';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
import {
|
||||
UsernameEditState,
|
||||
UsernameReservationState,
|
||||
UsernameReservationError,
|
||||
} from './usernameEnums';
|
||||
import { showToast, ToastType } from './toast';
|
||||
import type { ToastActionType } from './toast';
|
||||
|
||||
export type UsernameReservationStateType = Readonly<{
|
||||
state: UsernameReservationState;
|
||||
reservation?: UsernameReservationType;
|
||||
error?: UsernameReservationError;
|
||||
abortController?: AbortController;
|
||||
}>;
|
||||
|
||||
export type UsernameStateType = Readonly<{
|
||||
// ProfileEditor
|
||||
editState: UsernameEditState;
|
||||
|
||||
// EditUsernameModalBody
|
||||
usernameReservation: UsernameReservationStateType;
|
||||
}>;
|
||||
|
||||
// Actions
|
||||
|
||||
const SET_USERNAME_EDIT_STATE = 'username/SET_USERNAME_EDIT_STATE';
|
||||
const OPEN_USERNAME_RESERVATION_MODAL = 'username/OPEN_RESERVATION_MODAL';
|
||||
const CLOSE_USERNAME_RESERVATION_MODAL = 'username/CLOSE_RESERVATION_MODAL';
|
||||
const SET_USERNAME_RESERVATION_ERROR = 'username/SET_RESERVATION_ERROR';
|
||||
const RESERVE_USERNAME = 'username/RESERVE_USERNAME';
|
||||
const CONFIRM_USERNAME = 'username/CONFIRM_USERNAME';
|
||||
const DELETE_USERNAME = 'username/DELETE_USERNAME';
|
||||
|
||||
type SetUsernameEditStateActionType = {
|
||||
type: typeof SET_USERNAME_EDIT_STATE;
|
||||
payload: {
|
||||
editState: UsernameEditState;
|
||||
};
|
||||
};
|
||||
|
||||
type OpenUsernameReservationModalActionType = {
|
||||
type: typeof OPEN_USERNAME_RESERVATION_MODAL;
|
||||
};
|
||||
|
||||
type CloseUsernameReservationModalActionType = {
|
||||
type: typeof CLOSE_USERNAME_RESERVATION_MODAL;
|
||||
};
|
||||
|
||||
type SetUsernameReservationErrorActionType = {
|
||||
type: typeof SET_USERNAME_RESERVATION_ERROR;
|
||||
payload: {
|
||||
error: UsernameReservationError | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
type ReserveUsernameActionType = PromiseAction<
|
||||
typeof RESERVE_USERNAME,
|
||||
ReserveUsernameResultType | undefined,
|
||||
{ abortController: AbortController }
|
||||
>;
|
||||
type ConfirmUsernameActionType = PromiseAction<typeof CONFIRM_USERNAME, void>;
|
||||
type DeleteUsernameActionType = PromiseAction<typeof DELETE_USERNAME, void>;
|
||||
|
||||
export type UsernameActionType =
|
||||
| SetUsernameEditStateActionType
|
||||
| OpenUsernameReservationModalActionType
|
||||
| CloseUsernameReservationModalActionType
|
||||
| SetUsernameReservationErrorActionType
|
||||
| ReserveUsernameActionType
|
||||
| ConfirmUsernameActionType
|
||||
| DeleteUsernameActionType;
|
||||
|
||||
export const actions = {
|
||||
setUsernameEditState,
|
||||
openUsernameReservationModal,
|
||||
closeUsernameReservationModal,
|
||||
setUsernameReservationError,
|
||||
reserveUsername,
|
||||
confirmUsername,
|
||||
deleteUsername,
|
||||
};
|
||||
|
||||
export function setUsernameEditState(
|
||||
editState: UsernameEditState
|
||||
): SetUsernameEditStateActionType {
|
||||
return {
|
||||
type: SET_USERNAME_EDIT_STATE,
|
||||
payload: { editState },
|
||||
};
|
||||
}
|
||||
|
||||
export function openUsernameReservationModal(): OpenUsernameReservationModalActionType {
|
||||
return {
|
||||
type: OPEN_USERNAME_RESERVATION_MODAL,
|
||||
};
|
||||
}
|
||||
|
||||
export function closeUsernameReservationModal(): CloseUsernameReservationModalActionType {
|
||||
return {
|
||||
type: CLOSE_USERNAME_RESERVATION_MODAL,
|
||||
};
|
||||
}
|
||||
|
||||
export function setUsernameReservationError(
|
||||
error: UsernameReservationError | undefined
|
||||
): SetUsernameReservationErrorActionType {
|
||||
return {
|
||||
type: SET_USERNAME_RESERVATION_ERROR,
|
||||
payload: { error },
|
||||
};
|
||||
}
|
||||
|
||||
const INPUT_DELAY_MS = 500;
|
||||
|
||||
export type ReserveUsernameOptionsType = Readonly<{
|
||||
doReserveUsername?: typeof usernameServices.reserveUsername;
|
||||
delay?: number;
|
||||
}>;
|
||||
|
||||
export function reserveUsername(
|
||||
nickname: string,
|
||||
{
|
||||
doReserveUsername = usernameServices.reserveUsername,
|
||||
delay = INPUT_DELAY_MS,
|
||||
}: ReserveUsernameOptionsType = {}
|
||||
): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
ReserveUsernameActionType | SetUsernameReservationErrorActionType
|
||||
> {
|
||||
return (dispatch, getState) => {
|
||||
if (!nickname) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidNickname(nickname)) {
|
||||
const error = getNicknameInvalidError(nickname);
|
||||
if (error) {
|
||||
dispatch(setUsernameReservationError(error));
|
||||
} else {
|
||||
assertDev(false, 'This should not happen');
|
||||
dispatch(setUsernameReservationError(UsernameReservationError.General));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { username } = getMe(getState());
|
||||
|
||||
const abortController = new AbortController();
|
||||
const { signal: abortSignal } = abortController;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
await sleep(delay, abortSignal);
|
||||
} catch {
|
||||
// Aborted
|
||||
return;
|
||||
}
|
||||
|
||||
return doReserveUsername({
|
||||
previousUsername: username,
|
||||
nickname,
|
||||
abortSignal,
|
||||
});
|
||||
};
|
||||
|
||||
dispatch({
|
||||
type: RESERVE_USERNAME,
|
||||
payload: run(),
|
||||
meta: { abortController },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export type ConfirmUsernameOptionsType = Readonly<{
|
||||
doConfirmUsername?: typeof usernameServices.confirmUsername;
|
||||
}>;
|
||||
|
||||
export function confirmUsername({
|
||||
doConfirmUsername = usernameServices.confirmUsername,
|
||||
}: ConfirmUsernameOptionsType = {}): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
ConfirmUsernameActionType | SetUsernameReservationErrorActionType
|
||||
> {
|
||||
return (dispatch, getState) => {
|
||||
const { reservation } = getState().username.usernameReservation;
|
||||
if (reservation === undefined) {
|
||||
assertDev(false, 'This should not happen');
|
||||
dispatch(setUsernameReservationError(UsernameReservationError.General));
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: CONFIRM_USERNAME,
|
||||
payload: doConfirmUsername(reservation),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export type DeleteUsernameOptionsType = Readonly<{
|
||||
doDeleteUsername?: typeof usernameServices.deleteUsername;
|
||||
|
||||
// Only for testing
|
||||
username?: string;
|
||||
}>;
|
||||
|
||||
export function deleteUsername({
|
||||
doDeleteUsername = usernameServices.deleteUsername,
|
||||
username: defaultUsername,
|
||||
}: DeleteUsernameOptionsType = {}): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
DeleteUsernameActionType | ToastActionType
|
||||
> {
|
||||
return (dispatch, getState) => {
|
||||
const me = getMe(getState());
|
||||
const username = me.username ?? defaultUsername;
|
||||
|
||||
if (!username) {
|
||||
return;
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
await doDeleteUsername(username);
|
||||
} catch {
|
||||
dispatch(showToast(ToastType.FailedToDeleteUsername));
|
||||
}
|
||||
};
|
||||
|
||||
dispatch({
|
||||
type: DELETE_USERNAME,
|
||||
payload: run(),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Reducers
|
||||
|
||||
export function getEmptyState(): UsernameStateType {
|
||||
return {
|
||||
editState: UsernameEditState.Editing,
|
||||
usernameReservation: {
|
||||
state: UsernameReservationState.Closed,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function reducer(
|
||||
state: Readonly<UsernameStateType> = getEmptyState(),
|
||||
action: Readonly<UsernameActionType>
|
||||
): UsernameStateType {
|
||||
const { usernameReservation } = state;
|
||||
|
||||
if (action.type === SET_USERNAME_EDIT_STATE) {
|
||||
const { editState } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
editState,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === OPEN_USERNAME_RESERVATION_MODAL) {
|
||||
return {
|
||||
...state,
|
||||
usernameReservation: {
|
||||
state: UsernameReservationState.Open,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === CLOSE_USERNAME_RESERVATION_MODAL) {
|
||||
return {
|
||||
...state,
|
||||
usernameReservation: {
|
||||
state: UsernameReservationState.Closed,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === SET_USERNAME_RESERVATION_ERROR) {
|
||||
const { error } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
usernameReservation: {
|
||||
...usernameReservation,
|
||||
error,
|
||||
reservation: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'username/RESERVE_USERNAME_PENDING') {
|
||||
usernameReservation.abortController?.abort();
|
||||
|
||||
const { meta } = action;
|
||||
return {
|
||||
...state,
|
||||
usernameReservation: {
|
||||
state: UsernameReservationState.Reserving,
|
||||
abortController: meta.abortController,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'username/RESERVE_USERNAME_FULFILLED') {
|
||||
const { meta } = action;
|
||||
|
||||
// New reservation is pending
|
||||
if (meta.abortController !== usernameReservation.abortController) {
|
||||
return state;
|
||||
}
|
||||
|
||||
assertDev(
|
||||
usernameReservation.state === UsernameReservationState.Reserving,
|
||||
'Must be reserving before resolving reservation'
|
||||
);
|
||||
|
||||
const { payload } = action;
|
||||
assertDev(
|
||||
payload !== undefined,
|
||||
'Payload can be undefined only when aborted'
|
||||
);
|
||||
if (!payload.ok) {
|
||||
const { error } = payload;
|
||||
let stateError: UsernameReservationError;
|
||||
if (error === ReserveUsernameError.Unprocessable) {
|
||||
stateError = UsernameReservationError.CheckCharacters;
|
||||
} else if (error === ReserveUsernameError.Conflict) {
|
||||
stateError = UsernameReservationError.UsernameNotAvailable;
|
||||
} else {
|
||||
throw missingCaseError(error);
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
usernameReservation: {
|
||||
state: UsernameReservationState.Open,
|
||||
error: stateError,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { reservation } = payload;
|
||||
return {
|
||||
...state,
|
||||
usernameReservation: {
|
||||
state: UsernameReservationState.Open,
|
||||
reservation,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'username/RESERVE_USERNAME_REJECTED') {
|
||||
const { meta } = action;
|
||||
|
||||
// New reservation is pending
|
||||
if (meta.abortController !== usernameReservation.abortController) {
|
||||
return state;
|
||||
}
|
||||
|
||||
assertDev(
|
||||
usernameReservation.state === UsernameReservationState.Reserving,
|
||||
'Must be reserving before rejecting reservation'
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
usernameReservation: {
|
||||
state: UsernameReservationState.Open,
|
||||
error: UsernameReservationError.General,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'username/CONFIRM_USERNAME_PENDING') {
|
||||
assertDev(
|
||||
usernameReservation.state === UsernameReservationState.Open,
|
||||
'Must be open before confirmation'
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
usernameReservation: {
|
||||
reservation: usernameReservation.reservation,
|
||||
state: UsernameReservationState.Confirming,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'username/CONFIRM_USERNAME_FULFILLED') {
|
||||
assertDev(
|
||||
usernameReservation.state === UsernameReservationState.Confirming,
|
||||
'Must be reserving before resolving confirmation'
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
usernameReservation: {
|
||||
state: UsernameReservationState.Closed,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'username/CONFIRM_USERNAME_REJECTED') {
|
||||
assertDev(
|
||||
usernameReservation.state === UsernameReservationState.Confirming,
|
||||
'Must be reserving before rejecting reservation'
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
usernameReservation: {
|
||||
state: UsernameReservationState.Open,
|
||||
error: UsernameReservationError.General,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'username/DELETE_USERNAME_PENDING') {
|
||||
return {
|
||||
...state,
|
||||
editState: UsernameEditState.Deleting,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'username/DELETE_USERNAME_FULFILLED') {
|
||||
return {
|
||||
...state,
|
||||
editState: UsernameEditState.Editing,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'username/DELETE_USERNAME_REJECTED') {
|
||||
assertDev(false, 'Should never reject');
|
||||
return state;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
function getNicknameInvalidError(
|
||||
nickname: string | undefined
|
||||
): UsernameReservationError | undefined {
|
||||
if (!nickname) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (nickname.length < getMinNickname()) {
|
||||
return UsernameReservationError.NotEnoughCharacters;
|
||||
}
|
||||
|
||||
if (!/^[0-9a-z_]+$/.test(nickname)) {
|
||||
return UsernameReservationError.CheckCharacters;
|
||||
}
|
||||
if (!/^[a-z_]/.test(nickname)) {
|
||||
return UsernameReservationError.CheckStartingCharacter;
|
||||
}
|
||||
|
||||
if (nickname.length > getMaxNickname()) {
|
||||
return UsernameReservationError.TooManyCharacters;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
32
ts/state/ducks/usernameEnums.ts
Normal file
32
ts/state/ducks/usernameEnums.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//
|
||||
// ProfileEditor
|
||||
//
|
||||
|
||||
export enum UsernameEditState {
|
||||
Editing = 'Editing',
|
||||
ConfirmingDelete = 'ConfirmingDelete',
|
||||
Deleting = 'Deleting',
|
||||
}
|
||||
|
||||
//
|
||||
// EditUsernameModalBody
|
||||
//
|
||||
|
||||
export enum UsernameReservationState {
|
||||
Open = 'Open',
|
||||
Reserving = 'Reserving',
|
||||
Confirming = 'Confirming',
|
||||
Closed = 'Closed',
|
||||
}
|
||||
|
||||
export enum UsernameReservationError {
|
||||
NotEnoughCharacters = 'NotEnoughCharacters',
|
||||
TooManyCharacters = 'TooManyCharacters',
|
||||
CheckStartingCharacter = 'CheckStartingCharacter',
|
||||
CheckCharacters = 'CheckCharacters',
|
||||
UsernameNotAvailable = 'UsernameNotAvailable',
|
||||
General = 'General',
|
||||
}
|
|
@ -21,6 +21,7 @@ import { getEmptyState as getStoryDistributionListsEmptyState } from './ducks/st
|
|||
import { getEmptyState as getToastEmptyState } from './ducks/toast';
|
||||
import { getEmptyState as updates } from './ducks/updates';
|
||||
import { getEmptyState as user } from './ducks/user';
|
||||
import { getEmptyState as username } from './ducks/username';
|
||||
|
||||
import type { StateType } from './reducer';
|
||||
|
||||
|
@ -139,5 +140,6 @@ export function getInitialState({
|
|||
isMainWindowFullScreen: mainWindowStats.isFullScreen,
|
||||
menuOptions,
|
||||
},
|
||||
username: username(),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ import { reducer as storyDistributionLists } from './ducks/storyDistributionList
|
|||
import { reducer as toast } from './ducks/toast';
|
||||
import { reducer as updates } from './ducks/updates';
|
||||
import { reducer as user } from './ducks/user';
|
||||
import { reducer as username } from './ducks/username';
|
||||
|
||||
export const reducer = combineReducers({
|
||||
accounts,
|
||||
|
@ -53,6 +54,7 @@ export const reducer = combineReducers({
|
|||
toast,
|
||||
updates,
|
||||
user,
|
||||
username,
|
||||
});
|
||||
|
||||
export type StateType = ReturnType<typeof reducer>;
|
||||
|
|
|
@ -18,7 +18,6 @@ import type {
|
|||
PreJoinConversationType,
|
||||
} from '../ducks/conversations';
|
||||
import type { StoriesStateType } from '../ducks/stories';
|
||||
import type { UsernameSaveState } from '../ducks/conversationsEnums';
|
||||
import {
|
||||
ComposerStep,
|
||||
OneTimeModalState,
|
||||
|
@ -167,13 +166,6 @@ export const getSelectedMessage = createSelector(
|
|||
}
|
||||
);
|
||||
|
||||
export const getUsernameSaveState = createSelector(
|
||||
getConversations,
|
||||
(state: ConversationsStateType): UsernameSaveState => {
|
||||
return state.usernameSaveState;
|
||||
}
|
||||
);
|
||||
|
||||
export const getShowArchived = createSelector(
|
||||
getConversations,
|
||||
(state: ConversationsStateType): boolean => {
|
||||
|
|
50
ts/state/selectors/username.ts
Normal file
50
ts/state/selectors/username.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import type { UsernameReservationType } from '../../types/Username';
|
||||
import type { StateType } from '../reducer';
|
||||
import type {
|
||||
UsernameStateType,
|
||||
UsernameReservationStateType,
|
||||
} from '../ducks/username';
|
||||
import type {
|
||||
UsernameEditState,
|
||||
UsernameReservationState,
|
||||
UsernameReservationError,
|
||||
} from '../ducks/usernameEnums';
|
||||
|
||||
export const getUsernameState = (state: StateType): UsernameStateType =>
|
||||
state.username;
|
||||
|
||||
export const getUsernameEditState = createSelector(
|
||||
getUsernameState,
|
||||
(state: UsernameStateType): UsernameEditState => state.editState
|
||||
);
|
||||
|
||||
export const getUsernameReservation = createSelector(
|
||||
getUsernameState,
|
||||
(state: UsernameStateType): UsernameReservationStateType =>
|
||||
state.usernameReservation
|
||||
);
|
||||
|
||||
export const getUsernameReservationState = createSelector(
|
||||
getUsernameReservation,
|
||||
(reservation: UsernameReservationStateType): UsernameReservationState =>
|
||||
reservation.state
|
||||
);
|
||||
|
||||
export const getUsernameReservationObject = createSelector(
|
||||
getUsernameReservation,
|
||||
(
|
||||
reservation: UsernameReservationStateType
|
||||
): UsernameReservationType | undefined => reservation.reservation
|
||||
);
|
||||
|
||||
export const getUsernameReservationError = createSelector(
|
||||
getUsernameReservation,
|
||||
(
|
||||
reservation: UsernameReservationStateType
|
||||
): UsernameReservationError | undefined => reservation.error
|
||||
);
|
34
ts/state/smart/EditUsernameModalBody.tsx
Normal file
34
ts/state/smart/EditUsernameModalBody.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { mapDispatchToProps } from '../actions';
|
||||
|
||||
import type { PropsDataType } from '../../components/EditUsernameModalBody';
|
||||
import { EditUsernameModalBody } from '../../components/EditUsernameModalBody';
|
||||
|
||||
import type { StateType } from '../reducer';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import {
|
||||
getUsernameReservationState,
|
||||
getUsernameReservationObject,
|
||||
getUsernameReservationError,
|
||||
} from '../selectors/username';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
|
||||
function mapStateToProps(state: StateType): PropsDataType {
|
||||
const i18n = getIntl(state);
|
||||
const { username } = getMe(state);
|
||||
|
||||
return {
|
||||
i18n,
|
||||
currentUsername: username,
|
||||
state: getUsernameReservationState(state),
|
||||
reservation: getUsernameReservationObject(state),
|
||||
error: getUsernameReservationError(state),
|
||||
};
|
||||
}
|
||||
|
||||
const smart = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
export const SmartEditUsernameModalBody = smart(EditUsernameModalBody);
|
|
@ -1,16 +1,26 @@
|
|||
// Copyright 2021-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { mapDispatchToProps } from '../actions';
|
||||
import type { PropsDataType as ProfileEditorModalPropsType } from '../../components/ProfileEditorModal';
|
||||
import { ProfileEditorModal } from '../../components/ProfileEditorModal';
|
||||
import type { PropsDataType } from '../../components/ProfileEditor';
|
||||
import { SmartEditUsernameModalBody } from './EditUsernameModalBody';
|
||||
import type { StateType } from '../reducer';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getEmojiSkinTone, getUsernamesEnabled } from '../selectors/items';
|
||||
import { getMe, getUsernameSaveState } from '../selectors/conversations';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
import { selectRecentEmojis } from '../selectors/emojis';
|
||||
import { getUsernameEditState } from '../selectors/username';
|
||||
|
||||
function renderEditUsernameModalBody(props: {
|
||||
onClose: () => void;
|
||||
}): JSX.Element {
|
||||
return <SmartEditUsernameModalBody {...props} />;
|
||||
}
|
||||
|
||||
function mapStateToProps(
|
||||
state: StateType
|
||||
|
@ -30,6 +40,7 @@ function mapStateToProps(
|
|||
const recentEmojis = selectRecentEmojis(state);
|
||||
const skinTone = getEmojiSkinTone(state);
|
||||
const isUsernameFlagEnabled = getUsernamesEnabled(state);
|
||||
const usernameEditState = getUsernameEditState(state);
|
||||
|
||||
return {
|
||||
aboutEmoji,
|
||||
|
@ -46,7 +57,9 @@ function mapStateToProps(
|
|||
skinTone,
|
||||
userAvatarData,
|
||||
username,
|
||||
usernameSaveState: getUsernameSaveState(state),
|
||||
usernameEditState,
|
||||
|
||||
renderEditUsernameModalBody,
|
||||
};
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import type { actions as storyDistributionLists } from './ducks/storyDistributio
|
|||
import type { actions as toast } from './ducks/toast';
|
||||
import type { actions as updates } from './ducks/updates';
|
||||
import type { actions as user } from './ducks/user';
|
||||
import type { actions as username } from './ducks/username';
|
||||
|
||||
export type ReduxActions = {
|
||||
accounts: typeof accounts;
|
||||
|
@ -49,4 +50,5 @@ export type ReduxActions = {
|
|||
toast: typeof toast;
|
||||
updates: typeof updates;
|
||||
user: typeof user;
|
||||
username: typeof username;
|
||||
};
|
||||
|
|
27
ts/state/util.ts
Normal file
27
ts/state/util.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export type PromiseAction<Type extends string, Payload = void, Meta = void> =
|
||||
| ({
|
||||
type: Type;
|
||||
payload: Promise<Payload>;
|
||||
} & (Meta extends void
|
||||
? { meta?: void }
|
||||
: {
|
||||
meta: Meta;
|
||||
}))
|
||||
| {
|
||||
type: `${Type}_PENDING`;
|
||||
meta: Meta;
|
||||
}
|
||||
| {
|
||||
type: `${Type}_FULFILLED`;
|
||||
payload: Payload;
|
||||
meta: Meta;
|
||||
}
|
||||
| {
|
||||
type: `${Type}_REJECTED`;
|
||||
error: true;
|
||||
payload: Error;
|
||||
meta: Meta;
|
||||
};
|
463
ts/test-electron/state/ducks/username_test.ts
Normal file
463
ts/test-electron/state/ducks/username_test.ts
Normal file
|
@ -0,0 +1,463 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as sinon from 'sinon';
|
||||
import { assert } from 'chai';
|
||||
|
||||
import type { UsernameStateType } from '../../../state/ducks/username';
|
||||
import {
|
||||
getUsernameEditState,
|
||||
getUsernameReservationState,
|
||||
getUsernameReservationError,
|
||||
getUsernameReservationObject,
|
||||
} from '../../../state/selectors/username';
|
||||
import {
|
||||
UsernameEditState,
|
||||
UsernameReservationState,
|
||||
UsernameReservationError,
|
||||
} from '../../../state/ducks/usernameEnums';
|
||||
import { actions } from '../../../state/ducks/username';
|
||||
import { ToastType } from '../../../state/ducks/toast';
|
||||
import { noopAction } from '../../../state/ducks/noop';
|
||||
import { reducer } from '../../../state/reducer';
|
||||
import { ReserveUsernameError } from '../../../types/Username';
|
||||
|
||||
const DEFAULT_RESERVATION = {
|
||||
username: 'abc.12',
|
||||
previousUsername: undefined,
|
||||
reservationToken: 'def',
|
||||
};
|
||||
|
||||
describe('electron/state/ducks/username', () => {
|
||||
const emptyState = reducer(undefined, noopAction());
|
||||
const stateWithReservation = {
|
||||
...emptyState,
|
||||
username: {
|
||||
...emptyState.username,
|
||||
usernameReservation: {
|
||||
...emptyState.username.usernameReservation,
|
||||
state: UsernameReservationState.Open,
|
||||
reservation: DEFAULT_RESERVATION,
|
||||
},
|
||||
} as UsernameStateType,
|
||||
};
|
||||
|
||||
let sandbox: sinon.SinonSandbox;
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
describe('setUsernameEditState', () => {
|
||||
it('should update username edit state', () => {
|
||||
const updatedState = reducer(
|
||||
emptyState,
|
||||
actions.setUsernameEditState(UsernameEditState.ConfirmingDelete)
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
getUsernameEditState(updatedState),
|
||||
UsernameEditState.ConfirmingDelete
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openUsernameReservationModal/closeUsernameReservationModal', () => {
|
||||
it('should update reservation state', () => {
|
||||
const updatedState = reducer(
|
||||
emptyState,
|
||||
actions.openUsernameReservationModal()
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
getUsernameReservationState(updatedState),
|
||||
UsernameReservationState.Open
|
||||
);
|
||||
|
||||
const finalState = reducer(
|
||||
emptyState,
|
||||
actions.closeUsernameReservationModal()
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
getUsernameReservationState(finalState),
|
||||
UsernameReservationState.Closed
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setUsernameReservationError', () => {
|
||||
it('should update error and reset reservation', () => {
|
||||
const updatedState = reducer(
|
||||
stateWithReservation,
|
||||
actions.setUsernameReservationError(UsernameReservationError.General)
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
getUsernameReservationError(updatedState),
|
||||
UsernameReservationError.General
|
||||
);
|
||||
assert.strictEqual(getUsernameReservationObject(updatedState), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reserveUsername', () => {
|
||||
it('should dispatch correct actions after delay', async () => {
|
||||
const clock = sandbox.useFakeTimers({
|
||||
now: 0,
|
||||
});
|
||||
|
||||
const doReserveUsername = sinon.stub().resolves(DEFAULT_RESERVATION);
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
actions.reserveUsername('test', {
|
||||
doReserveUsername,
|
||||
delay: 1000,
|
||||
})(dispatch, () => emptyState, null);
|
||||
|
||||
await clock.runToLastAsync();
|
||||
assert.strictEqual(clock.now, 1000);
|
||||
|
||||
sinon.assert.calledOnce(dispatch);
|
||||
sinon.assert.calledWith(
|
||||
dispatch,
|
||||
sinon.match.has('type', 'username/RESERVE_USERNAME')
|
||||
);
|
||||
});
|
||||
|
||||
const NICKNAME_ERROR_COMBOS = [
|
||||
['x', UsernameReservationError.NotEnoughCharacters],
|
||||
['x'.repeat(128), UsernameReservationError.TooManyCharacters],
|
||||
['#$&^$)(', UsernameReservationError.CheckCharacters],
|
||||
['1abcdefg', UsernameReservationError.CheckStartingCharacter],
|
||||
];
|
||||
for (const [nickname, error] of NICKNAME_ERROR_COMBOS) {
|
||||
// eslint-disable-next-line no-loop-func
|
||||
it(`should dispatch ${error} error for "${nickname}"`, async () => {
|
||||
const clock = sandbox.useFakeTimers();
|
||||
|
||||
const doReserveUsername = sinon.stub().resolves(DEFAULT_RESERVATION);
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
actions.reserveUsername(nickname, {
|
||||
doReserveUsername,
|
||||
})(dispatch, () => emptyState, null);
|
||||
|
||||
await clock.runToLastAsync();
|
||||
|
||||
sinon.assert.calledOnce(dispatch);
|
||||
sinon.assert.calledWith(dispatch, {
|
||||
type: 'username/SET_RESERVATION_ERROR',
|
||||
payload: {
|
||||
error,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('should update reservation on success', () => {
|
||||
let state = emptyState;
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
state = reducer(state, {
|
||||
type: 'username/RESERVE_USERNAME_PENDING',
|
||||
meta: { abortController },
|
||||
});
|
||||
assert.strictEqual(
|
||||
getUsernameReservationState(state),
|
||||
UsernameReservationState.Reserving
|
||||
);
|
||||
|
||||
state = reducer(state, {
|
||||
type: 'username/RESERVE_USERNAME_FULFILLED',
|
||||
payload: {
|
||||
ok: true,
|
||||
reservation: DEFAULT_RESERVATION,
|
||||
},
|
||||
meta: { abortController },
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
getUsernameReservationState(state),
|
||||
UsernameReservationState.Open
|
||||
);
|
||||
assert.strictEqual(
|
||||
getUsernameReservationObject(state),
|
||||
DEFAULT_RESERVATION
|
||||
);
|
||||
assert.strictEqual(getUsernameReservationError(state), undefined);
|
||||
});
|
||||
|
||||
const REMOTE_ERRORS: Array<
|
||||
[ReserveUsernameError, UsernameReservationError]
|
||||
> = [
|
||||
[
|
||||
ReserveUsernameError.Unprocessable,
|
||||
UsernameReservationError.CheckCharacters,
|
||||
],
|
||||
[
|
||||
ReserveUsernameError.Conflict,
|
||||
UsernameReservationError.UsernameNotAvailable,
|
||||
],
|
||||
];
|
||||
for (const [error, mapping] of REMOTE_ERRORS) {
|
||||
it(`should update error on ${error}`, () => {
|
||||
let state = emptyState;
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
state = reducer(state, {
|
||||
type: 'username/RESERVE_USERNAME_PENDING',
|
||||
meta: { abortController },
|
||||
});
|
||||
state = reducer(state, {
|
||||
type: 'username/RESERVE_USERNAME_FULFILLED',
|
||||
payload: {
|
||||
ok: false,
|
||||
error,
|
||||
},
|
||||
meta: { abortController },
|
||||
});
|
||||
|
||||
assert.strictEqual(getUsernameReservationObject(state), undefined);
|
||||
assert.strictEqual(getUsernameReservationError(state), mapping);
|
||||
assert.strictEqual(
|
||||
getUsernameReservationState(state),
|
||||
UsernameReservationState.Open
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('should update error on rejection', () => {
|
||||
let state = emptyState;
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
state = reducer(state, {
|
||||
type: 'username/RESERVE_USERNAME_PENDING',
|
||||
meta: { abortController },
|
||||
});
|
||||
state = reducer(state, {
|
||||
type: 'username/RESERVE_USERNAME_REJECTED',
|
||||
error: true,
|
||||
payload: new Error(),
|
||||
meta: { abortController },
|
||||
});
|
||||
|
||||
assert.strictEqual(getUsernameReservationObject(state), undefined);
|
||||
assert.strictEqual(
|
||||
getUsernameReservationError(state),
|
||||
UsernameReservationError.General
|
||||
);
|
||||
assert.strictEqual(
|
||||
getUsernameReservationState(state),
|
||||
UsernameReservationState.Open
|
||||
);
|
||||
});
|
||||
|
||||
it('should abort previous AbortController', () => {
|
||||
const firstController = new AbortController();
|
||||
const firstAbort = sinon.stub(firstController, 'abort');
|
||||
|
||||
const updatedState = reducer(emptyState, {
|
||||
type: 'username/RESERVE_USERNAME_PENDING',
|
||||
meta: { abortController: firstController },
|
||||
});
|
||||
|
||||
reducer(updatedState, {
|
||||
type: 'username/RESERVE_USERNAME_PENDING',
|
||||
meta: { abortController: new AbortController() },
|
||||
});
|
||||
|
||||
sinon.assert.calledOnce(firstAbort);
|
||||
});
|
||||
|
||||
it('should ignore resolve/reject with different AbortController', () => {
|
||||
const firstController = new AbortController();
|
||||
const secondController = new AbortController();
|
||||
|
||||
let state = emptyState;
|
||||
state = reducer(state, {
|
||||
type: 'username/RESERVE_USERNAME_PENDING',
|
||||
meta: { abortController: firstController },
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: 'username/RESERVE_USERNAME_FULFILLED',
|
||||
payload: {
|
||||
ok: true,
|
||||
reservation: DEFAULT_RESERVATION,
|
||||
},
|
||||
meta: { abortController: secondController },
|
||||
});
|
||||
assert.strictEqual(getUsernameReservationObject(state), undefined);
|
||||
|
||||
state = reducer(state, {
|
||||
type: 'username/RESERVE_USERNAME_REJECTED',
|
||||
error: true,
|
||||
payload: new Error(),
|
||||
meta: { abortController: secondController },
|
||||
});
|
||||
assert.strictEqual(getUsernameReservationError(state), undefined);
|
||||
assert.strictEqual(
|
||||
getUsernameReservationState(state),
|
||||
UsernameReservationState.Reserving
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmUsername', () => {
|
||||
it('should dispatch promise when reservation is present', () => {
|
||||
const doConfirmUsername = sinon.stub().resolves();
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
actions.confirmUsername({
|
||||
doConfirmUsername,
|
||||
})(dispatch, () => stateWithReservation, null);
|
||||
|
||||
sinon.assert.calledOnce(dispatch);
|
||||
sinon.assert.calledWith(
|
||||
dispatch,
|
||||
sinon.match.has('type', 'username/CONFIRM_USERNAME')
|
||||
);
|
||||
});
|
||||
|
||||
it('should close modal on resolution', () => {
|
||||
let state = stateWithReservation;
|
||||
|
||||
state = reducer(state, {
|
||||
type: 'username/CONFIRM_USERNAME_PENDING',
|
||||
meta: undefined,
|
||||
});
|
||||
assert.strictEqual(
|
||||
getUsernameReservationState(state),
|
||||
UsernameReservationState.Confirming
|
||||
);
|
||||
assert.strictEqual(
|
||||
getUsernameReservationObject(state),
|
||||
DEFAULT_RESERVATION
|
||||
);
|
||||
|
||||
state = reducer(state, {
|
||||
type: 'username/CONFIRM_USERNAME_FULFILLED',
|
||||
payload: undefined,
|
||||
meta: undefined,
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
getUsernameReservationState(state),
|
||||
UsernameReservationState.Closed
|
||||
);
|
||||
assert.strictEqual(getUsernameReservationObject(state), undefined);
|
||||
assert.strictEqual(getUsernameReservationError(state), undefined);
|
||||
});
|
||||
|
||||
it('should not close modal on error', () => {
|
||||
let state = stateWithReservation;
|
||||
|
||||
state = reducer(state, {
|
||||
type: 'username/CONFIRM_USERNAME_PENDING',
|
||||
meta: undefined,
|
||||
});
|
||||
assert.strictEqual(
|
||||
getUsernameReservationState(state),
|
||||
UsernameReservationState.Confirming
|
||||
);
|
||||
assert.strictEqual(
|
||||
getUsernameReservationObject(state),
|
||||
DEFAULT_RESERVATION
|
||||
);
|
||||
|
||||
state = reducer(state, {
|
||||
type: 'username/CONFIRM_USERNAME_REJECTED',
|
||||
error: true,
|
||||
payload: new Error(),
|
||||
meta: undefined,
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
getUsernameReservationState(state),
|
||||
UsernameReservationState.Open
|
||||
);
|
||||
assert.strictEqual(getUsernameReservationObject(state), undefined);
|
||||
assert.strictEqual(
|
||||
getUsernameReservationError(state),
|
||||
UsernameReservationError.General
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUsername', () => {
|
||||
it('should dispatch once on success', () => {
|
||||
const doDeleteUsername = sinon.stub().resolves();
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
actions.deleteUsername({
|
||||
doDeleteUsername,
|
||||
username: 'test',
|
||||
})(dispatch, () => emptyState, null);
|
||||
|
||||
sinon.assert.calledOnce(dispatch);
|
||||
sinon.assert.calledWith(
|
||||
dispatch,
|
||||
sinon.match.has('type', 'username/DELETE_USERNAME')
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch twice on failure', async () => {
|
||||
const clock = sandbox.useFakeTimers({
|
||||
now: 0,
|
||||
});
|
||||
|
||||
const doDeleteUsername = sinon.stub().rejects(new Error());
|
||||
const dispatch = sinon.spy();
|
||||
|
||||
actions.deleteUsername({
|
||||
doDeleteUsername,
|
||||
username: 'test',
|
||||
})(dispatch, () => emptyState, null);
|
||||
|
||||
await clock.runToLastAsync();
|
||||
|
||||
sinon.assert.calledTwice(dispatch);
|
||||
sinon.assert.calledWith(
|
||||
dispatch,
|
||||
sinon.match.has('type', 'username/DELETE_USERNAME')
|
||||
);
|
||||
sinon.assert.calledWith(dispatch, {
|
||||
type: 'toast/SHOW_TOAST',
|
||||
payload: {
|
||||
toastType: ToastType.FailedToDeleteUsername,
|
||||
parameters: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should update editState', () => {
|
||||
let state = stateWithReservation;
|
||||
|
||||
state = reducer(state, {
|
||||
type: 'username/DELETE_USERNAME_PENDING',
|
||||
meta: undefined,
|
||||
});
|
||||
assert.strictEqual(
|
||||
getUsernameEditState(state),
|
||||
UsernameEditState.Deleting
|
||||
);
|
||||
|
||||
state = reducer(state, {
|
||||
type: 'username/DELETE_USERNAME_FULFILLED',
|
||||
payload: undefined,
|
||||
meta: undefined,
|
||||
});
|
||||
assert.strictEqual(
|
||||
getUsernameEditState(state),
|
||||
UsernameEditState.Editing
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -3,15 +3,13 @@
|
|||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import * as Username from '../../types/Username';
|
||||
import * as Username from '../../util/Username';
|
||||
|
||||
describe('Username', () => {
|
||||
describe('getUsernameFromSearch', () => {
|
||||
const { getUsernameFromSearch } = Username;
|
||||
|
||||
it('matches invalid username searches', () => {
|
||||
assert.strictEqual(getUsernameFromSearch('username!'), 'username!');
|
||||
assert.strictEqual(getUsernameFromSearch('1username'), '1username');
|
||||
assert.strictEqual(getUsernameFromSearch('use'), 'use');
|
||||
assert.strictEqual(
|
||||
getUsernameFromSearch('username9012345678901234567'),
|
||||
|
@ -22,6 +20,7 @@ describe('Username', () => {
|
|||
it('matches valid username searches', () => {
|
||||
assert.strictEqual(getUsernameFromSearch('username_34'), 'username_34');
|
||||
assert.strictEqual(getUsernameFromSearch('u5ername'), 'u5ername');
|
||||
assert.strictEqual(getUsernameFromSearch('username.12'), 'username.12');
|
||||
assert.strictEqual(getUsernameFromSearch('user'), 'user');
|
||||
assert.strictEqual(
|
||||
getUsernameFromSearch('username901234567890123456'),
|
||||
|
@ -33,6 +32,7 @@ describe('Username', () => {
|
|||
assert.strictEqual(getUsernameFromSearch('@username!'), 'username!');
|
||||
assert.strictEqual(getUsernameFromSearch('@1username'), '1username');
|
||||
assert.strictEqual(getUsernameFromSearch('@username_34'), 'username_34');
|
||||
assert.strictEqual(getUsernameFromSearch('@username.34'), 'username.34');
|
||||
assert.strictEqual(getUsernameFromSearch('@u5ername'), 'u5ername');
|
||||
});
|
||||
|
||||
|
@ -40,6 +40,7 @@ describe('Username', () => {
|
|||
assert.strictEqual(getUsernameFromSearch('username!@'), 'username!');
|
||||
assert.strictEqual(getUsernameFromSearch('1username@'), '1username');
|
||||
assert.strictEqual(getUsernameFromSearch('username_34@'), 'username_34');
|
||||
assert.strictEqual(getUsernameFromSearch('username.34@'), 'username.34');
|
||||
assert.strictEqual(getUsernameFromSearch('u5ername@'), 'u5ername');
|
||||
});
|
||||
|
||||
|
@ -57,16 +58,18 @@ describe('Username', () => {
|
|||
it('does not match invalid username searches', () => {
|
||||
assert.isFalse(isValidUsername('username!'));
|
||||
assert.isFalse(isValidUsername('1username'));
|
||||
assert.isFalse(isValidUsername('use'));
|
||||
assert.isFalse(isValidUsername('username9012345678901234567'));
|
||||
assert.isFalse(isValidUsername('u'));
|
||||
assert.isFalse(isValidUsername('username9012345678901234567890123'));
|
||||
assert.isFalse(isValidUsername('username.abc'));
|
||||
});
|
||||
|
||||
it('matches valid usernames', () => {
|
||||
assert.isTrue(isValidUsername('username_34'));
|
||||
assert.isTrue(isValidUsername('u5ername'));
|
||||
assert.isTrue(isValidUsername('_username'));
|
||||
assert.isTrue(isValidUsername('user'));
|
||||
assert.isTrue(isValidUsername('username901234567890123456'));
|
||||
assert.isTrue(isValidUsername('use'));
|
||||
assert.isTrue(isValidUsername('username901234567890123456789012'));
|
||||
assert.isTrue(isValidUsername('username.0123'));
|
||||
});
|
||||
|
||||
it('does not match valid and invalid usernames with @ prefix or suffix', () => {
|
|
@ -12,7 +12,9 @@ import {
|
|||
parseSgnlHref,
|
||||
parseCaptchaHref,
|
||||
parseE164FromSignalDotMeHash,
|
||||
parseUsernameFromSignalDotMeHash,
|
||||
parseSignalHttpsLink,
|
||||
generateUsernameLink,
|
||||
rewriteSignalHrefsIfNecessary,
|
||||
} from '../../util/sgnlHref';
|
||||
|
||||
|
@ -373,6 +375,48 @@ describe('sgnlHref', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('parseUsernameFromSignalDotMeHash', () => {
|
||||
it('returns undefined for invalid inputs', () => {
|
||||
['', ' u/+18885551234', 'z/18885551234'].forEach(hash => {
|
||||
assert.isUndefined(parseUsernameFromSignalDotMeHash(hash));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the username for valid inputs', () => {
|
||||
assert.strictEqual(
|
||||
parseUsernameFromSignalDotMeHash('u/signal.03'),
|
||||
'signal.03'
|
||||
);
|
||||
assert.strictEqual(
|
||||
parseUsernameFromSignalDotMeHash('u/signal%2F03'),
|
||||
'signal/03'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateUsernameLink', () => {
|
||||
it('generates regular link', () => {
|
||||
assert.strictEqual(
|
||||
generateUsernameLink('signal.03'),
|
||||
'https://signal.me/#u/signal.03'
|
||||
);
|
||||
});
|
||||
|
||||
it('generates encoded link', () => {
|
||||
assert.strictEqual(
|
||||
generateUsernameLink('signal/03'),
|
||||
'https://signal.me/#u/signal%2F03'
|
||||
);
|
||||
});
|
||||
|
||||
it('generates short link', () => {
|
||||
assert.strictEqual(
|
||||
generateUsernameLink('signal/03', { short: true }),
|
||||
'signal.me/#u/signal%2F03'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSignalHttpsLink', () => {
|
||||
it('returns a null command for invalid URLs', () => {
|
||||
['', 'https', 'https://example/?foo=bar'].forEach(href => {
|
||||
|
|
|
@ -2457,12 +2457,6 @@ export default class MessageSender {
|
|||
return this.server.getProfile(uuid.toString(), options);
|
||||
}
|
||||
|
||||
async getProfileForUsername(
|
||||
username: string
|
||||
): ReturnType<WebAPIType['getProfileForUsername']> {
|
||||
return this.server.getProfileForUsername(username);
|
||||
}
|
||||
|
||||
async getAvatar(path: string): Promise<ReturnType<WebAPIType['getAvatar']>> {
|
||||
return this.server.getAvatar(path);
|
||||
}
|
||||
|
@ -2595,13 +2589,4 @@ 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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -185,44 +185,34 @@ type BytesWithDetailsType = {
|
|||
response: Response;
|
||||
};
|
||||
|
||||
export const multiRecipient200ResponseSchema = z
|
||||
.object({
|
||||
export const multiRecipient200ResponseSchema = z.object({
|
||||
uuids404: z.array(z.string()).optional(),
|
||||
needsSync: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
});
|
||||
export type MultiRecipient200ResponseType = z.infer<
|
||||
typeof multiRecipient200ResponseSchema
|
||||
>;
|
||||
|
||||
export const multiRecipient409ResponseSchema = z.array(
|
||||
z
|
||||
.object({
|
||||
z.object({
|
||||
uuid: z.string(),
|
||||
devices: z
|
||||
.object({
|
||||
devices: z.object({
|
||||
missingDevices: z.array(z.number()).optional(),
|
||||
extraDevices: z.array(z.number()).optional(),
|
||||
}),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough()
|
||||
);
|
||||
export type MultiRecipient409ResponseType = z.infer<
|
||||
typeof multiRecipient409ResponseSchema
|
||||
>;
|
||||
|
||||
export const multiRecipient410ResponseSchema = z.array(
|
||||
z
|
||||
.object({
|
||||
z.object({
|
||||
uuid: z.string(),
|
||||
devices: z
|
||||
.object({
|
||||
devices: z.object({
|
||||
staleDevices: z.array(z.number()).optional(),
|
||||
}),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough()
|
||||
);
|
||||
export type MultiRecipient410ResponseType = z.infer<
|
||||
typeof multiRecipient410ResponseSchema
|
||||
|
@ -524,6 +514,8 @@ const URL_CALLS = {
|
|||
supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery',
|
||||
updateDeviceName: 'v1/accounts/name',
|
||||
username: 'v1/accounts/username',
|
||||
reservedUsername: 'v1/accounts/username/reserved',
|
||||
confirmUsername: 'v1/accounts/username/confirm',
|
||||
whoami: 'v1/accounts/whoami',
|
||||
};
|
||||
|
||||
|
@ -599,6 +591,7 @@ type AjaxOptionsType = {
|
|||
username?: string;
|
||||
validateResponse?: any;
|
||||
isRegistration?: true;
|
||||
abortSignal?: AbortSignal;
|
||||
} & (
|
||||
| {
|
||||
unauthenticated?: false;
|
||||
|
@ -671,8 +664,7 @@ export type ProfileRequestDataType = {
|
|||
version: string;
|
||||
};
|
||||
|
||||
const uploadAvatarHeadersZod = z
|
||||
.object({
|
||||
const uploadAvatarHeadersZod = z.object({
|
||||
acl: z.string(),
|
||||
algorithm: z.string(),
|
||||
credential: z.string(),
|
||||
|
@ -680,8 +672,7 @@ const uploadAvatarHeadersZod = z
|
|||
key: z.string(),
|
||||
policy: z.string(),
|
||||
signature: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
});
|
||||
export type UploadAvatarHeadersType = z.infer<typeof uploadAvatarHeadersZod>;
|
||||
|
||||
export type ProfileType = Readonly<{
|
||||
|
@ -724,14 +715,12 @@ export type MakeProxiedRequestResultType =
|
|||
totalSize: number;
|
||||
};
|
||||
|
||||
const whoamiResultZod = z
|
||||
.object({
|
||||
const whoamiResultZod = z.object({
|
||||
uuid: z.string(),
|
||||
pni: z.string(),
|
||||
number: z.string(),
|
||||
username: z.string().or(z.null()).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
});
|
||||
export type WhoamiResultType = z.infer<typeof whoamiResultZod>;
|
||||
|
||||
export type ConfirmCodeResultType = Readonly<{
|
||||
|
@ -784,20 +773,37 @@ export type GetGroupCredentialsResultType = Readonly<{
|
|||
credentials: ReadonlyArray<GroupCredentialType>;
|
||||
}>;
|
||||
|
||||
const verifyAciResponse = z
|
||||
.object({
|
||||
const verifyAciResponse = z.object({
|
||||
elements: z.array(
|
||||
z.object({
|
||||
aci: z.string(),
|
||||
identityKey: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
.passthrough();
|
||||
});
|
||||
|
||||
export type VerifyAciRequestType = Array<{ aci: string; fingerprint: string }>;
|
||||
export type VerifyAciResponseType = z.infer<typeof verifyAciResponse>;
|
||||
|
||||
export type ReserveUsernameOptionsType = Readonly<{
|
||||
nickname: string;
|
||||
abortSignal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
export type ConfirmUsernameOptionsType = Readonly<{
|
||||
usernameToConfirm: string;
|
||||
reservationToken: string;
|
||||
abortSignal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
const reserveUsernameResultZod = z.object({
|
||||
username: z.string(),
|
||||
reservationToken: z.string(),
|
||||
});
|
||||
export type ReserveUsernameResultType = z.infer<
|
||||
typeof reserveUsernameResultZod
|
||||
>;
|
||||
|
||||
export type ConfirmCodeOptionsType = Readonly<{
|
||||
number: string;
|
||||
code: string;
|
||||
|
@ -819,7 +825,7 @@ export type WebAPIType = {
|
|||
group: Proto.IGroup,
|
||||
options: GroupCredentialsType
|
||||
) => Promise<void>;
|
||||
deleteUsername: () => Promise<void>;
|
||||
deleteUsername: (abortSignal?: AbortSignal) => Promise<void>;
|
||||
getAttachment: (cdnKey: string, cdnNumber?: number) => Promise<Uint8Array>;
|
||||
getAvatar: (path: string) => Promise<Uint8Array>;
|
||||
getDevices: () => Promise<GetDevicesResultType>;
|
||||
|
@ -911,7 +917,10 @@ export type WebAPIType = {
|
|||
encryptedStickers: Array<Uint8Array>,
|
||||
onProgress?: () => void
|
||||
) => Promise<string>;
|
||||
putUsername: (newUsername: string) => Promise<void>;
|
||||
reserveUsername: (
|
||||
options: ReserveUsernameOptionsType
|
||||
) => Promise<ReserveUsernameResultType>;
|
||||
confirmUsername(options: ConfirmUsernameOptionsType): Promise<void>;
|
||||
registerCapabilities: (capabilities: CapabilitiesUploadType) => Promise<void>;
|
||||
registerKeys: (genKeys: KeysType, uuidKind: UUIDKind) => Promise<void>;
|
||||
registerSupportForUnauthenticatedDelivery: () => Promise<void>;
|
||||
|
@ -1288,7 +1297,8 @@ export function initialize({
|
|||
putAttachment,
|
||||
putProfile,
|
||||
putStickers,
|
||||
putUsername,
|
||||
reserveUsername,
|
||||
confirmUsername,
|
||||
registerCapabilities,
|
||||
registerKeys,
|
||||
registerSupportForUnauthenticatedDelivery,
|
||||
|
@ -1363,6 +1373,7 @@ export function initialize({
|
|||
version,
|
||||
unauthenticated: param.unauthenticated,
|
||||
accessKey: param.accessKey,
|
||||
abortSignal: param.abortSignal,
|
||||
};
|
||||
|
||||
try {
|
||||
|
@ -1654,7 +1665,7 @@ export function initialize({
|
|||
return (await _ajax({
|
||||
call: 'profile',
|
||||
httpType: 'GET',
|
||||
urlParameters: `/username/${usernameToFetch}`,
|
||||
urlParameters: `/username/${encodeURIComponent(usernameToFetch)}`,
|
||||
responseType: 'json',
|
||||
redactUrl: _createRedactor(usernameToFetch),
|
||||
})) as ProfileType;
|
||||
|
@ -1765,17 +1776,42 @@ export function initialize({
|
|||
});
|
||||
}
|
||||
|
||||
async function deleteUsername() {
|
||||
async function deleteUsername(abortSignal?: AbortSignal) {
|
||||
await _ajax({
|
||||
call: 'username',
|
||||
httpType: 'DELETE',
|
||||
abortSignal,
|
||||
});
|
||||
}
|
||||
async function putUsername(newUsername: string) {
|
||||
await _ajax({
|
||||
call: 'username',
|
||||
async function reserveUsername({
|
||||
nickname,
|
||||
abortSignal,
|
||||
}: ReserveUsernameOptionsType) {
|
||||
const response = await _ajax({
|
||||
call: 'reservedUsername',
|
||||
httpType: 'PUT',
|
||||
urlParameters: `/${newUsername}`,
|
||||
jsonData: {
|
||||
nickname,
|
||||
},
|
||||
responseType: 'json',
|
||||
abortSignal,
|
||||
});
|
||||
|
||||
return reserveUsernameResultZod.parse(response);
|
||||
}
|
||||
async function confirmUsername({
|
||||
usernameToConfirm,
|
||||
reservationToken,
|
||||
abortSignal,
|
||||
}: ConfirmUsernameOptionsType) {
|
||||
await _ajax({
|
||||
call: 'confirmUsername',
|
||||
httpType: 'PUT',
|
||||
jsonData: {
|
||||
usernameToConfirm,
|
||||
reservationToken,
|
||||
},
|
||||
abortSignal,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,23 +1,13 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export const MAX_USERNAME = 26;
|
||||
export const MIN_USERNAME = 4;
|
||||
export type UsernameReservationType = Readonly<{
|
||||
username: string;
|
||||
previousUsername: string | undefined;
|
||||
reservationToken: string;
|
||||
}>;
|
||||
|
||||
export function isValidUsername(searchTerm: string): boolean {
|
||||
return /^[a-z_][0-9a-z_]{3,25}$/.test(searchTerm);
|
||||
}
|
||||
|
||||
export function getUsernameFromSearch(searchTerm: string): string | undefined {
|
||||
if (/^[+0-9]+$/.test(searchTerm)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = /^@?(.*?)@?$/.exec(searchTerm);
|
||||
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
export enum ReserveUsernameError {
|
||||
Unprocessable = 'Unprocessable',
|
||||
Conflict = 'Conflict',
|
||||
}
|
||||
|
|
79
ts/util/Username.ts
Normal file
79
ts/util/Username.ts
Normal file
|
@ -0,0 +1,79 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as RemoteConfig from '../RemoteConfig';
|
||||
import { parseIntWithFallback } from './parseIntWithFallback';
|
||||
|
||||
export function getMaxNickname(): number {
|
||||
return parseIntWithFallback(
|
||||
RemoteConfig.getValue('global.nicknames.max'),
|
||||
32
|
||||
);
|
||||
}
|
||||
export function getMinNickname(): number {
|
||||
return parseIntWithFallback(RemoteConfig.getValue('global.nicknames.min'), 3);
|
||||
}
|
||||
|
||||
export function isValidNickname(nickname: string): boolean {
|
||||
if (!/^[a-z_][0-9a-z_]*$/.test(nickname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nickname.length < getMinNickname()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nickname.length > getMaxNickname()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isValidUsername(username: string): boolean {
|
||||
const match = username.match(/^([a-z_][0-9a-z_]*)(\.\d+)?$/);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [, nickname] = match;
|
||||
return isValidNickname(nickname);
|
||||
}
|
||||
|
||||
export function getUsernameFromSearch(searchTerm: string): string | undefined {
|
||||
// Search term contains username if it:
|
||||
// - Is a valid username with or without a discriminator
|
||||
// - Starts with @
|
||||
// - Ends with @
|
||||
const match = searchTerm.match(
|
||||
/^(?:(?<valid>[a-z_][0-9a-z_]*(?:\.\d*)?)|@(?<start>.*?)@?|@?(?<end>.*?)?@)$/
|
||||
);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { groups } = match;
|
||||
if (!groups) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (groups.valid || groups.start || groups.end) ?? undefined;
|
||||
}
|
||||
|
||||
export function getNickname(username: string): string | undefined {
|
||||
const match = username.match(/^(.*?)(?:\.|$)/);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return match[1];
|
||||
}
|
||||
|
||||
export function getDiscriminator(username: string): string {
|
||||
const match = username.match(/(\..*)$/);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return match[1];
|
||||
}
|
|
@ -4,6 +4,7 @@
|
|||
import { webFrame } from 'electron';
|
||||
import type { AudioDevice } from 'ringrtc';
|
||||
import * as React from 'react';
|
||||
import { noop } from 'lodash';
|
||||
import { getStoriesAvailable } from '../types/Stories';
|
||||
|
||||
import type { ZoomFactorType } from '../types/Storage.d';
|
||||
|
@ -34,7 +35,11 @@ import { PhoneNumberSharingMode } from './phoneNumberSharingMode';
|
|||
import { assertDev } from './assert';
|
||||
import * as durations from './durations';
|
||||
import { isPhoneNumberSharingEnabled } from './isPhoneNumberSharingEnabled';
|
||||
import { parseE164FromSignalDotMeHash } from './sgnlHref';
|
||||
import {
|
||||
parseE164FromSignalDotMeHash,
|
||||
parseUsernameFromSignalDotMeHash,
|
||||
} from './sgnlHref';
|
||||
import { lookupConversationWithoutUuid } from './lookupConversationWithoutUuid';
|
||||
import * as log from '../logging/log';
|
||||
|
||||
type ThemeType = 'light' | 'dark' | 'system';
|
||||
|
@ -100,7 +105,7 @@ export type IPCEventsCallbacksType = {
|
|||
removeDarkOverlay: () => void;
|
||||
resetAllChatColors: () => void;
|
||||
resetDefaultChatColor: () => void;
|
||||
showConversationViaSignalDotMe: (hash: string) => void;
|
||||
showConversationViaSignalDotMe: (hash: string) => Promise<void>;
|
||||
showKeyboardShortcuts: () => void;
|
||||
showGroupViaLink: (x: string) => Promise<void>;
|
||||
showReleaseNotes: () => void;
|
||||
|
@ -478,7 +483,7 @@ export function createIPCEvents(
|
|||
}
|
||||
window.isShowingModal = false;
|
||||
},
|
||||
showConversationViaSignalDotMe(hash: string) {
|
||||
async showConversationViaSignalDotMe(hash: string) {
|
||||
if (!window.Signal.Util.Registration.everDone()) {
|
||||
log.info(
|
||||
'showConversationViaSignalDotMe: Not registered, returning early'
|
||||
|
@ -486,13 +491,33 @@ export function createIPCEvents(
|
|||
return;
|
||||
}
|
||||
|
||||
const { showUserNotFoundModal } = window.reduxActions.globalModals;
|
||||
|
||||
const maybeE164 = parseE164FromSignalDotMeHash(hash);
|
||||
if (maybeE164) {
|
||||
const convo = window.ConversationController.lookupOrCreate({
|
||||
const convoId = await lookupConversationWithoutUuid({
|
||||
type: 'e164',
|
||||
e164: maybeE164,
|
||||
phoneNumber: maybeE164,
|
||||
showUserNotFoundModal,
|
||||
setIsFetchingUUID: noop,
|
||||
});
|
||||
if (convo) {
|
||||
trigger('showConversation', convo.id);
|
||||
if (convoId) {
|
||||
trigger('showConversation', convoId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const maybeUsername = parseUsernameFromSignalDotMeHash(hash);
|
||||
if (maybeUsername) {
|
||||
const convoId = await lookupConversationWithoutUuid({
|
||||
type: 'username',
|
||||
username: maybeUsername,
|
||||
showUserNotFoundModal,
|
||||
setIsFetchingUUID: noop,
|
||||
});
|
||||
if (convoId) {
|
||||
trigger('showConversation', convoId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,13 +7,13 @@ import type { UserNotFoundModalStateType } from '../state/ducks/globalModals';
|
|||
import * as log from '../logging/log';
|
||||
import { UUID } from '../types/UUID';
|
||||
import type { UUIDStringType } from '../types/UUID';
|
||||
import { isValidUsername } from '../types/Username';
|
||||
import * as Errors from '../types/errors';
|
||||
import { HTTPError } from '../textsecure/Errors';
|
||||
import { showToast } from './showToast';
|
||||
import { strictAssert } from './assert';
|
||||
import type { UUIDFetchStateKeyType } from './uuidFetchState';
|
||||
import { getUuidsForE164s } from './getUuidsForE164s';
|
||||
import { isValidUsername } from './Username';
|
||||
|
||||
export type LookupConversationWithoutUuidActionsType = Readonly<{
|
||||
lookupConversationWithoutUuid: typeof lookupConversationWithoutUuid;
|
||||
|
@ -137,13 +137,13 @@ async function checkForUsername(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const { messaging } = window.textsecure;
|
||||
if (!messaging) {
|
||||
throw new Error('messaging is not available!');
|
||||
const { server } = window.textsecure;
|
||||
if (!server) {
|
||||
throw new Error('server is not available!');
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await messaging.getProfileForUsername(username);
|
||||
const profile = await server.getProfileForUsername(username);
|
||||
|
||||
if (!profile.uuid) {
|
||||
log.error("checkForUsername: Returned profile didn't include a uuid");
|
||||
|
|
|
@ -6,7 +6,8 @@ import { maybeParseUrl } from './url';
|
|||
import { isValidE164 } from './isValidE164';
|
||||
|
||||
const SIGNAL_HOSTS = new Set(['signal.group', 'signal.art', 'signal.me']);
|
||||
const SIGNAL_DOT_ME_HASH_PREFIX = 'p/';
|
||||
const SIGNAL_DOT_ME_E164_PREFIX = 'p/';
|
||||
const SIGNAL_DOT_ME_USERNAME_PREFIX = 'u/';
|
||||
|
||||
function parseUrl(value: string | URL, logger: LoggerType): undefined | URL {
|
||||
if (value instanceof URL) {
|
||||
|
@ -138,14 +139,24 @@ export function parseSignalHttpsLink(
|
|||
}
|
||||
|
||||
export function parseE164FromSignalDotMeHash(hash: string): undefined | string {
|
||||
if (!hash.startsWith(SIGNAL_DOT_ME_HASH_PREFIX)) {
|
||||
if (!hash.startsWith(SIGNAL_DOT_ME_E164_PREFIX)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeE164 = hash.slice(SIGNAL_DOT_ME_HASH_PREFIX.length);
|
||||
const maybeE164 = hash.slice(SIGNAL_DOT_ME_E164_PREFIX.length);
|
||||
return isValidE164(maybeE164, true) ? maybeE164 : undefined;
|
||||
}
|
||||
|
||||
export function parseUsernameFromSignalDotMeHash(
|
||||
hash: string
|
||||
): undefined | string {
|
||||
if (!hash.startsWith(SIGNAL_DOT_ME_USERNAME_PREFIX)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return decodeURIComponent(hash.slice(SIGNAL_DOT_ME_USERNAME_PREFIX.length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts `http://signal.group/#abc` to `https://signal.group/#abc`. Does the same for
|
||||
* other Signal hosts, like signal.me. Does nothing to other URLs. Expects a valid href.
|
||||
|
@ -167,3 +178,18 @@ export function rewriteSignalHrefsIfNecessary(href: string): string {
|
|||
|
||||
return href;
|
||||
}
|
||||
|
||||
export type GenerateUsernameLinkOptionsType = Readonly<{
|
||||
short?: boolean;
|
||||
}>;
|
||||
|
||||
export function generateUsernameLink(
|
||||
username: string,
|
||||
{ short = false }: GenerateUsernameLinkOptionsType = {}
|
||||
): string {
|
||||
const shortVersion = `signal.me/#u/${encodeURIComponent(username)}`;
|
||||
if (short) {
|
||||
return shortVersion;
|
||||
}
|
||||
return `https://${shortVersion}`;
|
||||
}
|
||||
|
|
|
@ -1,8 +1,16 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(resolve, ms);
|
||||
export function sleep(ms: number, abortSignal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeout: NodeJS.Timeout | undefined = setTimeout(resolve, ms);
|
||||
|
||||
abortSignal?.addEventListener('abort', () => {
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
}
|
||||
reject(new Error('Aborted'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue