From becfab6fe7d4982362ce3ffce849bcbfb49a975c Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Wed, 17 Nov 2021 17:49:52 +0300 Subject: [PATCH] Profile: Common Chats tab (#1547) --- src/api/gramjs/gramjsBuilders/index.ts | 2 +- src/api/gramjs/helpers.ts | 8 +++- src/api/gramjs/methods/chats.ts | 8 +--- src/api/gramjs/methods/index.ts | 2 +- src/api/gramjs/methods/users.ts | 44 +++++++++++++++-- src/api/types/users.ts | 5 ++ src/components/right/Profile.scss | 1 + src/components/right/Profile.tsx | 48 ++++++++++++++++--- .../right/hooks/useProfileViewportIds.ts | 36 +++++++++++--- src/config.ts | 1 + src/global/types.ts | 2 +- src/lib/gramjs/tl/apiTl.js | 1 + src/lib/gramjs/tl/static/api.reduced.tl | 1 + src/modules/actions/api/users.ts | 37 ++++++++++++-- src/types/index.ts | 2 +- 15 files changed, 167 insertions(+), 31 deletions(-) diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index 56e29798..21700fb8 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -416,7 +416,7 @@ export function buildInputReportReason(reason: ApiReportReason) { return undefined; } -function buildMtpPeerId(id: string, type: 'user' | 'chat' | 'channel') { +export function buildMtpPeerId(id: string, type: 'user' | 'chat' | 'channel') { // Workaround for old-fashioned IDs stored locally if (typeof id === 'number') { return BigInt(Math.abs(id)); diff --git a/src/api/gramjs/helpers.ts b/src/api/gramjs/helpers.ts index f9754d90..9080b6f1 100644 --- a/src/api/gramjs/helpers.ts +++ b/src/api/gramjs/helpers.ts @@ -1,6 +1,6 @@ import { Api as GramJs } from '../../lib/gramjs'; import localDb from './localDb'; -import { getApiChatIdFromMtpPeer } from './apiBuilders/peers'; +import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers'; export function resolveMessageApiChatId(mtpMessage: GramJs.TypeMessage) { if (!(mtpMessage instanceof GramJs.Message || mtpMessage instanceof GramJs.MessageService)) { @@ -41,3 +41,9 @@ export function addPhotoToLocalDb(photo: GramJs.TypePhoto) { localDb.photos[String(photo.id)] = photo; } } + +export function addChatToLocalDb(chat: GramJs.TypeChat) { + if (chat instanceof GramJs.Chat || chat instanceof GramJs.Channel) { + localDb.chats[buildApiPeerId(chat.id, chat instanceof GramJs.Chat ? 'chat' : 'channel')] = chat; + } +} diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index 3ed79b0b..2d80e84b 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -39,7 +39,7 @@ import { buildChatBannedRights, buildChatAdminRights, } from '../gramjsBuilders'; -import { addMessageToLocalDb } from '../helpers'; +import { addChatToLocalDb, addMessageToLocalDb } from '../helpers'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; const MAX_INT_32 = 2 ** 31 - 1; @@ -1075,11 +1075,7 @@ function updateLocalDb(result: ( } if ('chats' in result) { - result.chats.forEach((chat) => { - if (chat instanceof GramJs.Chat || chat instanceof GramJs.Channel) { - localDb.chats[buildApiPeerId(chat.id, chat instanceof GramJs.Chat ? 'chat' : 'channel')] = chat; - } - }); + result.chats.forEach(addChatToLocalDb); } if ('messages' in result) { diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 1bf4c460..3f2848f1 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -27,7 +27,7 @@ export { export { fetchFullUser, fetchNearestCountry, fetchTopUsers, fetchContactList, fetchUsers, - addContact, updateContact, deleteUser, fetchProfilePhotos, + addContact, updateContact, deleteUser, fetchProfilePhotos, fetchCommonChats, } from './users'; export { diff --git a/src/api/gramjs/methods/users.ts b/src/api/gramjs/methods/users.ts index f0f69e2a..9d13cca2 100644 --- a/src/api/gramjs/methods/users.ts +++ b/src/api/gramjs/methods/users.ts @@ -4,19 +4,21 @@ import { OnApiUpdate, ApiUser, ApiChat, ApiPhoto, } from '../../types'; -import { PROFILE_PHOTOS_LIMIT } from '../../../config'; +import { COMMON_CHATS_LIMIT, PROFILE_PHOTOS_LIMIT } from '../../../config'; import { invokeRequest } from './client'; import { searchMessagesLocal } from './messages'; import { buildInputEntity, buildInputPeer, buildInputContact, + buildMtpPeerId, + getEntityTypeById, } from '../gramjsBuilders'; import { buildApiUser, buildApiUserFromFull } from '../apiBuilders/users'; import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { buildApiPhoto } from '../apiBuilders/common'; import localDb from '../localDb'; -import { addPhotoToLocalDb } from '../helpers'; +import { addChatToLocalDb, addPhotoToLocalDb } from '../helpers'; import { buildApiPeerId } from '../apiBuilders/peers'; let onUpdate: OnApiUpdate; @@ -54,6 +56,34 @@ export async function fetchFullUser({ }); } +export async function fetchCommonChats(id: string, accessHash?: string, maxId?: string) { + const commonChats = await invokeRequest(new GramJs.messages.GetCommonChats({ + userId: buildInputEntity(id, accessHash) as GramJs.InputUser, + maxId: maxId ? buildMtpPeerId(maxId, getEntityTypeById(maxId)) : undefined, + limit: COMMON_CHATS_LIMIT, + })); + + if (!commonChats) { + return undefined; + } + + updateLocalDb(commonChats); + + const chatIds: string[] = []; + const chats: ApiChat[] = []; + + commonChats.chats.forEach((mtpChat) => { + const chat = buildApiChatFromPreview(mtpChat); + + if (chat) { + chats.push(chat); + chatIds.push(chat.id); + } + }); + + return { chats, chatIds, isFullyLoaded: chatIds.length < COMMON_CHATS_LIMIT }; +} + export async function fetchNearestCountry() { const dcInfo = await invokeRequest(new GramJs.help.GetNearestDc()); @@ -217,6 +247,12 @@ export async function fetchProfilePhotos(user?: ApiUser, chat?: ApiChat) { }; } -function updateLocalDb(result: (GramJs.photos.Photos | GramJs.photos.PhotosSlice)) { - result.photos.forEach(addPhotoToLocalDb); +function updateLocalDb(result: (GramJs.photos.Photos | GramJs.photos.PhotosSlice | GramJs.messages.Chats)) { + if ('chats' in result) { + result.chats.forEach(addChatToLocalDb); + } + + if ('photos' in result) { + result.photos.forEach(addPhotoToLocalDb); + } } diff --git a/src/api/types/users.ts b/src/api/types/users.ts index a766e840..47071d89 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -18,6 +18,11 @@ export interface ApiUser { photos?: ApiPhoto[]; botPlaceholder?: string; canBeInvitedToGroup?: boolean; + commonChats?: { + ids: string[]; + maxId: string; + isFullyLoaded: boolean; + }; // Obtained from GetFullUser / UserFullInfo fullInfo?: ApiUserFullInfo; diff --git a/src/components/right/Profile.scss b/src/components/right/Profile.scss index 739b795a..83e22b75 100644 --- a/src/components/right/Profile.scss +++ b/src/components/right/Profile.scss @@ -129,6 +129,7 @@ } } + &.commonChats-list, &.members-list { padding: 0.5rem 1rem; diff --git a/src/components/right/Profile.tsx b/src/components/right/Profile.tsx index 28aad9e4..dc17bb63 100644 --- a/src/components/right/Profile.tsx +++ b/src/components/right/Profile.tsx @@ -7,7 +7,8 @@ import { ApiMessage, ApiChatMember, ApiUser, - MAIN_THREAD_ID, ApiChat, + ApiChat, + MAIN_THREAD_ID, } from '../../api/types'; import { GlobalActions } from '../../global/types'; import { @@ -31,6 +32,7 @@ import { selectIsRightColumnShown, selectTheme, selectActiveDownloadIds, + selectUser, } from '../../modules/selectors'; import { pick } from '../../util/iteratees'; import { captureEvents, SwipeDirection } from '../../util/captureEvents'; @@ -57,6 +59,7 @@ import WebLink from '../common/WebLink'; import NothingFound from '../common/NothingFound'; import FloatingActionButton from '../ui/FloatingActionButton'; import DeleteMemberModal from './DeleteMemberModal'; +import GroupChatInfo from '../common/GroupChatInfo'; import './Profile.scss'; @@ -75,11 +78,13 @@ type StateProps = { chatMessages?: Record; foundIds?: number[]; mediaSearchType?: SharedMediaType; + hasCommonChatsTab?: boolean; hasMembersTab?: boolean; areMembersHidden?: boolean; canAddMembers?: boolean; canDeleteMembers?: boolean; members?: ApiChatMember[]; + commonChatIds?: string[]; chatsById: Record; usersById: Record; isRightColumnShown: boolean; @@ -90,8 +95,9 @@ type StateProps = { }; type DispatchProps = Pick; const TABS = [ @@ -115,10 +121,12 @@ const Profile: FC = ({ chatMessages, foundIds, mediaSearchType, + hasCommonChatsTab, hasMembersTab, areMembersHidden, canAddMembers, canDeleteMembers, + commonChatIds, members, usersById, chatsById, @@ -129,6 +137,8 @@ const Profile: FC = ({ serverTimeOffset, setLocalMediaSearchType, loadMoreMembers, + loadCommonChats, + openChat, searchMediaMessagesLocal, openMediaViewer, openAudioPlayer, @@ -150,12 +160,15 @@ const Profile: FC = ({ type: 'members', title: isChannel ? 'ChannelSubscribers' : 'GroupMembers', }] : []), ...TABS, - ]), [hasMembersTab, isChannel]); + ...(hasCommonChatsTab ? [{ + type: 'commonChats', title: 'SharedGroupsTab2', + }] : []), + ]), [hasCommonChatsTab, hasMembersTab, isChannel]); const tabType = tabs[activeTab].type as ProfileTabType; const [resultType, viewportIds, getMore, noProfileInfo] = useProfileViewportIds( - isRightColumnShown, loadMoreMembers, searchMediaMessagesLocal, tabType, mediaSearchType, members, - usersById, chatMessages, foundIds, chatId, lastSyncTime, serverTimeOffset, + isRightColumnShown, loadMoreMembers, loadCommonChats, searchMediaMessagesLocal, tabType, mediaSearchType, members, + commonChatIds, usersById, chatsById, chatMessages, foundIds, chatId, lastSyncTime, serverTimeOffset, ); const activeKey = tabs.findIndex(({ type }) => type === resultType); @@ -273,6 +286,9 @@ const Profile: FC = ({ case 'members': text = areMembersHidden ? 'You have no access to group members list.' : 'No members found'; break; + case 'commonChats': + text = lang('NoGroupsInCommon'); + break; case 'documents': text = lang('lng_media_file_empty'); break; @@ -373,6 +389,17 @@ const Profile: FC = ({ )) + ) : resultType === 'commonChats' ? ( + (viewportIds as string[])!.map((id, i) => ( + openChat({ id })} + > + + + )) ) : undefined} ); @@ -474,11 +501,16 @@ export default memo(withGlobal( const activeDownloadIds = selectActiveDownloadIds(global, chatId); let resolvedUserId; + let user; if (userId) { resolvedUserId = userId; } else if (isUserId(chatId)) { resolvedUserId = chatId; } + const hasCommonChatsTab = Boolean(resolvedUserId); + if (resolvedUserId) { + user = selectUser(global, resolvedUserId); + } return { theme: selectTheme(global), @@ -487,6 +519,7 @@ export default memo(withGlobal( chatMessages, foundIds, mediaSearchType, + hasCommonChatsTab, hasMembersTab, areMembersHidden, canAddMembers, @@ -500,6 +533,7 @@ export default memo(withGlobal( usersById, chatsById, ...(hasMembersTab && members && { members }), + ...(hasCommonChatsTab && user && { commonChatIds: user.commonChats?.ids }), }; }, (setGlobal, actions): DispatchProps => pick(actions, [ @@ -512,5 +546,7 @@ export default memo(withGlobal( 'focusMessage', 'loadProfilePhotos', 'setNewChatMembersDialogState', + 'loadCommonChats', + 'openChat', ]), )(Profile)); diff --git a/src/components/right/hooks/useProfileViewportIds.ts b/src/components/right/hooks/useProfileViewportIds.ts index 18141122..a11dfcda 100644 --- a/src/components/right/hooks/useProfileViewportIds.ts +++ b/src/components/right/hooks/useProfileViewportIds.ts @@ -1,21 +1,26 @@ import { useMemo, useRef } from '../../../lib/teact/teact'; -import { ApiChatMember, ApiMessage, ApiUser } from '../../../api/types'; +import { + ApiChat, ApiChatMember, ApiMessage, ApiUser, +} from '../../../api/types'; import { ProfileTabType, SharedMediaType } from '../../../types'; import { MEMBERS_SLICE, MESSAGE_SEARCH_SLICE, SHARED_MEDIA_SLICE } from '../../../config'; -import { getMessageContentIds, sortUserIds } from '../../../modules/helpers'; +import { getMessageContentIds, sortChatIds, sortUserIds } from '../../../modules/helpers'; import useOnChange from '../../../hooks/useOnChange'; import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; export default function useProfileViewportIds( isRightColumnShown: boolean, loadMoreMembers: AnyToVoidFunction, + loadCommonChats: AnyToVoidFunction, searchMessages: AnyToVoidFunction, tabType: ProfileTabType, mediaSearchType?: SharedMediaType, groupChatMembers?: ApiChatMember[], + commonChatIds?: string[], usersById?: Record, + chatsById?: Record, chatMessages?: Record, foundIds?: number[], chatId?: string, @@ -32,10 +37,22 @@ export default function useProfileViewportIds( return sortUserIds(groupChatMembers.map(({ userId }) => userId), usersById, undefined, serverTimeOffset); }, [groupChatMembers, serverTimeOffset, usersById]); - const [memberViewportIds, getMoreMembers, noProfileInfoForMembers] = useInfiniteScrollForMembers( + const chatIds = useMemo(() => { + if (!commonChatIds || !chatsById) { + return undefined; + } + + return sortChatIds(commonChatIds, chatsById, true); + }, [chatsById, commonChatIds]); + + const [memberViewportIds, getMoreMembers, noProfileInfoForMembers] = useInfiniteScrollForLoadableItems( resultType, loadMoreMembers, lastSyncTime, memberIds, ); + const [commonChatViewportIds, getMoreCommonChats, noProfileInfoForCommonChats] = useInfiniteScrollForLoadableItems( + resultType, loadCommonChats, lastSyncTime, chatIds, + ); + const [mediaViewportIds, getMoreMedia, noProfileInfoForMedia] = useInfiniteScrollForSharedMedia( 'media', resultType, searchMessages, lastSyncTime, chatMessages, foundIds, ); @@ -66,6 +83,11 @@ export default function useProfileViewportIds( getMore = getMoreMembers; noProfileInfo = noProfileInfoForMembers; break; + case 'commonChats': + viewportIds = commonChatViewportIds; + getMore = getMoreCommonChats; + noProfileInfo = noProfileInfoForCommonChats; + break; case 'media': viewportIds = mediaViewportIds; getMore = getMoreMedia; @@ -96,20 +118,20 @@ export default function useProfileViewportIds( return [resultType, viewportIds, getMore, noProfileInfo] as const; } -function useInfiniteScrollForMembers( +function useInfiniteScrollForLoadableItems( currentResultType?: ProfileTabType, handleLoadMore?: AnyToVoidFunction, lastSyncTime?: number, - memberIds?: string[], + itemIds?: string[], ) { const [viewportIds, getMore] = useInfiniteScroll( lastSyncTime ? handleLoadMore : undefined, - memberIds, + itemIds, undefined, MEMBERS_SLICE, ); - const isOnTop = !viewportIds || !memberIds || viewportIds[0] === memberIds[0]; + const isOnTop = !viewportIds || !itemIds || viewportIds[0] === itemIds[0]; return [viewportIds, getMore, !isOnTop] as const; } diff --git a/src/config.ts b/src/config.ts index 0225f148..3d67e975 100644 --- a/src/config.ts +++ b/src/config.ts @@ -57,6 +57,7 @@ export const PINNED_MESSAGES_LIMIT = 50; export const BLOCKED_LIST_LIMIT = 100; export const PROFILE_PHOTOS_LIMIT = 40; export const PROFILE_SENSITIVE_AREA = 500; +export const COMMON_CHATS_LIMIT = 100; export const TOP_CHAT_MESSAGES_PRELOAD_LIMIT = 20; export const ALL_CHATS_PRELOAD_DISABLED = false; diff --git a/src/global/types.ts b/src/global/types.ts index e53ac1b8..bafeb562 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -505,7 +505,7 @@ export type ActionTypes = ( // users 'loadFullUser' | 'openUserInfo' | 'loadNearestCountry' | 'loadTopUsers' | 'loadContactList' | 'loadCurrentUser' | 'updateProfile' | 'checkUsername' | 'addContact' | 'updateContact' | - 'deleteUser' | 'loadUser' | 'setUserSearchQuery' | + 'deleteUser' | 'loadUser' | 'setUserSearchQuery' | 'loadCommonChats' | // chat creation 'createChannel' | 'createGroupChat' | 'resetChatCreation' | // settings diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 0a8cab14..d25803a2 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1026,6 +1026,7 @@ messages.saveDraft#bc39e14b flags:# no_webpage:flags.1?true reply_to_msg_id:flag messages.getAllDrafts#6a3f8d65 = Updates; messages.readFeaturedStickers#5b118126 id:Vector = Bool; messages.getRecentStickers#9da9403b flags:# attached:flags.0?true hash:long = messages.RecentStickers; +messages.getCommonChats#e40ca104 user_id:InputUser max_id:long limit:int = messages.Chats; messages.getWebPage#32ca8f91 url:string hash:int = WebPage; messages.toggleDialogPin#a731e257 flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; messages.getPinnedDialogs#d6b94df2 folder_id:int = messages.PeerDialogs; diff --git a/src/lib/gramjs/tl/static/api.reduced.tl b/src/lib/gramjs/tl/static/api.reduced.tl index a62af12d..d10ca3af 100644 --- a/src/lib/gramjs/tl/static/api.reduced.tl +++ b/src/lib/gramjs/tl/static/api.reduced.tl @@ -1027,6 +1027,7 @@ messages.saveDraft#bc39e14b flags:# no_webpage:flags.1?true reply_to_msg_id:flag messages.getAllDrafts#6a3f8d65 = Updates; messages.readFeaturedStickers#5b118126 id:Vector = Bool; messages.getRecentStickers#9da9403b flags:# attached:flags.0?true hash:long = messages.RecentStickers; +messages.getCommonChats#e40ca104 user_id:InputUser max_id:long limit:int = messages.Chats; messages.getWebPage#32ca8f91 url:string hash:int = WebPage; messages.toggleDialogPin#a731e257 flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; messages.getPinnedDialogs#d6b94df2 folder_id:int = messages.PeerDialogs; diff --git a/src/modules/actions/api/users.ts b/src/modules/actions/api/users.ts index 6557a226..125ae13f 100644 --- a/src/modules/actions/api/users.ts +++ b/src/modules/actions/api/users.ts @@ -6,10 +6,10 @@ import { ApiUser } from '../../../api/types'; import { ManagementProgress } from '../../../types'; import { debounce, throttle } from '../../../util/schedulers'; -import { buildCollectionByKey, pick } from '../../../util/iteratees'; -import { isUserId } from '../../helpers'; +import { buildCollectionByKey, pick, unique } from '../../../util/iteratees'; +import { isUserBot, isUserId } from '../../helpers'; import { callApi } from '../../../api/gramjs'; -import { selectChat, selectUser } from '../../selectors'; +import { selectChat, selectCurrentMessageList, selectUser } from '../../selectors'; import { addChats, addUsers, updateChat, updateManagementProgress, updateUser, updateUsers, updateUserSearch, updateUserSearchFetchingStatus, @@ -67,6 +67,37 @@ addReducer('loadCurrentUser', () => { void callApi('fetchCurrentUser'); }); +addReducer('loadCommonChats', (global) => { + const { chatId } = selectCurrentMessageList(global) || {}; + const user = chatId ? selectUser(global, chatId) : undefined; + if (!user || isUserBot(user) || user.commonChats?.isFullyLoaded) { + return; + } + + (async () => { + const maxId = user.commonChats?.maxId; + const result = await callApi('fetchCommonChats', user.id, user.accessHash!, maxId); + if (!result) { + return; + } + + const { chats, chatIds, isFullyLoaded } = result; + + global = getGlobal(); + if (chats.length) { + global = addChats(global, buildCollectionByKey(chats, 'id')); + } + global = updateUser(global, user.id, { + commonChats: { + maxId: chatIds.length ? chatIds[chatIds.length - 1] : '0', + ids: unique((user.commonChats?.ids || []).concat(chatIds)), + isFullyLoaded, + }, + }); + setGlobal(global); + })(); +}); + addReducer('updateContact', (global, actions, payload) => { const { userId, isMuted, firstName, lastName, diff --git a/src/types/index.ts b/src/types/index.ts index 30fa669c..7623750c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -284,7 +284,7 @@ export enum NewChatMembersProgress { Loading, } -export type ProfileTabType = 'members' | 'media' | 'documents' | 'links' | 'audio' | 'voice'; +export type ProfileTabType = 'members' | 'commonChats' | 'media' | 'documents' | 'links' | 'audio' | 'voice'; export type SharedMediaType = 'media' | 'documents' | 'links' | 'audio' | 'voice'; export type ApiPrivacyKey = 'phoneNumber' | 'lastSeen' | 'profilePhoto' | 'forwards' | 'chatInvite'; export type PrivacyVisibility = 'everybody' | 'contacts' | 'nonContacts' | 'nobody';