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 { invokeRequest } from './client';
import { buildInputEntity, buildInputPeer } from '../gramjsBuilders'; 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 { addEntitiesWithPhotosToLocalDb } from '../helpers';
import { buildApiExportedInvite, buildChatInviteImporter } from '../apiBuilders/chats'; import { buildApiExportedInvite, buildChatInviteImporter } from '../apiBuilders/chats';
import { buildApiUser } from '../apiBuilders/users'; import { buildApiUser } from '../apiBuilders/users';
@ -18,7 +20,12 @@ export function checkChatUsername({ username }: { username: string }) {
return invokeRequest(new GramJs.channels.CheckUsername({ return invokeRequest(new GramJs.channels.CheckUsername({
channel: new GramJs.InputChannelEmpty(), channel: new GramJs.InputChannelEmpty(),
username, username,
})); }), undefined, true).catch((error) => {
if ((error as ApiError).message === 'USERNAME_INVALID') {
return false;
}
throw error;
});
} }
export async function setChatUsername( export async function setChatUsername(

View File

@ -3,6 +3,8 @@ import { Api as GramJs } from '../../../lib/gramjs';
import type { import type {
ApiAppConfig, ApiAppConfig,
ApiChat,
ApiError,
ApiLangString, ApiLangString,
ApiLanguage, ApiLanguage,
ApiNotifyException, ApiNotifyException,
@ -52,7 +54,12 @@ export function updateProfile({
} }
export function checkUsername(username: string) { 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) { 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, { import React, {
useState, useCallback, memo, useEffect, useMemo, useState, useCallback, memo, useEffect, useMemo,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getActions } from '../../global';
import type { FC } from '../../lib/teact/teact';
import { TME_LINK_PREFIX } from '../../config'; import { TME_LINK_PREFIX } from '../../config';
import { debounce } from '../../util/schedulers'; import { debounce } from '../../util/schedulers';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import usePrevious from '../../hooks/usePrevious';
import InputText from '../ui/InputText'; import InputText from '../ui/InputText';
@ -15,14 +18,14 @@ type OwnProps = {
asLink?: boolean; asLink?: boolean;
isLoading?: boolean; isLoading?: boolean;
isUsernameAvailable?: boolean; isUsernameAvailable?: boolean;
checkUsername: AnyToVoidFunction; checkedUsername?: string;
onChange: (value: string | false) => void; onChange: (value: string | false) => void;
}; };
const MIN_USERNAME_LENGTH = 5; const MIN_USERNAME_LENGTH = 5;
const MAX_USERNAME_LENGTH = 32; const MAX_USERNAME_LENGTH = 32;
const LINK_PREFIX_REGEX = /https:\/\/t\.me\/?/i; 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); const runDebouncedForCheckUsername = debounce((cb) => cb(), 250, false);
@ -32,78 +35,84 @@ function isUsernameValid(username: string) {
&& USERNAME_REGEX.test(username); && USERNAME_REGEX.test(username);
} }
const SettingsEditProfile: FC<OwnProps> = ({ const UsernameInput: FC<OwnProps> = ({
currentUsername, currentUsername,
asLink, asLink,
isLoading, isLoading,
isUsernameAvailable, isUsernameAvailable,
checkUsername, checkedUsername,
onChange, onChange,
}) => { }) => {
const { checkUsername, checkPublicLink } = getActions();
const [username, setUsername] = useState(currentUsername || ''); const [username, setUsername] = useState(currentUsername || '');
const lang = useLang(); const lang = useLang();
const langPrefix = asLink ? 'SetUrl' : 'Username'; const langPrefix = asLink ? 'SetUrl' : 'Username';
const label = asLink ? lang('SetUrlPlaceholder') : lang('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(() => { const [usernameSuccess, usernameError] = useMemo(() => {
if (!username.length) { if (!username.length) {
return []; return [];
} }
if (username.length < MIN_USERNAME_LENGTH) { if (username.length < MIN_USERNAME_LENGTH) {
return [undefined, `${label} is too short`]; return [undefined, lang(`${langPrefix}InvalidShort`)];
} }
if (username.length > MAX_USERNAME_LENGTH) { if (username.length > MAX_USERNAME_LENGTH) {
return [undefined, `${label} is too long`]; return [undefined, lang(`${langPrefix}InvalidLong`)];
} }
if (!USERNAME_REGEX.test(username)) { if (!USERNAME_REGEX.test(username)) {
return [undefined, `${label} contains invalid characters`]; return [undefined, lang(`${langPrefix}Invalid`)];
} }
if (isUsernameAvailable === undefined) { if (renderingIsUsernameAvailable === undefined || isChecking) {
return []; return [];
} }
// Variable `isUsernameAvailable` is initialized with `undefined`, so a strict false check is required // Variable `isUsernameAvailable` is initialized with `undefined`, so a strict false check is required
return [ return [
isUsernameAvailable ? lang(`${langPrefix}Available`, 'Username') : undefined, renderingIsUsernameAvailable ? lang(`${langPrefix}Available`, label) : undefined,
isUsernameAvailable === false ? lang(`${langPrefix}InUse`) : undefined, renderingIsUsernameAvailable === false ? lang(`${langPrefix}InUse`) : undefined,
]; ];
}, [username, isUsernameAvailable, lang, langPrefix, label]); }, [username, renderingIsUsernameAvailable, isChecking, lang, langPrefix, label]);
useEffect(() => { useEffect(() => {
setUsername(currentUsername || ''); setUsername(currentUsername || '');
}, [asLink, 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, ''); const newUsername = e.target.value.trim().replace(LINK_PREFIX_REGEX, '');
setUsername(newUsername); setUsername(newUsername);
e.target.value = `${asLink ? TME_LINK_PREFIX : ''}${newUsername}`;
const isValid = isUsernameValid(newUsername); const isValid = isUsernameValid(newUsername);
if (!isValid) return;
if (isValid) { onChange?.(newUsername);
runDebouncedForCheckUsername(() => {
checkUsername({ username: newUsername });
});
}
if (onChange) { runDebouncedForCheckUsername(() => {
onChange(isValid ? newUsername : false); if (newUsername !== currentUsername) {
} const check = asLink ? checkPublicLink : checkUsername;
}, [asLink, checkUsername, onChange]); check({ username: newUsername });
}
});
}, [asLink, checkPublicLink, checkUsername, currentUsername, onChange]);
return ( return (
<InputText <InputText
value={`${asLink ? TME_LINK_PREFIX : ''}${username}`} value={`${asLink ? TME_LINK_PREFIX : ''}${username}`}
onChange={handleUsernameChange} onChange={handleUsernameChange}
label={label} label={isChecking ? lang('Checking') : label}
error={usernameError} error={usernameError}
success={usernameSuccess} success={usernameSuccess}
readOnly={isLoading} 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 { selectCurrentLimit } from '../../../global/selectors/limits';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';
import useHistoryBack from '../../../hooks/useHistoryBack'; import useHistoryBack from '../../../hooks/useHistoryBack';
import usePrevious from '../../../hooks/usePrevious';
import AvatarEditable from '../../ui/AvatarEditable'; import AvatarEditable from '../../ui/AvatarEditable';
import FloatingActionButton from '../../ui/FloatingActionButton'; import FloatingActionButton from '../../ui/FloatingActionButton';
@ -37,6 +38,7 @@ type StateProps = {
currentBio?: string; currentBio?: string;
currentUsername?: string; currentUsername?: string;
progress?: ProfileEditProgress; progress?: ProfileEditProgress;
checkedUsername?: string;
isUsernameAvailable?: boolean; isUsernameAvailable?: boolean;
maxBioLength: number; maxBioLength: number;
}; };
@ -47,20 +49,20 @@ const ERROR_FIRST_NAME_MISSING = 'Please provide your first name';
const SettingsEditProfile: FC<OwnProps & StateProps> = ({ const SettingsEditProfile: FC<OwnProps & StateProps> = ({
isActive, isActive,
onReset,
currentAvatarHash, currentAvatarHash,
currentFirstName, currentFirstName,
currentLastName, currentLastName,
currentBio, currentBio,
currentUsername, currentUsername,
progress, progress,
checkedUsername,
isUsernameAvailable, isUsernameAvailable,
maxBioLength, maxBioLength,
onReset,
}) => { }) => {
const { const {
loadCurrentUser, loadCurrentUser,
updateProfile, updateProfile,
checkUsername,
} = getActions(); } = getActions();
const lang = useLang(); const lang = useLang();
@ -80,13 +82,16 @@ const SettingsEditProfile: FC<OwnProps & StateProps> = ({
const isLoading = progress === ProfileEditProgress.InProgress; const isLoading = progress === ProfileEditProgress.InProgress;
const isUsernameError = username === false; const isUsernameError = username === false;
const previousIsUsernameAvailable = usePrevious(isUsernameAvailable);
const renderingIsUsernameAvailable = isUsernameAvailable ?? previousIsUsernameAvailable;
const isSaveButtonShown = useMemo(() => { const isSaveButtonShown = useMemo(() => {
if (isUsernameError) { if (isUsernameError) {
return false; return false;
} }
return Boolean(photo) || isProfileFieldsTouched || isUsernameAvailable === true; return Boolean(photo) || isProfileFieldsTouched || (isUsernameTouched && renderingIsUsernameAvailable === true);
}, [photo, isProfileFieldsTouched, isUsernameError, isUsernameAvailable]); }, [isUsernameError, photo, isProfileFieldsTouched, isUsernameTouched, renderingIsUsernameAvailable]);
useHistoryBack({ useHistoryBack({
isActive, isActive,
@ -144,8 +149,8 @@ const SettingsEditProfile: FC<OwnProps & StateProps> = ({
const handleUsernameChange = useCallback((value: string | false) => { const handleUsernameChange = useCallback((value: string | false) => {
setUsername(value); setUsername(value);
setIsUsernameTouched(true); setIsUsernameTouched(currentUsername !== value);
}, []); }, [currentUsername]);
const handleProfileSave = useCallback(() => { const handleProfileSave = useCallback(() => {
const trimmedFirstName = firstName.trim(); 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> <h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>{lang('Username')}</h4>
<UsernameInput <UsernameInput
currentUsername={username || ''} currentUsername={currentUsername}
isLoading={isLoading} isLoading={isLoading}
isUsernameAvailable={isUsernameAvailable} isUsernameAvailable={isUsernameAvailable}
checkUsername={checkUsername} checkedUsername={checkedUsername}
onChange={handleUsernameChange} onChange={handleUsernameChange}
/> />
@ -239,7 +244,7 @@ const SettingsEditProfile: FC<OwnProps & StateProps> = ({
isShown={isSaveButtonShown} isShown={isSaveButtonShown}
onClick={handleProfileSave} onClick={handleProfileSave}
disabled={isLoading} disabled={isLoading}
ariaLabel="Save changes" ariaLabel={lang('Save')}
> >
{isLoading ? ( {isLoading ? (
<Spinner color="white" /> <Spinner color="white" />
@ -254,7 +259,7 @@ const SettingsEditProfile: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global): StateProps => { (global): StateProps => {
const { currentUserId } = global; const { currentUserId } = global;
const { progress, isUsernameAvailable } = global.profileEdit || {}; const { progress, isUsernameAvailable, checkedUsername } = global.profileEdit || {};
const currentUser = currentUserId ? selectUser(global, currentUserId) : undefined; const currentUser = currentUserId ? selectUser(global, currentUserId) : undefined;
const maxBioLength = selectCurrentLimit(global, 'aboutLength'); const maxBioLength = selectCurrentLimit(global, 'aboutLength');
@ -262,6 +267,7 @@ export default memo(withGlobal<OwnProps>(
if (!currentUser) { if (!currentUser) {
return { return {
progress, progress,
checkedUsername,
isUsernameAvailable, isUsernameAvailable,
maxBioLength, maxBioLength,
}; };
@ -284,6 +290,7 @@ export default memo(withGlobal<OwnProps>(
currentUsername, currentUsername,
progress, progress,
isUsernameAvailable, isUsernameAvailable,
checkedUsername,
maxBioLength, maxBioLength,
}; };
}, },

View File

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

View File

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

View File

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

View File

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

View File

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