From 08a27a98b8ad1c06669e6b37ddc20c1dcefd2d48 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 16 Jul 2021 17:44:17 +0300 Subject: [PATCH] Management: Support adding and removing members, fix editing legacy groups (#1224) --- src/api/gramjs/apiBuilders/messages.ts | 103 +++++---- src/api/gramjs/apiBuilders/users.ts | 4 +- src/api/gramjs/methods/chats.ts | 53 ++++- src/api/gramjs/methods/index.ts | 2 +- src/api/gramjs/updater.ts | 8 + src/api/types/messages.ts | 3 +- src/api/types/users.ts | 1 + src/components/common/DeleteChatModal.tsx | 2 +- src/components/common/DeleteMessageModal.tsx | 2 +- src/components/common/Picker.tsx | 3 + src/components/common/PinMessageModal.tsx | 3 +- .../helpers/renderActionMessageText.tsx | 23 +- src/components/left/main/Chat.tsx | 22 +- src/components/left/newChat/NewChatStep1.tsx | 8 +- src/components/middle/ActionMessage.tsx | 22 +- .../middle/DeleteSelectedMessageModal.tsx | 2 +- src/components/right/AddChatMembers.scss | 10 + src/components/right/AddChatMembers.tsx | 203 ++++++++++++++++++ src/components/right/DeleteMemberModal.tsx | 77 +++++++ src/components/right/Profile.scss | 12 +- src/components/right/Profile.tsx | 66 +++++- src/components/right/RightColumn.scss | 2 +- src/components/right/RightColumn.tsx | 36 +++- src/components/right/RightHeader.tsx | 8 + .../right/management/ManageGroup.tsx | 7 +- src/components/ui/Modal.scss | 1 + src/global/initial.ts | 4 + src/global/types.ts | 16 +- src/hooks/useContextMenuPosition.ts | 16 +- src/lib/gramjs/tl/apiTl.js | 1 + src/lib/gramjs/tl/static/api.reduced.tl | 1 + src/modules/actions/api/chats.ts | 34 ++- src/modules/actions/api/users.ts | 45 +++- src/modules/actions/ui/chats.ts | 8 + src/modules/actions/ui/users.ts | 13 +- src/modules/reducers/users.ts | 21 ++ src/modules/selectors/ui.ts | 6 +- src/types/index.ts | 7 + src/util/langProvider.ts | 15 +- src/util/notifications.ts | 11 +- 40 files changed, 778 insertions(+), 103 deletions(-) create mode 100644 src/components/right/AddChatMembers.scss create mode 100644 src/components/right/AddChatMembers.tsx create mode 100644 src/components/right/DeleteMemberModal.tsx diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index eec4ee947..1fce42b16 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -573,74 +573,104 @@ function buildAction( } let text = ''; + const translationValues = []; let type: ApiAction['type'] = 'other'; let photo: ApiPhoto | undefined; - const targetUserId = 'users' in action - // Api returns array of userIds, but no action currently has multiple users in it - ? action.users && action.users[0] - : ('userId' in action && action.userId) || undefined; + const targetUserIds = 'users' in action + ? action.users && action.users + : ('userId' in action && [action.userId]) || []; let targetChatId: number | undefined; if (action instanceof GramJs.MessageActionChatCreate) { - text = `%action_origin% created the group «${action.title}»`; + text = 'Notification.CreatedChatWithTitle'; + translationValues.push('%action_origin%', action.title); } else if (action instanceof GramJs.MessageActionChatEditTitle) { - text = isChannelPost - ? `Channel renamed to «${action.title}»` - : `%action_origin% changed group name to «${action.title}»`; + if (isChannelPost) { + text = 'Channel.MessageTitleUpdated'; + translationValues.push(action.title); + } else { + text = 'Notification.ChangedGroupName'; + translationValues.push('%action_origin%', action.title); + } } else if (action instanceof GramJs.MessageActionChatEditPhoto) { - text = isChannelPost - ? 'Channel photo updated' - : '%action_origin% updated group photo'; + if (isChannelPost) { + text = 'Channel.MessagePhotoUpdated'; + } else { + text = 'Notification.ChangedGroupPhoto'; + translationValues.push('%action_origin%'); + } } else if (action instanceof GramJs.MessageActionChatDeletePhoto) { - text = isChannelPost - ? 'Channel photo was deleted' - : 'Chat photo was deleted'; + if (isChannelPost) { + text = 'Channel.MessagePhotoRemoved'; + } else { + text = 'Group.MessagePhotoRemoved'; + } } else if (action instanceof GramJs.MessageActionChatAddUser) { - text = !senderId || senderId === targetUserId - ? '%target_user% joined the group' - : '%action_origin% added %target_user% to the group'; + if (!senderId || targetUserIds.includes(senderId)) { + text = 'Notification.JoinedChat'; + translationValues.push('%target_user%'); + } else { + text = 'Notification.Invited'; + translationValues.push('%action_origin%', '%target_user%'); + } } else if (action instanceof GramJs.MessageActionChatDeleteUser) { - text = !senderId || senderId === targetUserId - ? '%target_user% left the group' - : '%action_origin% removed %target_user% from the group'; + if (!senderId || targetUserIds.includes(senderId)) { + text = 'Notification.LeftChat'; + translationValues.push('%target_user%'); + } else { + text = 'Notification.Kicked'; + translationValues.push('%action_origin%', '%target_user%'); + } } else if (action instanceof GramJs.MessageActionChatJoinedByLink) { - text = '%action_origin% joined the chat from invitation link'; + text = 'Notification.JoinedGroupByLink'; + translationValues.push('%action_origin%'); } else if (action instanceof GramJs.MessageActionChannelCreate) { - text = 'Channel created'; + text = 'Notification.CreatedChannel'; } else if (action instanceof GramJs.MessageActionChatMigrateTo) { + targetChatId = getApiChatIdFromMtpPeer(action); text = 'Migrated to %target_chat%'; - targetChatId = getApiChatIdFromMtpPeer(action); + translationValues.push('%target_chat%'); } else if (action instanceof GramJs.MessageActionChannelMigrateFrom) { - text = 'Migrated from %target_chat%'; targetChatId = getApiChatIdFromMtpPeer(action); + text = 'Migrated from %target_chat%'; + translationValues.push('%target_chat%'); } else if (action instanceof GramJs.MessageActionPinMessage) { - text = '%action_origin% pinned %message%'; + text = 'Notification.PinnedTextMessage'; + translationValues.push('%action_origin%', '%message%'); } else if (action instanceof GramJs.MessageActionHistoryClear) { - text = 'Chat history was cleared'; + text = 'HistoryCleared'; type = 'historyClear'; } else if (action instanceof GramJs.MessageActionPhoneCall) { - text = `${isOutgoing ? 'Outgoing' : 'Incoming'} ${action.video ? 'Video' : 'Phone'} Call`; + const withDuration = Boolean(action.duration); + text = [ + withDuration ? 'ChatList.Service' : 'Chat', + action.video ? 'VideoCall' : 'Call', + isOutgoing ? (withDuration ? 'outgoing' : 'Outgoing') : (withDuration ? 'incoming' : 'Incoming'), + ].join('.'); - if (action.duration) { - const mins = Math.max(Math.round(action.duration / 60), 1); - text += ` (${mins} min${mins > 1 ? 's' : ''})`; + if (withDuration) { + const mins = Math.max(Math.round(action.duration! / 60), 1); + translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`); } } else if (action instanceof GramJs.MessageActionContactSignUp) { - text = '%action_origin% joined Telegram'; + text = 'Notification.Joined'; + translationValues.push('%action_origin%'); } else if (action instanceof GramJs.MessageActionPaymentSent) { const currencySign = getCurrencySign(action.currency); const amount = (Number(action.totalAmount) / 100).toFixed(2); - text = `You successfully transferred ${currencySign}${amount} to shop for %product%`; + text = 'Notification.PaymentSent'; + translationValues.push(currencySign, amount, '%product%'); } else if (action instanceof GramJs.MessageActionGroupCall) { if (action.duration) { const mins = Math.max(Math.round(action.duration / 60), 1); - text = `Voice chat ended (${mins} min${mins > 1 ? 's' : ''})`; + text = 'Notification.VoiceChatEnded'; + translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`); } else { - text = 'Voice chat started'; + text = 'Notification.VoiceChatStartedChannel'; } } else { - text = '%ACTION_NOT_IMPLEMENTED%'; + text = 'ChatList.UnsupportedMessage'; } if ('photo' in action && action.photo instanceof GramJs.Photo) { @@ -651,9 +681,10 @@ function buildAction( return { text, type, - targetUserId, + targetUserIds, targetChatId, photo, // TODO Only used internally now, will be used for the UI in future + translationValues, }; } diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts index 623a08a6d..905091e70 100644 --- a/src/api/gramjs/apiBuilders/users.ts +++ b/src/api/gramjs/apiBuilders/users.ts @@ -26,6 +26,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { const avatarHash = mtpUser.photo instanceof GramJs.UserProfilePhoto ? String(mtpUser.photo.photoId) : undefined; + const userType = buildApiUserType(mtpUser); return { id, @@ -33,8 +34,9 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { ...(mtpUser.self && { isSelf: true }), ...(mtpUser.verified && { isVerified: true }), ...((mtpUser.contact || mtpUser.mutualContact) && { isContact: true }), - type: buildApiUserType(mtpUser), + type: userType, ...(firstName && { firstName }), + ...(userType === 'userTypeBot' && { canBeInvitedToGroup: !mtpUser.botNochats }), ...(lastName && { lastName }), username: mtpUser.username || '', phoneNumber: mtpUser.phone || '', diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index 9cee391e9..4f34ceddb 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -172,7 +172,7 @@ export async function searchChats({ query }: { query: string }) { updateLocalDb(result); const localPeerIds = result.myResults.map(getApiChatIdFromMtpPeer); - const allChats = [...result.chats, ...result.users] + const allChats = result.chats.concat(result.users) .map((user) => buildApiChatFromPreview(user)) .filter(Boolean as any); const allUsers = result.users.map(buildApiUser).filter((user) => !!user && !user.isSelf) as ApiUser[]; @@ -756,15 +756,15 @@ export function updateChatDefaultBannedRights({ } export function updateChatMemberBannedRights({ - chat, user, bannedRights, -}: { chat: ApiChat; user: ApiUser; bannedRights: ApiChatBannedRights }) { + chat, user, bannedRights, untilDate, +}: { chat: ApiChat; user: ApiUser; bannedRights: ApiChatBannedRights; untilDate?: number }) { const channel = buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel; const participant = buildInputPeer(user.id, user.accessHash) as GramJs.InputUser; return invokeRequest(new GramJs.channels.EditBanned({ channel, participant, - bannedRights: buildChatBannedRights(bannedRights), + bannedRights: buildChatBannedRights(bannedRights, untilDate), }), true); } @@ -957,6 +957,51 @@ export async function openChatByInvite(hash: string) { return { chatId: chat.id }; } +export function addChatMembers(chat: ApiChat, users: ApiUser[]) { + if (chat.type === 'chatTypeChannel' || chat.type === 'chatTypeSuperGroup') { + return invokeRequest(new GramJs.channels.InviteToChannel({ + channel: buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel, + users: users.map((user) => buildInputEntity(user.id, user.accessHash)) as GramJs.InputUser[], + }), true); + } + + return Promise.all(users.map((user) => { + return invokeRequest(new GramJs.messages.AddChatUser({ + chatId: buildInputEntity(chat.id) as number, + userId: buildInputEntity(user.id, user.accessHash) as GramJs.InputUser, + }), true); + })); +} + +export function deleteChatMember(chat: ApiChat, user: ApiUser) { + if (chat.type === 'chatTypeChannel' || chat.type === 'chatTypeSuperGroup') { + return updateChatMemberBannedRights({ + chat, + user, + bannedRights: { + viewMessages: true, + sendMessages: true, + sendMedia: true, + sendStickers: true, + sendGifs: true, + sendGames: true, + sendInline: true, + embedLinks: true, + sendPolls: true, + changeInfo: true, + inviteUsers: true, + pinMessages: true, + }, + untilDate: MAX_INT_32, + }); + } else { + return invokeRequest(new GramJs.messages.DeleteChatUser({ + chatId: buildInputEntity(chat.id) as number, + userId: buildInputEntity(user.id, user.accessHash) as GramJs.InputUser, + }), true); + } +} + function preparePeers( result: GramJs.messages.Dialogs | GramJs.messages.DialogsSlice | GramJs.messages.PeerDialogs, ) { diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 2cd0236cf..82b77a7b2 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -14,7 +14,7 @@ export { fetchChatFolders, editChatFolder, deleteChatFolder, fetchRecommendedChatFolders, getChatByUsername, togglePreHistoryHidden, updateChatDefaultBannedRights, updateChatMemberBannedRights, updateChatTitle, updateChatAbout, toggleSignatures, updateChatAdmin, fetchGroupsForDiscussion, setDiscussionGroup, - migrateChat, openChatByInvite, fetchMembers, importChatInvite, + migrateChat, openChatByInvite, fetchMembers, importChatInvite, addChatMembers, deleteChatMember, } from './chats'; export { diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index 34ab5f241..9bd983580 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -213,6 +213,14 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { if (update._entities && update._entities.some((e): e is GramJs.User => ( e instanceof GramJs.User && !!e.self && e.id === action.userId ))) { + onUpdate({ + '@type': 'updateChat', + id: message.chatId, + chat: { + isRestricted: true, + }, + }); + onUpdate({ '@type': 'updateChatLeave', id: message.chatId, diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index b13f9078e..28c5c36ca 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -145,10 +145,11 @@ export type ApiNewPoll = { export interface ApiAction { text: string; - targetUserId?: number; + targetUserIds?: number[]; targetChatId?: number; type: 'historyClear' | 'other'; photo?: ApiPhoto; + translationValues: string[]; } export interface ApiWebPage { diff --git a/src/api/types/users.ts b/src/api/types/users.ts index 223722715..5826930dc 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -15,6 +15,7 @@ export interface ApiUser { accessHash?: string; avatarHash?: string; photos?: ApiPhoto[]; + canBeInvitedToGroup?: boolean; // Obtained from GetFullUser / UserFullInfo fullInfo?: ApiUserFullInfo; diff --git a/src/components/common/DeleteChatModal.tsx b/src/components/common/DeleteChatModal.tsx index 2c6c66835..3540da9b3 100644 --- a/src/components/common/DeleteChatModal.tsx +++ b/src/components/common/DeleteChatModal.tsx @@ -165,7 +165,7 @@ const DeleteChatModal: FC = ({ {renderMessage()} {canDeleteForAll && ( )} )} diff --git a/src/components/common/Picker.tsx b/src/components/common/Picker.tsx index 70df477ec..d6af03edf 100644 --- a/src/components/common/Picker.tsx +++ b/src/components/common/Picker.tsx @@ -26,6 +26,7 @@ type OwnProps = { notFoundText?: string; searchInputId?: string; isLoading?: boolean; + noScrollRestore?: boolean; onSelectedIdsChange: (ids: number[]) => void; onFilterChange: (value: string) => void; onLoadMore?: () => void; @@ -45,6 +46,7 @@ const Picker: FC = ({ notFoundText, searchInputId, isLoading, + noScrollRestore, onSelectedIdsChange, onFilterChange, onLoadMore, @@ -107,6 +109,7 @@ const Picker: FC = ({ className="picker-list custom-scroll" items={viewportIds} onLoadMore={getMore} + noScrollRestore={noScrollRestore} > {viewportIds.map((id) => ( = ({ {canPinForAll && ( )} diff --git a/src/components/common/helpers/renderActionMessageText.tsx b/src/components/common/helpers/renderActionMessageText.tsx index e005cbf10..e725a9fc4 100644 --- a/src/components/common/helpers/renderActionMessageText.tsx +++ b/src/components/common/helpers/renderActionMessageText.tsx @@ -29,7 +29,7 @@ export function renderActionMessageText( lang: LangFn, message: ApiMessage, actionOrigin?: ApiUser | ApiChat, - targetUser?: ApiUser, + targetUsers?: ApiUser[], targetMessage?: ApiMessage, targetChatId?: number, options: ActionMessageTextOptions = {}, @@ -37,13 +37,13 @@ export function renderActionMessageText( if (!message.content.action) { return []; } - const { text } = message.content.action; + const { text, translationValues } = message.content.action; const content: TextPart[] = []; const textOptions: ActionMessageTextOptions = { ...options, maxTextLength: 16 }; let unprocessed: string; let processed = processPlaceholder( - text, + lang(text, translationValues && translationValues.length ? translationValues : undefined), '%action_origin%', actionOrigin ? (!options.isEmbedded && renderOriginContent(lang, actionOrigin, options.asPlain)) || NBSP @@ -56,8 +56,8 @@ export function renderActionMessageText( processed = processPlaceholder( unprocessed, '%target_user%', - targetUser - ? renderUserContent(targetUser, options.asPlain) + targetUsers + ? targetUsers.map((user) => renderUserContent(user, options.asPlain)).filter(Boolean as any) : 'User', ); @@ -180,7 +180,7 @@ function renderMigratedContent(chatId: number, asPlain?: boolean): string | Text return {text}; } -function processPlaceholder(text: string, placeholder: string, replaceValue?: TextPart): TextPart[] { +function processPlaceholder(text: string, placeholder: string, replaceValue?: TextPart | TextPart[]): TextPart[] { const placeholderPosition = text.indexOf(placeholder); if (placeholderPosition < 0 || !replaceValue) { return [text]; @@ -188,7 +188,16 @@ function processPlaceholder(text: string, placeholder: string, replaceValue?: Te const content: TextPart[] = []; content.push(text.substring(0, placeholderPosition)); - content.push(replaceValue); + if (Array.isArray(replaceValue)) { + replaceValue.forEach((value, index) => { + content.push(value); + if (index + 1 < replaceValue.length) { + content.push(', '); + } + }); + } else { + content.push(replaceValue); + } content.push(text.substring(placeholderPosition + placeholder.length)); return content; diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index af3776579..a1a8d0d5f 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -1,5 +1,5 @@ import React, { - FC, memo, useCallback, useLayoutEffect, useRef, + FC, memo, useCallback, useLayoutEffect, useMemo, useRef, } from '../../../lib/teact/teact'; import { withGlobal } from '../../../lib/teact/teactn'; @@ -66,7 +66,8 @@ type StateProps = { chat?: ApiChat; isMuted?: boolean; privateChatUser?: ApiUser; - actionTargetUser?: ApiUser; + usersById?: Record; + actionTargetUserIds?: number[]; actionTargetMessage?: ApiMessage; actionTargetChatId?: number; lastMessageSender?: ApiUser; @@ -91,8 +92,9 @@ const Chat: FC = ({ isPinned, chat, isMuted, + usersById, privateChatUser, - actionTargetUser, + actionTargetUserIds, lastMessageSender, lastMessageOutgoingStatus, actionTargetMessage, @@ -122,6 +124,12 @@ const Chat: FC = ({ const mediaBlobUrl = useMedia(lastMessage ? getMessageMediaHash(lastMessage, 'micro') : undefined); const isRoundVideo = Boolean(lastMessage && getMessageRoundVideo(lastMessage)); + const actionTargetUsers = useMemo(() => { + return actionTargetUserIds + ? actionTargetUserIds.map((userId) => usersById && usersById[userId]).filter(Boolean as any) + : undefined; + }, [actionTargetUserIds, usersById]); + // Sets animation excess values when `orderDiff` changes and then resets excess values to animate. useLayoutEffect(() => { const element = ref.current; @@ -221,7 +229,7 @@ const Chat: FC = ({ lang, lastMessage, actionOrigin, - actionTargetUser, + actionTargetUsers, actionTargetMessage, actionTargetChatId, { asPlain: true }, @@ -322,8 +330,9 @@ export default memo(withGlobal( const actionTargetMessage = lastMessageAction && replyToMessageId ? selectChatMessage(global, chat.id, replyToMessageId) : undefined; - const { targetUserId: actionTargetUserId, targetChatId: actionTargetChatId } = lastMessageAction || {}; + const { targetUserIds: actionTargetUserIds, targetChatId: actionTargetChatId } = lastMessageAction || {}; const privateChatUserId = getPrivateChatUserId(chat); + const { byId: usersById } = global.users; const { chatId: currentChatId, threadId: currentThreadId, @@ -336,7 +345,8 @@ export default memo(withGlobal( lastMessageSender, ...(isOutgoing && { lastMessageOutgoingStatus: selectOutgoingStatus(global, chat.lastMessage) }), ...(privateChatUserId && { privateChatUser: selectUser(global, privateChatUserId) }), - ...(actionTargetUserId && { actionTargetUser: selectUser(global, actionTargetUserId) }), + usersById, + actionTargetUserIds, actionTargetChatId, actionTargetMessage, draft: selectDraft(global, chatId, MAIN_THREAD_ID), diff --git a/src/components/left/newChat/NewChatStep1.tsx b/src/components/left/newChat/NewChatStep1.tsx index 50999dcc1..257fad107 100644 --- a/src/components/left/newChat/NewChatStep1.tsx +++ b/src/components/left/newChat/NewChatStep1.tsx @@ -9,7 +9,7 @@ import { ApiChat, ApiUser } from '../../../api/types'; import { pick, unique } from '../../../util/iteratees'; import { throttle } from '../../../util/schedulers'; import searchWords from '../../../util/searchWords'; -import { getUserFullName, sortChatIds } from '../../../modules/helpers'; +import { getUserFullName, isUserBot, sortChatIds } from '../../../modules/helpers'; import useLang from '../../../hooks/useLang'; import useHistoryBack from '../../../hooks/useHistoryBack'; @@ -98,7 +98,11 @@ const NewChatStep1: FC = ({ ...foundContactIds, ...(localUserIds || []), ...(globalUserIds || []), - ]), + ]).filter((contactId) => { + const user = usersById[contactId]; + + return !user || !isUserBot(user) || user.canBeInvitedToGroup; + }), chatsById, false, selectedMemberIds, diff --git a/src/components/middle/ActionMessage.tsx b/src/components/middle/ActionMessage.tsx index 481e40b83..9fef1aee1 100644 --- a/src/components/middle/ActionMessage.tsx +++ b/src/components/middle/ActionMessage.tsx @@ -1,5 +1,5 @@ import React, { - FC, memo, useEffect, useRef, + FC, memo, useEffect, useMemo, useRef, } from '../../lib/teact/teact'; import { withGlobal } from '../../lib/teact/teactn'; @@ -36,8 +36,9 @@ type OwnProps = { }; type StateProps = { + usersById: Record; sender?: ApiUser | ApiChat; - targetUser?: ApiUser; + targetUserIds?: number[]; targetMessage?: ApiMessage; targetChatId?: number; isFocused: boolean; @@ -53,8 +54,9 @@ const ActionMessage: FC = ({ isEmbedded, appearanceOrder = 0, isLastInList, + usersById, sender, - targetUser, + targetUserIds, targetMessage, targetChatId, isFocused, @@ -81,11 +83,17 @@ const ActionMessage: FC = ({ }, [appearanceOrder, markShown, noAppearanceAnimation]); const { transitionClassNames } = useShowTransition(isShown, undefined, noAppearanceAnimation, false); + const targetUsers = useMemo(() => { + return targetUserIds + ? targetUserIds.map((userId) => usersById && usersById[userId]).filter(Boolean as any) + : undefined; + }, [targetUserIds, usersById]); + const content = renderActionMessageText( lang, message, sender, - targetUser, + targetUsers, targetMessage, targetChatId, isEmbedded ? { isEmbedded: true, asPlain: true } : undefined, @@ -140,8 +148,9 @@ const ActionMessage: FC = ({ export default memo(withGlobal( (global, { message }): StateProps => { + const { byId: usersById } = global.users; const userId = message.senderId; - const { targetUserId, targetChatId } = message.content.action || {}; + const { targetUserIds, targetChatId } = message.content.action || {}; const targetMessageId = message.replyToMessageId; const targetMessage = targetMessageId ? selectChatMessage(global, message.chatId, targetMessageId) @@ -156,9 +165,10 @@ export default memo(withGlobal( : userId ? selectUser(global, userId) : undefined; return { + usersById, sender, - ...(targetUserId && { targetUser: selectUser(global, targetUserId) }), targetChatId, + targetUserIds, targetMessage, isFocused, ...(isFocused && { focusDirection, noFocusHighlight }), diff --git a/src/components/middle/DeleteSelectedMessageModal.tsx b/src/components/middle/DeleteSelectedMessageModal.tsx index 7ad3c8ed5..94e099b09 100644 --- a/src/components/middle/DeleteSelectedMessageModal.tsx +++ b/src/components/middle/DeleteSelectedMessageModal.tsx @@ -90,7 +90,7 @@ const DeleteSelectedMessageModal: FC = ({ {canDeleteForAll && ( )} diff --git a/src/components/right/AddChatMembers.scss b/src/components/right/AddChatMembers.scss new file mode 100644 index 000000000..b850573d1 --- /dev/null +++ b/src/components/right/AddChatMembers.scss @@ -0,0 +1,10 @@ +.AddChatMembers { + height: 100%; + overflow: hidden; + position: relative; + + &-inner { + height: 100%; + overflow: hidden; + } +} diff --git a/src/components/right/AddChatMembers.tsx b/src/components/right/AddChatMembers.tsx new file mode 100644 index 000000000..812b174e1 --- /dev/null +++ b/src/components/right/AddChatMembers.tsx @@ -0,0 +1,203 @@ +import React, { + FC, useCallback, useMemo, memo, useState, useEffect, +} from '../../lib/teact/teact'; +import { withGlobal } from '../../lib/teact/teactn'; + +import { GlobalActions } from '../../global/types'; +import { + ApiChat, ApiChatMember, ApiUpdateConnectionStateType, ApiUser, +} from '../../api/types'; +import { NewChatMembersProgress } from '../../types'; + +import { pick, unique } from '../../util/iteratees'; +import { selectChat } from '../../modules/selectors'; +import searchWords from '../../util/searchWords'; +import { + getUserFullName, isChatChannel, isUserBot, sortChatIds, +} from '../../modules/helpers'; +import useLang from '../../hooks/useLang'; +import usePrevious from '../../hooks/usePrevious'; +import useHistoryBack from '../../hooks/useHistoryBack'; + +import Picker from '../common/Picker'; +import FloatingActionButton from '../ui/FloatingActionButton'; +import Spinner from '../ui/Spinner'; + +import './AddChatMembers.scss'; + +export type OwnProps = { + chatId: number; + isActive: boolean; + onNextStep: (memberIds: number[]) => void; + onClose: NoneToVoidFunction; +}; + +type StateProps = { + connectionState?: ApiUpdateConnectionStateType; + isChannel?: boolean; + members?: ApiChatMember[]; + currentUserId?: number; + usersById: Record; + chatsById: Record; + localContactIds?: number[]; + searchQuery?: string; + isLoading: boolean; + isSearching?: boolean; + localUserIds?: number[]; + globalUserIds?: number[]; +}; + +type DispatchProps = Pick; + +const AddChatMembers: FC = ({ + isChannel, + connectionState, + members, + onNextStep, + currentUserId, + usersById, + chatsById, + localContactIds, + isLoading, + searchQuery, + isSearching, + localUserIds, + globalUserIds, + setUserSearchQuery, + onClose, + isActive, + loadContactList, +}) => { + const lang = useLang(); + const [selectedMemberIds, setSelectedMemberIds] = useState([]); + const prevSelectedMemberIds = usePrevious(selectedMemberIds); + const noPickerScrollRestore = prevSelectedMemberIds === selectedMemberIds; + + useEffect(() => { + if (isActive && connectionState === 'connectionStateReady') { + loadContactList(); + } + }, [connectionState, isActive, loadContactList]); + + useHistoryBack(isActive, onClose); + + const memberIds = useMemo(() => { + return members ? members.map((member) => member.userId) : []; + }, [members]); + + const handleFilterChange = useCallback((query: string) => { + setUserSearchQuery({ query }); + }, [setUserSearchQuery]); + + const displayedIds = useMemo(() => { + const contactIds = localContactIds + ? sortChatIds(localContactIds.filter((id) => id !== currentUserId), chatsById) + : []; + + if (!searchQuery) { + return contactIds.filter((id) => !memberIds.includes(id)); + } + + const foundContactIds = contactIds.filter((id) => { + const user = usersById[id]; + if (!user) { + return false; + } + const fullName = getUserFullName(user); + return fullName && searchWords(fullName, searchQuery); + }); + + return sortChatIds( + unique([ + ...foundContactIds, + ...(localUserIds || []), + ...(globalUserIds || []), + ]).filter((contactId) => { + const user = usersById[contactId]; + + // The user can be added to the chat if the following conditions are met: + // the user has not yet been added to the current chat + // AND (it is not found (user from global search) OR it is not a bot OR it is a bot, + // but the current chat is not a channel AND the appropriate permission is set). + return !memberIds.includes(contactId) + && (!user || !isUserBot(user) || (!isChannel && user.canBeInvitedToGroup)); + }), + chatsById, + ); + }, [ + localContactIds, chatsById, searchQuery, localUserIds, globalUserIds, + currentUserId, usersById, memberIds, isChannel, + ]); + + const handleNextStep = useCallback(() => { + if (selectedMemberIds.length) { + setUserSearchQuery({ query: '' }); + onNextStep(selectedMemberIds); + } + }, [selectedMemberIds, setUserSearchQuery, onNextStep]); + + return ( +
+
+ + + + {isLoading ? ( + + ) : ( + + )} + +
+
+ ); +}; + +export default memo(withGlobal( + (global, { chatId }): StateProps => { + const chat = selectChat(global, chatId); + const { userIds: localContactIds } = global.contactList || {}; + const { byId: usersById } = global.users; + const { byId: chatsById } = global.chats; + const { currentUserId, newChatMembersProgress, connectionState } = global; + const isChannel = chat && isChatChannel(chat); + + const { + query: searchQuery, + fetchingStatus, + globalUserIds, + localUserIds, + } = global.userSearch; + + return { + isChannel, + members: chat && chat.fullInfo ? chat.fullInfo.members : undefined, + currentUserId, + usersById, + chatsById, + localContactIds, + searchQuery, + isSearching: fetchingStatus, + isLoading: newChatMembersProgress === NewChatMembersProgress.Loading, + globalUserIds, + localUserIds, + connectionState, + }; + }, + (setGlobal, actions): DispatchProps => pick(actions, ['loadContactList', 'setUserSearchQuery']), +)(AddChatMembers)); diff --git a/src/components/right/DeleteMemberModal.tsx b/src/components/right/DeleteMemberModal.tsx new file mode 100644 index 000000000..84da59dc5 --- /dev/null +++ b/src/components/right/DeleteMemberModal.tsx @@ -0,0 +1,77 @@ +import React, { FC, useCallback, memo } from '../../lib/teact/teact'; +import { withGlobal } from '../../lib/teact/teactn'; + +import { GlobalActions } from '../../global/types'; +import { ApiChat } from '../../api/types'; + +import { pick } from '../../util/iteratees'; +import { selectCurrentChat, selectUser } from '../../modules/selectors'; +import { getUserFirstOrLastName } from '../../modules/helpers'; +import renderText from '../common/helpers/renderText'; +import useLang from '../../hooks/useLang'; + +import Modal from '../ui/Modal'; +import Button from '../ui/Button'; + +export type OwnProps = { + isOpen: boolean; + userId?: number; + onClose: () => void; +}; + +type StateProps = { + chat?: ApiChat; + contactName?: string; +}; + +type DispatchProps = Pick; + +const DeleteMemberModal: FC = ({ + isOpen, + chat, + userId, + contactName, + onClose, + deleteChatMember, +}) => { + const lang = useLang(); + + const handleDeleteChatMember = useCallback(() => { + deleteChatMember({ chatId: chat!.id, userId }); + onClose(); + }, [chat, deleteChatMember, onClose, userId]); + + if (!chat || !userId) { + return undefined; + } + + return ( + +

{renderText(lang('PeerInfo.Confirm.RemovePeer', contactName))}

+ + +
+ ); +}; + +export default memo(withGlobal( + (global, { userId }): StateProps => { + const chat = selectCurrentChat(global); + const user = userId && selectUser(global, userId); + const contactName = user ? getUserFirstOrLastName(user) : undefined; + + return { + chat, + contactName, + }; + }, + (setGlobal, actions): DispatchProps => pick(actions, ['deleteChatMember']), +)(DeleteMemberModal)); diff --git a/src/components/right/Profile.scss b/src/components/right/Profile.scss index d1d9a4d6e..2406a7727 100644 --- a/src/components/right/Profile.scss +++ b/src/components/right/Profile.scss @@ -1,14 +1,15 @@ .Profile { height: 100%; - overflow-y: scroll; - overflow-x: hidden; display: flex; flex-direction: column; + overflow-y: scroll; + overflow-x: hidden; @supports (overflow-y: overlay) { overflow-y: overlay !important; } + > .profile-info > .ChatInfo { grid-area: chat_info; @@ -124,7 +125,14 @@ @media (max-width: 600px) { padding: .5rem 0; + .ListItem.chat-item-clickable { + margin: 0; + } } } } + + .FloatingActionButton { + z-index: 1; + } } diff --git a/src/components/right/Profile.tsx b/src/components/right/Profile.tsx index e2a136d61..52bf3180e 100644 --- a/src/components/right/Profile.tsx +++ b/src/components/right/Profile.tsx @@ -11,8 +11,7 @@ import { } from '../../api/types'; import { GlobalActions } from '../../global/types'; import { - ISettings, - MediaViewerOrigin, ProfileState, ProfileTabType, SharedMediaType, + NewChatMembersProgress, ISettings, MediaViewerOrigin, ProfileState, ProfileTabType, SharedMediaType, } from '../../types'; import { @@ -23,7 +22,7 @@ import { } from '../../config'; import { IS_TOUCH_ENV } from '../../util/environment'; import { - isChatAdmin, isChatChannel, isChatGroup, isChatPrivate, + getHasAdminRight, isChatAdmin, isChatChannel, isChatGroup, isChatPrivate, } from '../../modules/helpers'; import { selectChatMessages, @@ -54,6 +53,8 @@ import ChatExtra from './ChatExtra'; import Media from '../common/Media'; import WebLink from '../common/WebLink'; import NothingFound from '../common/NothingFound'; +import FloatingActionButton from '../ui/FloatingActionButton'; +import DeleteMemberModal from './DeleteMemberModal'; import './Profile.scss'; @@ -67,12 +68,15 @@ type OwnProps = { type StateProps = { theme: ISettings['theme']; isChannel?: boolean; + currentUserId?: number; resolvedUserId?: number; chatMessages?: Record; foundIds?: number[]; mediaSearchType?: SharedMediaType; hasMembersTab?: boolean; areMembersHidden?: boolean; + canAddMembers?: boolean; + canDeleteMembers?: boolean; members?: ApiChatMember[]; usersById?: Record; isRightColumnShown: boolean; @@ -83,7 +87,7 @@ type StateProps = { type DispatchProps = Pick; const TABS = [ @@ -102,11 +106,14 @@ const Profile: FC = ({ theme, isChannel, resolvedUserId, + currentUserId, chatMessages, foundIds, mediaSearchType, hasMembersTab, areMembersHidden, + canAddMembers, + canDeleteMembers, members, usersById, isRightColumnShown, @@ -120,6 +127,7 @@ const Profile: FC = ({ openUserInfo, focusMessage, loadProfilePhotos, + setNewChatMembersDialogState, serverTimeOffset, }) => { // eslint-disable-next-line no-null/no-null @@ -128,6 +136,7 @@ const Profile: FC = ({ const transitionRef = useRef(null); const lang = useLang(); const [activeTab, setActiveTab] = useState(0); + const [deletingUserId, setDeletingUserId] = useState(); const tabs = useMemo(() => ([ ...(hasMembersTab ? [{ @@ -154,6 +163,10 @@ const Profile: FC = ({ resetCacheBuster(); }, [releaseTransitionFix, resetCacheBuster]); + const handleNewMemberDialogOpen = useCallback(() => { + setNewChatMembersDialogState(NewChatMembersProgress.InProgress); + }, [setNewChatMembersDialogState]); + // Update search type when switching tabs useEffect(() => { setLocalMediaSearchType({ mediaType: tabType }); @@ -188,6 +201,10 @@ const Profile: FC = ({ focusMessage({ chatId: profileId, messageId }); }, [profileId, focusMessage]); + const handleDeleteMembersModalClose = useCallback(() => { + setDeletingUserId(undefined); + }, []); + useEffect(() => { if (!transitionRef.current || !IS_TOUCH_ENV) { return undefined; @@ -215,9 +232,19 @@ const Profile: FC = ({ } const canRenderContents = useAsyncRendering([chatId, resultType], renderingDelay); + function getMemberContextAction(id: number) { + return id === currentUserId || !canDeleteMembers ? undefined : [{ + title: lang('lng_context_remove_from_group'), + icon: 'stop', + handler: () => { + setDeletingUserId(id); + }, + }]; + } + function renderSharedMedia() { if (!viewportIds || !canRenderContents || !chatMessages) { - // This is just a single-frame delay so we do not show spinner + // This is just a single-frame delay, so we do not show spinner const noSpinner = isFirstTab && viewportIds && !canRenderContents; return ( @@ -308,6 +335,7 @@ const Profile: FC = ({ teactOrderKey={i} className="chat-item-clickable scroll-item" onClick={() => handleMemberClick(id)} + contextActions={getMemberContextAction(id)} >
@@ -334,7 +362,9 @@ const Profile: FC = ({ > {!noProfileInfo && renderProfileInfo(chatId, resolvedUserId)} {!isRestricted && ( -
+
= ({ {renderSharedMedia} +
)} + + {canAddMembers && ( + + + + )} + {canDeleteMembers && ( + + )} ); }; @@ -390,6 +438,8 @@ export default memo(withGlobal( const hasMembersTab = isGroup || (isChannel && isChatAdmin(chat!)); const members = chat && chat.fullInfo && chat.fullInfo.members; const areMembersHidden = hasMembersTab && chat && chat.fullInfo && !chat.fullInfo.canViewMembers; + const canAddMembers = hasMembersTab && chat && (getHasAdminRight(chat, 'inviteUsers') || chat.isCreator); + const canDeleteMembers = hasMembersTab && chat && (getHasAdminRight(chat, 'banUsers') || chat.isCreator); let resolvedUserId; if (userId) { @@ -407,10 +457,13 @@ export default memo(withGlobal( mediaSearchType, hasMembersTab, areMembersHidden, + canAddMembers, + canDeleteMembers, ...(hasMembersTab && members && { members, usersById, }), + currentUserId: global.currentUserId, isRightColumnShown: selectIsRightColumnShown(global), isRestricted: chat && chat.isRestricted, lastSyncTime: global.lastSyncTime, @@ -426,5 +479,6 @@ export default memo(withGlobal( 'openUserInfo', 'focusMessage', 'loadProfilePhotos', + 'setNewChatMembersDialogState', ]), )(Profile)); diff --git a/src/components/right/RightColumn.scss b/src/components/right/RightColumn.scss index 60a4271be..359756df5 100644 --- a/src/components/right/RightColumn.scss +++ b/src/components/right/RightColumn.scss @@ -17,7 +17,7 @@ // @optimization &:not(:hover) { - .chat-item-clickable:nth-child(n + 18) { + .chat-item-clickable:not(.picker-list-item):nth-child(n + 18) { display: none !important; } } diff --git a/src/components/right/RightColumn.tsx b/src/components/right/RightColumn.tsx index daceb47db..cc89fbf4d 100644 --- a/src/components/right/RightColumn.tsx +++ b/src/components/right/RightColumn.tsx @@ -4,7 +4,9 @@ import React, { import { withGlobal } from '../../lib/teact/teactn'; import { GlobalActions } from '../../global/types'; -import { ManagementScreens, ProfileState, RightColumnContent } from '../../types'; +import { + ManagementScreens, NewChatMembersProgress, ProfileState, RightColumnContent, +} from '../../types'; import { MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN } from '../../config'; import captureEscKeyListener from '../../util/captureEscKeyListener'; @@ -27,6 +29,7 @@ import Management from './management/Management.async'; import StickerSearch from './StickerSearch.async'; import GifSearch from './GifSearch.async'; import PollResults from './PollResults.async'; +import AddChatMembers from './AddChatMembers'; import './RightColumn.scss'; @@ -40,8 +43,8 @@ type StateProps = { }; type DispatchProps = Pick; @@ -69,6 +72,8 @@ const RightColumn: FC = ({ setStickerSearchQuery, setGifSearchQuery, closePollResults, + addChatMembers, + setNewChatMembersDialogState, shouldSkipHistoryAnimations, }) => { const { width: windowWidth } = useWindowSize(); @@ -85,6 +90,7 @@ const RightColumn: FC = ({ const isStickerSearch = contentKey === RightColumnContent.StickerSearch; const isGifSearch = contentKey === RightColumnContent.GifSearch; const isPollResults = contentKey === RightColumnContent.PollResults; + const isAddingChatMembers = contentKey === RightColumnContent.AddingMembers; const isOverlaying = windowWidth <= MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN; const [shouldSkipTransition, setShouldSkipTransition] = useState(!isOpen); @@ -93,6 +99,9 @@ const RightColumn: FC = ({ const close = useCallback((shouldScrollUp = true) => { switch (contentKey) { + case RightColumnContent.AddingMembers: + setNewChatMembersDialogState(NewChatMembersProgress.Closed); + break; case RightColumnContent.ChatInfo: if (isScrolledDown && shouldScrollUp) { setProfileState(ProfileState.Profile); @@ -155,7 +164,7 @@ const RightColumn: FC = ({ break; } }, [ - contentKey, isScrolledDown, toggleChatInfo, openUserInfo, closePollResults, + contentKey, isScrolledDown, toggleChatInfo, openUserInfo, closePollResults, setNewChatMembersDialogState, managementScreen, toggleManagement, closeLocalTextSearch, setStickerSearchQuery, setGifSearchQuery, ]); @@ -164,6 +173,10 @@ const RightColumn: FC = ({ setIsPromotedByCurrentUser(isPromoted); }, []); + const handleAppendingChatMembers = useCallback((memberIds: number[]) => { + addChatMembers({ chatId, memberIds }); + }, [addChatMembers, chatId]); + useEffect(() => (isOpen ? captureEscKeyListener(close) : undefined), [isOpen, close]); useEffect(() => { @@ -194,7 +207,8 @@ const RightColumn: FC = ({ useHistoryBack(isChatSelected && (contentKey === RightColumnContent.ChatInfo - || contentKey === RightColumnContent.UserInfo || contentKey === RightColumnContent.Management), + || contentKey === RightColumnContent.UserInfo || contentKey === RightColumnContent.Management + || contentKey === RightColumnContent.AddingMembers), () => close(false), toggleChatInfo); // eslint-disable-next-line consistent-return @@ -204,6 +218,15 @@ const RightColumn: FC = ({ } switch (renderingContentKey) { + case RightColumnContent.AddingMembers: + return ( + + ); case RightColumnContent.ChatInfo: case RightColumnContent.UserInfo: return ( @@ -258,6 +281,7 @@ const RightColumn: FC = ({ isStickerSearch={isStickerSearch} isGifSearch={isGifSearch} isPollResults={isPollResults} + isAddingChatMembers={isAddingChatMembers} profileState={profileState} managementScreen={managementScreen} onClose={close} @@ -299,5 +323,7 @@ export default memo(withGlobal( 'setStickerSearchQuery', 'setGifSearchQuery', 'closePollResults', + 'addChatMembers', + 'setNewChatMembersDialogState', ]), )(RightColumn)); diff --git a/src/components/right/RightHeader.tsx b/src/components/right/RightHeader.tsx index 4cfa3b1d6..7cb1e12a0 100644 --- a/src/components/right/RightHeader.tsx +++ b/src/components/right/RightHeader.tsx @@ -36,6 +36,7 @@ type OwnProps = { isStickerSearch?: boolean; isGifSearch?: boolean; isPollResults?: boolean; + isAddingChatMembers?: boolean; shouldSkipAnimation?: boolean; profileState?: ProfileState; managementScreen?: ManagementScreens; @@ -79,6 +80,7 @@ enum HeaderContent { StickerSearch, GifSearch, PollResults, + AddingMembers, } const RightHeader: FC = ({ @@ -89,6 +91,7 @@ const RightHeader: FC = ({ isStickerSearch, isGifSearch, isPollResults, + isAddingChatMembers, profileState, managementScreen, canManage, @@ -149,6 +152,8 @@ const RightHeader: FC = ({ HeaderContent.StickerSearch ) : isGifSearch ? ( HeaderContent.GifSearch + ) : isAddingChatMembers ? ( + HeaderContent.AddingMembers ) : isManagement ? ( managementScreen === ManagementScreens.Initial ? ( HeaderContent.ManageInitial @@ -206,6 +211,8 @@ const RightHeader: FC = ({ ); + case HeaderContent.AddingMembers: + return

{lang('GroupAddMembers')}

; case HeaderContent.ManageInitial: return

{lang('Edit')}

; case HeaderContent.ManageChatPrivacyType: @@ -275,6 +282,7 @@ const RightHeader: FC = ({ IS_SINGLE_COLUMN_LAYOUT || contentKey === HeaderContent.SharedMedia || contentKey === HeaderContent.MemberList + || contentKey === HeaderContent.AddingMembers || isManagement ); diff --git a/src/components/right/management/ManageGroup.tsx b/src/components/right/management/ManageGroup.tsx index 049f3b63c..e89f0db13 100644 --- a/src/components/right/management/ManageGroup.tsx +++ b/src/components/right/management/ManageGroup.tsx @@ -320,14 +320,15 @@ export default memo(withGlobal( const chat = selectChat(global, chatId)!; const { progress } = global.management; const hasLinkedChannel = Boolean(chat.fullInfo && chat.fullInfo.linkedChatId); + const isBasicGroup = isChatBasicGroup(chat); return { chat, progress, - isBasicGroup: isChatBasicGroup(chat), + isBasicGroup, hasLinkedChannel, - canChangeInfo: getHasAdminRight(chat, 'changeInfo'), - canBanUsers: getHasAdminRight(chat, 'banUsers'), + canChangeInfo: isBasicGroup ? chat.isCreator : getHasAdminRight(chat, 'changeInfo'), + canBanUsers: isBasicGroup ? chat.isCreator : getHasAdminRight(chat, 'banUsers'), }; }, (setGlobal, actions): DispatchProps => pick(actions, [ diff --git a/src/components/ui/Modal.scss b/src/components/ui/Modal.scss index 66f5c4b32..7179cf492 100644 --- a/src/components/ui/Modal.scss +++ b/src/components/ui/Modal.scss @@ -147,5 +147,6 @@ margin-left: auto; text-align: right; font-weight: 500; + white-space: pre-wrap; } } diff --git a/src/global/initial.ts b/src/global/initial.ts index c570c338c..982769c78 100644 --- a/src/global/initial.ts +++ b/src/global/initial.ts @@ -1,4 +1,5 @@ import { GlobalState } from './types'; +import { NewChatMembersProgress } from '../types'; import { ANIMATION_LEVEL_DEFAULT, DARK_THEME_PATTERN_COLOR, DEFAULT_MESSAGE_TEXT_SIZE_PX, DEFAULT_PATTERN_COLOR, @@ -7,6 +8,7 @@ import { export const INITIAL_STATE: GlobalState = { isLeftColumnShown: true, isChatInfoShown: false, + newChatMembersProgress: NewChatMembersProgress.Closed, uiReadyState: 0, serverTimeOffset: 0, @@ -73,6 +75,8 @@ export const INITIAL_STATE: GlobalState = { globalSearch: {}, + userSearch: {}, + localTextSearch: { byChatThreadKey: {}, }, diff --git a/src/global/types.ts b/src/global/types.ts index 19e8260df..422dabb19 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -40,6 +40,7 @@ import { NotifyException, LangCode, EmojiKeywords, + NewChatMembersProgress, } from '../types'; export type MessageListType = 'thread' | 'pinned' | 'scheduled'; @@ -65,6 +66,7 @@ export type GlobalState = { isChatInfoShown: boolean; isLeftColumnShown: boolean; isPollModalOpen?: boolean; + newChatMembersProgress?: NewChatMembersProgress; uiReadyState: 0 | 1 | 2; shouldSkipHistoryAnimations?: boolean; connectionState?: ApiUpdateConnectionStateType; @@ -248,6 +250,13 @@ export type GlobalState = { }>>; }; + userSearch: { + query?: string; + fetchingStatus?: boolean; + localUserIds?: number[]; + globalUserIds?: number[]; + }; + localTextSearch: { byChatThreadKey: Record HTMLElement | null, @@ -31,16 +33,18 @@ export default ( const menuRect = menuEl ? { width: menuEl.offsetWidth, height: menuEl.offsetHeight } : emptyRect; const rootRect = rootEl ? rootEl.getBoundingClientRect() : emptyRect; + let horizontalPostition: 'left' | 'right'; if (x + menuRect.width + extraPaddingX < rootRect.width + rootRect.left) { - setPositionX('left'); x += 3; + horizontalPostition = 'left'; } else if (x - menuRect.width > 0) { - setPositionX('right'); + horizontalPostition = 'right'; x -= 3; } else { - setPositionX('left'); + horizontalPostition = 'left'; x = 16; } + setPositionX(horizontalPostition); if (y + menuRect.height < rootRect.height + rootRect.top) { setPositionY('top'); @@ -52,7 +56,11 @@ export default ( } } - setStyle(`left: ${x - triggerRect.left}px; top: ${y - triggerRect.top}px;`); + const left = horizontalPostition === 'left' + ? Math.min(x - triggerRect.left, rootRect.width - menuRect.width - MENU_POSITION_VISUAL_COMFORT_SPACE_PX) + : Math.max((x - triggerRect.left), menuRect.width + MENU_POSITION_VISUAL_COMFORT_SPACE_PX); + + setStyle(`left: ${left}px; top: ${y - triggerRect.top}px;`); }, [ anchor, extraPaddingX, extraTopPadding, getMenuElement, getRootElement, getTriggerElement, diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index defaada1b..d58d3d091 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -987,6 +987,7 @@ messages.getChats#3c6aa187 id:Vector = messages.Chats; messages.getFullChat#3b831c66 chat_id:int = messages.ChatFull; messages.editChatTitle#dc452855 chat_id:int title:string = Updates; messages.editChatPhoto#ca4c79d8 chat_id:int photo:InputChatPhoto = Updates; +messages.addChatUser#f9a0aa09 chat_id:int user_id:InputUser fwd_limit:int = Updates; messages.deleteChatUser#c534459a flags:# revoke_history:flags.0?true chat_id:int user_id:InputUser = Updates; messages.createChat#9cb126e users:Vector title:string = Updates; messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; diff --git a/src/lib/gramjs/tl/static/api.reduced.tl b/src/lib/gramjs/tl/static/api.reduced.tl index d1b999e1c..ae7e485e2 100644 --- a/src/lib/gramjs/tl/static/api.reduced.tl +++ b/src/lib/gramjs/tl/static/api.reduced.tl @@ -987,6 +987,7 @@ messages.getChats#3c6aa187 id:Vector = messages.Chats; messages.getFullChat#3b831c66 chat_id:int = messages.ChatFull; messages.editChatTitle#dc452855 chat_id:int title:string = Updates; messages.editChatPhoto#ca4c79d8 chat_id:int photo:InputChatPhoto = Updates; +messages.addChatUser#f9a0aa09 chat_id:int user_id:InputUser fwd_limit:int = Updates; messages.deleteChatUser#c534459a flags:# revoke_history:flags.0?true chat_id:int user_id:InputUser = Updates; messages.createChat#9cb126e users:Vector title:string = Updates; messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; diff --git a/src/modules/actions/api/chats.ts b/src/modules/actions/api/chats.ts index 213cdb5f9..4094c53d8 100644 --- a/src/modules/actions/api/chats.ts +++ b/src/modules/actions/api/chats.ts @@ -5,7 +5,7 @@ import { import { ApiChat, ApiUser, ApiChatFolder, MAIN_THREAD_ID, } from '../../../api/types'; -import { ChatCreationProgress, ManagementProgress } from '../../../types'; +import { NewChatMembersProgress, ChatCreationProgress, ManagementProgress } from '../../../types'; import { GlobalActions } from '../../../global/types'; import { @@ -774,6 +774,38 @@ addReducer('loadMoreMembers', (global) => { })(); }); +addReducer('addChatMembers', (global, actions, payload) => { + const { chatId, memberIds } = payload; + const chat = selectChat(global, chatId); + const users = (memberIds as number[]).map((userId) => selectUser(global, userId)).filter(Boolean as any); + + if (!chat || !users.length) { + return; + } + + actions.setNewChatMembersDialogState(NewChatMembersProgress.Loading); + (async () => { + await callApi('addChatMembers', chat, users); + actions.setNewChatMembersDialogState(NewChatMembersProgress.Closed); + loadFullChat(chat); + })(); +}); + +addReducer('deleteChatMember', (global, actions, payload) => { + const { chatId, userId } = payload; + const chat = selectChat(global, chatId); + const user = selectUser(global, userId); + + if (!chat || !user) { + return; + } + + (async () => { + await callApi('deleteChatMember', chat, user); + loadFullChat(chat); + })(); +}); + async function loadChats(listType: 'active' | 'archived', offsetId?: number, offsetDate?: number) { const result = await callApi('fetchChats', { limit: CHAT_LIST_LOAD_SLICE, diff --git a/src/modules/actions/api/users.ts b/src/modules/actions/api/users.ts index 624eb7fe3..4811b234b 100644 --- a/src/modules/actions/api/users.ts +++ b/src/modules/actions/api/users.ts @@ -5,17 +5,19 @@ import { import { ApiUser } from '../../../api/types'; import { ManagementProgress } from '../../../types'; -import { debounce } from '../../../util/schedulers'; +import { debounce, throttle } from '../../../util/schedulers'; import { buildCollectionByKey } from '../../../util/iteratees'; import { isChatPrivate } from '../../helpers'; import { callApi } from '../../../api/gramjs'; import { selectChat, selectUser } from '../../selectors'; import { addChats, addUsers, updateChat, updateManagementProgress, updateUser, updateUsers, + updateUserSearch, updateUserSearchFetchingStatus, } from '../../reducers'; const runDebouncedForFetchFullUser = debounce((cb) => cb(), 500, false, true); const TOP_PEERS_REQUEST_COOLDOWN = 60; // 1 min +const runThrottledForSearch = throttle((cb) => cb(), 500, false); addReducer('loadFullUser', (global, actions, payload) => { const { userId } = payload!; @@ -200,3 +202,44 @@ addReducer('loadProfilePhotos', (global, actions, payload) => { setGlobal(newGlobal); })(); }); + + +addReducer('setUserSearchQuery', (global, actions, payload) => { + const { query } = payload!; + + if (!query) return; + + void runThrottledForSearch(() => { + searchUsers(query); + }); +}); + +async function searchUsers(query: string) { + const result = await callApi('searchChats', { query }); + + let global = getGlobal(); + const currentSearchQuery = global.userSearch.query; + + if (!result || !currentSearchQuery || (query !== currentSearchQuery)) { + setGlobal(updateUserSearchFetchingStatus(global, false)); + return; + } + + const { localUsers, globalUsers } = result; + + let localUserIds; + let globalUserIds; + if (localUsers.length) { + global = addUsers(global, buildCollectionByKey(localUsers, 'id')); + localUserIds = localUsers.map(({ id }) => id); + } + if (globalUsers.length) { + global = addUsers(global, buildCollectionByKey(globalUsers, 'id')); + globalUserIds = globalUsers.map(({ id }) => id); + } + + global = updateUserSearchFetchingStatus(global, false); + global = updateUserSearch(global, { localUserIds, globalUserIds }); + + setGlobal(global); +} diff --git a/src/modules/actions/ui/chats.ts b/src/modules/actions/ui/chats.ts index 004a4f797..c7269728a 100644 --- a/src/modules/actions/ui/chats.ts +++ b/src/modules/actions/ui/chats.ts @@ -1,4 +1,5 @@ import { addReducer, setGlobal } from '../../../lib/teact/teactn'; + import { exitMessageSelectMode, replaceThreadParam, updateCurrentMessageList, } from '../../reducers'; @@ -55,6 +56,13 @@ addReducer('resetChatCreation', (global) => { }; }); +addReducer('setNewChatMembersDialogState', (global, actions, payload) => { + return { + ...global, + newChatMembersProgress: payload, + }; +}); + addReducer('openNextChat', (global, actions, payload) => { const { targetIndexDelta, orderedIds } = payload; diff --git a/src/modules/actions/ui/users.ts b/src/modules/actions/ui/users.ts index 05cb02edf..0ff923b83 100644 --- a/src/modules/actions/ui/users.ts +++ b/src/modules/actions/ui/users.ts @@ -2,7 +2,7 @@ import { addReducer } from '../../../lib/teact/teactn'; import { GlobalState } from '../../../global/types'; -import { updateSelectedUserId } from '../../reducers'; +import { updateSelectedUserId, updateUserSearch } from '../../reducers'; addReducer('openUserInfo', (global, actions, payload) => { const { id } = payload!; @@ -13,3 +13,14 @@ addReducer('openUserInfo', (global, actions, payload) => { const clearSelectedUserId = (global: GlobalState) => updateSelectedUserId(global, undefined); addReducer('openChat', clearSelectedUserId); + +addReducer('setUserSearchQuery', (global, actions, payload) => { + const { query } = payload!; + + return updateUserSearch(global, { + globalUserIds: undefined, + localUserIds: undefined, + fetchingStatus: Boolean(query), + query, + }); +}); diff --git a/src/modules/reducers/users.ts b/src/modules/reducers/users.ts index c4962d42a..08d34d4c6 100644 --- a/src/modules/reducers/users.ts +++ b/src/modules/reducers/users.ts @@ -148,3 +148,24 @@ export function deleteUser(global: GlobalState, userId: number): GlobalState { return replaceUsers(global, byId); } + +export function updateUserSearch( + global: GlobalState, + searchStatePartial: Partial, +) { + return { + ...global, + userSearch: { + ...global.userSearch, + ...searchStatePartial, + }, + }; +} + +export function updateUserSearchFetchingStatus( + global: GlobalState, newState: boolean, +) { + return updateUserSearch(global, { + fetchingStatus: newState, + }); +} diff --git a/src/modules/selectors/ui.ts b/src/modules/selectors/ui.ts index 7e0e4f69e..641995239 100644 --- a/src/modules/selectors/ui.ts +++ b/src/modules/selectors/ui.ts @@ -1,5 +1,5 @@ import { GlobalState } from '../../global/types'; -import { RightColumnContent } from '../../types'; +import { NewChatMembersProgress, RightColumnContent } from '../../types'; import { getSystemTheme, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; import { selectCurrentMessageList, selectIsPollResultsOpen } from './messages'; @@ -17,8 +17,10 @@ export function selectRightColumnContentKey(global: GlobalState) { const { users, isChatInfoShown, + newChatMembersProgress, } = global; + const isAddingChatMembersShown = newChatMembersProgress !== NewChatMembersProgress.Closed; const isPollResults = selectIsPollResultsOpen(global); const isSearch = Boolean(!IS_SINGLE_COLUMN_LAYOUT && selectCurrentTextSearch(global)); const isManagement = selectCurrentManagement(global); @@ -43,6 +45,8 @@ export function selectRightColumnContentKey(global: GlobalState) { RightColumnContent.StickerSearch ) : isGifSearch ? ( RightColumnContent.GifSearch + ) : isAddingChatMembersShown ? ( + RightColumnContent.AddingMembers ) : isUserInfo ? ( RightColumnContent.UserInfo ) : isChatInfo ? ( diff --git a/src/types/index.ts b/src/types/index.ts index 9d12f3ec9..cb961d793 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -215,6 +215,7 @@ export enum RightColumnContent { StickerSearch, GifSearch, PollResults, + AddingMembers, } export enum MediaViewerOrigin { @@ -249,6 +250,12 @@ export enum ManagementProgress { Error, } +export enum NewChatMembersProgress { + Closed, + InProgress, + Loading, +} + export type ProfileTabType = 'members' | 'media' | 'documents' | 'links' | 'audio'; export type SharedMediaType = 'media' | 'documents' | 'links' | 'audio'; export type ApiPrivacyKey = 'phoneNumber' | 'lastSeen' | 'profilePhoto' | 'forwards' | 'chatInvite'; diff --git a/src/util/langProvider.ts b/src/util/langProvider.ts index 897cf59ce..ebf548b18 100644 --- a/src/util/langProvider.ts +++ b/src/util/langProvider.ts @@ -14,6 +14,7 @@ interface LangFn { } const FALLBACK_LANG_CODE = 'en'; +const SUBSTITUTION_REGEX = /%\d?\$?[sdf@]/g; const PLURAL_OPTIONS = ['value', 'zeroValue', 'oneValue', 'twoValue', 'fewValue', 'manyValue', 'otherValue'] as const; const PLURAL_RULES = { /* eslint-disable max-len */ @@ -55,7 +56,8 @@ let currentLangCode: string | undefined; export const getTranslation: LangFn = (key: string, value?: any, format?: 'i') => { if (value !== undefined) { - const cached = cache.get(`${key}_${value}_${format}`); + const cacheValue = Array.isArray(value) ? JSON.stringify(value) : value; + const cached = cache.get(`${key}_${cacheValue}_${format}`); if (cached) { return cached; } @@ -84,7 +86,8 @@ export const getTranslation: LangFn = (key: string, value?: any, format?: 'i') = if (value !== undefined) { const formattedValue = format === 'i' ? formatInteger(value) : value; const result = processTemplate(template, formattedValue); - cache.set(`${key}_${value}_${format}`, result); + const cacheValue = Array.isArray(value) ? JSON.stringify(value) : value; + cache.set(`${key}_${cacheValue}_${format}`, result); return result; } @@ -158,5 +161,11 @@ function getPluralOption(amount: number) { } function processTemplate(template: string, value: any) { - return template.replace(/%\d?\$?[sdf@]/, String(value)); + value = Array.isArray(value) ? value : [value]; + const translationSlices = template.split(SUBSTITUTION_REGEX); + const initialValue = translationSlices.shift(); + + return translationSlices.reduce((result, str, index) => { + return `${result}${String(value[index] || '')}${str}`; + }, initialValue || ''); } diff --git a/src/util/notifications.ts b/src/util/notifications.ts index 1f53f5e4f..cbac6d2a1 100644 --- a/src/util/notifications.ts +++ b/src/util/notifications.ts @@ -1,5 +1,5 @@ import { callApi } from '../api/gramjs'; -import { ApiChat, ApiMessage } from '../api/types'; +import { ApiChat, ApiMessage, ApiUser } from '../api/types'; import { renderActionMessageText } from '../components/common/helpers/renderActionMessageText'; import { DEBUG } from '../config'; import { getDispatch, getGlobal, setGlobal } from '../lib/teact/teactn'; @@ -221,10 +221,13 @@ function getNotificationContent(chat: ApiChat, message: ApiMessage) { ? selectChatMessage(global, chat.id, replyToMessageId) : undefined; const { - targetUserId: actionTargetUserId, + targetUserIds: actionTargetUserIds, targetChatId: actionTargetChatId, } = messageAction || {}; - const actionTargetUser = actionTargetUserId ? selectUser(global, actionTargetUserId) : undefined; + + const actionTargetUsers = actionTargetUserIds + ? actionTargetUserIds.map((userId) => selectUser(global, userId)).filter(Boolean as any) + : undefined; const privateChatUserId = getPrivateChatUserId(chat); const privateChatUser = privateChatUserId ? selectUser(global, privateChatUserId) : undefined; let body: string; @@ -236,7 +239,7 @@ function getNotificationContent(chat: ApiChat, message: ApiMessage) { getTranslation, message, actionOrigin, - actionTargetUser, + actionTargetUsers, actionTargetMessage, actionTargetChatId, { asPlain: true },