From d0ad84217d93a5412aee7c1a01b3d98c6acfb47b Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 3 Jan 2025 17:15:32 +0100 Subject: [PATCH] Global Search: Support filters (#5386) Co-authored-by: Alexander Zinchuk --- src/api/gramjs/methods/messages.ts | 8 +- src/api/types/messages.ts | 1 + src/assets/localization/fallback.strings | 5 + src/components/left/search/ChatResults.scss | 44 +++++ src/components/left/search/ChatResults.tsx | 188 +++++++++++++++++--- src/global/actions/api/globalSearch.ts | 32 +++- src/global/reducers/globalSearch.ts | 1 + src/global/types/actions.ts | 4 + src/types/language.d.ts | 7 + 9 files changed, 255 insertions(+), 35 deletions(-) create mode 100644 src/components/left/search/ChatResults.scss diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 9a647f5b0..df9cef011 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -12,6 +12,7 @@ import type { ApiInputReplyInfo, ApiMessage, ApiMessageEntity, + ApiMessageSearchContext, ApiMessageSearchType, ApiNewPoll, ApiOnProgress, @@ -1256,7 +1257,7 @@ export async function searchMessagesInChat({ } export async function searchMessagesGlobal({ - query, offsetRate = 0, offsetPeer, offsetId, limit, type = 'text', minDate, maxDate, + query, offsetRate = 0, offsetPeer, offsetId, limit, type = 'text', minDate, maxDate, context = 'all', }: { query: string; offsetRate?: number; @@ -1264,6 +1265,7 @@ export async function searchMessagesGlobal({ offsetId?: number; limit: number; type?: ApiGlobalMessageSearchType; + context?: ApiMessageSearchContext; minDate?: number; maxDate?: number; }): Promise { @@ -1301,7 +1303,9 @@ export async function searchMessagesGlobal({ offsetRate, offsetPeer: peer, offsetId, - broadcastsOnly: type === 'channels' || undefined, + broadcastsOnly: type === 'channels' || context === 'channels' || undefined, + groupsOnly: context === 'groups' || undefined, + usersOnly: context === 'users' || undefined, limit, filter, minDate, diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 0157a5122..692b3f93f 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -1008,6 +1008,7 @@ export type ApiTranscription = { export type ApiMessageSearchType = 'text' | 'media' | 'documents' | 'links' | 'audio' | 'voice' | 'profilePhoto'; export type ApiGlobalMessageSearchType = 'text' | 'channels' | 'media' | 'documents' | 'links' | 'audio' | 'voice'; +export type ApiMessageSearchContext = 'all' | 'users' | 'groups' | 'channels'; export type ApiReportReason = 'spam' | 'violence' | 'pornography' | 'childAbuse' | 'copyright' | 'geoIrrelevant' | 'fake' | 'illegalDrugs' | 'personalDetails' | 'other'; diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 943a8c755..c6603343a 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -1406,6 +1406,11 @@ "StarsSubscribeBotText_one" = "Do you want to subscribe to **{name}** in **{bot}** for **{amount}** star per month?" "StarsSubscribeBotText_other" = "Do you want to subscribe to **{name}** in **{bot}** for **{amount}** stars per month?" "StarsSubscribeBotButtonMonth" = "Subscribe for {amount} / month"; +"AllChatsSearchContext" = "All Chats"; +"PrivateChatsSearchContext" = "Private Chats"; +"GroupChatsSearchContext" = "Group Chats"; +"ChannelsSearchContext" = "Channels"; +"SearchContextCaption" = "From {type}"; "FolderLinkTitleDescription" = "Anyone with this link can add {folder} folder and {chats} selected below."; "FolderLinkTitleDescriptionChats_one" = "the chat"; "FolderLinkTitleDescriptionChats_other" = "the {count} chats"; diff --git a/src/components/left/search/ChatResults.scss b/src/components/left/search/ChatResults.scss new file mode 100644 index 000000000..0f1fb3a09 --- /dev/null +++ b/src/components/left/search/ChatResults.scss @@ -0,0 +1,44 @@ +.iconPlaceholder { + width: 1.5rem; +} + +.chatResultsContextMenu { + position: absolute; + right: 0.75rem; + top: 2.5rem !important; + + .bubble { + width: auto; + } +} + +.dropDownLink { + align-items: center; + display: flex; + + .Loading { + height: 1rem !important; + margin-bottom: 0 !important; + + .Spinner { + --spinner-size: 1rem !important; + } + } + + .iconContainer { + width: 1rem; + flex-shrink: 0; + margin-inline-start: 0.25rem; + } + + .iconContainerSlide { + display: flex; + align-items: center; + justify-content: center; + } +} + +.menuOwner { + position: relative; + min-height: 20rem; +} diff --git a/src/components/left/search/ChatResults.tsx b/src/components/left/search/ChatResults.tsx index c0803cbaf..a10eaa988 100644 --- a/src/components/left/search/ChatResults.tsx +++ b/src/components/left/search/ChatResults.tsx @@ -1,10 +1,11 @@ import type { FC } from '../../../lib/teact/teact'; import React, { - memo, useCallback, useMemo, useRef, useState, + memo, useCallback, useEffect, + useMemo, useRef, useState, } from '../../../lib/teact/teact'; import { getActions, getGlobal, withGlobal } from '../../../global'; -import type { ApiMessage } from '../../../api/types'; +import type { ApiMessage, ApiMessageSearchContext } from '../../../api/types'; import { LoadMoreDirection } from '../../../types'; import { ALL_FOLDER_ID, GLOBAL_SUGGESTED_CHANNELS_ID } from '../../../config'; @@ -14,6 +15,7 @@ import { isChatChannel, } from '../../../global/helpers'; import { selectSimilarChannelIds, selectTabState } from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; import { getOrderedIds } from '../../../util/folderManager'; import { unique } from '../../../util/iteratees'; import { parseSearchResultKey, type SearchResultKey } from '../../../util/keys/searchResultKey'; @@ -23,19 +25,29 @@ import { renderMessageSummary } from '../../common/helpers/renderMessageText'; import sortChatIds from '../../common/helpers/sortChatIds'; import useAppLayout from '../../../hooks/useAppLayout'; +import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import useEffectOnce from '../../../hooks/useEffectOnce'; import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; +import Icon from '../../common/icons/Icon'; import NothingFound from '../../common/NothingFound'; import PeerChip from '../../common/PeerChip'; import InfiniteScroll from '../../ui/InfiniteScroll'; import Link from '../../ui/Link'; +import Loading from '../../ui/Loading'; +import Menu from '../../ui/Menu'; +import MenuItem from '../../ui/MenuItem'; +import Transition from '../../ui/Transition'; import ChatMessage from './ChatMessage'; import DateSuggest from './DateSuggest'; import LeftSearchResultChat from './LeftSearchResultChat'; import RecentContacts from './RecentContacts'; +import './ChatResults.scss'; + export type OwnProps = { searchQuery?: string; dateSearchQuery?: string; @@ -78,17 +90,22 @@ const ChatResults: FC = ({ onSearchDateSelect, }) => { const { - openChat, addRecentlyFoundChatId, searchMessagesGlobal, setGlobalSearchChatId, loadChannelRecommendations, + openChat, addRecentlyFoundChatId, searchMessagesGlobal, + setGlobalSearchChatId, loadChannelRecommendations, } = getActions(); // eslint-disable-next-line no-null/no-null const chatSelectionRef = useRef(null); - const lang = useOldLang(); + const oldLang = useOldLang(); + const lang = useLang(); const { isMobile } = useAppLayout(); const [shouldShowMoreLocal, setShouldShowMoreLocal] = useState(false); const [shouldShowMoreGlobal, setShouldShowMoreGlobal] = useState(false); + const [searchContext, setSearchContext] = useState('all'); + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); useEffectOnce(() => { if (isChannelList) loadChannelRecommendations({}); @@ -99,11 +116,12 @@ const ChatResults: FC = ({ runThrottled(() => { searchMessagesGlobal({ type: isChannelList ? 'channels' : 'text', + context: searchContext, }); }); } // eslint-disable-next-line react-hooks-static-deps/exhaustive-deps -- `searchQuery` is required to prevent infinite message loading - }, [searchQuery]); + }, [searchQuery, searchContext]); const handleChatClick = useCallback( (id: string) => { @@ -124,6 +142,79 @@ const ChatResults: FC = ({ setGlobalSearchChatId({ id }); }, [setGlobalSearchChatId]); + function getSearchContextCaption(context: ApiMessageSearchContext) { + if (context === 'users') return lang('PrivateChatsSearchContext'); + if (context === 'groups') return lang('GroupChatsSearchContext'); + if (context === 'channels') return lang('ChannelsSearchContext'); + return lang('AllChatsSearchContext'); + } + + const { + isContextMenuOpen, contextMenuAnchor, handleContextMenu, + handleContextMenuClose, handleContextMenuHide, + } = useContextMenuHandlers(ref); + + const getRootElement = useLastCallback(() => ref.current!); + const getMenuElement = useLastCallback(() => ref.current!.querySelector('.chatResultsContextMenu .bubble')); + const getTriggerElement = useLastCallback(() => ref.current!.querySelector('.menuTrigger')); + + const handleClickContext = useLastCallback((e: React.MouseEvent): void => { + handleContextMenu(e); + }); + + const itemPlaceholderClass = buildClassName('icon', 'iconPlaceholder'); + + function renderContextMenu() { + return ( + + <> + : undefined} + // eslint-disable-next-line react/jsx-no-bind + onClick={() => setSearchContext('all')} + > + {getSearchContextCaption('all')} + + : undefined} + // eslint-disable-next-line react/jsx-no-bind + onClick={() => setSearchContext('users')} + > + {getSearchContextCaption('users')} + + : undefined} + // eslint-disable-next-line react/jsx-no-bind + onClick={() => setSearchContext('groups')} + > + {getSearchContextCaption('groups')} + + : undefined} + // eslint-disable-next-line react/jsx-no-bind + onClick={() => setSearchContext('channels')} + > + {getSearchContextCaption('channels')} + + + + ); + } + const localResults = useMemo(() => { if (!isChannelList && (!searchQuery || (searchQuery.startsWith('@') && searchQuery.length < 2))) { return MEMO_EMPTY_ARRAY; @@ -139,7 +230,7 @@ const ChatResults: FC = ({ const chat = chatsById[id]; return chat && isChatChannel(chat); }); - const localChatIds = filterChatsByName(lang, filteredChatIds, chatsById, searchQuery, currentUserId); + const localChatIds = filterChatsByName(oldLang, filteredChatIds, chatsById, searchQuery, currentUserId); if (isChannelList) return localChatIds; @@ -149,7 +240,7 @@ const ChatResults: FC = ({ ]; const localContactIds = filterUsersByName( - contactIdsWithMe, usersById, searchQuery, currentUserId, lang('SavedMessages'), + contactIdsWithMe, usersById, searchQuery, currentUserId, oldLang('SavedMessages'), ); const localPeerIds = [ @@ -161,7 +252,7 @@ const ChatResults: FC = ({ ...sortChatIds(localPeerIds, undefined, currentUserId ? [currentUserId] : undefined), ...sortChatIds(accountPeerIds || []), ]); - }, [searchQuery, lang, currentUserId, contactIds, accountPeerIds, isChannelList]); + }, [searchQuery, oldLang, currentUserId, contactIds, accountPeerIds, isChannelList]); useHorizontalScroll(chatSelectionRef, !localResults.length || isChannelList, true); @@ -202,6 +293,17 @@ const ChatResults: FC = ({ .filter(Boolean); }, [searchQuery, searchDate, foundIds, isChannelList, globalMessagesByChatId]); + useEffect(() => { + if (!searchQuery) return; + searchMessagesGlobal({ + type: isChannelList ? 'channels' : 'text', + context: searchContext, + shouldResetResultsByType: true, + shouldCheckFetchingMessagesStatus: true, + }); + // eslint-disable-next-line react-hooks-static-deps/exhaustive-deps + }, [searchContext]); + const handleClickShowMoreLocal = useCallback(() => { setShouldShowMoreLocal(!shouldShowMoreLocal); }, [shouldShowMoreLocal]); @@ -213,7 +315,7 @@ const ChatResults: FC = ({ function renderFoundMessage(message: ApiMessage) { const chatsById = getGlobal().chats.byId; - const text = renderMessageSummary(lang, message); + const text = renderMessageSummary(oldLang, message); const chat = chatsById[message.chatId]; if (!text || !chat) { @@ -229,17 +331,22 @@ const ChatResults: FC = ({ ); } - const nothingFound = fetchingStatus && !fetchingStatus.chats && !fetchingStatus.messages - && !localResults.length && !globalResults.length && !foundMessages.length; + const actualFoundIds = foundMessages; + + const nothingFound = searchContext === 'all' && fetchingStatus && !fetchingStatus.chats && !fetchingStatus.messages + && !localResults.length && !globalResults.length && !actualFoundIds.length; + const isMessagesFetching = fetchingStatus?.messages; if (!searchQuery && !searchDate && !isChannelList) { return ; } + const shouldRenderMessagesSection = searchContext === 'all' ? Boolean(actualFoundIds.length) : true; + return ( = ({ )} {nothingFound && ( )} {Boolean(localResults.length) && !isChannelList && (
{localResults.map((id) => ( @@ -277,13 +384,13 @@ const ChatResults: FC = ({ )} {Boolean(localResults.length) && (
-

+

{localResults.length > LESS_LIST_ITEMS_AMOUNT && ( - {lang(shouldShowMoreLocal ? 'ChatList.Search.ShowLess' : 'ChatList.Search.ShowMore')} + {oldLang(shouldShowMoreLocal ? 'ChatList.Search.ShowLess' : 'ChatList.Search.ShowMore')} )} - {lang(isChannelList ? 'SearchMyChannels' : 'DialogList.SearchSectionDialogs')} + {oldLang(isChannelList ? 'SearchMyChannels' : 'DialogList.SearchSectionDialogs')}

{localResults.map((id, index) => { if (!shouldShowMoreLocal && index >= LESS_LIST_ITEMS_AMOUNT) { @@ -301,13 +408,13 @@ const ChatResults: FC = ({ )} {Boolean(globalResults.length) && (
-

+

{globalResults.length > LESS_LIST_ITEMS_AMOUNT && ( - {lang(shouldShowMoreGlobal ? 'ChatList.Search.ShowLess' : 'ChatList.Search.ShowMore')} + {oldLang(shouldShowMoreGlobal ? 'ChatList.Search.ShowLess' : 'ChatList.Search.ShowMore')} )} - {lang('DialogList.SearchSectionGlobal')} + {oldLang('DialogList.SearchSectionGlobal')}

{globalResults.map((id, index) => { if (!shouldShowMoreGlobal && index >= LESS_LIST_ITEMS_AMOUNT) { @@ -326,8 +433,8 @@ const ChatResults: FC = ({ )} {Boolean(suggestedChannelIds?.length) && !searchQuery && (
-

- {lang('SearchRecommendedChannels')} +

+ {oldLang('SearchRecommendedChannels')}

{suggestedChannelIds.map((id) => { return ( @@ -340,12 +447,35 @@ const ChatResults: FC = ({ })}
)} - {Boolean(foundMessages.length) && ( -
-

{lang('SearchMessages')}

- {foundMessages.map(renderFoundMessage)} -
- )} +
+ {renderContextMenu()} + {shouldRenderMessagesSection && ( +
+

+ + {lang('SearchContextCaption', { + type: getSearchContextCaption(searchContext), + }, { + withNodes: true, + })} + + + {isMessagesFetching && ()} + {!isMessagesFetching && } + + + {oldLang('SearchMessages')} +

+ {actualFoundIds.map(renderFoundMessage)} +
+ )} +
); }; diff --git a/src/global/actions/api/globalSearch.ts b/src/global/actions/api/globalSearch.ts index b4dde6149..4c4b0289a 100644 --- a/src/global/actions/api/globalSearch.ts +++ b/src/global/actions/api/globalSearch.ts @@ -1,5 +1,5 @@ import type { - ApiChat, ApiGlobalMessageSearchType, ApiMessage, ApiPeer, ApiTopic, + ApiChat, ApiGlobalMessageSearchType, ApiMessage, ApiMessageSearchContext, ApiPeer, ApiTopic, ApiUserStatus, } from '../../../api/types'; import type { ActionReturnType, GlobalState, TabArgs } from '../../types'; @@ -86,13 +86,22 @@ addActionHandler('setGlobalSearchDate', (global, actions, payload): ActionReturn }); addActionHandler('searchMessagesGlobal', (global, actions, payload): ActionReturnType => { - const { type, tabId = getCurrentTabId() } = payload; + const { + type, context, shouldResetResultsByType, shouldCheckFetchingMessagesStatus, tabId = getCurrentTabId(), + } = payload; + + if (shouldCheckFetchingMessagesStatus) { + global = updateGlobalSearchFetchingStatus(global, { messages: true }, tabId); + setGlobal(global); + global = getGlobal(); + } + const { query, resultsByType, chatId, } = selectTabState(global, tabId).globalSearch; const { totalCount, foundIds, nextOffsetId, nextOffsetPeerId, nextOffsetRate, - } = resultsByType?.[type] || {}; + } = (!shouldResetResultsByType && resultsByType?.[type]) || {}; // Stop loading if we have all the messages or server returned 0 if (totalCount !== undefined && (!totalCount || (foundIds && foundIds.length >= totalCount))) { @@ -105,6 +114,8 @@ addActionHandler('searchMessagesGlobal', (global, actions, payload): ActionRetur searchMessagesGlobal(global, { query, type, + context, + shouldResetResultsByType, offsetRate: nextOffsetRate, offsetId: nextOffsetId, offsetPeer, @@ -145,6 +156,7 @@ addActionHandler('searchPopularBotApps', async (global, actions, payload): Promi async function searchMessagesGlobal(global: T, params: { query?: string; type: ApiGlobalMessageSearchType; + context?: ApiMessageSearchContext; offsetRate?: number; offsetId?: number; offsetPeer?: ApiPeer; @@ -152,9 +164,11 @@ async function searchMessagesGlobal(global: T, params: { maxDate?: number; minDate?: number; tabId: TabArgs[0]; + shouldResetResultsByType?: boolean; }) { const { - query = '', type, offsetRate, offsetId, offsetPeer, peer, maxDate, minDate, tabId = getCurrentTabId(), + query = '', type, context, offsetRate, offsetId, offsetPeer, + peer, maxDate, minDate, shouldResetResultsByType, tabId = getCurrentTabId(), } = params; let result: { messages: ApiMessage[]; @@ -211,6 +225,7 @@ async function searchMessagesGlobal(global: T, params: { offsetPeer, limit: GLOBAL_SEARCH_SLICE, type, + context, maxDate, minDate, }); @@ -225,6 +240,15 @@ async function searchMessagesGlobal(global: T, params: { } global = getGlobal(); + + if (shouldResetResultsByType) { + global = updateGlobalSearch(global, { + resultsByType: { + ...(selectTabState(global, tabId).globalSearch || {}).resultsByType, + [type]: undefined, + }, + }, tabId); + } const currentSearchQuery = selectCurrentGlobalSearchQuery(global, tabId); if (!result || (query !== '' && query !== currentSearchQuery)) { global = updateGlobalSearchFetchingStatus(global, { messages: false }, tabId); diff --git a/src/global/reducers/globalSearch.ts b/src/global/reducers/globalSearch.ts index 005532a19..0fbfb4130 100644 --- a/src/global/reducers/globalSearch.ts +++ b/src/global/reducers/globalSearch.ts @@ -57,6 +57,7 @@ export function updateGlobalSearchResults( resultsByType: { ...(selectTabState(global, tabId).globalSearch || {}).resultsByType, [type]: { + foundIds: foundIdsForType, totalCount, nextOffsetId, nextOffsetRate, diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index 6c6ac9802..3d47eb330 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -21,6 +21,7 @@ import type { ApiLimitTypeWithModal, ApiMessage, ApiMessageEntity, + ApiMessageSearchContext, ApiNewPoll, ApiNotification, ApiPaymentStatus, @@ -386,6 +387,9 @@ export interface ActionPayloads { } & WithTabId; searchMessagesGlobal: { type: ApiGlobalMessageSearchType; + context?: ApiMessageSearchContext; + shouldResetResultsByType?: boolean; + shouldCheckFetchingMessagesStatus?: boolean; } & WithTabId; searchPopularBotApps: WithTabId | undefined; addRecentlyFoundChatId: { diff --git a/src/types/language.d.ts b/src/types/language.d.ts index f0bf27989..a3a4190b5 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -1168,6 +1168,10 @@ export interface LangPair { 'PrivacyGiftsInfo': undefined; 'PrivacyValueBots': undefined; 'CustomShareGiftsInfo': undefined; + 'AllChatsSearchContext': undefined; + 'PrivateChatsSearchContext': undefined; + 'GroupChatsSearchContext': undefined; + 'ChannelsSearchContext': undefined; } export interface LangPairWithVariables { @@ -1574,6 +1578,9 @@ export interface LangPairWithVariables { 'StarsSubscribeBotButtonMonth': { 'amount': V; }; + 'SearchContextCaption': { + 'type': V; + }; 'FolderLinkTitleDescription': { 'folder': V; 'chats': V;