Custom Discriminator in EditUsernameModalBody

This commit is contained in:
Fedor Indutny 2024-01-18 11:53:24 -08:00 committed by GitHub
parent fa3937e084
commit 38914a45cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 615 additions and 165 deletions

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

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

View file

@ -66,6 +66,7 @@ export default {
i18n,
onClose: action('onClose'),
setUsernameReservationError: action('setUsernameReservationError'),
clearUsernameReservation: action('clearUsernameReservation'),
reserveUsername: action('reserveUsername'),
confirmUsername: action('confirmUsername'),
},

View file

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

View file

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

View file

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

View file

@ -130,6 +130,8 @@ function getToast(toastType: ToastType): AnyToast {
group: 'Hike Group 🏔',
},
};
case ToastType.WhoCanFindMeReadOnly:
return { toastType: ToastType.WhoCanFindMeReadOnly };
default:
throw missingCaseError(toastType);
}

View file

@ -392,5 +392,11 @@ export function ToastManager({
);
}
if (toastType === ToastType.WhoCanFindMeReadOnly) {
return (
<Toast onClose={hideToast}>{i18n('icu:WhoCanFindMeReadOnlyToast')}</Toast>
);
}
throw missingCaseError(toastType);
}

View file

@ -13,7 +13,13 @@ import { sleep } from '../util/sleep';
import { getMinNickname, getMaxNickname } from '../util/Username';
import { bytesToUuid, uuidToBytes } from '../util/uuidToBytes';
import type { UsernameReservationType } from '../types/Username';
import { ReserveUsernameError, ConfirmUsernameResult } from '../types/Username';
import {
ReserveUsernameError,
ConfirmUsernameResult,
getNickname,
getDiscriminator,
isCaseChange,
} from '../types/Username';
import * as Errors from '../types/errors';
import * as log from '../logging/log';
import MessageSender from '../textsecure/SendMessage';
@ -35,6 +41,7 @@ export type WriteUsernameOptionsType = Readonly<
export type ReserveUsernameOptionsType = Readonly<{
nickname: string;
customDiscriminator: string | undefined;
previousUsername: string | undefined;
abortSignal?: AbortSignal;
}>;
@ -60,7 +67,8 @@ export async function reserveUsername(
throw new Error('server interface is not available!');
}
const { nickname, previousUsername, abortSignal } = options;
const { nickname, customDiscriminator, previousUsername, abortSignal } =
options;
const me = window.ConversationController.getOurConversationOrThrow();
@ -69,11 +77,39 @@ export async function reserveUsername(
}
try {
const candidates = usernames.generateCandidates(
nickname,
getMinNickname(),
getMaxNickname()
);
if (previousUsername !== undefined && !customDiscriminator) {
const previousNickname = getNickname(previousUsername);
// Case change
if (
previousNickname !== undefined &&
nickname.toLowerCase() === previousNickname.toLowerCase()
) {
const previousDiscriminator = getDiscriminator(previousUsername);
const newUsername = `${nickname}.${previousDiscriminator}`;
const hash = usernames.hash(newUsername);
return {
ok: true,
reservation: { previousUsername, username: newUsername, hash },
};
}
}
const candidates = customDiscriminator
? [
usernames.fromParts(
nickname,
customDiscriminator,
getMinNickname(),
getMaxNickname()
).username,
]
: usernames.generateCandidates(
nickname,
getMinNickname(),
getMaxNickname()
);
const hashes = candidates.map(username => usernames.hash(username));
const { usernameHash } = await server.reserveUsername({
@ -111,7 +147,7 @@ export async function reserveUsername(
}
if (error instanceof LibSignalErrorBase) {
if (
error.code === ErrorCode.CannotBeEmpty ||
error.code === ErrorCode.NicknameCannotBeEmpty ||
error.code === ErrorCode.NicknameTooShort
) {
return {
@ -137,6 +173,32 @@ export async function reserveUsername(
error: ReserveUsernameError.CheckCharacters,
};
}
if (error.code === ErrorCode.DiscriminatorCannotBeZero) {
return {
ok: false,
error: ReserveUsernameError.AllZeroDiscriminator,
};
}
if (error.code === ErrorCode.DiscriminatorCannotHaveLeadingZeros) {
return {
ok: false,
error: ReserveUsernameError.LeadingZeroDiscriminator,
};
}
if (
error.code === ErrorCode.DiscriminatorCannotBeEmpty ||
error.code === ErrorCode.DiscriminatorCannotBeSingleDigit ||
// This is handled on UI level
error.code === ErrorCode.DiscriminatorTooLarge
) {
return {
ok: false,
error: ReserveUsernameError.NotEnoughDiscriminator,
};
}
}
throw error;
}
@ -171,32 +233,54 @@ export async function confirmUsername(
throw new Error('server interface is not available!');
}
const { previousUsername, username, hash } = reservation;
const { previousUsername, username } = reservation;
const previousLink = window.storage.get('usernameLink');
const me = window.ConversationController.getOurConversationOrThrow();
if (me.get('username') !== previousUsername) {
throw new Error('Username has changed on another device');
}
const proof = usernames.generateProof(username);
const { hash } = reservation;
strictAssert(usernames.hash(username).equals(hash), 'username hash mismatch');
try {
const { entropy, encryptedUsername } =
usernames.createUsernameLink(username);
await window.storage.remove('usernameLink');
await window.storage.remove('usernameCorrupted');
await window.storage.remove('usernameLinkCorrupted');
const { usernameLinkHandle: serverIdString } = await server.confirmUsername(
{
let serverIdString: string;
let entropy: Buffer;
if (previousLink && isCaseChange(reservation)) {
log.info('confirmUsername: updating link only');
const updatedLink = usernames.createUsernameLink(
username,
Buffer.from(previousLink.entropy)
);
({ entropy } = updatedLink);
({ usernameLinkHandle: serverIdString } =
await server.replaceUsernameLink({
encryptedUsername: updatedLink.encryptedUsername,
keepLinkHandle: true,
}));
} else {
log.info('confirmUsername: confirming and replacing link');
const newLink = usernames.createUsernameLink(username);
({ entropy } = newLink);
const proof = usernames.generateProof(username);
({ usernameLinkHandle: serverIdString } = await server.confirmUsername({
hash,
proof,
encryptedUsername,
encryptedUsername: newLink.encryptedUsername,
abortSignal,
}
);
}));
}
await window.storage.put('usernameLink', {
entropy,
@ -263,7 +347,10 @@ export async function resetLink(username: string): Promise<void> {
await window.storage.remove('usernameLinkCorrupted');
const { usernameLinkHandle: serverIdString } =
await server.replaceUsernameLink({ encryptedUsername });
await server.replaceUsernameLink({
encryptedUsername,
keepLinkHandle: false,
});
await window.storage.put('usernameLink', {
entropy,

View file

@ -53,6 +53,7 @@ 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 CLEAR_USERNAME_RESERVATION = 'username/CLEAR_RESERVATION';
const RESERVE_USERNAME = 'username/RESERVE_USERNAME';
const CONFIRM_USERNAME = 'username/CONFIRM_USERNAME';
const DELETE_USERNAME = 'username/DELETE_USERNAME';
@ -80,6 +81,10 @@ type SetUsernameReservationErrorActionType = ReadonlyDeep<{
};
}>;
type ClearUsernameReservation = ReadonlyDeep<{
type: typeof CLEAR_USERNAME_RESERVATION;
}>;
type ReserveUsernameActionType = ReadonlyDeep<
PromiseAction<
typeof RESERVE_USERNAME,
@ -102,6 +107,7 @@ export type UsernameActionType = ReadonlyDeep<
| OpenUsernameReservationModalActionType
| CloseUsernameReservationModalActionType
| SetUsernameReservationErrorActionType
| ClearUsernameReservation
| ReserveUsernameActionType
| ConfirmUsernameActionType
| DeleteUsernameActionType
@ -113,6 +119,7 @@ export const actions = {
openUsernameReservationModal,
closeUsernameReservationModal,
setUsernameReservationError,
clearUsernameReservation,
reserveUsername,
confirmUsername,
deleteUsername,
@ -152,20 +159,27 @@ export function setUsernameReservationError(
};
}
export function clearUsernameReservation(): ClearUsernameReservation {
return {
type: CLEAR_USERNAME_RESERVATION,
};
}
const INPUT_DELAY_MS = 500;
export type ReserveUsernameOptionsType = ReadonlyDeep<{
nickname: string;
customDiscriminator?: string;
doReserveUsername?: typeof usernameServices.reserveUsername;
delay?: number;
}>;
export function reserveUsername(
nickname: string,
{
doReserveUsername = usernameServices.reserveUsername,
delay = INPUT_DELAY_MS,
}: ReserveUsernameOptionsType = {}
): ThunkAction<
export function reserveUsername({
nickname,
customDiscriminator,
doReserveUsername = usernameServices.reserveUsername,
delay = INPUT_DELAY_MS,
}: ReserveUsernameOptionsType): ThunkAction<
void,
RootStateType,
unknown,
@ -192,6 +206,7 @@ export function reserveUsername(
return doReserveUsername({
previousUsername: username,
nickname,
customDiscriminator,
abortSignal,
});
};
@ -387,6 +402,17 @@ export function reducer(
};
}
if (action.type === CLEAR_USERNAME_RESERVATION) {
return {
...state,
usernameReservation: {
...usernameReservation,
error: undefined,
reservation: undefined,
},
};
}
if (action.type === 'username/RESERVE_USERNAME_PENDING') {
usernameReservation.abortController?.abort();
@ -394,6 +420,8 @@ export function reducer(
return {
...state,
usernameReservation: {
...usernameReservation,
error: undefined,
state: UsernameReservationState.Reserving,
abortController: meta.abortController,
},
@ -433,6 +461,12 @@ export function reducer(
stateError = UsernameReservationError.CheckStartingCharacter;
} else if (error === ReserveUsernameError.CheckCharacters) {
stateError = UsernameReservationError.CheckCharacters;
} else if (error === ReserveUsernameError.NotEnoughDiscriminator) {
stateError = UsernameReservationError.NotEnoughDiscriminator;
} else if (error === ReserveUsernameError.AllZeroDiscriminator) {
stateError = UsernameReservationError.AllZeroDiscriminator;
} else if (error === ReserveUsernameError.LeadingZeroDiscriminator) {
stateError = UsernameReservationError.LeadingZeroDiscriminator;
} else {
throw missingCaseError(error);
}

View file

@ -40,4 +40,7 @@ export enum UsernameReservationError {
UsernameNotAvailable = 'UsernameNotAvailable',
General = 'General',
ConflictOrGone = 'ConflictOrGone',
NotEnoughDiscriminator = 'NotEnoughDiscriminator',
AllZeroDiscriminator = 'AllZeroDiscriminator',
LeadingZeroDiscriminator = 'LeadingZeroDiscriminator',
}

View file

@ -116,7 +116,8 @@ describe('electron/state/ducks/username', () => {
const doReserveUsername = sinon.stub().resolves(DEFAULT_RESERVATION);
const dispatch = sinon.spy();
actions.reserveUsername('test', {
actions.reserveUsername({
nickname: 'test',
doReserveUsername,
delay: 1000,
})(dispatch, () => emptyState, null);

View file

@ -165,14 +165,14 @@ describe('pnp/username', function (this: Mocha.Suite) {
debug('waiting for generated discriminator');
const discriminator = profileEditor.locator(
'.EditUsernameModalBody__discriminator:not(:empty)'
'.EditUsernameModalBody__discriminator__input[value]'
);
await discriminator.waitFor();
const discriminatorValue = await discriminator.innerText();
assert.match(discriminatorValue, /^\.\d+$/);
const discriminatorValue = await discriminator.inputValue();
assert.match(discriminatorValue, /^\d+$/);
const username = `${NICKNAME}${discriminatorValue}`;
const username = `${NICKNAME}.${discriminatorValue}`;
debug('saving username');
let state = await phone.expectStorageState('consistency check');

View file

@ -828,6 +828,7 @@ export type ReserveUsernameOptionsType = Readonly<{
export type ReplaceUsernameLinkOptionsType = Readonly<{
encryptedUsername: Uint8Array;
keepLinkHandle: boolean;
}>;
export type ConfirmUsernameOptionsType = Readonly<{
@ -2036,6 +2037,7 @@ export function initialize({
async function replaceUsernameLink({
encryptedUsername,
keepLinkHandle,
}: ReplaceUsernameLinkOptionsType): Promise<ReplaceUsernameLinkResultType> {
return replaceUsernameLinkResultZod.parse(
await _ajax({
@ -2046,6 +2048,7 @@ export function initialize({
usernameLinkEncryptedValue: toWebSafeBase64(
Bytes.toBase64(encryptedUsername)
),
keepLinkHandle,
},
})
);

View file

@ -48,6 +48,7 @@ export enum ToastType {
UnsupportedMultiAttachment = 'UnsupportedMultiAttachment',
UnsupportedOS = 'UnsupportedOS',
UserAddedToGroup = 'UserAddedToGroup',
WhoCanFindMeReadOnly = 'WhoCanFindMeReadOnly',
}
export type AnyToast =
@ -108,4 +109,5 @@ export type AnyToast =
| {
toastType: ToastType.UserAddedToGroup;
parameters: { contact: string; group: string };
};
}
| { toastType: ToastType.WhoCanFindMeReadOnly };

View file

@ -16,6 +16,9 @@ export enum ReserveUsernameError {
TooManyCharacters = 'TooManyCharacters',
CheckStartingCharacter = 'CheckStartingCharacter',
CheckCharacters = 'CheckCharacters',
NotEnoughDiscriminator = 'NotEnoughDiscriminator',
AllZeroDiscriminator = 'AllZeroDiscriminator',
LeadingZeroDiscriminator = 'LeadingZeroDiscriminator',
}
export enum ConfirmUsernameResult {
@ -41,11 +44,18 @@ export function getNickname(username: string): string | undefined {
return match[1];
}
export function getDiscriminator(username: string): string {
const match = username.match(/(\..*)$/);
export function getDiscriminator(username: string): string | undefined {
const match = username.match(/\.([0-9]*)$/);
if (!match) {
return '';
return undefined;
}
return match[1];
}
export function isCaseChange({
previousUsername,
username,
}: UsernameReservationType): boolean {
return previousUsername?.toLowerCase() === username.toLowerCase();
}

View file

@ -2765,6 +2765,14 @@
"updated": "2021-12-10T23:24:03.829Z",
"reasonDetail": "Doesn't touch the DOM."
},
{
"rule": "React-useRef",
"path": "ts/components/AutoSizeInput.tsx",
"line": " const hiddenRef = useRef<HTMLSpanElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2024-01-11T16:58:57.146Z",
"reasonDetail": "Needs access to a hidden span element to get its width"
},
{
"rule": "React-useRef",
"path": "ts/components/AvatarTextEditor.tsx",
@ -2801,6 +2809,46 @@
"reasonCategory": "usageTrusted",
"updated": "2021-07-30T16:57:33.618Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CallReactionBurst.tsx",
"line": " const timeouts = useRef<Map<string, NodeJS.Timeout>>(new Map());",
"reasonCategory": "usageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "For hiding call reaction bursts after timeouts."
},
{
"rule": "React-useRef",
"path": "ts/components/CallReactionBurst.tsx",
"line": " const burstsShown = useRef<Set<string>>(new Set());",
"reasonCategory": "usageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "In wrapping function, track bursts so we can hide on unmount."
},
{
"rule": "React-useRef",
"path": "ts/components/CallReactionBurst.tsx",
"line": " const shownBursts = useRef<Set<string>>(new Set());",
"reasonCategory": "usageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "Keep track of shown reaction bursts."
},
{
"rule": "React-useRef",
"path": "ts/components/CallReactionBurstEmoji.tsx",
"line": " const containerRef = React.useRef<HTMLDivElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "For determining position of container for animations."
},
{
"rule": "React-useRef",
"path": "ts/components/CallScreen.tsx",
"line": " const burstsShown = useRef<Map<string, number>>(new Map());",
"reasonCategory": "sageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "Recent bursts shown for burst behavior like throttling."
},
{
"rule": "React-useRef",
"path": "ts/components/CallScreen.tsx",
@ -2846,6 +2894,14 @@
"updated": "2023-12-21T11:13:56.623Z",
"reasonDetail": "Calling reactions bursts"
},
{
"rule": "React-useRef",
"path": "ts/components/CallScreen.tsx",
"line": " const reactionsShown = useRef<",
"reasonCategory": "usageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "Recent reactions shown for reactions burst"
},
{
"rule": "React-useRef",
"path": "ts/components/CallingLobby.tsx",
@ -3449,6 +3505,13 @@
"reasonCategory": "usageTrusted",
"updated": "2023-08-10T00:23:35.320Z"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/CallingNotification.tsx",
"line": " const menuTriggerRef = React.useRef<ContextMenuTriggerType | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2023-12-08T20:28:57.595Z"
},
{
"rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.tsx",
@ -3534,13 +3597,6 @@
"updated": "2021-01-20T21:30:08.430Z",
"reasonDetail": "Doesn't touch the DOM."
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/CallingNotification.tsx",
"line": " const menuTriggerRef = React.useRef<ContextMenuTriggerType | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2023-12-08T20:28:57.595Z"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/TimelineMessage.tsx",
@ -3816,53 +3872,5 @@
"line": " message.innerHTML = window.i18n('icu:optimizingApplication');",
"reasonCategory": "usageTrusted",
"updated": "2021-09-17T21:02:59.414Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CallReactionBurst.tsx",
"line": " const timeouts = useRef<Map<string, NodeJS.Timeout>>(new Map());",
"reasonCategory": "usageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "For hiding call reaction bursts after timeouts."
},
{
"rule": "React-useRef",
"path": "ts/components/CallReactionBurst.tsx",
"line": " const shownBursts = useRef<Set<string>>(new Set());",
"reasonCategory": "usageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "Keep track of shown reaction bursts."
},
{
"rule": "React-useRef",
"path": "ts/components/CallReactionBurst.tsx",
"line": " const burstsShown = useRef<Set<string>>(new Set());",
"reasonCategory": "usageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "In wrapping function, track bursts so we can hide on unmount."
},
{
"rule": "React-useRef",
"path": "ts/components/CallReactionBurstEmoji.tsx",
"line": " const containerRef = React.useRef<HTMLDivElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "For determining position of container for animations."
},
{
"rule": "React-useRef",
"path": "ts/components/CallScreen.tsx",
"line": " const reactionsShown = useRef<",
"reasonCategory": "usageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "Recent reactions shown for reactions burst"
},
{
"rule": "React-useRef",
"path": "ts/components/CallScreen.tsx",
"line": " const burstsShown = useRef<Map<string, number>>(new Map());",
"reasonCategory": "sageTrusted",
"updated": "2024-01-06T00:59:20.678Z",
"reasonDetail": "Recent bursts shown for burst behavior like throttling."
}
]