From 1b37ccc5335e953c90681b5c667313f49c148a87 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Thu, 15 Dec 2022 19:19:17 +0100 Subject: [PATCH] Profile: Support multiple usernames (#2202) --- src/api/gramjs/apiBuilders/chats.ts | 4 +- src/api/gramjs/apiBuilders/common.ts | 30 +- src/api/gramjs/apiBuilders/users.ts | 10 +- src/api/gramjs/methods/bots.ts | 2 +- src/api/gramjs/methods/index.ts | 2 +- src/api/gramjs/methods/management.ts | 34 +- src/api/gramjs/methods/messages.ts | 2 +- src/api/gramjs/methods/settings.ts | 62 ++- src/api/gramjs/updater.ts | 20 +- src/api/types/chats.ts | 4 +- src/api/types/users.ts | 8 +- src/components/common/ChatExtra.tsx | 90 +++- src/components/common/GroupChatInfo.tsx | 9 +- .../common/ManageUsernames.module.scss | 41 ++ src/components/common/ManageUsernames.tsx | 213 ++++++++++ src/components/common/PrivateChatInfo.tsx | 9 +- src/components/common/UsernameInput.tsx | 2 +- .../left/settings/SettingsEditProfile.tsx | 69 ++- .../settings/SettingsPrivacyBlockedUsers.tsx | 26 +- .../settings/folders/SettingsFoldersMain.tsx | 13 +- .../middle/composer/BotCommandTooltip.tsx | 2 +- .../composer/hooks/useMentionTooltip.ts | 9 +- src/components/middle/message/Message.tsx | 14 +- src/components/right/RightHeader.tsx | 2 +- .../right/management/ManageChannel.tsx | 7 +- .../management/ManageChatPrivacyType.tsx | 72 +++- .../right/management/ManageGroup.tsx | 11 +- .../right/management/ManageInvites.tsx | 13 +- .../statistics/StatisticsPublicForward.tsx | 16 +- src/components/ui/Draggable.tsx | 8 +- src/components/ui/ListItem.scss | 10 + src/config.ts | 4 +- src/global/actions/api/bots.ts | 2 +- src/global/actions/api/chats.ts | 5 +- src/global/actions/api/management.ts | 18 +- src/global/actions/api/settings.ts | 123 +++++- src/global/actions/ui/calls.ts | 23 +- src/global/cache.ts | 25 ++ src/global/helpers/chats.ts | 13 +- src/global/helpers/users.ts | 7 +- src/global/selectors/chats.ts | 2 +- src/global/selectors/users.ts | 2 +- src/global/types.ts | 17 + src/lib/gramjs/client/MockClient.ts | 1 + src/lib/gramjs/tl/AllTLObjects.js | 2 +- src/lib/gramjs/tl/api.d.ts | 398 +++++++++++++++++- src/lib/gramjs/tl/apiTl.js | 72 ++-- src/lib/gramjs/tl/static/api.json | 4 + src/lib/gramjs/tl/static/api.tl | 90 ++-- 49 files changed, 1389 insertions(+), 233 deletions(-) create mode 100644 src/components/common/ManageUsernames.module.scss create mode 100644 src/components/common/ManageUsernames.tsx diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index 5baef8453..76ba95b8f 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -20,6 +20,7 @@ import { import { omitVirtualClassFields } from './helpers'; import { getServerTime } from '../../../util/serverTime'; import { buildApiReaction } from './messages'; +import { buildApiUsernames } from './common'; type PeerEntityApiChatFields = Omit { + usernames.push({ + username, + ...(active && { isActive: true }), + ...(editable && { isEditable: true }), + }); + }); + } + + return usernames; +} diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts index fba83011b..4e4ffd89f 100644 --- a/src/api/gramjs/apiBuilders/users.ts +++ b/src/api/gramjs/apiBuilders/users.ts @@ -2,11 +2,13 @@ import { Api as GramJs } from '../../../lib/gramjs'; import type { ApiEmojiStatus, ApiPremiumGiftOption, - ApiUser, ApiUserStatus, ApiUserType, + ApiUser, + ApiUserStatus, + ApiUserType, } from '../../types'; import { buildApiPeerId } from './peers'; import { buildApiBotInfo } from './bots'; -import { buildApiPhoto } from './common'; +import { buildApiPhoto, buildApiUsernames } from './common'; export function buildApiUserFromFull(mtpUserFull: GramJs.users.UserFull): ApiUser { const { @@ -50,6 +52,8 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { : undefined; const userType = buildApiUserType(mtpUser); + const usernames = buildApiUsernames(mtpUser); + return { id: buildApiPeerId(id, 'user'), isMin: Boolean(mtpUser.min), @@ -62,7 +66,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { ...(firstName && { firstName }), ...(userType === 'userTypeBot' && { canBeInvitedToGroup: !mtpUser.botNochats }), ...(lastName && { lastName }), - username: mtpUser.username || '', + ...(usernames && { usernames }), phoneNumber: mtpUser.phone || '', noStatus: !mtpUser.status, ...(mtpUser.accessHash && { accessHash: String(mtpUser.accessHash) }), diff --git a/src/api/gramjs/methods/bots.ts b/src/api/gramjs/methods/bots.ts index 81c0fd34d..1bb4ce5b6 100644 --- a/src/api/gramjs/methods/bots.ts +++ b/src/api/gramjs/methods/bots.ts @@ -100,7 +100,7 @@ export async function fetchInlineBotResults({ return { isGallery: Boolean(result.gallery), help: bot.botPlaceholder, - nextOffset: getInlineBotResultsNextOffset(bot.username, result.nextOffset), + nextOffset: getInlineBotResultsNextOffset(bot.usernames![0].username, result.nextOffset), switchPm: buildBotSwitchPm(result.switchPm), users: result.users.map(buildApiUser).filter(Boolean), results: processInlineBotResult(String(result.queryId), result.results), diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index db530f560..3f7374091 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -60,7 +60,7 @@ export { fetchNotificationExceptions, fetchNotificationSettings, updateContactSignUpNotification, updateNotificationSettings, fetchLanguages, fetchLangPack, fetchPrivacySettings, setPrivacySettings, registerDevice, unregisterDevice, updateIsOnline, fetchContentSettings, updateContentSettings, fetchLangStrings, fetchCountryList, fetchAppConfig, - fetchGlobalPrivacySettings, updateGlobalPrivacySettings, + fetchGlobalPrivacySettings, updateGlobalPrivacySettings, toggleUsername, reorderUsernames, } from './settings'; export { diff --git a/src/api/gramjs/methods/management.ts b/src/api/gramjs/methods/management.ts index f3efb1173..9a22d196c 100644 --- a/src/api/gramjs/methods/management.ts +++ b/src/api/gramjs/methods/management.ts @@ -5,6 +5,8 @@ import { buildInputEntity, buildInputPeer } from '../gramjsBuilders'; import type { ApiChat, ApiError, ApiUser, OnApiUpdate, } from '../../types'; + +import { USERNAME_PURCHASE_ERROR } from '../../../config'; import { addEntitiesWithPhotosToLocalDb } from '../helpers'; import { buildApiExportedInvite, buildChatInviteImporter } from '../apiBuilders/chats'; import { buildApiUser } from '../apiBuilders/users'; @@ -12,20 +14,32 @@ import { buildCollectionByKey } from '../../../util/iteratees'; let onUpdate: OnApiUpdate; +export const ACCEPTABLE_USERNAME_ERRORS = new Set([USERNAME_PURCHASE_ERROR, 'USERNAME_INVALID']); + export function init(_onUpdate: OnApiUpdate) { onUpdate = _onUpdate; } -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; +export async function checkChatUsername({ username }: { username: string }) { + try { + const result = await invokeRequest(new GramJs.channels.CheckUsername({ + channel: new GramJs.InputChannelEmpty(), + username, + }), undefined, true); + + return { result, error: undefined }; + } catch (error) { + const errorMessage = (error as ApiError).message; + + if (ACCEPTABLE_USERNAME_ERRORS.has(errorMessage)) { + return { + result: false, + error: errorMessage, + }; } + throw error; - }); + } } export async function setChatUsername( @@ -36,11 +50,13 @@ export async function setChatUsername( username, })); + const usernames = chat.usernames!.map((u) => (u.isEditable ? { ...u, username } : u)); + if (result) { onUpdate({ '@type': 'updateChat', id: chat.id, - chat: { username }, + chat: { usernames }, }); } } diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 14266f2bc..62c938e07 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -1358,7 +1358,7 @@ export async function fetchSponsoredMessages({ chat }: { chat: ApiChat }) { channel: buildInputPeer(chat.id, chat.accessHash), })); - if (!result || !result.messages.length) { + if (!result || result instanceof GramJs.messages.SponsoredMessagesEmpty || !result.messages.length) { return undefined; } diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts index a8c73da82..ec30735af 100644 --- a/src/api/gramjs/methods/settings.ts +++ b/src/api/gramjs/methods/settings.ts @@ -12,6 +12,7 @@ import type { ApiPrivacyKey, InputPrivacyRules, LangCode } from '../../../types' import type { LANG_PACKS } from '../../../config'; import { BLOCKED_LIST_LIMIT, DEFAULT_LANG_PACK } from '../../../config'; +import { ACCEPTABLE_USERNAME_ERRORS } from './management'; import { buildApiCountryList, buildApiNotifyException, @@ -55,13 +56,25 @@ export function updateProfile({ }), true); } -export function checkUsername(username: string) { - return invokeRequest(new GramJs.account.CheckUsername({ username }), undefined, true).catch((error) => { - if ((error as ApiError).message === 'USERNAME_INVALID') { - return false; +export async function checkUsername(username: string) { + try { + const result = await invokeRequest(new GramJs.account.CheckUsername({ + username, + }), undefined, true); + + return { result, error: undefined }; + } catch (error) { + const errorMessage = (error as ApiError).message; + + if (ACCEPTABLE_USERNAME_ERRORS.has(errorMessage)) { + return { + result: false, + error: errorMessage, + }; } + throw error; - }); + } } export function updateUsername(username: string) { @@ -544,3 +557,42 @@ export async function updateGlobalPrivacySettings({ shouldArchiveAndMuteNewNonCo shouldArchiveAndMuteNewNonContact: Boolean(result.archiveAndMuteNewNoncontactPeers), }; } + +export function toggleUsername({ + chatId, accessHash, username, isActive, +}: { + username: string; + isActive: boolean; + chatId?: string; + accessHash?: string; +}) { + if (chatId) { + return invokeRequest(new GramJs.channels.ToggleUsername({ + channel: buildInputEntity(chatId, accessHash) as GramJs.InputChannel, + username, + active: isActive, + })); + } + + return invokeRequest(new GramJs.account.ToggleUsername({ + username, + active: isActive, + })); +} + +export function reorderUsernames({ chatId, accessHash, usernames }: { + usernames: string[]; + chatId?: string; + accessHash?: string; +}) { + if (chatId) { + return invokeRequest(new GramJs.channels.ReorderUsernames({ + channel: buildInputEntity(chatId, accessHash) as GramJs.InputChannel, + order: usernames, + })); + } + + return invokeRequest(new GramJs.account.ReorderUsernames({ + order: usernames, + })); +} diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index 57c34a462..a5f4ce849 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -27,7 +27,11 @@ import { buildApiChatFolder, buildApiChatSettings, } from './apiBuilders/chats'; -import { buildApiUser, buildApiUserEmojiStatus, buildApiUserStatus } from './apiBuilders/users'; +import { + buildApiUser, + buildApiUserEmojiStatus, + buildApiUserStatus, +} from './apiBuilders/users'; import { buildMessageFromUpdate, isMessageWithMedia, @@ -46,7 +50,7 @@ import { swapLocalInvoiceMedia, } from './helpers'; import { buildApiNotifyException, buildPrivacyKey, buildPrivacyRules } from './apiBuilders/misc'; -import { buildApiPhoto } from './apiBuilders/common'; +import { buildApiPhoto, buildApiUsernames } from './apiBuilders/common'; import { buildApiGroupCall, buildApiGroupCallParticipant, @@ -779,14 +783,20 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { } else if (update instanceof GramJs.UpdateUserName) { const apiUserId = buildApiPeerId(update.userId, 'user'); const updatedUser = localDb.users[apiUserId]; + const user = updatedUser?.mutualContact && !updatedUser.self - ? pick(update, ['username']) - : pick(update, ['firstName', 'lastName', 'username']); + ? pick(update, []) + : pick(update, ['firstName', 'lastName']); + + const usernames = buildApiUsernames(update); onUpdate({ '@type': 'updateUser', id: apiUserId, - user, + user: { + ...user, + usernames, + }, }); } else if (update instanceof GramJs.UpdateUserPhoto) { const { userId, photo } = update; diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 92aa4f167..dca2be7eb 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -1,7 +1,7 @@ import type { ApiMessage, ApiPhoto, ApiStickerSet } from './messages'; import type { ApiBotCommand } from './bots'; import type { ApiChatInviteImporter } from './misc'; -import type { ApiFakeType } from './users'; +import type { ApiFakeType, ApiUsername } from './users'; type ApiChatType = ( 'chatTypePrivate' | 'chatTypeSecret' | @@ -29,7 +29,7 @@ export interface ApiChat { isMin?: boolean; hasVideoAvatar?: boolean; avatarHash?: string; - username?: string; + usernames?: ApiUsername[]; membersCount?: number; joinDate?: number; isSupport?: boolean; diff --git a/src/api/types/users.ts b/src/api/types/users.ts index adcff0af3..075d52eec 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -13,7 +13,7 @@ export interface ApiUser { firstName?: string; lastName?: string; noStatus?: boolean; - username: string; + usernames?: ApiUsername[]; phoneNumber: string; accessHash?: string; hasVideoAvatar?: boolean; @@ -58,6 +58,12 @@ export interface ApiUserStatus { expires?: number; } +export interface ApiUsername { + username: string; + isActive?: true; + isEditable?: true; +} + export type ApiChatType = typeof API_CHAT_TYPES[number]; export type ApiAttachMenuPeerType = 'self' | ApiChatType; diff --git a/src/components/common/ChatExtra.tsx b/src/components/common/ChatExtra.tsx index f94e5df9d..5f6aa98a6 100644 --- a/src/components/common/ChatExtra.tsx +++ b/src/components/common/ChatExtra.tsx @@ -1,12 +1,15 @@ import type { FC } from '../../lib/teact/teact'; import React, { - memo, useCallback, useEffect, useState, + memo, useCallback, useEffect, useMemo, useState, } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; import type { GlobalState } from '../../global/types'; -import type { ApiChat, ApiCountryCode, ApiUser } from '../../api/types'; +import type { + ApiChat, ApiCountryCode, ApiUser, ApiUsername, +} from '../../api/types'; +import { TME_LINK_PREFIX } from '../../config'; import { selectChat, selectNotifyExceptions, selectNotifySettings, selectUser, } from '../../global/selectors'; @@ -17,6 +20,7 @@ import renderText from './helpers/renderText'; import { copyTextToClipboard } from '../../util/clipboard'; import { formatPhoneNumberWithCode } from '../../util/phoneNumber'; import { debounce } from '../../util/schedulers'; +import stopEvent from '../../util/stopEvent'; import useLang from '../../hooks/useLang'; import ListItem from '../ui/ListItem'; @@ -57,11 +61,11 @@ const ChatExtra: FC = ({ const { id: userId, fullInfo, - username, + usernames, phoneNumber, isSelf, } = user || {}; - const { id: chatId } = chat || {}; + const { id: chatId, usernames: chatUsernames } = chat || {}; const lang = useLang(); const [areNotificationsEnabled, setAreNotificationsEnabled] = useState(!isMuted); @@ -70,6 +74,17 @@ const ChatExtra: FC = ({ loadFullUser({ userId }); } }, [loadFullUser, userId, lastSyncTime]); + const activeUsernames = useMemo(() => { + const result = usernames?.filter((u) => u.isActive); + + return result?.length ? result : undefined; + }, [usernames]); + const activeChatUsernames = useMemo(() => { + const result = chatUsernames?.filter((u) => u.isActive); + + return result?.length ? result : undefined; + }, [chatUsernames]); + const link = useMemo(() => (chat ? getChatLink(chat) : undefined), [chat]); const handleNotificationChange = useCallback(() => { setAreNotificationsEnabled((current) => { @@ -93,9 +108,55 @@ const ChatExtra: FC = ({ } const formattedNumber = phoneNumber && formatPhoneNumberWithCode(phoneCodeList, phoneNumber); - const link = getChatLink(chat); const description = (fullInfo?.bio) || getChatDescription(chat); + function renderUsernames(usernameList: ApiUsername[], isChat?: boolean) { + const [mainUsername, ...otherUsernames] = usernameList; + const usernameLinks = otherUsernames.length + ? (lang('UsernameAlso', '%USERNAMES%') as string) + .split('%') + .map((s) => { + return (s === 'USERNAMES' ? ( + <> + {otherUsernames.map(({ username: nick }, idx) => ( + <> + {idx > 0 ? ', ' : ''} + { + stopEvent(e); + copy(`@${nick}`, lang(isChat ? 'Link' : 'Username')); + }} + className="username-link" + > + {`@${nick}`} + + + ))} + + ) : s); + }) + : undefined; + + return ( + copy(`@${mainUsername.username}`, lang(isChat ? 'Link' : 'Username'))} + > + {renderText(mainUsername.username)} + + {usernameLinks && {usernameLinks}} + {lang(isChat ? 'Link' : 'Username')} + + + ); + } + return (
{formattedNumber && Boolean(formattedNumber.length) && ( @@ -105,19 +166,7 @@ const ChatExtra: FC = ({ {lang('Phone')} )} - {username && ( - copy(`@${username}`, lang('Username'))} - > - {renderText(username)} - {lang('Username')} - - )} + {activeUsernames && renderUsernames(activeUsernames)} {description && Boolean(description.length) && ( = ({ {lang(userId ? 'UserBio' : 'Info')} )} - {(canInviteUsers || !username) && link && ( + {activeChatUsernames && renderUsernames(activeChatUsernames, true)} + {!activeChatUsernames && canInviteUsers && link && ( = ({ }, [chat, avatarSize, openMediaViewer]); const lang = useLang(); + const mainUsername = useMemo(() => chat && withUsername && getMainUsername(chat), [chat, withUsername]); if (!chat) { return undefined; @@ -125,13 +129,12 @@ const GroupChatInfo: FC = ({ ); } - const handle = withUsername ? chat.username : undefined; const groupStatus = getGroupStatus(lang, chat); const onlineStatus = onlineCount ? `, ${lang('OnlineCount', onlineCount, 'i')}` : undefined; return ( - {handle && {handle}} + {mainUsername && {mainUsername}} {groupStatus} {onlineStatus && {onlineStatus}} diff --git a/src/components/common/ManageUsernames.module.scss b/src/components/common/ManageUsernames.module.scss new file mode 100644 index 000000000..6c113e81f --- /dev/null +++ b/src/components/common/ManageUsernames.module.scss @@ -0,0 +1,41 @@ +.container { + background-color: var(--color-background); + padding: 1.5rem 1.5rem 0; + box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent); + margin-bottom: 0.625rem; +} + +.header { + font-size: 1rem; + color: var(--color-text-secondary); + margin-bottom: 2rem; + position: relative; + + &[dir="rtl"] { + text-align: right; + } +} + +.description { + font-size: 0.875rem; + color: var(--color-text-secondary); + + margin-top: 1rem; + margin-bottom: 0; + padding-top: 0.5rem; + padding-bottom: 1.5rem; +} + +.sortableContainer { + position: relative; + + :global(.draggable-knob) { + margin-top: -0.25rem; + } +} + +.item { + margin-bottom: 0; + margin-left: -1rem; + margin-right: -1rem; +} diff --git a/src/components/common/ManageUsernames.tsx b/src/components/common/ManageUsernames.tsx new file mode 100644 index 000000000..f9daba165 --- /dev/null +++ b/src/components/common/ManageUsernames.tsx @@ -0,0 +1,213 @@ +import React, { + memo, useCallback, useEffect, useMemo, useState, +} from '../../lib/teact/teact'; +import { getActions } from '../../global'; + +import type { FC } from '../../lib/teact/teact'; +import type { ApiUsername } from '../../api/types'; + +import { copyTextToClipboard } from '../../util/clipboard'; +import buildClassName from '../../util/buildClassName'; +import { isBetween } from '../../util/math'; +import usePrevious from '../../hooks/usePrevious'; +import useLang from '../../hooks/useLang'; + +import Draggable from '../ui/Draggable'; +import ListItem from '../ui/ListItem'; +import ConfirmDialog from '../ui/ConfirmDialog'; + +import styles from './ManageUsernames.module.scss'; + +type SortState = { + orderedUsernames?: string[]; + dragOrderUsernames?: string[]; + draggedIndex?: number; +}; + +type OwnProps = { + chatId?: string; + usernames: ApiUsername[]; + onEditUsername: (username: string) => void; +}; + +const USERNAME_HEIGHT_PX = 60; + +const ManageUsernames: FC = ({ + chatId, + usernames, + onEditUsername, +}) => { + const { + showNotification, + toggleUsername, + toggleChatUsername, + sortUsernames, + sortChatUsernames, + } = getActions(); + const lang = useLang(); + const [usernameForConfirm, setUsernameForConfirm] = useState(); + + const usernameList = useMemo(() => usernames.map(({ username }) => username), [usernames]); + const prevUsernameList = usePrevious(usernameList); + + const [state, setState] = useState({ + orderedUsernames: usernameList, + dragOrderUsernames: usernameList, + draggedIndex: undefined, + }); + + // Sync folders state after changing folders in other clients + useEffect(() => { + if (prevUsernameList !== usernameList) { + setState({ + orderedUsernames: usernameList, + dragOrderUsernames: usernameList, + draggedIndex: undefined, + }); + } + }, [prevUsernameList, usernameList]); + + const handleCopyUsername = useCallback((value: string) => { + copyTextToClipboard(`@${value}`); + showNotification({ + message: lang('UsernameCopied'), + }); + }, [lang, showNotification]); + + const handleUsernameClick = useCallback((data: ApiUsername) => { + if (data.isEditable) { + onEditUsername(data.username); + } else { + setUsernameForConfirm(data); + } + }, [onEditUsername]); + + const closeConfirmUsernameDialog = useCallback(() => { + setUsernameForConfirm(undefined); + }, []); + + const handleUsernameToggle = useCallback(() => { + if (chatId) { + toggleChatUsername({ + chatId, + username: usernameForConfirm!.username, + isActive: !usernameForConfirm!.isActive, + }); + } else { + toggleUsername({ + username: usernameForConfirm!.username, + isActive: !usernameForConfirm!.isActive, + }); + } + closeConfirmUsernameDialog(); + }, [chatId, closeConfirmUsernameDialog, toggleChatUsername, toggleUsername, usernameForConfirm]); + + const handleDrag = useCallback((translation: { x: number; y: number }, id: string | number) => { + const delta = Math.round(translation.y / USERNAME_HEIGHT_PX); + const index = state.orderedUsernames?.indexOf(id as string) || 0; + const dragOrderUsernames = state.orderedUsernames?.filter((username) => username !== id); + + if (!dragOrderUsernames || !isBetween(index + delta, 0, usernameList.length)) { + return; + } + + dragOrderUsernames.splice(index + delta, 0, id as string); + setState((current) => ({ + ...current, + draggedIndex: index, + dragOrderUsernames, + })); + }, [state.orderedUsernames, usernameList.length]); + + const handleDragEnd = useCallback(() => { + setState((current) => { + if (chatId) { + sortChatUsernames({ + chatId, + usernames: current.dragOrderUsernames!, + }); + } else { + sortUsernames({ usernames: current.dragOrderUsernames! }); + } + + return { + ...current, + orderedUsernames: current.dragOrderUsernames, + draggedIndex: undefined, + }; + }); + }, [chatId, sortChatUsernames, sortUsernames]); + + return ( + <> +
+

+ {lang('lng_usernames_subtitle')} +

+
+ {usernames.map((usernameData, i) => { + const isDragged = state.draggedIndex === i; + const draggedTop = (state.orderedUsernames?.indexOf(usernameData.username) ?? 0) * USERNAME_HEIGHT_PX; + const top = (state.dragOrderUsernames?.indexOf(usernameData.username) ?? 0) * USERNAME_HEIGHT_PX; + const subtitle = usernameData.isEditable + ? 'lng_usernames_edit' + : (usernameData.isActive ? 'lng_usernames_active' : 'lng_usernames_non_active'); + + return ( + + { + handleCopyUsername(usernameData.username); + }, + title: lang('Copy'), + icon: 'copy', + }, + ]} + // eslint-disable-next-line react/jsx-no-bind + onClick={() => { + handleUsernameClick(usernameData); + }} + > + @{usernameData.username} + {lang(subtitle)} + + + ); + })} +
+

+ {lang('lng_usernames_description')} +

+
+ + + ); +}; + +export default memo(ManageUsernames); diff --git a/src/components/common/PrivateChatInfo.tsx b/src/components/common/PrivateChatInfo.tsx index f4db42fa0..409e536bc 100644 --- a/src/components/common/PrivateChatInfo.tsx +++ b/src/components/common/PrivateChatInfo.tsx @@ -1,4 +1,6 @@ -import React, { useEffect, useCallback, memo } from '../../lib/teact/teact'; +import React, { + useEffect, useCallback, memo, useMemo, +} from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; import type { FC } from '../../lib/teact/teact'; @@ -10,7 +12,7 @@ import type { AnimationLevel } from '../../types'; import { MediaViewerOrigin } from '../../types'; import { selectChatMessages, selectUser, selectUserStatus } from '../../global/selectors'; -import { getUserStatus, isUserOnline } from '../../global/helpers'; +import { getMainUsername, getUserStatus, isUserOnline } from '../../global/helpers'; import buildClassName from '../../util/buildClassName'; import renderText from './helpers/renderText'; @@ -99,6 +101,7 @@ const PrivateChatInfo: FC = ({ }, [user, avatarSize, openMediaViewer]); const lang = useLang(); + const mainUsername = useMemo(() => user && withUsername && getMainUsername(user), [user, withUsername]); if (!user) { return undefined; @@ -129,7 +132,7 @@ const PrivateChatInfo: FC = ({ return ( - {withUsername && user.username && {user.username}} + {mainUsername && {mainUsername}} {getUserStatus(lang, user, userStatus, serverTimeOffset)} ); diff --git a/src/components/common/UsernameInput.tsx b/src/components/common/UsernameInput.tsx index 73bab511e..9a77e8417 100644 --- a/src/components/common/UsernameInput.tsx +++ b/src/components/common/UsernameInput.tsx @@ -25,7 +25,7 @@ type OwnProps = { const MIN_USERNAME_LENGTH = 5; const MAX_USERNAME_LENGTH = 32; const LINK_PREFIX_REGEX = /https:\/\/t\.me\/?/i; -const USERNAME_REGEX = /^[^\d]([a-zA-Z0-9_]+)$/; +const USERNAME_REGEX = /^\D([a-zA-Z0-9_]+)$/; const runDebouncedForCheckUsername = debounce((cb) => cb(), 250, false); diff --git a/src/components/left/settings/SettingsEditProfile.tsx b/src/components/left/settings/SettingsEditProfile.tsx index 39eca149e..4170e44df 100644 --- a/src/components/left/settings/SettingsEditProfile.tsx +++ b/src/components/left/settings/SettingsEditProfile.tsx @@ -1,21 +1,22 @@ 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, withGlobal } from '../../../global'; +import type { FC } from '../../../lib/teact/teact'; +import type { ApiUsername } from '../../../api/types'; import { ApiMediaFormat } from '../../../api/types'; import { ProfileEditProgress } from '../../../types'; -import { TME_LINK_PREFIX } from '../../../config'; +import { PURCHASE_USERNAME, TME_LINK_PREFIX, USERNAME_PURCHASE_ERROR } from '../../../config'; import { throttle } from '../../../util/schedulers'; import { selectUser } from '../../../global/selectors'; import { getChatAvatarHash } from '../../../global/helpers'; -import useMedia from '../../../hooks/useMedia'; -import useLang from '../../../hooks/useLang'; import { selectCurrentLimit } from '../../../global/selectors/limits'; import renderText from '../../common/helpers/renderText'; +import useMedia from '../../../hooks/useMedia'; +import useLang from '../../../hooks/useLang'; import useHistoryBack from '../../../hooks/useHistoryBack'; import usePrevious from '../../../hooks/usePrevious'; @@ -25,6 +26,8 @@ import Spinner from '../../ui/Spinner'; import InputText from '../../ui/InputText'; import UsernameInput from '../../common/UsernameInput'; import TextArea from '../../ui/TextArea'; +import ManageUsernames from '../../common/ManageUsernames'; +import SafeLink from '../../common/SafeLink'; type OwnProps = { isActive: boolean; @@ -36,11 +39,12 @@ type StateProps = { currentFirstName?: string; currentLastName?: string; currentBio?: string; - currentUsername?: string; progress?: ProfileEditProgress; checkedUsername?: string; + editUsernameError?: string; isUsernameAvailable?: boolean; maxBioLength: number; + usernames?: ApiUsername[]; }; const runThrottled = throttle((cb) => cb(), 60000, true); @@ -53,11 +57,12 @@ const SettingsEditProfile: FC = ({ currentFirstName, currentLastName, currentBio, - currentUsername, progress, checkedUsername, + editUsernameError, isUsernameAvailable, maxBioLength, + usernames, onReset, }) => { const { @@ -67,6 +72,8 @@ const SettingsEditProfile: FC = ({ const lang = useLang(); + const firstEditableUsername = useMemo(() => usernames?.find(({ isEditable }) => isEditable), [usernames]); + const currentUsername = firstEditableUsername?.username || ''; const [isUsernameTouched, setIsUsernameTouched] = useState(false); const [isProfileFieldsTouched, setIsProfileFieldsTouched] = useState(false); const [error, setError] = useState(); @@ -75,15 +82,16 @@ const SettingsEditProfile: FC = ({ const [firstName, setFirstName] = useState(currentFirstName || ''); const [lastName, setLastName] = useState(currentLastName || ''); const [bio, setBio] = useState(currentBio || ''); - const [username, setUsername] = useState(currentUsername || ''); + const [editableUsername, setEditableUsername] = useState(currentUsername); const currentAvatarBlobUrl = useMedia(currentAvatarHash, false, ApiMediaFormat.BlobUrl); const isLoading = progress === ProfileEditProgress.InProgress; - const isUsernameError = username === false; + const isUsernameError = editableUsername === false; const previousIsUsernameAvailable = usePrevious(isUsernameAvailable); const renderingIsUsernameAvailable = isUsernameAvailable ?? previousIsUsernameAvailable; + const shouldRenderUsernamesManage = usernames && usernames.length > 1; const isSaveButtonShown = useMemo(() => { if (isUsernameError) { @@ -117,7 +125,7 @@ const SettingsEditProfile: FC = ({ }, [currentFirstName, currentLastName, currentBio]); useEffect(() => { - setUsername(currentUsername || ''); + setEditableUsername(currentUsername || ''); }, [currentUsername]); useEffect(() => { @@ -148,7 +156,7 @@ const SettingsEditProfile: FC = ({ }, []); const handleUsernameChange = useCallback((value: string | false) => { - setUsername(value); + setEditableUsername(value); setIsUsernameTouched(currentUsername !== value); }, [currentUsername]); @@ -170,16 +178,31 @@ const SettingsEditProfile: FC = ({ bio: trimmedBio, }), ...(isUsernameTouched && { - username, + username: editableUsername, }), }); }, [ photo, firstName, lastName, bio, isProfileFieldsTouched, - username, isUsernameTouched, + editableUsername, isUsernameTouched, updateProfile, ]); + function renderPurchaseLink() { + const purchaseInfoLink = `${TME_LINK_PREFIX}${PURCHASE_USERNAME}`; + + return ( +

+ {(lang('lng_username_purchase_available') as string) + .replace('{link}', '%PURCHASE_LINK%') + .split('%') + .map((s) => { + return (s === 'PURCHASE_LINK' ? : s); + })} +

+ ); + } + return (
@@ -228,16 +251,24 @@ const SettingsEditProfile: FC = ({ onChange={handleUsernameChange} /> + {editUsernameError === USERNAME_PURCHASE_ERROR && renderPurchaseLink()}

{renderText(lang('UsernameHelp'), ['br', 'simple_markdown'])}

- {username && ( + {editableUsername && (

{lang('lng_username_link')}
- {TME_LINK_PREFIX}{username} + {TME_LINK_PREFIX}{editableUsername}

)}
+ + {shouldRenderUsernamesManage && ( + + )}
= ({ export default memo(withGlobal( (global): StateProps => { const { currentUserId } = global; - const { progress, isUsernameAvailable, checkedUsername } = global.profileEdit || {}; + const { + progress, isUsernameAvailable, checkedUsername, error: editUsernameError, + } = global.profileEdit || {}; const currentUser = currentUserId ? selectUser(global, currentUserId) : undefined; const maxBioLength = selectCurrentLimit(global, 'aboutLength'); @@ -269,6 +302,7 @@ export default memo(withGlobal( progress, checkedUsername, isUsernameAvailable, + editUsernameError, maxBioLength, }; } @@ -276,7 +310,7 @@ export default memo(withGlobal( const { firstName: currentFirstName, lastName: currentLastName, - username: currentUsername, + usernames, fullInfo, } = currentUser; const { bio: currentBio } = fullInfo || {}; @@ -287,11 +321,12 @@ export default memo(withGlobal( currentFirstName, currentLastName, currentBio, - currentUsername, progress, isUsernameAvailable, checkedUsername, + editUsernameError, maxBioLength, + usernames, }; }, )(SettingsEditProfile)); diff --git a/src/components/left/settings/SettingsPrivacyBlockedUsers.tsx b/src/components/left/settings/SettingsPrivacyBlockedUsers.tsx index 63f941a1c..6b482d3cd 100644 --- a/src/components/left/settings/SettingsPrivacyBlockedUsers.tsx +++ b/src/components/left/settings/SettingsPrivacyBlockedUsers.tsx @@ -1,14 +1,12 @@ import type { FC } from '../../../lib/teact/teact'; -import React, { memo, useCallback } from '../../../lib/teact/teact'; +import React, { memo, useCallback, useMemo } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { ApiChat, ApiCountryCode, ApiUser } from '../../../api/types'; import { CHAT_HEIGHT_PX } from '../../../config'; import { formatPhoneNumberWithCode } from '../../../util/phoneNumber'; -import { - isUserId, -} from '../../../global/helpers'; +import { getMainUsername, isUserId } from '../../../global/helpers'; import buildClassName from '../../../util/buildClassName'; import useLang from '../../../hooks/useLang'; import useHistoryBack from '../../../hooks/useHistoryBack'; @@ -54,6 +52,20 @@ const SettingsPrivacyBlockedUsers: FC = ({ onBack: onReset, }); + const blockedUsernamesById = useMemo(() => { + return blockedIds.reduce((acc, contactId) => { + const isPrivate = isUserId(contactId); + const user = isPrivate ? usersByIds[contactId] : undefined; + const mainUsername = user && !user.phoneNumber && getMainUsername(user); + + if (mainUsername) { + acc[contactId] = mainUsername; + } + + return acc; + }, {} as Record); + }, [blockedIds, usersByIds]); + function renderContact(contactId: string, i: number, viewportOffset: number) { const isPrivate = isUserId(contactId); const user = isPrivate ? usersByIds[contactId] : undefined; @@ -65,6 +77,8 @@ const SettingsPrivacyBlockedUsers: FC = ({ isPrivate ? 'private' : 'group', ); + const userMainUsername = blockedUsernamesById[contactId]; + return ( = ({ {user?.phoneNumber && (
{formatPhoneNumberWithCode(phoneCodeList, user.phoneNumber)}
)} - {user && !user.phoneNumber && user.username && ( -
@{user.username}
- )} + {userMainUsername && (
@{userMainUsername}
)}
); diff --git a/src/components/left/settings/folders/SettingsFoldersMain.tsx b/src/components/left/settings/folders/SettingsFoldersMain.tsx index 21ed8cd9e..47a5fcbef 100644 --- a/src/components/left/settings/folders/SettingsFoldersMain.tsx +++ b/src/components/left/settings/folders/SettingsFoldersMain.tsx @@ -10,6 +10,7 @@ import { ALL_FOLDER_ID, STICKER_SIZE_FOLDER_SETTINGS } from '../../../../config' import { LOCAL_TGS_URLS } from '../../../common/helpers/animatedAssets'; import { MEMO_EMPTY_ARRAY } from '../../../../util/memo'; import { throttle } from '../../../../util/schedulers'; +import { isBetween } from '../../../../util/math'; import { getFolderDescriptionText } from '../../../../global/helpers'; import { selectCurrentLimit } from '../../../../global/selectors/limits'; import { selectIsCurrentUserPremium } from '../../../../global/selectors'; @@ -154,16 +155,16 @@ const SettingsFoldersMain: FC = ({ addChatFolder({ folder }); }, [foldersById, maxFolders, addChatFolder, openLimitReachedModal]); - const handleDrag = useCallback((translation: { x: number; y: number }, id: number) => { + const handleDrag = useCallback((translation: { x: number; y: number }, id: string | number) => { const delta = Math.round(translation.y / FOLDER_HEIGHT_PX); - const index = state.orderedFolderIds?.indexOf(id) || 0; + const index = state.orderedFolderIds?.indexOf(id as number) || 0; const dragOrderIds = state.orderedFolderIds?.filter((folderId) => folderId !== id); - if (!dragOrderIds || !inRange(index + delta, 0, folderIds?.length || 0)) { + if (!dragOrderIds || !isBetween(index + delta, 0, folderIds?.length || 0)) { return; } - dragOrderIds.splice(index + delta + (isPremium ? 0 : 1), 0, id); + dragOrderIds.splice(index + delta + (isPremium ? 0 : 1), 0, id as number); setState((current) => ({ ...current, draggedIndex: index, @@ -362,7 +363,3 @@ export default memo(withGlobal( }; }, )(SettingsFoldersMain)); - -function inRange(x: number, min: number, max: number) { - return x >= min && x <= max; -} diff --git a/src/components/middle/composer/BotCommandTooltip.tsx b/src/components/middle/composer/BotCommandTooltip.tsx index ce57a2700..d4e60712b 100644 --- a/src/components/middle/composer/BotCommandTooltip.tsx +++ b/src/components/middle/composer/BotCommandTooltip.tsx @@ -45,7 +45,7 @@ const BotCommandTooltip: FC = ({ const handleSendCommand = useCallback(({ botId, command }: ApiBotCommand) => { const bot = usersById[botId]; sendBotCommand({ - command: `/${command}${withUsername && bot ? `@${bot.username}` : ''}`, + command: `/${command}${withUsername && bot ? `@${bot.usernames![0].username}` : ''}`, botId, }); onClick(); diff --git a/src/components/middle/composer/hooks/useMentionTooltip.ts b/src/components/middle/composer/hooks/useMentionTooltip.ts index d697f852c..ecd78aec4 100644 --- a/src/components/middle/composer/hooks/useMentionTooltip.ts +++ b/src/components/middle/composer/hooks/useMentionTooltip.ts @@ -6,7 +6,7 @@ import { getGlobal } from '../../../../global'; import type { ApiChatMember, ApiUser } from '../../../../api/types'; import { ApiMessageEntityTypes } from '../../../../api/types'; -import { filterUsersByName, getUserFirstOrLastName } from '../../../../global/helpers'; +import { filterUsersByName, getMainUsername, getUserFirstOrLastName } from '../../../../global/helpers'; import { prepareForRegExp } from '../helpers/prepareForRegExp'; import focusEditableElement from '../../../../util/focusEditableElement'; import { pickTruthy, unique } from '../../../../util/iteratees'; @@ -106,12 +106,13 @@ export default function useMentionTooltip( }, [markIsOpen, unmarkIsOpen, usersToMention]); const insertMention = useCallback((user: ApiUser, forceFocus = false) => { - if (!user.username && !getUserFirstOrLastName(user)) { + if (!user.usernames && !getUserFirstOrLastName(user)) { return; } - const insertedHtml = user.username - ? `@${user.username}` + const mainUsername = getMainUsername(user); + const insertedHtml = mainUsername + ? `@${mainUsername}` : ` = ({ message, - chatUsername, + chatUsernames, observeIntersectionForBottom, observeIntersectionForLoading, observeIntersectionForPlaying, @@ -990,7 +991,7 @@ const Message: FC = ({ className="interactive" onClick={handleViaBotClick} > - {renderText(`@${botSender.username}`)} + {renderText(`@${botSender.usernames![0].username}`)} )} @@ -1012,6 +1013,7 @@ const Message: FC = ({ } const forwardAuthor = isGroup && asForwarded ? message.postAuthorTitle : undefined; + const chatUsername = useMemo(() => chatUsernames?.find((c) => c.isActive), [chatUsernames]); return (
= ({ anchor={contextMenuPosition} message={message} album={album} - chatUsername={chatUsername} + chatUsername={chatUsername?.username} messageListType={messageListType} onClose={handleContextMenuClose} onCloseAnimationEnd={handleContextMenuHide} @@ -1150,7 +1152,7 @@ export default memo(withGlobal( const isRepliesChat = isChatWithRepliesBot(chatId); const isChannel = chat && isChatChannel(chat); const isGroup = chat && isChatGroup(chat); - const chatUsername = chat?.username; + const chatUsernames = chat?.usernames; const isForwarding = forwardMessages.messageIds && forwardMessages.messageIds.includes(id); const forceSenderName = !isChatWithSelf && isAnonymousOwnMessage(message); @@ -1215,7 +1217,7 @@ export default memo(withGlobal( return { theme: selectTheme(global), - chatUsername, + chatUsernames, forceSenderName, sender, canShowSender, diff --git a/src/components/right/RightHeader.tsx b/src/components/right/RightHeader.tsx index 787f472d2..8814e4b77 100644 --- a/src/components/right/RightHeader.tsx +++ b/src/components/right/RightHeader.tsx @@ -391,7 +391,7 @@ const RightHeader: FC = ({ default: return ( <> -

Profile

+

{lang(isChannel ? 'Channel.TitleInfo' : (userId ? 'UserInfo.Title' : 'GroupInfo.Title'))}

{canAddContact && (