From d2b834d1a695d0ff9d1601c852eef51f75644fef Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Thu, 28 Dec 2023 14:38:07 +0100 Subject: [PATCH] Profile: add similar channels tab (#4104) --- src/api/gramjs/apiBuilders/appConfig.ts | 19 ++++- src/api/gramjs/methods/chats.ts | 16 +++++ src/api/gramjs/methods/index.ts | 1 + .../main/premium/PremiumFeatureModal.tsx | 3 + src/components/right/Profile.scss | 26 +++++++ src/components/right/Profile.tsx | 69 ++++++++++++++++++- .../right/hooks/useProfileViewportIds.ts | 4 ++ src/config.ts | 1 + src/global/actions/api/chats.ts | 24 +++++++ src/global/cache.ts | 5 ++ src/global/initialState.ts | 1 + src/global/reducers/chats.ts | 17 +++++ src/global/selectors/chats.ts | 7 ++ src/global/types.ts | 24 +++++-- src/lib/gramjs/tl/apiTl.js | 1 + src/lib/gramjs/tl/static/api.json | 1 + src/types/index.ts | 14 +++- 17 files changed, 221 insertions(+), 12 deletions(-) diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index ac688885f..716ae68c3 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -15,9 +15,21 @@ import localDb from '../localDb'; import { buildJson } from './misc'; type LimitType = 'default' | 'premium'; -type Limit = 'upload_max_fileparts' | 'stickers_faved_limit' | 'saved_gifs_limit' | 'dialog_filters_chats_limit' | -'dialog_filters_limit' | 'dialogs_folder_pinned_limit' | 'dialogs_pinned_limit' | 'caption_length_limit' | -'channels_limit' | 'channels_public_limit' | 'about_length_limit' | 'chatlist_invites_limit' | 'chatlist_joined_limit'; +type Limit = + | 'upload_max_fileparts' + | 'stickers_faved_limit' + | 'saved_gifs_limit' + | 'dialog_filters_chats_limit' + | 'dialog_filters_limit' + | 'dialogs_folder_pinned_limit' + | 'dialogs_pinned_limit' + | 'caption_length_limit' + | 'channels_limit' + | 'channels_public_limit' + | 'about_length_limit' + | 'chatlist_invites_limit' + | 'chatlist_joined_limit' + | 'recommended_channels_limit'; type LimitKey = `${Limit}_${LimitType}`; type LimitsConfig = Record; @@ -111,6 +123,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp aboutLength: getLimit(appConfig, 'about_length_limit', 'aboutLength'), chatlistInvites: getLimit(appConfig, 'chatlist_invites_limit', 'chatlistInvites'), chatlistJoined: getLimit(appConfig, 'chatlist_joined_limit', 'chatlistJoined'), + recommendedChannels: getLimit(appConfig, 'recommended_channels_limit', 'recommendedChannels'), }, hash, areStoriesHidden: appConfig.stories_all_hidden, diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index 1b968145f..cb9bc3d48 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -1895,6 +1895,22 @@ export function setViewForumAsMessages({ chat, isEnabled }: { chat: ApiChat; isE }); } +export async function fetchChannelRecommendations({ chat }: { chat: ApiChat }) { + const { id, accessHash } = chat; + const channel = buildInputEntity(id, accessHash); + + const result = await invokeRequest(new GramJs.channels.GetChannelRecommendations({ + channel: channel as GramJs.InputChannel, + })); + if (!result) { + return undefined; + } + + updateLocalDb(result); + + return result?.chats.map((_chat) => buildApiChatFromPreview(_chat)).filter(Boolean); +} + function handleUserPrivacyRestrictedUpdates(updates: GramJs.TypeUpdates) { if (!(updates instanceof GramJs.Updates) && !(updates instanceof GramJs.UpdatesCombined)) { return undefined; diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 91908dbb3..e47fcce3f 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -24,6 +24,7 @@ export { editTopic, toggleForum, fetchTopicById, createTopic, toggleParticipantsHidden, checkChatlistInvite, joinChatlistInvite, createChalistInvite, editChatlistInvite, deleteChatlistInvite, fetchChatlistInvites, fetchLeaveChatlistSuggestions, leaveChatlist, togglePeerTranslations, setViewForumAsMessages, + fetchChannelRecommendations, } from './chats'; export { diff --git a/src/components/main/premium/PremiumFeatureModal.tsx b/src/components/main/premium/PremiumFeatureModal.tsx index f9feae319..34b75b14a 100644 --- a/src/components/main/premium/PremiumFeatureModal.tsx +++ b/src/components/main/premium/PremiumFeatureModal.tsx @@ -98,6 +98,7 @@ const LIMITS_ORDER: ApiLimitTypeWithoutUpload[] = [ 'captionLength', 'dialogFilters', 'dialogFiltersChats', + 'recommendedChannels', ]; const LIMITS_TITLES: Record = { @@ -110,6 +111,7 @@ const LIMITS_TITLES: Record = { captionLength: 'CaptionsLimitTitle', dialogFilters: 'FoldersLimitTitle', dialogFiltersChats: 'ChatPerFolderLimitTitle', + recommendedChannels: 'SimilarChannelsLimitTitle', }; const LIMITS_DESCRIPTIONS: Record = { @@ -122,6 +124,7 @@ const LIMITS_DESCRIPTIONS: Record = { captionLength: 'CaptionsLimitSubtitle', dialogFilters: 'FoldersLimitSubtitle', dialogFiltersChats: 'ChatPerFolderLimitSubtitle', + recommendedChannels: 'SimilarChannelsLimitSubtitle', }; const BORDER_THRESHOLD = 20; diff --git a/src/components/right/Profile.scss b/src/components/right/Profile.scss index 4f9270774..2527c9205 100644 --- a/src/components/right/Profile.scss +++ b/src/components/right/Profile.scss @@ -121,6 +121,7 @@ } } + &.similarChannels-list, &.commonChats-list, &.members-list { padding: 0.5rem; @@ -133,5 +134,30 @@ } } } + + &.similarChannels-list { + .ListItem.blured { + filter: opacity(0.8); + } + + .show-more-channels { + width: calc(100% - 1rem); + margin: 0 auto; + margin-top: -1.8125rem; + z-index: 1; + border-radius: var(--border-radius-default-small); + box-shadow: 0px 0px 1rem 1rem white; + + .icon { + margin-left: 0.625rem; + } + } + + .more-similar { + text-align: center; + margin-top: 1rem; + font-size: 0.8125rem; + } + } } } diff --git a/src/components/right/Profile.tsx b/src/components/right/Profile.tsx index 42c1a4378..8171310c6 100644 --- a/src/components/right/Profile.tsx +++ b/src/components/right/Profile.tsx @@ -34,15 +34,20 @@ import { selectChatFullInfo, selectChatMessages, selectCurrentMediaSearch, + selectIsCurrentUserPremium, selectIsRightColumnShown, selectPeerFullInfo, selectPeerStories, + selectSimilarChannelIds, selectTabState, selectTheme, selectUser, } from '../../global/selectors'; +import { selectPremiumLimit } from '../../global/selectors/limits'; +import buildClassName from '../../util/buildClassName'; import { captureEvents, SwipeDirection } from '../../util/captureEvents'; import { IS_TOUCH_ENV } from '../../util/windowEnvironment'; +import renderText from '../common/helpers/renderText'; import { getSenderName } from '../left/search/helpers/getSenderName'; import usePeerStoriesPolling from '../../hooks/polling/usePeerStoriesPolling'; @@ -66,6 +71,7 @@ import PrivateChatInfo from '../common/PrivateChatInfo'; import ProfileInfo from '../common/ProfileInfo'; import WebLink from '../common/WebLink'; import MediaStory from '../story/MediaStory'; +import Button from '../ui/Button'; import FloatingActionButton from '../ui/FloatingActionButton'; import InfiniteScroll from '../ui/InfiniteScroll'; import ListItem, { type MenuItemContextAction } from '../ui/ListItem'; @@ -113,6 +119,9 @@ type StateProps = { isChatProtected?: boolean; nextProfileTab?: ProfileTabType; shouldWarnAboutSvg?: boolean; + similarChannels?: string[]; + isCurrentUserPremium?: boolean; + limitSimilarChannels: number; }; const TABS = [ @@ -158,6 +167,9 @@ const Profile: FC = ({ isChatProtected, nextProfileTab, shouldWarnAboutSvg, + similarChannels, + isCurrentUserPremium, + limitSimilarChannels, }) => { const { setLocalMediaSearchType, @@ -172,6 +184,8 @@ const Profile: FC = ({ setNewChatMembersDialogState, loadPeerPinnedStories, loadStoriesArchive, + openPremiumModal, + fetchChannelRecommendations, } = getActions(); // eslint-disable-next-line no-null/no-null @@ -192,7 +206,19 @@ const Profile: FC = ({ // in forum topics. Return it when it's fixed on the server side. ...(!topicId ? [{ type: 'voice', title: 'SharedVoiceTab2' }] : []), ...(hasCommonChatsTab ? [{ type: 'commonChats', title: 'SharedGroupsTab2' }] : []), - ]), [chatId, currentUserId, hasCommonChatsTab, hasMembersTab, hasStoriesTab, isChannel, topicId]); + ...(isChannel && similarChannels?.length + ? [{ type: 'similarChannels', title: 'SimilarChannelsTab' }] + : []), + ]), [ + chatId, + currentUserId, + hasCommonChatsTab, + hasMembersTab, + hasStoriesTab, + isChannel, + topicId, + similarChannels, + ]); const initialTab = useMemo(() => { if (!nextProfileTab) { @@ -213,6 +239,12 @@ const Profile: FC = ({ setActiveTab(index); }, [nextProfileTab, tabs]); + useEffect(() => { + if (isChannel) { + fetchChannelRecommendations({ chatId }); + } + }, [chatId, isChannel]); + const renderingActiveTab = activeTab > tabs.length - 1 ? tabs.length - 1 : activeTab; const tabType = tabs[renderingActiveTab].type as ProfileTabType; const handleLoadPeerStories = useCallback(({ offsetId }: { offsetId: number }) => { @@ -240,6 +272,7 @@ const Profile: FC = ({ topicId, storyIds, archiveStoryIds, + similarChannels, ); const isFirstTab = (hasStoriesTab && resultType === 'stories') || resultType === 'members' @@ -512,6 +545,35 @@ const Profile: FC = ({ )) + ) : resultType === 'similarChannels' ? ( +
+ {(viewportIds as string[])!.map((channelId, i) => ( + openChat({ id: channelId })} + > + + + ))} + {!isCurrentUserPremium && ( + <> + {/* eslint-disable-next-line react/jsx-no-bind */} + +
+ {renderText(lang('MoreSimilarText', limitSimilarChannels), ['simple_markdown'])} +
+ + )} +
) : undefined} ); @@ -604,6 +666,8 @@ export default memo(withGlobal( && (getHasAdminRight(chat, 'inviteUsers') || !isUserRightBanned(chat, 'inviteUsers') || chat.isCreator); const canDeleteMembers = hasMembersTab && chat && (getHasAdminRight(chat, 'banUsers') || chat.isCreator); const activeDownloads = selectActiveDownloads(global, chatId); + const similarChannels = selectSimilarChannelIds(global, chatId); + const isCurrentUserPremium = selectIsCurrentUserPremium(global); let hasCommonChatsTab; let resolvedUserId; @@ -648,6 +712,9 @@ export default memo(withGlobal( isChatProtected: chat?.isProtected, nextProfileTab: selectTabState(global).nextProfileTab, shouldWarnAboutSvg: global.settings.byKey.shouldWarnAboutSvg, + similarChannels, + isCurrentUserPremium, + limitSimilarChannels: selectPremiumLimit(global, 'recommendedChannels'), ...(hasMembersTab && members && { members, adminMembersById }), ...(hasCommonChatsTab && user && { commonChatIds: user.commonChats?.ids }), }; diff --git a/src/components/right/hooks/useProfileViewportIds.ts b/src/components/right/hooks/useProfileViewportIds.ts index fc3215917..d9356149f 100644 --- a/src/components/right/hooks/useProfileViewportIds.ts +++ b/src/components/right/hooks/useProfileViewportIds.ts @@ -29,6 +29,7 @@ export default function useProfileViewportIds( topicId?: number, storyIds?: number[], archiveStoryIds?: number[], + similarChannels?: string[], ) { const resultType = tabType === 'members' || !mediaSearchType ? tabType : mediaSearchType; @@ -142,6 +143,9 @@ export default function useProfileViewportIds( getMore = getMoreStoriesArchive; noProfileInfo = noProfileInfoForStoriesArchive; break; + case 'similarChannels': + viewportIds = similarChannels; + break; } return [resultType, viewportIds, getMore, noProfileInfo] as const; diff --git a/src/config.ts b/src/config.ts index 378194872..1a9d0a33e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -337,4 +337,5 @@ export const DEFAULT_LIMITS: Record = { aboutLength: [70, 140], chatlistInvites: [3, 100], chatlistJoined: [2, 20], + recommendedChannels: [10, 100], }; diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index b2f6bf3b4..d26c8ab78 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -51,6 +51,7 @@ import { addChatMembers, addChats, addMessages, + addSimilarChannels, addUsers, addUserStatuses, addUsersToRestrictedInviteList, @@ -2517,6 +2518,29 @@ addActionHandler('setViewForumAsMessages', (global, actions, payload): ActionRet void callApi('setViewForumAsMessages', { chat, isEnabled }); }); +addActionHandler('fetchChannelRecommendations', async (global, actions, payload): Promise => { + const { chatId } = payload; + const chat = selectChat(global, chatId); + + if (!chat) { + return; + } + + const similarChannels = await callApi('fetchChannelRecommendations', { + chat, + }); + + if (!similarChannels) { + return; + } + + global = getGlobal(); + global = addChats(global, buildCollectionByKey(similarChannels, 'id')); + global = addSimilarChannels(global, chatId, similarChannels.map((channel) => channel.id)); + + setGlobal(global); +}); + async function loadChats( listType: 'active' | 'archived', offsetId?: string, diff --git a/src/global/cache.ts b/src/global/cache.ts index a8c224980..3db18ae4a 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -205,6 +205,10 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) { cached.stories.orderedPeerIds = initialState.stories.orderedPeerIds; } + if (!cached.chats.similarChannelsById) { + cached.chats.similarChannelsById = initialState.chats.similarChannelsById; + } + // Clear old color storage to optimize cache size if (untypedCached?.appConfig?.peerColors) { untypedCached.appConfig.peerColors = undefined; @@ -373,6 +377,7 @@ function reduceChats(global: T): GlobalState['chats'] { return { ...global.chats, + similarChannelsById: {}, isFullyLoaded: {}, byId: pick(global.chats.byId, idsToSave), fullInfoById: pick(global.chats.fullInfoById, idsToSave), diff --git a/src/global/initialState.ts b/src/global/initialState.ts index bd606a3e1..febc75b60 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -105,6 +105,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = { totalCount: {}, byId: {}, fullInfoById: {}, + similarChannelsById: {}, }, messages: { diff --git a/src/global/reducers/chats.ts b/src/global/reducers/chats.ts index a9f68f262..294ee2e3e 100644 --- a/src/global/reducers/chats.ts +++ b/src/global/reducers/chats.ts @@ -401,3 +401,20 @@ export function deleteTopic( return global; } + +export function addSimilarChannels( + global: T, + chatId: string, + similarChannels: string[], +) { + return { + ...global, + chats: { + ...global.chats, + similarChannelsById: { + ...global.chats.similarChannelsById, + [chatId]: similarChannels, + }, + }, + }; +} diff --git a/src/global/selectors/chats.ts b/src/global/selectors/chats.ts index f3bf07233..8481da0e5 100644 --- a/src/global/selectors/chats.ts +++ b/src/global/selectors/chats.ts @@ -316,3 +316,10 @@ export function selectRequestedChatTranslationLanguage( return requestedTranslations.byChatId[chatId]?.toLanguage; } + +export function selectSimilarChannelIds( + global: T, + chatId: string, +): string[] | undefined { + return global.chats.similarChannelsById[chatId]; +} diff --git a/src/global/types.ts b/src/global/types.ts index 7d5f9b82e..b72208113 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -169,13 +169,23 @@ export interface ServiceNotification { isDeleted?: boolean; } -export type ApiLimitType = ( - 'uploadMaxFileparts' | 'stickersFaved' | 'savedGifs' | 'dialogFiltersChats' | 'dialogFilters' | 'dialogFolderPinned' | - 'captionLength' | 'channels' | 'channelsPublic' | 'aboutLength' | 'chatlistInvites' | 'chatlistJoined' -); +export type ApiLimitType = + | 'uploadMaxFileparts' + | 'stickersFaved' + | 'savedGifs' + | 'dialogFiltersChats' + | 'dialogFilters' + | 'dialogFolderPinned' + | 'captionLength' + | 'channels' + | 'channelsPublic' + | 'aboutLength' + | 'chatlistInvites' + | 'chatlistJoined' + | 'recommendedChannels'; export type ApiLimitTypeWithModal = Exclude; export type TranslatedMessage = { @@ -771,6 +781,7 @@ export type GlobalState = { forDiscussionIds?: string[]; // Obtained from GetFullChat / GetFullChannel fullInfoById: Record; + similarChannelsById: Record; }; messages: { @@ -1759,6 +1770,9 @@ export interface ActionPayloads { fetchChat: { chatId: string; }; + fetchChannelRecommendations: { + chatId: string; + }; updateChatMutedState: { chatId: string; isMuted?: boolean; diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 640ebeddb..d9fbcb3f1 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1443,6 +1443,7 @@ channels.deleteTopicHistory#34435f2d channel:InputChannel top_msg_id:int = messa channels.toggleParticipantsHidden#6a6e7854 channel:InputChannel enabled:Bool = Updates; channels.clickSponsoredMessage#18afbc93 channel:InputChannel random_id:bytes = Bool; channels.toggleViewForumAsMessages#9738bb15 channel:InputChannel enabled:Bool = Updates; +channels.getChannelRecommendations#83b70d97 channel:InputChannel = messages.Chats; bots.canSendMessage#1359f4e6 bot:InputUser = Bool; bots.allowSendMessage#f132e3ef bot:InputUser = Updates; bots.invokeWebViewCustomMethod#87fc5e7 bot:InputUser custom_method:string params:DataJSON = DataJSON; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 0dbc70371..7c139001f 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -214,6 +214,7 @@ "channels.toggleUsername", "channels.viewSponsoredMessage", "channels.getSponsoredMessages", + "channels.getChannelRecommendations", "bots.canSendMessage", "bots.allowSendMessage", "bots.invokeWebViewCustomMethod", diff --git a/src/types/index.ts b/src/types/index.ts index 8e382faf0..e2dd4b35e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -355,9 +355,17 @@ export enum NewChatMembersProgress { Loading, } -export type ProfileTabType = ( - 'members' | 'commonChats' | 'media' | 'documents' | 'links' | 'audio' | 'voice' | 'stories' | 'storiesArchive' -); +export type ProfileTabType = + | 'members' + | 'commonChats' + | 'media' + | 'documents' + | 'links' + | 'audio' + | 'voice' + | 'stories' + | 'storiesArchive' + | 'similarChannels'; export type SharedMediaType = 'media' | 'documents' | 'links' | 'audio' | 'voice'; export type ApiPrivacyKey = 'phoneNumber' | 'addByPhone' | 'lastSeen' | 'profilePhoto' | 'voiceMessages' | 'forwards' | 'chatInvite' | 'phoneCall' | 'phoneP2P' | 'bio';