diff --git a/src/api/gramjs/methods/management.ts b/src/api/gramjs/methods/management.ts index 6907c7cb0..f3efb1173 100644 --- a/src/api/gramjs/methods/management.ts +++ b/src/api/gramjs/methods/management.ts @@ -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( diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts index d24a04943..bde73865d 100644 --- a/src/api/gramjs/methods/settings.ts +++ b/src/api/gramjs/methods/settings.ts @@ -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) { diff --git a/src/components/common/UsernameInput.tsx b/src/components/common/UsernameInput.tsx index a39f324a7..73bab511e 100644 --- a/src/components/common/UsernameInput.tsx +++ b/src/components/common/UsernameInput.tsx @@ -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 = ({ +const UsernameInput: FC = ({ 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) => { + const handleUsernameChange = useCallback((e: React.ChangeEvent) => { 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 ( ); }; -export default memo(SettingsEditProfile); +export default memo(UsernameInput); diff --git a/src/components/left/settings/SettingsEditProfile.tsx b/src/components/left/settings/SettingsEditProfile.tsx index 2675b2883..39eca149e 100644 --- a/src/components/left/settings/SettingsEditProfile.tsx +++ b/src/components/left/settings/SettingsEditProfile.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({

{lang('Username')}

@@ -239,7 +244,7 @@ const SettingsEditProfile: FC = ({ isShown={isSaveButtonShown} onClick={handleProfileSave} disabled={isLoading} - ariaLabel="Save changes" + ariaLabel={lang('Save')} > {isLoading ? ( @@ -254,7 +259,7 @@ const SettingsEditProfile: FC = ({ export default memo(withGlobal( (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( if (!currentUser) { return { progress, + checkedUsername, isUsernameAvailable, maxBioLength, }; @@ -284,6 +290,7 @@ export default memo(withGlobal( currentUsername, progress, isUsernameAvailable, + checkedUsername, maxBioLength, }; }, diff --git a/src/components/right/management/ManageChatPrivacyType.tsx b/src/components/right/management/ManageChatPrivacyType.tsx index 75940fd67..8cce80662 100644 --- a/src/components/right/management/ManageChatPrivacyType.tsx +++ b/src/components/right/management/ManageChatPrivacyType.tsx @@ -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 = ({ chat, - onClose, isActive, isChannel, progress, isUsernameAvailable, + checkedUsername, isProtected, maxPublicLinks, + onClose, }) => { const { - checkPublicLink, updatePublicLink, updatePrivateLink, toggleIsProtected, @@ -66,8 +69,11 @@ const ManageChatPrivacyType: FC = ({ 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 = ({ currentUsername={chat.username} isLoading={isLoading} isUsernameAvailable={isUsernameAvailable} - checkUsername={checkPublicLink} + checkedUsername={checkedUsername} onChange={setUsername} />

@@ -219,13 +225,14 @@ const ManageChatPrivacyType: FC = ({ export default memo(withGlobal( (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'), }; diff --git a/src/global/actions/api/management.ts b/src/global/actions/api/management.ts index 6364dbd25..fcbad483c 100644 --- a/src/global/actions/api/management.ts +++ b/src/global/actions/api/management.ts @@ -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); }); diff --git a/src/global/actions/api/settings.ts b/src/global/actions/api/settings.ts index a917deed6..850eb2261 100644 --- a/src/global/actions/api/settings.ts +++ b/src/global/actions/api/settings.ts @@ -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, }, }); diff --git a/src/global/types.ts b/src/global/types.ts index 266713143..338f0d164 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -511,6 +511,7 @@ export type GlobalState = { profileEdit?: { progress: ProfileEditProgress; + checkedUsername?: string; isUsernameAvailable?: boolean; }; diff --git a/src/types/index.ts b/src/types/index.ts index ac9e44105..69badfe06 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -313,6 +313,7 @@ export enum ManagementProgress { export interface ManagementState { isActive: boolean; nextScreen?: ManagementScreens; + checkedUsername?: string; isUsernameAvailable?: boolean; error?: string; invites?: ApiExportedInvite[];