463 lines
13 KiB
TypeScript
463 lines
13 KiB
TypeScript
// 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 { ToastType } from '../types/Toast';
|
|
import { missingCaseError } from '../util/missingCaseError';
|
|
import { getNickname, getDiscriminator, isCaseChange } from '../types/Username';
|
|
import {
|
|
UsernameReservationState,
|
|
UsernameReservationError,
|
|
} from '../state/ducks/usernameEnums';
|
|
import type { ReserveUsernameOptionsType } from '../state/ducks/username';
|
|
import type { ShowToastAction } from '../state/ducks/toast';
|
|
|
|
import { AutoSizeInput } from './AutoSizeInput';
|
|
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;
|
|
usernameCorrupted: boolean;
|
|
reservation?: UsernameReservationType;
|
|
error?: UsernameReservationError;
|
|
state: UsernameReservationState;
|
|
recoveredUsername: string | undefined;
|
|
minNickname: number;
|
|
maxNickname: number;
|
|
}>;
|
|
|
|
export type ActionPropsDataType = Readonly<{
|
|
setUsernameReservationError(
|
|
error: UsernameReservationError | undefined
|
|
): void;
|
|
clearUsernameReservation(): void;
|
|
reserveUsername(optiona: ReserveUsernameOptionsType): void;
|
|
confirmUsername(): void;
|
|
showToast: ShowToastAction;
|
|
}>;
|
|
|
|
export type ExternalPropsDataType = Readonly<{
|
|
onClose(): void;
|
|
isRootModal: boolean;
|
|
}>;
|
|
|
|
export type PropsType = PropsDataType &
|
|
ActionPropsDataType &
|
|
ExternalPropsDataType;
|
|
|
|
enum UpdateState {
|
|
Original = 'Original',
|
|
Nickname = 'Nickname',
|
|
Discriminator = 'Discriminator',
|
|
}
|
|
|
|
const DISCRIMINATOR_MAX_LENGTH = 9;
|
|
|
|
export function EditUsernameModalBody({
|
|
i18n,
|
|
currentUsername,
|
|
usernameCorrupted,
|
|
reserveUsername,
|
|
confirmUsername,
|
|
showToast,
|
|
minNickname,
|
|
maxNickname,
|
|
reservation,
|
|
setUsernameReservationError,
|
|
clearUsernameReservation,
|
|
error,
|
|
state,
|
|
recoveredUsername,
|
|
isRootModal,
|
|
onClose,
|
|
}: PropsType): JSX.Element {
|
|
const currentNickname = useMemo(() => {
|
|
if (!currentUsername) {
|
|
return undefined;
|
|
}
|
|
|
|
return getNickname(currentUsername);
|
|
}, [currentUsername]);
|
|
|
|
const currentDiscriminator =
|
|
currentUsername === undefined
|
|
? undefined
|
|
: getDiscriminator(currentUsername);
|
|
|
|
const [updateState, setUpdateState] = useState(UpdateState.Original);
|
|
const [nickname, setNickname] = useState(currentNickname);
|
|
const [isLearnMoreVisible, setIsLearnMoreVisible] = useState(false);
|
|
const [isConfirmingSave, setIsConfirmingSave] = useState(false);
|
|
const [isConfirmingReset, setIsConfirmingReset] = useState(false);
|
|
|
|
const [customDiscriminator, setCustomDiscriminator] = useState<
|
|
string | undefined
|
|
>(undefined);
|
|
|
|
const discriminator = useMemo(() => {
|
|
// Always give preference to user-selected custom discriminator.
|
|
if (
|
|
customDiscriminator !== undefined ||
|
|
updateState === UpdateState.Discriminator
|
|
) {
|
|
return customDiscriminator;
|
|
}
|
|
|
|
if (reservation !== undefined) {
|
|
// New discriminator from reservation
|
|
return getDiscriminator(reservation.username);
|
|
}
|
|
|
|
return currentDiscriminator;
|
|
}, [reservation, updateState, currentDiscriminator, customDiscriminator]);
|
|
|
|
// Disallow non-numeric discriminator
|
|
const updateCustomDiscriminator = useCallback((newValue: string): void => {
|
|
const digits = newValue.replace(/[^\d]+/g, '');
|
|
setUpdateState(UpdateState.Discriminator);
|
|
setCustomDiscriminator(digits);
|
|
}, []);
|
|
|
|
// When we change nickname with previously erased discriminator - reset the
|
|
// discriminator state.
|
|
useEffect(() => {
|
|
if (customDiscriminator !== '' || !reservation) {
|
|
return;
|
|
}
|
|
setCustomDiscriminator(undefined);
|
|
}, [customDiscriminator, reservation]);
|
|
|
|
// Clear reservation if user erases the nickname
|
|
useEffect(() => {
|
|
if (updateState === UpdateState.Nickname && !nickname) {
|
|
clearUsernameReservation();
|
|
}
|
|
}, [clearUsernameReservation, nickname, updateState]);
|
|
|
|
const isReserving = state === UsernameReservationState.Reserving;
|
|
const isConfirming = state === UsernameReservationState.Confirming;
|
|
const canSave =
|
|
!isReserving &&
|
|
!isConfirming &&
|
|
(reservation !== undefined || customDiscriminator);
|
|
const isDiscriminatorVisible =
|
|
Boolean(nickname || customDiscriminator) &&
|
|
(discriminator || updateState === UpdateState.Discriminator);
|
|
|
|
useEffect(() => {
|
|
if (state === UsernameReservationState.Closed) {
|
|
onClose();
|
|
}
|
|
}, [state, onClose]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
state === UsernameReservationState.Closed &&
|
|
recoveredUsername &&
|
|
isRootModal
|
|
) {
|
|
showToast({
|
|
toastType: ToastType.UsernameRecovered,
|
|
parameters: {
|
|
username: recoveredUsername,
|
|
},
|
|
});
|
|
}
|
|
}, [state, recoveredUsername, showToast, isRootModal]);
|
|
|
|
const errorString = useMemo(() => {
|
|
if (!error) {
|
|
return undefined;
|
|
}
|
|
if (error === UsernameReservationError.NotEnoughCharacters) {
|
|
return i18n('icu:ProfileEditor--username--check-character-min', {
|
|
min: minNickname,
|
|
});
|
|
}
|
|
if (error === UsernameReservationError.TooManyCharacters) {
|
|
return i18n('icu:ProfileEditor--username--check-character-max', {
|
|
max: maxNickname,
|
|
});
|
|
}
|
|
if (error === UsernameReservationError.CheckStartingCharacter) {
|
|
return i18n('icu:ProfileEditor--username--check-starting-character');
|
|
}
|
|
if (error === UsernameReservationError.CheckCharacters) {
|
|
return i18n('icu:ProfileEditor--username--check-characters');
|
|
}
|
|
if (error === UsernameReservationError.UsernameNotAvailable) {
|
|
return i18n('icu:ProfileEditor--username--unavailable');
|
|
}
|
|
if (error === UsernameReservationError.NotEnoughDiscriminator) {
|
|
return i18n('icu:ProfileEditor--username--check-discriminator-min');
|
|
}
|
|
if (error === UsernameReservationError.AllZeroDiscriminator) {
|
|
return i18n('icu:ProfileEditor--username--check-discriminator-all-zero');
|
|
}
|
|
if (error === UsernameReservationError.LeadingZeroDiscriminator) {
|
|
return i18n(
|
|
'icu:ProfileEditor--username--check-discriminator-leading-zero'
|
|
);
|
|
}
|
|
if (error === UsernameReservationError.TooManyAttempts) {
|
|
return i18n('icu:ProfileEditor--username--too-many-attempts');
|
|
}
|
|
// Displayed through confirmation modal below
|
|
if (
|
|
error === UsernameReservationError.General ||
|
|
error === UsernameReservationError.ConflictOrGone
|
|
) {
|
|
return;
|
|
}
|
|
throw missingCaseError(error);
|
|
}, [error, i18n, minNickname, maxNickname]);
|
|
|
|
useEffect(() => {
|
|
// Initial effect run
|
|
if (updateState === UpdateState.Original) {
|
|
return;
|
|
}
|
|
|
|
// Sanity-check, we should never get here.
|
|
if (!nickname) {
|
|
return;
|
|
}
|
|
|
|
// User just erased discriminator
|
|
if (updateState === UpdateState.Discriminator && !customDiscriminator) {
|
|
return;
|
|
}
|
|
|
|
if (isConfirming) {
|
|
return;
|
|
}
|
|
|
|
reserveUsername({ nickname, customDiscriminator });
|
|
}, [
|
|
updateState,
|
|
nickname,
|
|
reserveUsername,
|
|
isConfirming,
|
|
customDiscriminator,
|
|
]);
|
|
|
|
const onChange = useCallback((newNickname: string) => {
|
|
setUpdateState(UpdateState.Nickname);
|
|
setNickname(newNickname);
|
|
}, []);
|
|
|
|
const onSave = useCallback(() => {
|
|
if (usernameCorrupted) {
|
|
setIsConfirmingReset(true);
|
|
} else if (!currentUsername || (reservation && isCaseChange(reservation))) {
|
|
confirmUsername();
|
|
} else {
|
|
setIsConfirmingSave(true);
|
|
}
|
|
}, [confirmUsername, currentUsername, reservation, usernameCorrupted]);
|
|
|
|
const onCancelSave = useCallback(() => {
|
|
setIsConfirmingReset(false);
|
|
setIsConfirmingSave(false);
|
|
}, []);
|
|
|
|
const onConfirmUsername = useCallback(() => {
|
|
confirmUsername();
|
|
}, [confirmUsername]);
|
|
|
|
const onCancel = useCallback(() => {
|
|
onClose();
|
|
}, [onClose]);
|
|
|
|
const onLearnMore = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
|
|
setIsLearnMoreVisible(true);
|
|
}, []);
|
|
|
|
let title = i18n('icu:ProfileEditor--username--title');
|
|
if (nickname && discriminator) {
|
|
title = `${nickname}.${discriminator}`;
|
|
}
|
|
|
|
const learnMoreTitle = (
|
|
<>
|
|
<i className="EditUsernameModalBody__learn-more__hashtag" />
|
|
{i18n('icu: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="EditUsernameModalBody__input"
|
|
i18n={i18n}
|
|
disableSpellcheck
|
|
disabled={isConfirming}
|
|
onChange={onChange}
|
|
onEnter={onSave}
|
|
placeholder={i18n('icu:EditUsernameModalBody__username-placeholder')}
|
|
value={nickname}
|
|
>
|
|
{isReserving && <Spinner size="16px" svgSize="small" />}
|
|
{isDiscriminatorVisible ? (
|
|
<>
|
|
<div className="EditUsernameModalBody__divider" />
|
|
<AutoSizeInput
|
|
moduleClassName="EditUsernameModalBody__discriminator"
|
|
disableSpellcheck
|
|
disabled={isConfirming}
|
|
value={discriminator}
|
|
onChange={updateCustomDiscriminator}
|
|
placeholder="00"
|
|
maxLength={DISCRIMINATOR_MAX_LENGTH}
|
|
/>
|
|
</>
|
|
) : null}
|
|
</Input>
|
|
|
|
{errorString && (
|
|
<div className="EditUsernameModalBody__error">{errorString}</div>
|
|
)}
|
|
<div
|
|
className={classNames(
|
|
'EditUsernameModalBody__info',
|
|
!errorString ? 'EditUsernameModalBody__info--no-error' : undefined
|
|
)}
|
|
>
|
|
{i18n('icu:EditUsernameModalBody__username-helper')}
|
|
|
|
<button
|
|
type="button"
|
|
className="EditUsernameModalBody__learn-more-button"
|
|
onClick={onLearnMore}
|
|
>
|
|
{i18n('icu:EditUsernameModalBody__learn-more')}
|
|
</button>
|
|
</div>
|
|
|
|
<Modal.ButtonFooter>
|
|
<Button
|
|
disabled={isConfirming}
|
|
onClick={onCancel}
|
|
variant={ButtonVariant.Secondary}
|
|
>
|
|
{i18n('icu:cancel')}
|
|
</Button>
|
|
<Button disabled={!canSave} onClick={onSave}>
|
|
{isConfirming ? (
|
|
<Spinner size="20px" svgSize="small" direction="on-avatar" />
|
|
) : (
|
|
i18n('icu:save')
|
|
)}
|
|
</Button>
|
|
</Modal.ButtonFooter>
|
|
|
|
{isLearnMoreVisible && (
|
|
<Modal
|
|
modalName="EditUsernamModalBody.LearnMore"
|
|
moduleClassName="EditUsernameModalBody__learn-more"
|
|
i18n={i18n}
|
|
onClose={() => setIsLearnMoreVisible(false)}
|
|
title={learnMoreTitle}
|
|
>
|
|
{i18n('icu:EditUsernameModalBody__learn-more__body')}
|
|
|
|
<Modal.ButtonFooter>
|
|
<Button
|
|
onClick={() => setIsLearnMoreVisible(false)}
|
|
variant={ButtonVariant.Secondary}
|
|
>
|
|
{i18n('icu:ok')}
|
|
</Button>
|
|
</Modal.ButtonFooter>
|
|
</Modal>
|
|
)}
|
|
|
|
{error === UsernameReservationError.General && (
|
|
<ConfirmationDialog
|
|
dialogName="EditUsernameModalBody.generalError"
|
|
cancelText={i18n('icu:ok')}
|
|
cancelButtonVariant={ButtonVariant.Secondary}
|
|
i18n={i18n}
|
|
onClose={() => setUsernameReservationError(undefined)}
|
|
>
|
|
{i18n('icu:ProfileEditor--username--general-error')}
|
|
</ConfirmationDialog>
|
|
)}
|
|
|
|
{error === UsernameReservationError.ConflictOrGone && (
|
|
<ConfirmationDialog
|
|
dialogName="EditUsernameModalBody.conflictOrGone"
|
|
cancelText={i18n('icu:ok')}
|
|
cancelButtonVariant={ButtonVariant.Secondary}
|
|
i18n={i18n}
|
|
onClose={() => {
|
|
if (nickname) {
|
|
reserveUsername({ nickname, customDiscriminator });
|
|
}
|
|
}}
|
|
>
|
|
{i18n('icu:ProfileEditor--username--reservation-gone', {
|
|
username: reservation?.username ?? nickname ?? '',
|
|
})}
|
|
</ConfirmationDialog>
|
|
)}
|
|
|
|
{isConfirmingSave && (
|
|
<ConfirmationDialog
|
|
dialogName="EditUsernameModalBody.confirmChange"
|
|
cancelText={i18n('icu:cancel')}
|
|
actions={[
|
|
{
|
|
action: onConfirmUsername,
|
|
style: 'negative',
|
|
text: i18n(
|
|
'icu:EditUsernameModalBody__change-confirmation__continue'
|
|
),
|
|
},
|
|
]}
|
|
i18n={i18n}
|
|
onClose={onCancelSave}
|
|
>
|
|
{i18n('icu:EditUsernameModalBody__change-confirmation')}
|
|
</ConfirmationDialog>
|
|
)}
|
|
|
|
{isConfirmingReset && (
|
|
<ConfirmationDialog
|
|
dialogName="EditUsernameModalBody.confirmReset"
|
|
cancelText={i18n('icu:cancel')}
|
|
actions={[
|
|
{
|
|
action: onConfirmUsername,
|
|
style: 'negative',
|
|
text: i18n(
|
|
'icu:EditUsernameModalBody__change-confirmation__continue'
|
|
),
|
|
},
|
|
]}
|
|
i18n={i18n}
|
|
onClose={onCancelSave}
|
|
>
|
|
{i18n('icu:EditUsernameModalBody__recover-confirmation')}
|
|
</ConfirmationDialog>
|
|
)}
|
|
</>
|
|
);
|
|
}
|