Global Search: Implement Channels tab (#4549)

This commit is contained in:
Alexander Zinchuk 2024-05-03 14:38:32 +02:00
parent 6b60fb4759
commit dd6bc51b59
12 changed files with 129 additions and 66 deletions

View File

@ -1998,12 +1998,9 @@ export function setViewForumAsMessages({ chat, isEnabled }: { chat: ApiChat; isE
});
}
export async function fetchChannelRecommendations({ chat }: { chat: ApiChat }) {
const { id, accessHash } = chat;
const channel = buildInputEntity(id, accessHash);
export async function fetchChannelRecommendations({ chat }: { chat?: ApiChat }) {
const result = await invokeRequest(new GramJs.channels.GetChannelRecommendations({
channel: channel as GramJs.InputChannel,
channel: chat && buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel,
}));
if (!result) {
return undefined;
@ -2011,12 +2008,13 @@ export async function fetchChannelRecommendations({ chat }: { chat: ApiChat }) {
updateLocalDb(result);
const similarChannels = result?.chats
.map((c) => buildApiChatFromPreview(c))
.filter(Boolean);
return {
similarChannels: result?.chats
.map((_chat) => buildApiChatFromPreview(_chat))
.filter(Boolean),
count:
result instanceof GramJs.messages.ChatsSlice ? result.count : undefined,
similarChannels,
count: result instanceof GramJs.messages.ChatsSlice ? result.count : similarChannels.length,
};
}

View File

@ -1232,6 +1232,7 @@ export async function searchMessagesGlobal({
q: query,
offsetRate,
offsetPeer: new GramJs.InputPeerEmpty(),
broadcastsOnly: type === 'channels' || undefined,
limit,
filter,
folderId: ALL_FOLDER_ID,

View File

@ -786,7 +786,7 @@ export type ApiReplyKeyboard = {
};
export type ApiMessageSearchType = 'text' | 'media' | 'documents' | 'links' | 'audio' | 'voice' | 'profilePhoto';
export type ApiGlobalMessageSearchType = 'text' | 'media' | 'documents' | 'links' | 'audio' | 'voice';
export type ApiGlobalMessageSearchType = 'text' | 'channels' | 'media' | 'documents' | 'links' | 'audio' | 'voice';
export type ApiReportReason = 'spam' | 'violence' | 'pornography' | 'childAbuse'
| 'copyright' | 'geoIrrelevant' | 'fake' | 'illegalDrugs' | 'personalDetails' | 'other';

View File

@ -4,15 +4,16 @@ import React, {
} from '../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../global';
import type { ApiChat, ApiMessage } from '../../../api/types';
import type { ApiMessage } from '../../../api/types';
import { LoadMoreDirection } from '../../../types';
import { ALL_FOLDER_ID } from '../../../config';
import { ALL_FOLDER_ID, GLOBAL_SUGGESTED_CHANNELS_ID } from '../../../config';
import {
filterChatsByName,
filterUsersByName,
isChatChannel,
} from '../../../global/helpers';
import { selectTabState } from '../../../global/selectors';
import { selectSimilarChannelIds, selectTabState } from '../../../global/selectors';
import { getOrderedIds } from '../../../util/folderManager';
import { unique } from '../../../util/iteratees';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
@ -21,6 +22,7 @@ import { renderMessageSummary } from '../../common/helpers/renderMessageText';
import sortChatIds from '../../common/helpers/sortChatIds';
import useAppLayout from '../../../hooks/useAppLayout';
import useEffectOnce from '../../../hooks/useEffectOnce';
import useHorizontalScroll from '../../../hooks/useHorizontalScroll';
import useLang from '../../../hooks/useLang';
@ -37,6 +39,7 @@ export type OwnProps = {
searchQuery?: string;
dateSearchQuery?: string;
searchDate?: number;
isChannelList?: boolean;
onReset: () => void;
onSearchDateSelect: (value: Date) => void;
};
@ -50,8 +53,8 @@ type StateProps = {
globalUserIds?: string[];
foundIds?: string[];
globalMessagesByChatId?: Record<string, { byId: Record<number, ApiMessage> }>;
chatsById: Record<string, ApiChat>;
fetchingStatus?: { chats?: boolean; messages?: boolean };
suggestedChannelIds?: string[];
};
const MIN_QUERY_LENGTH_FOR_GLOBAL_SEARCH = 4;
@ -60,6 +63,7 @@ const LESS_LIST_ITEMS_AMOUNT = 5;
const runThrottled = throttle((cb) => cb(), 500, false);
const ChatResults: FC<OwnProps & StateProps> = ({
isChannelList,
searchQuery,
searchDate,
dateSearchQuery,
@ -71,13 +75,13 @@ const ChatResults: FC<OwnProps & StateProps> = ({
globalUserIds,
foundIds,
globalMessagesByChatId,
chatsById,
fetchingStatus,
suggestedChannelIds,
onReset,
onSearchDateSelect,
}) => {
const {
openChat, addRecentlyFoundChatId, searchMessagesGlobal, setGlobalSearchChatId,
openChat, addRecentlyFoundChatId, searchMessagesGlobal, setGlobalSearchChatId, loadChannelRecommendations,
} = getActions();
// eslint-disable-next-line no-null/no-null
@ -89,11 +93,15 @@ const ChatResults: FC<OwnProps & StateProps> = ({
const [shouldShowMoreLocal, setShouldShowMoreLocal] = useState<boolean>(false);
const [shouldShowMoreGlobal, setShouldShowMoreGlobal] = useState<boolean>(false);
useEffectOnce(() => {
if (isChannelList) loadChannelRecommendations({});
});
const handleLoadMore = useCallback(({ direction }: { direction: LoadMoreDirection }) => {
if (direction === LoadMoreDirection.Backwards) {
runThrottled(() => {
searchMessagesGlobal({
type: 'text',
type: isChannelList ? 'channels' : 'text',
});
});
}
@ -120,21 +128,32 @@ const ChatResults: FC<OwnProps & StateProps> = ({
}, [setGlobalSearchChatId]);
const localResults = useMemo(() => {
if (!searchQuery || (searchQuery.startsWith('@') && searchQuery.length < 2)) {
if (!isChannelList && (!searchQuery || (searchQuery.startsWith('@') && searchQuery.length < 2))) {
return MEMO_EMPTY_ARRAY;
}
// No need for expensive global updates, so we avoid them
const usersById = getGlobal().users.byId;
const chatsById = getGlobal().chats.byId;
const orderedChatIds = getOrderedIds(ALL_FOLDER_ID) ?? [];
const filteredChatIds = orderedChatIds.filter((id) => {
if (!isChannelList) return true;
const chat = chatsById[id];
return chat && isChatChannel(chat);
});
const localChatIds = filterChatsByName(lang, filteredChatIds, chatsById, searchQuery, currentUserId);
if (isChannelList) return localChatIds;
const contactIdsWithMe = [
...(currentUserId ? [currentUserId] : []),
...(contactIds || []),
];
// No need for expensive global updates on users, so we avoid them
const usersById = getGlobal().users.byId;
const localContactIds = filterUsersByName(
contactIdsWithMe, usersById, searchQuery, currentUserId, lang('SavedMessages'),
);
const orderedChatIds = getOrderedIds(ALL_FOLDER_ID) ?? [];
const localChatIds = filterChatsByName(lang, orderedChatIds, chatsById, searchQuery, currentUserId);
const localPeerIds = unique([
...localContactIds,
@ -150,20 +169,27 @@ const ChatResults: FC<OwnProps & StateProps> = ({
...sortChatIds(localPeerIds, undefined, currentUserId ? [currentUserId] : undefined),
...sortChatIds(accountPeerIds),
];
}, [searchQuery, currentUserId, contactIds, lang, accountChatIds, accountUserIds, chatsById]);
}, [searchQuery, lang, currentUserId, contactIds, accountChatIds, accountUserIds, isChannelList]);
useHorizontalScroll(chatSelectionRef, !localResults.length, true);
useHorizontalScroll(chatSelectionRef, !localResults.length || isChannelList, true);
const globalResults = useMemo(() => {
if (!searchQuery || searchQuery.length < MIN_QUERY_LENGTH_FOR_GLOBAL_SEARCH || !globalChatIds || !globalUserIds) {
return MEMO_EMPTY_ARRAY;
}
return sortChatIds(
unique([...globalChatIds, ...globalUserIds]),
true,
);
}, [globalChatIds, globalUserIds, searchQuery]);
// No need for expensive global updates, so we avoid them
const chatsById = getGlobal().chats.byId;
const ids = unique([...globalChatIds, ...globalUserIds]);
const filteredIds = ids.filter((id) => {
if (!isChannelList) return true;
const chat = chatsById[id];
return chat && isChatChannel(chat);
});
return sortChatIds(filteredIds, true);
}, [globalChatIds, globalUserIds, isChannelList, searchQuery]);
const foundMessages = useMemo(() => {
if ((!searchQuery && !searchDate) || !foundIds || foundIds.length === 0) {
@ -188,6 +214,8 @@ const ChatResults: FC<OwnProps & StateProps> = ({
}, [shouldShowMoreGlobal]);
function renderFoundMessage(message: ApiMessage) {
const chatsById = getGlobal().chats.byId;
const text = renderMessageSummary(lang, message);
const chat = chatsById[message.chatId];
@ -207,7 +235,7 @@ const ChatResults: FC<OwnProps & StateProps> = ({
const nothingFound = fetchingStatus && !fetchingStatus.chats && !fetchingStatus.messages
&& !localResults.length && !globalResults.length && !foundMessages.length;
if (!searchQuery && !searchDate) {
if (!searchQuery && !searchDate && !isChannelList) {
return <RecentContacts onReset={onReset} />;
}
@ -234,7 +262,7 @@ const ChatResults: FC<OwnProps & StateProps> = ({
description={lang('ChatList.Search.NoResultsDescription')}
/>
)}
{Boolean(localResults.length) && (
{Boolean(localResults.length) && !isChannelList && (
<div
className="chat-selection no-scrollbar"
dir={lang.isRtl ? 'rtl' : undefined}
@ -257,7 +285,7 @@ const ChatResults: FC<OwnProps & StateProps> = ({
{lang(shouldShowMoreLocal ? 'ChatList.Search.ShowLess' : 'ChatList.Search.ShowMore')}
</Link>
)}
{lang('DialogList.SearchSectionDialogs')}
{lang(isChannelList ? 'SearchMyChannels' : 'DialogList.SearchSectionDialogs')}
</h3>
{localResults.map((id, index) => {
if (!shouldShowMoreLocal && index >= LESS_LIST_ITEMS_AMOUNT) {
@ -298,6 +326,22 @@ const ChatResults: FC<OwnProps & StateProps> = ({
})}
</div>
)}
{Boolean(suggestedChannelIds?.length) && !searchQuery && (
<div className="search-section">
<h3 className="section-heading" dir={lang.isRtl ? 'auto' : undefined}>
{lang('SearchRecommendedChannels')}
</h3>
{suggestedChannelIds.map((id) => {
return (
<LeftSearchResultChat
chatId={id}
withUsername
onClick={handleChatClick}
/>
);
})}
</div>
)}
{Boolean(foundMessages.length) && (
<div className="search-section">
<h3 className="section-heading" dir={lang.isRtl ? 'auto' : undefined}>{lang('SearchMessages')}</h3>
@ -309,18 +353,14 @@ const ChatResults: FC<OwnProps & StateProps> = ({
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const { byId: chatsById } = global.chats;
(global, { isChannelList }): StateProps => {
const { userIds: contactIds } = global.contactList || {};
const {
currentUserId, messages,
} = global;
if (!contactIds) {
return {
chatsById,
};
return {};
}
const {
@ -329,7 +369,8 @@ export default memo(withGlobal<OwnProps>(
const { chatIds: globalChatIds, userIds: globalUserIds } = globalResults || {};
const { chatIds: accountChatIds, userIds: accountUserIds } = localResults || {};
const { byChatId: globalMessagesByChatId } = messages;
const foundIds = resultsByType?.text?.foundIds;
const foundIds = resultsByType?.[isChannelList ? 'channels' : 'text']?.foundIds;
const { similarChannelIds } = selectSimilarChannelIds(global, GLOBAL_SUGGESTED_CHANNELS_ID) || {};
return {
currentUserId,
@ -340,8 +381,8 @@ export default memo(withGlobal<OwnProps>(
globalUserIds,
foundIds,
globalMessagesByChatId,
chatsById,
fetchingStatus,
suggestedChannelIds: similarChannelIds,
};
},
)(ChatResults));

View File

@ -1,6 +1,8 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useMemo, useRef,
memo,
useMemo,
useRef,
useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
@ -13,6 +15,7 @@ import { parseDateString } from '../../../util/date/dateFormat';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useKeyboardListNavigation from '../../../hooks/useKeyboardListNavigation';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import TabList from '../../ui/TabList';
import Transition from '../../ui/Transition';
@ -39,6 +42,7 @@ type StateProps = {
const TABS = [
{ type: GlobalSearchContent.ChatList, title: 'SearchAllChatsShort' },
{ type: GlobalSearchContent.ChannelList, title: 'ChannelsTab' },
{ type: GlobalSearchContent.Media, title: 'SharedMediaTab2' },
{ type: GlobalSearchContent.Links, title: 'SharedLinksTab2' },
{ type: GlobalSearchContent.Files, title: 'SharedFilesTab2' },
@ -48,11 +52,9 @@ const TABS = [
const CHAT_TABS = [
{ type: GlobalSearchContent.ChatList, title: 'All Messages' },
...TABS.slice(1),
...TABS.slice(2), // Skip ChatList and ChannelList, replaced with All Messages
];
const TRANSITION_RENDER_COUNT = Object.keys(GlobalSearchContent).length / 2;
const LeftSearch: FC<OwnProps & StateProps> = ({
searchQuery,
searchDate,
@ -70,15 +72,17 @@ const LeftSearch: FC<OwnProps & StateProps> = ({
const [activeTab, setActiveTab] = useState(currentContent);
const dateSearchQuery = useMemo(() => parseDateString(searchQuery), [searchQuery]);
const handleSwitchTab = useCallback((index: number) => {
const tab = TABS[index];
const tabs = chatId ? CHAT_TABS : TABS;
const handleSwitchTab = useLastCallback((index: number) => {
const tab = tabs[index];
setGlobalSearchContent({ content: tab.type });
setActiveTab(index);
}, [setGlobalSearchContent]);
});
const handleSearchDateSelect = useCallback((value: Date) => {
const handleSearchDateSelect = useLastCallback((value: Date) => {
setGlobalSearchDate({ date: value.getTime() / 1000 });
}, [setGlobalSearchDate]);
});
useHistoryBack({
isActive,
@ -91,15 +95,16 @@ const LeftSearch: FC<OwnProps & StateProps> = ({
return (
<div className="LeftSearch" ref={containerRef} onKeyDown={handleKeyDown}>
<TabList activeTab={activeTab} tabs={chatId ? CHAT_TABS : TABS} onSwitchTab={handleSwitchTab} />
<TabList activeTab={activeTab} tabs={tabs} onSwitchTab={handleSwitchTab} />
<Transition
name={lang.isRtl ? 'slideOptimizedRtl' : 'slideOptimized'}
renderCount={TRANSITION_RENDER_COUNT}
renderCount={tabs.length}
activeKey={currentContent}
>
{(() => {
switch (currentContent) {
case GlobalSearchContent.ChatList:
case GlobalSearchContent.ChannelList:
if (chatId) {
return (
<ChatMessageResults
@ -112,6 +117,7 @@ const LeftSearch: FC<OwnProps & StateProps> = ({
}
return (
<ChatResults
isChannelList={currentContent === GlobalSearchContent.ChannelList}
searchQuery={searchQuery}
searchDate={searchDate}
dateSearchQuery={dateSearchQuery}

View File

@ -197,7 +197,7 @@ const Profile: FC<OwnProps & StateProps> = ({
loadPeerPinnedStories,
loadStoriesArchive,
openPremiumModal,
fetchChannelRecommendations,
loadChannelRecommendations,
} = getActions();
// eslint-disable-next-line no-null/no-null
@ -257,7 +257,7 @@ const Profile: FC<OwnProps & StateProps> = ({
useEffect(() => {
if (isChannel && !similarChannels) {
fetchChannelRecommendations({ chatId });
loadChannelRecommendations({ chatId });
}
}, [chatId, isChannel, similarChannels]);

View File

@ -93,6 +93,8 @@ export const STORY_VIEWS_MIN_SEARCH = 15;
export const STORY_MIN_REACTIONS_SORT = 10;
export const STORY_VIEWS_MIN_CONTACTS_FILTER = 20;
export const GLOBAL_SUGGESTED_CHANNELS_ID = 'global';
// As in Telegram for Android
// https://github.com/DrKLO/Telegram/blob/51e9947527/TMessagesProj/src/main/java/org/telegram/messenger/MediaDataController.java#L7799
export const TOP_REACTIONS_LIMIT = 100;

View File

@ -21,6 +21,7 @@ import {
CHAT_LIST_LOAD_SLICE,
DEBUG,
GLOBAL_STATE_CACHE_ARCHIVED_CHAT_LIST_LIMIT,
GLOBAL_SUGGESTED_CHANNELS_ID,
RE_TG_LINK,
SAVED_FOLDER_ID,
SERVICE_NOTIFICATIONS_USER_ID,
@ -105,6 +106,7 @@ import {
selectIsChatPinned,
selectIsChatWithSelf,
selectLastServiceNotification,
selectSimilarChannelIds,
selectStickerSet,
selectSupportChat,
selectTabState,
@ -2615,25 +2617,34 @@ addActionHandler('setViewForumAsMessages', (global, actions, payload): ActionRet
void callApi('setViewForumAsMessages', { chat, isEnabled });
});
addActionHandler('fetchChannelRecommendations', async (global, actions, payload): Promise<void> => {
addActionHandler('loadChannelRecommendations', async (global, actions, payload): Promise<void> => {
const { chatId } = payload;
const chat = selectChat(global, chatId);
const chat = chatId ? selectChat(global, chatId) : undefined;
if (!chat) {
if (chatId && !chat) {
return;
}
const { similarChannels, count } = await callApi('fetchChannelRecommendations', {
if (!chatId) {
const similarChannelIds = selectSimilarChannelIds(global, GLOBAL_SUGGESTED_CHANNELS_ID);
if (similarChannelIds) return; // Already cached
}
const result = await callApi('fetchChannelRecommendations', {
chat,
}) || {};
});
if (!similarChannels) {
if (!result) {
return;
}
const { similarChannels, count } = result;
const chatsById = buildCollectionByKey(similarChannels, 'id');
global = getGlobal();
global = addChats(global, buildCollectionByKey(similarChannels, 'id'));
global = addSimilarChannels(global, chatId, similarChannels.map((channel) => channel.id), count);
global = addChats(global, chatsById);
global = addSimilarChannels(global, chatId || GLOBAL_SUGGESTED_CHANNELS_ID, Object.keys(chatsById), count);
setGlobal(global);
});

View File

@ -95,7 +95,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
const listType = selectChatListType(global, update.id);
const chat = selectChat(global, update.id);
if (chat && isChatChannel(chat)) {
actions.fetchChannelRecommendations({ chatId: chat.id });
actions.loadChannelRecommendations({ chatId: chat.id });
const lastMessageId = selectChatLastMessageId(global, chat.id);
const localMessage = buildLocalMessage(chat, lastMessageId);
localMessage.content.action = {

View File

@ -184,7 +184,10 @@ export function addChats<T extends GlobalState>(global: T, newById: Record<strin
const existingChat = byId[id];
const newChat = newById[id];
if (existingChat && !existingChat.isMin && (newChat.isMin || existingChat.accessHash === newChat.accessHash)) {
const membersCountChanged = !existingChat?.membersCount && newChat.membersCount;
if (existingChat && !existingChat.isMin && !membersCountChanged
&& (newChat.isMin || existingChat.accessHash === newChat.accessHash)) {
return acc;
}

View File

@ -1931,8 +1931,8 @@ export interface ActionPayloads {
fetchChat: {
chatId: string;
};
fetchChannelRecommendations: {
chatId: string;
loadChannelRecommendations: {
chatId?: string;
};
toggleChannelRecommendations: {
chatId: string;

View File

@ -276,6 +276,7 @@ export enum LeftColumnContent {
export enum GlobalSearchContent {
ChatList,
ChannelList,
Media,
Links,
Files,