Custom Discriminator in EditUsernameModalBody
This commit is contained in:
parent
fa3937e084
commit
38914a45cb
26 changed files with 615 additions and 165 deletions
33
ts/components/AutoSizeInput.stories.tsx
Normal file
33
ts/components/AutoSizeInput.stories.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import type { PropsType } from './AutoSizeInput';
|
||||
import { AutoSizeInput } from './AutoSizeInput';
|
||||
|
||||
export default {
|
||||
title: 'Components/AutoSizeInput',
|
||||
argTypes: {},
|
||||
args: {},
|
||||
} satisfies Meta<PropsType>;
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
disabled: Boolean(overrideProps.disabled),
|
||||
disableSpellcheck: overrideProps.disableSpellcheck,
|
||||
onChange: action('onChange'),
|
||||
placeholder: overrideProps.placeholder ?? 'Enter some text here',
|
||||
value: overrideProps.value ?? '',
|
||||
});
|
||||
|
||||
function Controller(props: PropsType): JSX.Element {
|
||||
const { value: initialValue } = props;
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
return <AutoSizeInput {...props} onChange={setValue} value={value} />;
|
||||
}
|
||||
|
||||
export function Simple(): JSX.Element {
|
||||
return <Controller {...createProps()} />;
|
||||
}
|
99
ts/components/AutoSizeInput.tsx
Normal file
99
ts/components/AutoSizeInput.tsx
Normal file
|
@ -0,0 +1,99 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { getClassNamesFor } from '../util/getClassNamesFor';
|
||||
|
||||
export type PropsType = Readonly<{
|
||||
disableSpellcheck?: boolean;
|
||||
disabled?: boolean;
|
||||
moduleClassName?: string;
|
||||
onChange: (newValue: string) => void;
|
||||
onEnter?: () => void;
|
||||
placeholder: string;
|
||||
value?: string;
|
||||
maxLength?: number;
|
||||
}>;
|
||||
|
||||
export function AutoSizeInput({
|
||||
disableSpellcheck,
|
||||
disabled,
|
||||
moduleClassName,
|
||||
onChange,
|
||||
onEnter,
|
||||
placeholder,
|
||||
value = '',
|
||||
maxLength,
|
||||
}: PropsType): JSX.Element {
|
||||
const [root, setRoot] = useState<HTMLElement | null>(null);
|
||||
const hiddenRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
const [width, setWidth] = useState<undefined | number>(undefined);
|
||||
const getClassName = getClassNamesFor('AutoSizeInput', moduleClassName);
|
||||
|
||||
const handleChange = useCallback(
|
||||
e => {
|
||||
onChange(e.target.value);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
event => {
|
||||
if (onEnter && event.key === 'Enter') {
|
||||
onEnter();
|
||||
}
|
||||
},
|
||||
[onEnter]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const elem = document.createElement('div');
|
||||
document.body.appendChild(elem);
|
||||
|
||||
setRoot(elem);
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(elem);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setWidth(hiddenRef.current?.clientWidth || undefined);
|
||||
}, [value, root]);
|
||||
|
||||
return (
|
||||
<div className={getClassName('__container')}>
|
||||
<input
|
||||
type="text"
|
||||
className={getClassName('__input')}
|
||||
dir="auto"
|
||||
maxLength={maxLength}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
spellCheck={!disableSpellcheck}
|
||||
style={{ width }}
|
||||
/>
|
||||
|
||||
{root &&
|
||||
createPortal(
|
||||
<span
|
||||
ref={hiddenRef}
|
||||
className={classNames(
|
||||
getClassName('__input'),
|
||||
getClassName('__input--sizer')
|
||||
)}
|
||||
>
|
||||
{value || placeholder}
|
||||
</span>,
|
||||
root
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -66,6 +66,7 @@ export default {
|
|||
i18n,
|
||||
onClose: action('onClose'),
|
||||
setUsernameReservationError: action('setUsernameReservationError'),
|
||||
clearUsernameReservation: action('clearUsernameReservation'),
|
||||
reserveUsername: action('reserveUsername'),
|
||||
confirmUsername: action('confirmUsername'),
|
||||
},
|
||||
|
|
|
@ -7,12 +7,14 @@ import classNames from 'classnames';
|
|||
import type { LocalizerType } from '../types/Util';
|
||||
import type { UsernameReservationType } from '../types/Username';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { getNickname, getDiscriminator } from '../types/Username';
|
||||
import { getNickname, getDiscriminator, isCaseChange } from '../types/Username';
|
||||
import {
|
||||
UsernameReservationState,
|
||||
UsernameReservationError,
|
||||
} from '../state/ducks/usernameEnums';
|
||||
import type { ReserveUsernameOptionsType } from '../state/ducks/username';
|
||||
|
||||
import { AutoSizeInput } from './AutoSizeInput';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import { Input } from './Input';
|
||||
import { Spinner } from './Spinner';
|
||||
|
@ -33,7 +35,8 @@ export type ActionPropsDataType = Readonly<{
|
|||
setUsernameReservationError(
|
||||
error: UsernameReservationError | undefined
|
||||
): void;
|
||||
reserveUsername(nickname: string | undefined): void;
|
||||
clearUsernameReservation(): void;
|
||||
reserveUsername(optiona: ReserveUsernameOptionsType): void;
|
||||
confirmUsername(): void;
|
||||
}>;
|
||||
|
||||
|
@ -45,6 +48,14 @@ export type PropsType = PropsDataType &
|
|||
ActionPropsDataType &
|
||||
ExternalPropsDataType;
|
||||
|
||||
enum UpdateState {
|
||||
Original = 'Original',
|
||||
Nickname = 'Nickname',
|
||||
Discriminator = 'Discriminator',
|
||||
}
|
||||
|
||||
const DISCRIMINATOR_MAX_LENGTH = 19;
|
||||
|
||||
export function EditUsernameModalBody({
|
||||
i18n,
|
||||
currentUsername,
|
||||
|
@ -54,6 +65,7 @@ export function EditUsernameModalBody({
|
|||
maxNickname,
|
||||
reservation,
|
||||
setUsernameReservationError,
|
||||
clearUsernameReservation,
|
||||
error,
|
||||
state,
|
||||
onClose,
|
||||
|
@ -66,37 +78,76 @@ export function EditUsernameModalBody({
|
|||
return getNickname(currentUsername);
|
||||
}, [currentUsername]);
|
||||
|
||||
const isReserving = state === UsernameReservationState.Reserving;
|
||||
const isConfirming = state === UsernameReservationState.Confirming;
|
||||
const canSave = !isReserving && !isConfirming && reservation !== undefined;
|
||||
const currentDiscriminator =
|
||||
currentUsername === undefined
|
||||
? undefined
|
||||
: getDiscriminator(currentUsername);
|
||||
|
||||
const [hasEverChanged, setHasEverChanged] = useState(false);
|
||||
const [updateState, setUpdateState] = useState(UpdateState.Original);
|
||||
const [nickname, setNickname] = useState(currentNickname);
|
||||
const [isLearnMoreVisible, setIsLearnMoreVisible] = useState(false);
|
||||
const [isConfirmingSave, setIsConfirmingSave] = 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]);
|
||||
|
||||
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;
|
||||
|
@ -120,6 +171,17 @@ export function EditUsernameModalBody({
|
|||
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'
|
||||
);
|
||||
}
|
||||
// Displayed through confirmation modal below
|
||||
if (
|
||||
error === UsernameReservationError.General ||
|
||||
|
@ -132,25 +194,45 @@ export function EditUsernameModalBody({
|
|||
|
||||
useEffect(() => {
|
||||
// Initial effect run
|
||||
if (!hasEverChanged) {
|
||||
if (updateState === UpdateState.Original) {
|
||||
return;
|
||||
}
|
||||
|
||||
reserveUsername(nickname);
|
||||
}, [hasEverChanged, nickname, reserveUsername]);
|
||||
// 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) => {
|
||||
setHasEverChanged(true);
|
||||
setUpdateState(UpdateState.Nickname);
|
||||
setNickname(newNickname);
|
||||
}, []);
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
if (!currentUsername) {
|
||||
if (!currentUsername || (reservation && isCaseChange(reservation))) {
|
||||
confirmUsername();
|
||||
} else {
|
||||
setIsConfirmingSave(true);
|
||||
}
|
||||
}, [confirmUsername, currentUsername]);
|
||||
}, [confirmUsername, currentUsername, reservation]);
|
||||
|
||||
const onCancelSave = useCallback(() => {
|
||||
setIsConfirmingSave(false);
|
||||
|
@ -172,7 +254,7 @@ export function EditUsernameModalBody({
|
|||
|
||||
let title = i18n('icu:ProfileEditor--username--title');
|
||||
if (nickname && discriminator) {
|
||||
title = `${nickname}${discriminator}`;
|
||||
title = `${nickname}.${discriminator}`;
|
||||
}
|
||||
|
||||
const learnMoreTitle = (
|
||||
|
@ -201,14 +283,20 @@ export function EditUsernameModalBody({
|
|||
value={nickname}
|
||||
>
|
||||
{isReserving && <Spinner size="16px" svgSize="small" />}
|
||||
{discriminator && (
|
||||
{isDiscriminatorVisible ? (
|
||||
<>
|
||||
<div className="EditUsernameModalBody__divider" />
|
||||
<div className="EditUsernameModalBody__discriminator">
|
||||
{discriminator}
|
||||
</div>
|
||||
<AutoSizeInput
|
||||
moduleClassName="EditUsernameModalBody__discriminator"
|
||||
disableSpellcheck
|
||||
disabled={isConfirming}
|
||||
value={discriminator}
|
||||
onChange={updateCustomDiscriminator}
|
||||
placeholder="00"
|
||||
maxLength={DISCRIMINATOR_MAX_LENGTH}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</Input>
|
||||
|
||||
{errorString && (
|
||||
|
@ -289,7 +377,7 @@ export function EditUsernameModalBody({
|
|||
i18n={i18n}
|
||||
onClose={() => {
|
||||
if (nickname) {
|
||||
reserveUsername(nickname);
|
||||
reserveUsername({ nickname, customDiscriminator });
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -22,6 +22,8 @@ import type {
|
|||
ZoomFactorType,
|
||||
} from '../types/Storage.d';
|
||||
import type { ThemeSettingType } from '../types/StorageUIKeys';
|
||||
import type { AnyToast } from '../types/Toast';
|
||||
import { ToastType } from '../types/Toast';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type {
|
||||
ConversationColorType,
|
||||
|
@ -47,7 +49,9 @@ import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability';
|
|||
import { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode';
|
||||
import { Select } from './Select';
|
||||
import { Spinner } from './Spinner';
|
||||
import { ToastManager } from './ToastManager';
|
||||
import { getCustomColorStyle } from '../util/getCustomColorStyle';
|
||||
import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
|
||||
import {
|
||||
DEFAULT_DURATIONS_IN_SECONDS,
|
||||
DEFAULT_DURATIONS_SET,
|
||||
|
@ -357,6 +361,7 @@ export function Preferences({
|
|||
string | null
|
||||
>(localeOverride);
|
||||
const [languageSearchInput, setLanguageSearchInput] = useState('');
|
||||
const [toast, setToast] = useState<AnyToast | undefined>();
|
||||
|
||||
function closeLanguageDialog() {
|
||||
setLanguageDialog(null);
|
||||
|
@ -1464,16 +1469,16 @@ export function Preferences({
|
|||
text: i18n('icu:Preferences__pnp__discoverability__everyone'),
|
||||
value: PhoneNumberDiscoverability.Discoverable,
|
||||
},
|
||||
...(whoCanSeeMe === PhoneNumberSharingMode.Nobody
|
||||
? [
|
||||
{
|
||||
text: i18n(
|
||||
'icu:Preferences__pnp__discoverability__nobody'
|
||||
),
|
||||
value: PhoneNumberDiscoverability.NotDiscoverable,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
text: i18n('icu:Preferences__pnp__discoverability__nobody'),
|
||||
value: PhoneNumberDiscoverability.NotDiscoverable,
|
||||
readOnly: whoCanSeeMe === PhoneNumberSharingMode.Everybody,
|
||||
onClick:
|
||||
whoCanSeeMe === PhoneNumberSharingMode.Everybody
|
||||
? () =>
|
||||
setToast({ toastType: ToastType.WhoCanFindMeReadOnly })
|
||||
: noop,
|
||||
},
|
||||
]}
|
||||
value={whoCanFindMe}
|
||||
/>
|
||||
|
@ -1572,6 +1577,14 @@ export function Preferences({
|
|||
{settings}
|
||||
</div>
|
||||
</div>
|
||||
<ToastManager
|
||||
OS="unused"
|
||||
hideToast={() => setToast(undefined)}
|
||||
i18n={i18n}
|
||||
onUndoArchive={shouldNeverBeCalled}
|
||||
openFileInFolder={shouldNeverBeCalled}
|
||||
toast={toast}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1638,6 +1651,8 @@ function Control({
|
|||
type SettingsRadioOptionType<Enum> = Readonly<{
|
||||
text: string;
|
||||
value: Enum;
|
||||
readOnly?: boolean;
|
||||
onClick?: () => void;
|
||||
}>;
|
||||
|
||||
function SettingsRadio<Enum>({
|
||||
|
@ -1655,11 +1670,13 @@ function SettingsRadio<Enum>({
|
|||
|
||||
return (
|
||||
<div className="Preferences__padding">
|
||||
{options.map(({ text, value: optionValue }, i) => {
|
||||
{options.map(({ text, value: optionValue, readOnly, onClick }, i) => {
|
||||
const htmlId = htmlIds[i];
|
||||
return (
|
||||
<label
|
||||
className="Preferences__settings-radio__label"
|
||||
className={classNames('Preferences__settings-radio__label', {
|
||||
'Preferences__settings-radio__label--readonly': readOnly,
|
||||
})}
|
||||
key={htmlId}
|
||||
htmlFor={htmlId}
|
||||
>
|
||||
|
@ -1668,7 +1685,8 @@ function SettingsRadio<Enum>({
|
|||
variant={CircleCheckboxVariant.Small}
|
||||
id={htmlId}
|
||||
checked={value === optionValue}
|
||||
onChange={() => onChange(optionValue)}
|
||||
onClick={onClick}
|
||||
onChange={readOnly ? noop : () => onChange(optionValue)}
|
||||
/>
|
||||
{text}
|
||||
</label>
|
||||
|
|
|
@ -100,6 +100,7 @@ function renderEditUsernameModalBody(props: {
|
|||
state={UsernameReservationState.Open}
|
||||
error={undefined}
|
||||
setUsernameReservationError={action('setUsernameReservationError')}
|
||||
clearUsernameReservation={action('clearUsernameReservation')}
|
||||
reserveUsername={action('reserveUsername')}
|
||||
confirmUsername={action('confirmUsername')}
|
||||
{...props}
|
||||
|
|
|
@ -130,6 +130,8 @@ function getToast(toastType: ToastType): AnyToast {
|
|||
group: 'Hike Group 🏔',
|
||||
},
|
||||
};
|
||||
case ToastType.WhoCanFindMeReadOnly:
|
||||
return { toastType: ToastType.WhoCanFindMeReadOnly };
|
||||
default:
|
||||
throw missingCaseError(toastType);
|
||||
}
|
||||
|
|
|
@ -392,5 +392,11 @@ export function ToastManager({
|
|||
);
|
||||
}
|
||||
|
||||
if (toastType === ToastType.WhoCanFindMeReadOnly) {
|
||||
return (
|
||||
<Toast onClose={hideToast}>{i18n('icu:WhoCanFindMeReadOnlyToast')}</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
throw missingCaseError(toastType);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue