Profile: add similar channels tab (#4104)

This commit is contained in:
Alexander Zinchuk 2023-12-28 14:38:07 +01:00
parent 20d8087d87
commit d2b834d1a6
17 changed files with 221 additions and 12 deletions

View File

@ -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<LimitKey, number>;
@ -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,

View File

@ -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;

View File

@ -24,6 +24,7 @@ export {
editTopic, toggleForum, fetchTopicById, createTopic, toggleParticipantsHidden, checkChatlistInvite,
joinChatlistInvite, createChalistInvite, editChatlistInvite, deleteChatlistInvite, fetchChatlistInvites,
fetchLeaveChatlistSuggestions, leaveChatlist, togglePeerTranslations, setViewForumAsMessages,
fetchChannelRecommendations,
} from './chats';
export {

View File

@ -98,6 +98,7 @@ const LIMITS_ORDER: ApiLimitTypeWithoutUpload[] = [
'captionLength',
'dialogFilters',
'dialogFiltersChats',
'recommendedChannels',
];
const LIMITS_TITLES: Record<ApiLimitTypeWithoutUpload, string> = {
@ -110,6 +111,7 @@ const LIMITS_TITLES: Record<ApiLimitTypeWithoutUpload, string> = {
captionLength: 'CaptionsLimitTitle',
dialogFilters: 'FoldersLimitTitle',
dialogFiltersChats: 'ChatPerFolderLimitTitle',
recommendedChannels: 'SimilarChannelsLimitTitle',
};
const LIMITS_DESCRIPTIONS: Record<ApiLimitTypeWithoutUpload, string> = {
@ -122,6 +124,7 @@ const LIMITS_DESCRIPTIONS: Record<ApiLimitTypeWithoutUpload, string> = {
captionLength: 'CaptionsLimitSubtitle',
dialogFilters: 'FoldersLimitSubtitle',
dialogFiltersChats: 'ChatPerFolderLimitSubtitle',
recommendedChannels: 'SimilarChannelsLimitSubtitle',
};
const BORDER_THRESHOLD = 20;

View File

@ -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;
}
}
}
}

View File

@ -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<OwnProps & StateProps> = ({
isChatProtected,
nextProfileTab,
shouldWarnAboutSvg,
similarChannels,
isCurrentUserPremium,
limitSimilarChannels,
}) => {
const {
setLocalMediaSearchType,
@ -172,6 +184,8 @@ const Profile: FC<OwnProps & StateProps> = ({
setNewChatMembersDialogState,
loadPeerPinnedStories,
loadStoriesArchive,
openPremiumModal,
fetchChannelRecommendations,
} = getActions();
// eslint-disable-next-line no-null/no-null
@ -192,7 +206,19 @@ const Profile: FC<OwnProps & StateProps> = ({
// 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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
topicId,
storyIds,
archiveStoryIds,
similarChannels,
);
const isFirstTab = (hasStoriesTab && resultType === 'stories')
|| resultType === 'members'
@ -512,6 +545,35 @@ const Profile: FC<OwnProps & StateProps> = ({
<GroupChatInfo chatId={id} />
</ListItem>
))
) : resultType === 'similarChannels' ? (
<div key={resultType}>
{(viewportIds as string[])!.map((channelId, i) => (
<ListItem
key={channelId}
teactOrderKey={i}
className={buildClassName(
'chat-item-clickable search-result',
!isCurrentUserPremium && i === similarChannels!.length - 1 && 'blured',
)}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => openChat({ id: channelId })}
>
<GroupChatInfo avatarSize="large" chatId={channelId} withFullInfo />
</ListItem>
))}
{!isCurrentUserPremium && (
<>
{/* eslint-disable-next-line react/jsx-no-bind */}
<Button className="show-more-channels" size="smaller" onClick={() => openPremiumModal()}>
{lang('UnlockSimilar')}
<i className="icon icon-unlock-badge" />
</Button>
<div className="more-similar">
{renderText(lang('MoreSimilarText', limitSimilarChannels), ['simple_markdown'])}
</div>
</>
)}
</div>
) : undefined}
</div>
);
@ -604,6 +666,8 @@ export default memo(withGlobal<OwnProps>(
&& (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<OwnProps>(
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 }),
};

View File

@ -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;

View File

@ -337,4 +337,5 @@ export const DEFAULT_LIMITS: Record<ApiLimitType, readonly [number, number]> = {
aboutLength: [70, 140],
chatlistInvites: [3, 100],
chatlistJoined: [2, 20],
recommendedChannels: [10, 100],
};

View File

@ -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<void> => {
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,

View File

@ -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<T extends GlobalState>(global: T): GlobalState['chats'] {
return {
...global.chats,
similarChannelsById: {},
isFullyLoaded: {},
byId: pick(global.chats.byId, idsToSave),
fullInfoById: pick(global.chats.fullInfoById, idsToSave),

View File

@ -105,6 +105,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
totalCount: {},
byId: {},
fullInfoById: {},
similarChannelsById: {},
},
messages: {

View File

@ -401,3 +401,20 @@ export function deleteTopic<T extends GlobalState>(
return global;
}
export function addSimilarChannels<T extends GlobalState>(
global: T,
chatId: string,
similarChannels: string[],
) {
return {
...global,
chats: {
...global.chats,
similarChannelsById: {
...global.chats.similarChannelsById,
[chatId]: similarChannels,
},
},
};
}

View File

@ -316,3 +316,10 @@ export function selectRequestedChatTranslationLanguage<T extends GlobalState>(
return requestedTranslations.byChatId[chatId]?.toLanguage;
}
export function selectSimilarChannelIds<T extends GlobalState>(
global: T,
chatId: string,
): string[] | undefined {
return global.chats.similarChannelsById[chatId];
}

View File

@ -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<ApiLimitType, (
'captionLength' | 'aboutLength' | 'stickersFaved' | 'savedGifs'
'captionLength' | 'aboutLength' | 'stickersFaved' | 'savedGifs' | 'recommendedChannels'
)>;
export type TranslatedMessage = {
@ -771,6 +781,7 @@ export type GlobalState = {
forDiscussionIds?: string[];
// Obtained from GetFullChat / GetFullChannel
fullInfoById: Record<string, ApiChatFullInfo>;
similarChannelsById: Record<string, string[]>;
};
messages: {
@ -1759,6 +1770,9 @@ export interface ActionPayloads {
fetchChat: {
chatId: string;
};
fetchChannelRecommendations: {
chatId: string;
};
updateChatMutedState: {
chatId: string;
isMuted?: boolean;

View File

@ -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;

View File

@ -214,6 +214,7 @@
"channels.toggleUsername",
"channels.viewSponsoredMessage",
"channels.getSponsoredMessages",
"channels.getChannelRecommendations",
"bots.canSendMessage",
"bots.allowSendMessage",
"bots.invokeWebViewCustomMethod",

View File

@ -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';