Settings, Management: Various fixes for username input (#2088)

This commit is contained in:
Alexander Zinchuk 2022-10-29 15:18:40 +02:00
parent 55e1f85267
commit 76c1816eba
9 changed files with 91 additions and 50 deletions

View File

@ -2,7 +2,9 @@ import { Api as GramJs } from '../../../lib/gramjs';
import { invokeRequest } from './client';
import { buildInputEntity, buildInputPeer } from '../gramjsBuilders';
import type { ApiChat, ApiUser, OnApiUpdate } from '../../types';
import type {
ApiChat, ApiError, ApiUser, OnApiUpdate,
} from '../../types';
import { addEntitiesWithPhotosToLocalDb } from '../helpers';
import { buildApiExportedInvite, buildChatInviteImporter } from '../apiBuilders/chats';
import { buildApiUser } from '../apiBuilders/users';
@ -18,7 +20,12 @@ export function checkChatUsername({ username }: { username: string }) {
return invokeRequest(new GramJs.channels.CheckUsername({
channel: new GramJs.InputChannelEmpty(),
username,
}));
}), undefined, true).catch((error) => {
if ((error as ApiError).message === 'USERNAME_INVALID') {
return false;
}
throw error;
});
}
export async function setChatUsername(

View File

@ -3,6 +3,8 @@ import { Api as GramJs } from '../../../lib/gramjs';
import type {
ApiAppConfig,
ApiChat,
ApiError,
ApiLangString,
ApiLanguage,
ApiNotifyException,
@ -52,7 +54,12 @@ export function updateProfile({
}
export function checkUsername(username: string) {
return invokeRequest(new GramJs.account.CheckUsername({ username }));
return invokeRequest(new GramJs.account.CheckUsername({ username }), undefined, true).catch((error) => {
if ((error as ApiError).message === 'USERNAME_INVALID') {
return false;
}
throw error;
});
}
export function updateUsername(username: string) {

View File

@ -1,12 +1,15 @@
import type { ChangeEvent } from 'react';
import type { FC } from '../../lib/teact/teact';
import React, {
useState, useCallback, memo, useEffect, useMemo,
} from '../../lib/teact/teact';
import { getActions } from '../../global';
import type { FC } from '../../lib/teact/teact';
import { TME_LINK_PREFIX } from '../../config';
import { debounce } from '../../util/schedulers';
import useLang from '../../hooks/useLang';
import usePrevious from '../../hooks/usePrevious';
import InputText from '../ui/InputText';
@ -15,14 +18,14 @@ type OwnProps = {
asLink?: boolean;
isLoading?: boolean;
isUsernameAvailable?: boolean;
checkUsername: AnyToVoidFunction;
checkedUsername?: string;
onChange: (value: string | false) => void;
};
const MIN_USERNAME_LENGTH = 5;
const MAX_USERNAME_LENGTH = 32;
const LINK_PREFIX_REGEX = /https:\/\/t\.me\/?/i;
const USERNAME_REGEX = /^([a-zA-Z0-9_]+)$/;
const USERNAME_REGEX = /^[^\d]([a-zA-Z0-9_]+)$/;
const runDebouncedForCheckUsername = debounce((cb) => cb(), 250, false);
@ -32,78 +35,84 @@ function isUsernameValid(username: string) {
&& USERNAME_REGEX.test(username);
}
const SettingsEditProfile: FC<OwnProps> = ({
const UsernameInput: FC<OwnProps> = ({
currentUsername,
asLink,
isLoading,
isUsernameAvailable,
checkUsername,
checkedUsername,
onChange,
}) => {
const { checkUsername, checkPublicLink } = getActions();
const [username, setUsername] = useState(currentUsername || '');
const lang = useLang();
const langPrefix = asLink ? 'SetUrl' : 'Username';
const label = asLink ? lang('SetUrlPlaceholder') : lang('Username');
const previousIsUsernameAvailable = usePrevious(isUsernameAvailable);
const renderingIsUsernameAvailable = currentUsername !== username
? (isUsernameAvailable ?? previousIsUsernameAvailable) : undefined;
const isChecking = username && currentUsername !== username && checkedUsername !== username;
const [usernameSuccess, usernameError] = useMemo(() => {
if (!username.length) {
return [];
}
if (username.length < MIN_USERNAME_LENGTH) {
return [undefined, `${label} is too short`];
return [undefined, lang(`${langPrefix}InvalidShort`)];
}
if (username.length > MAX_USERNAME_LENGTH) {
return [undefined, `${label} is too long`];
return [undefined, lang(`${langPrefix}InvalidLong`)];
}
if (!USERNAME_REGEX.test(username)) {
return [undefined, `${label} contains invalid characters`];
return [undefined, lang(`${langPrefix}Invalid`)];
}
if (isUsernameAvailable === undefined) {
if (renderingIsUsernameAvailable === undefined || isChecking) {
return [];
}
// Variable `isUsernameAvailable` is initialized with `undefined`, so a strict false check is required
return [
isUsernameAvailable ? lang(`${langPrefix}Available`, 'Username') : undefined,
isUsernameAvailable === false ? lang(`${langPrefix}InUse`) : undefined,
renderingIsUsernameAvailable ? lang(`${langPrefix}Available`, label) : undefined,
renderingIsUsernameAvailable === false ? lang(`${langPrefix}InUse`) : undefined,
];
}, [username, isUsernameAvailable, lang, langPrefix, label]);
}, [username, renderingIsUsernameAvailable, isChecking, lang, langPrefix, label]);
useEffect(() => {
setUsername(currentUsername || '');
}, [asLink, currentUsername]);
const handleUsernameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const handleUsernameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newUsername = e.target.value.trim().replace(LINK_PREFIX_REGEX, '');
setUsername(newUsername);
e.target.value = `${asLink ? TME_LINK_PREFIX : ''}${newUsername}`;
const isValid = isUsernameValid(newUsername);
if (!isValid) return;
if (isValid) {
runDebouncedForCheckUsername(() => {
checkUsername({ username: newUsername });
});
}
onChange?.(newUsername);
if (onChange) {
onChange(isValid ? newUsername : false);
}
}, [asLink, checkUsername, onChange]);
runDebouncedForCheckUsername(() => {
if (newUsername !== currentUsername) {
const check = asLink ? checkPublicLink : checkUsername;
check({ username: newUsername });
}
});
}, [asLink, checkPublicLink, checkUsername, currentUsername, onChange]);
return (
<InputText
value={`${asLink ? TME_LINK_PREFIX : ''}${username}`}
onChange={handleUsernameChange}
label={label}
label={isChecking ? lang('Checking') : label}
error={usernameError}
success={usernameSuccess}
readOnly={isLoading}
teactExperimentControlled
/>
);
};
export default memo(SettingsEditProfile);
export default memo(UsernameInput);

View File

@ -17,6 +17,7 @@ import useLang from '../../../hooks/useLang';
import { selectCurrentLimit } from '../../../global/selectors/limits';
import renderText from '../../common/helpers/renderText';
import useHistoryBack from '../../../hooks/useHistoryBack';
import usePrevious from '../../../hooks/usePrevious';
import AvatarEditable from '../../ui/AvatarEditable';
import FloatingActionButton from '../../ui/FloatingActionButton';
@ -37,6 +38,7 @@ type StateProps = {
currentBio?: string;
currentUsername?: string;
progress?: ProfileEditProgress;
checkedUsername?: string;
isUsernameAvailable?: boolean;
maxBioLength: number;
};
@ -47,20 +49,20 @@ const ERROR_FIRST_NAME_MISSING = 'Please provide your first name';
const SettingsEditProfile: FC<OwnProps & StateProps> = ({
isActive,
onReset,
currentAvatarHash,
currentFirstName,
currentLastName,
currentBio,
currentUsername,
progress,
checkedUsername,
isUsernameAvailable,
maxBioLength,
onReset,
}) => {
const {
loadCurrentUser,
updateProfile,
checkUsername,
} = getActions();
const lang = useLang();
@ -80,13 +82,16 @@ const SettingsEditProfile: FC<OwnProps & StateProps> = ({
const isLoading = progress === ProfileEditProgress.InProgress;
const isUsernameError = username === false;
const previousIsUsernameAvailable = usePrevious(isUsernameAvailable);
const renderingIsUsernameAvailable = isUsernameAvailable ?? previousIsUsernameAvailable;
const isSaveButtonShown = useMemo(() => {
if (isUsernameError) {
return false;
}
return Boolean(photo) || isProfileFieldsTouched || isUsernameAvailable === true;
}, [photo, isProfileFieldsTouched, isUsernameError, isUsernameAvailable]);
return Boolean(photo) || isProfileFieldsTouched || (isUsernameTouched && renderingIsUsernameAvailable === true);
}, [isUsernameError, photo, isProfileFieldsTouched, isUsernameTouched, renderingIsUsernameAvailable]);
useHistoryBack({
isActive,
@ -144,8 +149,8 @@ const SettingsEditProfile: FC<OwnProps & StateProps> = ({
const handleUsernameChange = useCallback((value: string | false) => {
setUsername(value);
setIsUsernameTouched(true);
}, []);
setIsUsernameTouched(currentUsername !== value);
}, [currentUsername]);
const handleProfileSave = useCallback(() => {
const trimmedFirstName = firstName.trim();
@ -216,10 +221,10 @@ const SettingsEditProfile: FC<OwnProps & StateProps> = ({
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>{lang('Username')}</h4>
<UsernameInput
currentUsername={username || ''}
currentUsername={currentUsername}
isLoading={isLoading}
isUsernameAvailable={isUsernameAvailable}
checkUsername={checkUsername}
checkedUsername={checkedUsername}
onChange={handleUsernameChange}
/>
@ -239,7 +244,7 @@ const SettingsEditProfile: FC<OwnProps & StateProps> = ({
isShown={isSaveButtonShown}
onClick={handleProfileSave}
disabled={isLoading}
ariaLabel="Save changes"
ariaLabel={lang('Save')}
>
{isLoading ? (
<Spinner color="white" />
@ -254,7 +259,7 @@ const SettingsEditProfile: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const { currentUserId } = global;
const { progress, isUsernameAvailable } = global.profileEdit || {};
const { progress, isUsernameAvailable, checkedUsername } = global.profileEdit || {};
const currentUser = currentUserId ? selectUser(global, currentUserId) : undefined;
const maxBioLength = selectCurrentLimit(global, 'aboutLength');
@ -262,6 +267,7 @@ export default memo(withGlobal<OwnProps>(
if (!currentUser) {
return {
progress,
checkedUsername,
isUsernameAvailable,
maxBioLength,
};
@ -284,6 +290,7 @@ export default memo(withGlobal<OwnProps>(
currentUsername,
progress,
isUsernameAvailable,
checkedUsername,
maxBioLength,
};
},

View File

@ -10,10 +10,12 @@ import { ManagementProgress } from '../../../types';
import { selectChat, selectManagement } from '../../../global/selectors';
import { isChatChannel } from '../../../global/helpers';
import { selectCurrentLimit } from '../../../global/selectors/limits';
import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
import useHistoryBack from '../../../hooks/useHistoryBack';
import { selectCurrentLimit } from '../../../global/selectors/limits';
import usePrevious from '../../../hooks/usePrevious';
import SafeLink from '../../common/SafeLink';
import ListItem from '../../ui/ListItem';
@ -37,22 +39,23 @@ type StateProps = {
isChannel: boolean;
progress?: ManagementProgress;
isUsernameAvailable?: boolean;
checkedUsername?: string;
isProtected?: boolean;
maxPublicLinks: number;
};
const ManageChatPrivacyType: FC<OwnProps & StateProps> = ({
chat,
onClose,
isActive,
isChannel,
progress,
isUsernameAvailable,
checkedUsername,
isProtected,
maxPublicLinks,
onClose,
}) => {
const {
checkPublicLink,
updatePublicLink,
updatePrivateLink,
toggleIsProtected,
@ -66,8 +69,11 @@ const ManageChatPrivacyType: FC<OwnProps & StateProps> = ({
const [username, setUsername] = useState();
const [isRevokeConfirmDialogOpen, openRevokeConfirmDialog, closeRevokeConfirmDialog] = useFlag();
const previousIsUsernameAvailable = usePrevious(isUsernameAvailable);
const renderingIsUsernameAvailable = isUsernameAvailable ?? previousIsUsernameAvailable;
const canUpdate = Boolean(
(privacyType === 'public' && username && isUsernameAvailable)
(privacyType === 'public' && username && renderingIsUsernameAvailable)
|| (privacyType === 'private' && isPublic),
);
@ -175,7 +181,7 @@ const ManageChatPrivacyType: FC<OwnProps & StateProps> = ({
currentUsername={chat.username}
isLoading={isLoading}
isUsernameAvailable={isUsernameAvailable}
checkUsername={checkPublicLink}
checkedUsername={checkedUsername}
onChange={setUsername}
/>
<p className="section-info" dir="auto">
@ -219,13 +225,14 @@ const ManageChatPrivacyType: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const chat = selectChat(global, chatId)!;
const { isUsernameAvailable } = selectManagement(global, chatId)!;
const { isUsernameAvailable, checkedUsername } = selectManagement(global, chatId)!;
return {
chat,
isChannel: isChatChannel(chat),
progress: global.management.progress,
isUsernameAvailable,
checkedUsername,
isProtected: chat?.isProtected,
maxPublicLinks: selectCurrentLimit(global, 'channelsPublic'),
};

View File

@ -1,6 +1,7 @@
import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { ManagementProgress } from '../../../types';
import { callApi } from '../../../api/gramjs';
import {
addUsers, updateChat, updateManagement, updateManagementProgress,
@ -22,17 +23,16 @@ addActionHandler('checkPublicLink', async (global, actions, payload) => {
const { username } = payload!;
global = updateManagementProgress(global, ManagementProgress.InProgress);
global = updateManagement(global, chatId, { isUsernameAvailable: undefined });
global = updateManagement(global, chatId, { isUsernameAvailable: undefined, checkedUsername: undefined });
setGlobal(global);
const isUsernameAvailable = await callApi('checkChatUsername', { username })!;
const isUsernameAvailable = (await callApi('checkChatUsername', { username }))!;
global = getGlobal();
global = updateManagementProgress(
global, isUsernameAvailable ? ManagementProgress.Complete : ManagementProgress.Error,
);
global = updateManagement(global, chatId, { isUsernameAvailable });
global = updateManagement(global, chatId, { isUsernameAvailable, checkedUsername: username });
setGlobal(global);
if (isUsernameAvailable === undefined) {
@ -66,7 +66,7 @@ addActionHandler('updatePublicLink', async (global, actions, payload) => {
global = getGlobal();
global = updateManagementProgress(global, result ? ManagementProgress.Complete : ManagementProgress.Error);
global = updateManagement(global, chatId, { isUsernameAvailable: undefined });
global = updateManagement(global, chatId, { isUsernameAvailable: undefined, checkedUsername: undefined });
setGlobal(global);
});

View File

@ -92,6 +92,7 @@ addActionHandler('checkUsername', async (global, actions, payload) => {
...global,
profileEdit: {
progress: global.profileEdit ? global.profileEdit.progress : ProfileEditProgress.Idle,
checkedUsername: undefined,
isUsernameAvailable: undefined,
},
});
@ -103,6 +104,7 @@ addActionHandler('checkUsername', async (global, actions, payload) => {
...global,
profileEdit: {
...global.profileEdit!,
checkedUsername: username,
isUsernameAvailable,
},
});

View File

@ -511,6 +511,7 @@ export type GlobalState = {
profileEdit?: {
progress: ProfileEditProgress;
checkedUsername?: string;
isUsernameAvailable?: boolean;
};

View File

@ -313,6 +313,7 @@ export enum ManagementProgress {
export interface ManagementState {
isActive: boolean;
nextScreen?: ManagementScreens;
checkedUsername?: string;
isUsernameAvailable?: boolean;
error?: string;
invites?: ApiExportedInvite[];