Settings, Management: Various fixes for username input (#2088)
This commit is contained in:
parent
55e1f85267
commit
76c1816eba
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@ -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'),
|
||||
};
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -511,6 +511,7 @@ export type GlobalState = {
|
||||
|
||||
profileEdit?: {
|
||||
progress: ProfileEditProgress;
|
||||
checkedUsername?: string;
|
||||
isUsernameAvailable?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@ -313,6 +313,7 @@ export enum ManagementProgress {
|
||||
export interface ManagementState {
|
||||
isActive: boolean;
|
||||
nextScreen?: ManagementScreens;
|
||||
checkedUsername?: string;
|
||||
isUsernameAvailable?: boolean;
|
||||
error?: string;
|
||||
invites?: ApiExportedInvite[];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user