signal-desktop/ts/components/EditUsernameModalBody.tsx

464 lines
13 KiB
TypeScript
Raw Normal View History

2022-10-18 17:12:02 +00:00
// 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';
2024-02-06 18:35:59 +00:00
import { ToastType } from '../types/Toast';
2022-10-18 17:12:02 +00:00
import { missingCaseError } from '../util/missingCaseError';
import { getNickname, getDiscriminator, isCaseChange } from '../types/Username';
2022-10-18 17:12:02 +00:00
import {
UsernameReservationState,
UsernameReservationError,
} from '../state/ducks/usernameEnums';
import type { ReserveUsernameOptionsType } from '../state/ducks/username';
2024-02-06 18:35:59 +00:00
import type { ShowToastAction } from '../state/ducks/toast';
2022-10-18 17:12:02 +00:00
import { AutoSizeInput } from './AutoSizeInput';
2022-10-18 17:12:02 +00:00
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;
2024-02-06 18:35:59 +00:00
usernameCorrupted: boolean;
2022-10-18 17:12:02 +00:00
reservation?: UsernameReservationType;
error?: UsernameReservationError;
state: UsernameReservationState;
2024-02-06 18:35:59 +00:00
recoveredUsername: string | undefined;
minNickname: number;
maxNickname: number;
2022-10-18 17:12:02 +00:00
}>;
export type ActionPropsDataType = Readonly<{
setUsernameReservationError(
error: UsernameReservationError | undefined
): void;
clearUsernameReservation(): void;
reserveUsername(optiona: ReserveUsernameOptionsType): void;
2022-10-18 17:12:02 +00:00
confirmUsername(): void;
2024-02-06 18:35:59 +00:00
showToast: ShowToastAction;
2022-10-18 17:12:02 +00:00
}>;
export type ExternalPropsDataType = Readonly<{
onClose(): void;
2024-02-06 18:35:59 +00:00
isRootModal: boolean;
2022-10-18 17:12:02 +00:00
}>;
export type PropsType = PropsDataType &
ActionPropsDataType &
ExternalPropsDataType;
enum UpdateState {
Original = 'Original',
Nickname = 'Nickname',
Discriminator = 'Discriminator',
}
2024-02-15 19:02:35 +00:00
const DISCRIMINATOR_MAX_LENGTH = 9;
2022-11-18 00:45:19 +00:00
export function EditUsernameModalBody({
2022-10-18 17:12:02 +00:00
i18n,
currentUsername,
2024-02-06 18:35:59 +00:00
usernameCorrupted,
2022-10-18 17:12:02 +00:00
reserveUsername,
confirmUsername,
2024-02-06 18:35:59 +00:00
showToast,
minNickname,
maxNickname,
2022-10-18 17:12:02 +00:00
reservation,
setUsernameReservationError,
clearUsernameReservation,
2022-10-18 17:12:02 +00:00
error,
state,
2024-02-06 18:35:59 +00:00
recoveredUsername,
isRootModal,
2022-10-18 17:12:02 +00:00
onClose,
2022-11-18 00:45:19 +00:00
}: PropsType): JSX.Element {
2022-10-18 17:12:02 +00:00
const currentNickname = useMemo(() => {
if (!currentUsername) {
return undefined;
}
return getNickname(currentUsername);
}, [currentUsername]);
const currentDiscriminator =
currentUsername === undefined
? undefined
: getDiscriminator(currentUsername);
2022-10-18 17:12:02 +00:00
const [updateState, setUpdateState] = useState(UpdateState.Original);
2022-10-18 17:12:02 +00:00
const [nickname, setNickname] = useState(currentNickname);
const [isLearnMoreVisible, setIsLearnMoreVisible] = useState(false);
2023-07-20 03:14:08 +00:00
const [isConfirmingSave, setIsConfirmingSave] = useState(false);
2024-02-06 18:35:59 +00:00
const [isConfirmingReset, setIsConfirmingReset] = useState(false);
2022-10-18 17:12:02 +00:00
const [customDiscriminator, setCustomDiscriminator] = useState<
string | undefined
>(undefined);
2022-10-18 17:12:02 +00:00
const discriminator = useMemo(() => {
// Always give preference to user-selected custom discriminator.
if (
customDiscriminator !== undefined ||
updateState === UpdateState.Discriminator
) {
return customDiscriminator;
}
2022-10-18 17:12:02 +00:00
if (reservation !== undefined) {
// New discriminator from reservation
2022-10-18 17:12:02 +00:00
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();
2022-10-18 17:12:02 +00:00
}
}, [clearUsernameReservation, nickname, updateState]);
2022-10-18 17:12:02 +00:00
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]);
2022-10-18 17:12:02 +00:00
2024-02-06 18:35:59 +00:00
useEffect(() => {
if (
state === UsernameReservationState.Closed &&
recoveredUsername &&
isRootModal
) {
showToast({
toastType: ToastType.UsernameRecovered,
parameters: {
username: recoveredUsername,
},
});
}
}, [state, recoveredUsername, showToast, isRootModal]);
2022-10-18 17:12:02 +00:00
const errorString = useMemo(() => {
if (!error) {
return undefined;
}
if (error === UsernameReservationError.NotEnoughCharacters) {
2023-03-30 00:03:25 +00:00
return i18n('icu:ProfileEditor--username--check-character-min', {
min: minNickname,
2022-10-18 17:12:02 +00:00
});
}
if (error === UsernameReservationError.TooManyCharacters) {
2023-03-30 00:03:25 +00:00
return i18n('icu:ProfileEditor--username--check-character-max', {
max: maxNickname,
2022-10-18 17:12:02 +00:00
});
}
if (error === UsernameReservationError.CheckStartingCharacter) {
2023-03-30 00:03:25 +00:00
return i18n('icu:ProfileEditor--username--check-starting-character');
2022-10-18 17:12:02 +00:00
}
if (error === UsernameReservationError.CheckCharacters) {
2023-03-30 00:03:25 +00:00
return i18n('icu:ProfileEditor--username--check-characters');
2022-10-18 17:12:02 +00:00
}
if (error === UsernameReservationError.UsernameNotAvailable) {
2023-03-30 00:03:25 +00:00
return i18n('icu:ProfileEditor--username--unavailable');
2022-10-18 17:12:02 +00:00
}
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');
}
2022-10-18 17:12:02 +00:00
// Displayed through confirmation modal below
if (
error === UsernameReservationError.General ||
error === UsernameReservationError.ConflictOrGone
) {
2022-10-18 17:12:02 +00:00
return;
}
throw missingCaseError(error);
}, [error, i18n, minNickname, maxNickname]);
2022-10-18 17:12:02 +00:00
useEffect(() => {
// Initial effect run
if (updateState === UpdateState.Original) {
2022-10-18 17:12:02 +00:00
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,
]);
2022-10-18 17:12:02 +00:00
const onChange = useCallback((newNickname: string) => {
setUpdateState(UpdateState.Nickname);
2022-10-18 17:12:02 +00:00
setNickname(newNickname);
}, []);
const onSave = useCallback(() => {
2024-02-06 18:35:59 +00:00
if (usernameCorrupted) {
setIsConfirmingReset(true);
} else if (!currentUsername || (reservation && isCaseChange(reservation))) {
2023-07-20 03:14:08 +00:00
confirmUsername();
} else {
setIsConfirmingSave(true);
}
2024-02-06 18:35:59 +00:00
}, [confirmUsername, currentUsername, reservation, usernameCorrupted]);
2023-07-20 03:14:08 +00:00
const onCancelSave = useCallback(() => {
2024-02-06 18:35:59 +00:00
setIsConfirmingReset(false);
2023-07-20 03:14:08 +00:00
setIsConfirmingSave(false);
}, []);
const onConfirmUsername = useCallback(() => {
2022-10-18 17:12:02 +00:00
confirmUsername();
}, [confirmUsername]);
const onCancel = useCallback(() => {
onClose();
}, [onClose]);
const onLearnMore = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsLearnMoreVisible(true);
}, []);
2023-03-30 00:03:25 +00:00
let title = i18n('icu:ProfileEditor--username--title');
2022-10-18 17:12:02 +00:00
if (nickname && discriminator) {
title = `${nickname}.${discriminator}`;
2022-10-18 17:12:02 +00:00
}
const learnMoreTitle = (
<>
<i className="EditUsernameModalBody__learn-more__hashtag" />
2023-03-30 00:03:25 +00:00
{i18n('icu:EditUsernameModalBody__learn-more__title')}
2022-10-18 17:12:02 +00:00
</>
);
return (
<>
<div className="EditUsernameModalBody__header">
<div className="EditUsernameModalBody__header__large-at" />
<div className="EditUsernameModalBody__header__preview">{title}</div>
</div>
<Input
moduleClassName="EditUsernameModalBody__input"
2022-10-18 17:12:02 +00:00
i18n={i18n}
disableSpellcheck
disabled={isConfirming}
onChange={onChange}
onEnter={onSave}
2023-03-30 00:03:25 +00:00
placeholder={i18n('icu:EditUsernameModalBody__username-placeholder')}
2022-10-18 17:12:02 +00:00
value={nickname}
>
{isReserving && <Spinner size="16px" svgSize="small" />}
{isDiscriminatorVisible ? (
2022-10-18 17:12:02 +00:00
<>
<div className="EditUsernameModalBody__divider" />
<AutoSizeInput
moduleClassName="EditUsernameModalBody__discriminator"
disableSpellcheck
disabled={isConfirming}
value={discriminator}
onChange={updateCustomDiscriminator}
placeholder="00"
maxLength={DISCRIMINATOR_MAX_LENGTH}
/>
2022-10-18 17:12:02 +00:00
</>
) : null}
2022-10-18 17:12:02 +00:00
</Input>
{errorString && (
<div className="EditUsernameModalBody__error">{errorString}</div>
)}
<div
className={classNames(
'EditUsernameModalBody__info',
!errorString ? 'EditUsernameModalBody__info--no-error' : undefined
)}
>
2023-03-30 00:03:25 +00:00
{i18n('icu:EditUsernameModalBody__username-helper')}
2022-10-18 17:12:02 +00:00
&nbsp;
<button
type="button"
className="EditUsernameModalBody__learn-more-button"
onClick={onLearnMore}
>
2023-03-30 00:03:25 +00:00
{i18n('icu:EditUsernameModalBody__learn-more')}
2022-10-18 17:12:02 +00:00
</button>
</div>
<Modal.ButtonFooter>
<Button
disabled={isConfirming}
onClick={onCancel}
variant={ButtonVariant.Secondary}
>
2023-03-30 00:03:25 +00:00
{i18n('icu:cancel')}
2022-10-18 17:12:02 +00:00
</Button>
<Button disabled={!canSave} onClick={onSave}>
{isConfirming ? (
<Spinner size="20px" svgSize="small" direction="on-avatar" />
) : (
2023-03-30 00:03:25 +00:00
i18n('icu:save')
2022-10-18 17:12:02 +00:00
)}
</Button>
</Modal.ButtonFooter>
{isLearnMoreVisible && (
<Modal
modalName="EditUsernamModalBody.LearnMore"
moduleClassName="EditUsernameModalBody__learn-more"
i18n={i18n}
onClose={() => setIsLearnMoreVisible(false)}
title={learnMoreTitle}
>
2023-03-30 00:03:25 +00:00
{i18n('icu:EditUsernameModalBody__learn-more__body')}
2022-10-18 17:12:02 +00:00
<Modal.ButtonFooter>
<Button
onClick={() => setIsLearnMoreVisible(false)}
variant={ButtonVariant.Secondary}
>
2023-03-30 00:03:25 +00:00
{i18n('icu:ok')}
2022-10-18 17:12:02 +00:00
</Button>
</Modal.ButtonFooter>
</Modal>
)}
{error === UsernameReservationError.General && (
<ConfirmationDialog
dialogName="EditUsernameModalBody.generalError"
2023-03-30 00:03:25 +00:00
cancelText={i18n('icu:ok')}
2022-10-18 17:12:02 +00:00
cancelButtonVariant={ButtonVariant.Secondary}
i18n={i18n}
onClose={() => setUsernameReservationError(undefined)}
>
2023-03-30 00:03:25 +00:00
{i18n('icu:ProfileEditor--username--general-error')}
2022-10-18 17:12:02 +00:00
</ConfirmationDialog>
)}
{error === UsernameReservationError.ConflictOrGone && (
<ConfirmationDialog
dialogName="EditUsernameModalBody.conflictOrGone"
2023-03-30 00:03:25 +00:00
cancelText={i18n('icu:ok')}
cancelButtonVariant={ButtonVariant.Secondary}
i18n={i18n}
onClose={() => {
if (nickname) {
reserveUsername({ nickname, customDiscriminator });
}
}}
>
{i18n('icu:ProfileEditor--username--reservation-gone', {
2024-03-04 18:03:11 +00:00
username: reservation?.username ?? nickname ?? '',
})}
</ConfirmationDialog>
)}
2023-07-20 03:14:08 +00:00
{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>
)}
2024-02-06 18:35:59 +00:00
{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>
)}
2022-10-18 17:12:02 +00:00
</>
);
2022-11-18 00:45:19 +00:00
}