diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 6aba17527..edc502e59 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -1476,6 +1476,7 @@ export async function searchMessagesInChat({ offsetId, addOffset, limit, + fromPeer, }: { peer: ApiPeer; isSavedDialog?: boolean; @@ -1488,6 +1489,7 @@ export async function searchMessagesInChat({ limit: number; minDate?: number; maxDate?: number; + fromPeer?: ApiPeer; }): Promise { let filter; switch (type) { @@ -1519,6 +1521,7 @@ export async function searchMessagesInChat({ } const inputPeer = buildInputPeer(peer.id, peer.accessHash); + const inputFromPeer = fromPeer ? buildInputPeer(fromPeer.id, fromPeer.accessHash) : undefined; const result = await invokeRequest(new GramJs.messages.Search({ peer: isSavedDialog ? new GramJs.InputPeerSelf() : inputPeer, @@ -1527,6 +1530,7 @@ export async function searchMessagesInChat({ topMsgId: threadId !== MAIN_THREAD_ID && !isSavedDialog ? Number(threadId) : undefined, filter, q: query, + fromId: inputFromPeer, minDate: minDate ?? DEFAULT_PRIMITIVES.INT, maxDate: maxDate ?? DEFAULT_PRIMITIVES.INT, maxId: DEFAULT_PRIMITIVES.INT, diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 0e9c8cb9c..02f66ed14 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -1711,6 +1711,7 @@ "GroupChatsSearchContext" = "Group Chats"; "ChannelsSearchContext" = "Channels"; "SearchContextCaption" = "From {type}"; +"SearchFilterFrom" = "From:"; "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/middle/message/SenderGroupContainer.tsx b/src/components/middle/message/SenderGroupContainer.tsx index dc01a345c..27d881149 100644 --- a/src/components/middle/message/SenderGroupContainer.tsx +++ b/src/components/middle/message/SenderGroupContainer.tsx @@ -19,10 +19,12 @@ import { getMainUsername, isAnonymousForwardsChat, isAnonymousOwnMessage, + isChatChannel, isSystemBot, } from '../../../global/helpers'; import { isApiPeerUser } from '../../../global/helpers/peers'; import { + selectChat, selectForwardedSender, selectIsChatWithSelf, selectSender, @@ -58,6 +60,7 @@ type StateProps = { isChatWithSelf?: boolean; isRepliesChat?: boolean; isAnonymousForwards?: boolean; + isChannel?: boolean; }; const SenderGroupContainer: FC = ({ @@ -72,9 +75,10 @@ const SenderGroupContainer: FC = ({ isChatWithSelf, isRepliesChat, isAnonymousForwards, + isChannel, canPost, }) => { - const { openChat, updateInsertingPeerIdMention } = getActions(); + const { openChat, updateInsertingPeerIdMention, openMiddleSearch } = getActions(); const { forwardInfo } = message; @@ -115,6 +119,14 @@ const SenderGroupContainer: FC = ({ } }); + const handleSearchMessages = useLastCallback(() => { + if (!avatarPeer) { + return; + } + + openMiddleSearch({ fromPeerId: avatarPeer.id }); + }); + const handleAvatarClick = useLastCallback(() => { handleOpenChat(); }); @@ -142,7 +154,8 @@ const SenderGroupContainer: FC = ({ const getLayout = useLastCallback(() => ({ withPortal: true })); const canMention = canPost && avatarPeer && (isAvatarPeerUser || Boolean(getMainUsername(avatarPeer))); - const shouldRenderContextMenu = Boolean(contextMenuAnchor) && (isAvatarPeerUser || canMention); + const canSearch = !isChannel; + const shouldRenderContextMenu = Boolean(contextMenuAnchor) && (isAvatarPeerUser || canMention || canSearch); function renderContextMenu() { return ( @@ -176,6 +189,14 @@ const SenderGroupContainer: FC = ({ {lang('ContextMenuItemMention')} )} + {canSearch && ( + + {lang('Search')} + + )} ); @@ -221,6 +242,7 @@ export default memo(withGlobal( } = ownProps; const { chatId } = message; + const chat = selectChat(global, chatId); const isChatWithSelf = selectIsChatWithSelf(global, chatId); const isSystemBotChat = isSystemBot(chatId); const isAnonymousForwards = isAnonymousForwardsChat(chatId); @@ -237,6 +259,7 @@ export default memo(withGlobal( isChatWithSelf, isRepliesChat: isSystemBotChat, isAnonymousForwards, + isChannel: chat && isChatChannel(chat), }; }, )(SenderGroupContainer)); diff --git a/src/components/middle/search/MiddleSearch.module.scss b/src/components/middle/search/MiddleSearch.module.scss index 3f554360c..e0cf989cd 100644 --- a/src/components/middle/search/MiddleSearch.module.scss +++ b/src/components/middle/search/MiddleSearch.module.scss @@ -202,6 +202,21 @@ color: var(--color-text-secondary); } +.fromTag { + + --color-chat-hover: var(--color-borders); + + display: flex; + gap: 0.25rem; + align-items: center; + + margin-inline-start: 0.5rem; + + font-size: 1rem; + color: var(--color-text); + white-space: nowrap; +} + .searchTypes { overflow-x: scroll; display: flex; @@ -235,6 +250,12 @@ } } +.icons { + display: flex; + gap: 0.25rem; + align-items: center; +} + .footer { pointer-events: auto; @@ -306,3 +327,9 @@ transform: translateY(0); } } + +.memberItem { + :global(.ListItem-button) { + padding: 0.375rem 0.75rem !important; + } +} diff --git a/src/components/middle/search/MiddleSearch.tsx b/src/components/middle/search/MiddleSearch.tsx index 3c1b80f3f..3b2310b2f 100644 --- a/src/components/middle/search/MiddleSearch.tsx +++ b/src/components/middle/search/MiddleSearch.tsx @@ -16,7 +16,7 @@ import type { import { ANONYMOUS_USER_ID } from '../../../config'; import { requestMeasure, requestMutation, requestNextMutation } from '../../../lib/fasterdom/fasterdom'; import { - getIsSavedDialog, getReactionKey, isSameReaction, isSystemBot, + getIsSavedDialog, getReactionKey, isChatGroup, isSameReaction, isSystemBot, } from '../../../global/helpers'; import { selectChat, @@ -27,6 +27,7 @@ import { selectIsChatWithSelf, selectIsCurrentUserPremium, selectMonoforumChannel, + selectPeer, selectSender, selectTabState, } from '../../../global/selectors'; @@ -50,11 +51,13 @@ import useKeyboardListNavigation from '../../../hooks/useKeyboardListNavigation' import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; +import usePeerSearch, { prepareChatMemberSearch } from '../../../hooks/usePeerSearch'; import Avatar from '../../common/Avatar'; import PeerChip from '../../common/PeerChip'; import Button from '../../ui/Button'; import InfiniteScroll from '../../ui/InfiniteScroll'; +import Loading from '../../ui/Loading'; import SearchInput from '../../ui/SearchInput'; import SavedTagButton from '../message/reactions/SavedTagButton'; import MiddleSearchResult from './MiddleSearchResult'; @@ -82,6 +85,8 @@ type StateProps = { isHashtagQuery?: boolean; searchType?: MiddleSearchType; currentUserId?: string; + fromPeerId?: string; + isGroupChat?: boolean; }; const CHANNELS_PEER: CustomPeer = { @@ -92,6 +97,8 @@ const CHANNELS_PEER: CustomPeer = { const FOCUSED_SEARCH_TRIGGER_OFFSET = 5; const HIDE_TIMEOUT = 200; const RESULT_ITEM_CLASS_NAME = 'MiddleSearchResult'; +const FROM_FILTER_PREFIX = 'from:'; +const INLINE_MEMBER_COUNT = 5; const runDebouncedForSearch = debounce((cb) => cb(), 200, false); @@ -113,6 +120,8 @@ const MiddleSearch: FC = ({ isHashtagQuery, searchType = 'chat', currentUserId, + fromPeerId, + isGroupChat, }) => { const { updateMiddleSearch, @@ -146,6 +155,28 @@ const MiddleSearch: FC = ({ const [isFocused, markFocused, markBlurred] = useFlag(); const [isViewAsList, setIsViewAsList] = useState(true); const [isLoading, setIsLoading] = useState(false); + const [isFromFilterMode, setIsFromFilterMode] = useState(Boolean(fromPeerId)); + const [memberSearchQuery, setMemberSearchQuery] = useState(''); + + useEffect(() => { + if (fromPeerId) { + setIsFromFilterMode(true); + focusInput(); + } + }, [fromPeerId]); + + const memberSearchFn = useMemo( + () => (chat && isGroupChat ? prepareChatMemberSearch(chat) : undefined), + [chat, isGroupChat], + ); + + const hasMemberDropdown = isFromFilterMode && !fromPeerId; + + const { result: memberSearchResults, isLoading: isMemberSearchLoading } = usePeerSearch({ + query: hasMemberDropdown ? (memberSearchQuery || ' ') : query, + queryFn: memberSearchFn, + isDisabled: !isGroupChat || Boolean(fromPeerId) || (!isFromFilterMode && (!query || isHashtagQuery)), + }); const handleClickOutside = useLastCallback((event: MouseEvent) => { if (maybeLongPressActiveRef.current) return; @@ -155,17 +186,11 @@ const MiddleSearch: FC = ({ }); useClickOutside([ref], handleClickOutside); - const hasResultsContainer = Boolean((query && foundIds) || isHashtagQuery); + const hasResultsContainer = Boolean(((query || fromPeerId) && foundIds) || isHashtagQuery); const isOnlyHash = isHashtagQuery && !query; - const areResultsEmpty = Boolean(query && foundIds && !foundIds.length && !isLoading && !isOnlyHash); - const hasResultsPlaceholder = areResultsEmpty || isOnlyHash; const isNonFocusedDropdownForced = searchType === 'myChats' || searchType === 'channels'; - const hasResultsDropdown = isActive && (isViewAsList || !isMobile) && (isFocused || isNonFocusedDropdownForced) - && Boolean( - hasResultsContainer || hasResultsPlaceholder || savedTags, - ); - - const hasQueryData = Boolean((query && !isOnlyHash) || savedTag); + const hasMemberResults = !isHashtagQuery && Boolean(memberSearchResults?.length); + const hasQueryData = Boolean((query && !isOnlyHash) || savedTag || fromPeerId); const hasNavigationButtons = searchType === 'chat' && Boolean(foundIds?.length); const handleClose = useLastCallback(() => { @@ -236,6 +261,8 @@ const MiddleSearch: FC = ({ setIsViewAsList(true); setFocusedIndex(0); setQuery(''); + setIsFromFilterMode(false); + setMemberSearchQuery(''); hiddenTimerRef.current = window.setTimeout(() => setIsFullyHidden(true), HIDE_TIMEOUT); } else { setIsFullyHidden(false); @@ -274,13 +301,15 @@ const MiddleSearch: FC = ({ }, [isHistoryCalendarOpen, isActive]); const handleReset = useLastCallback(() => { - if (!query?.length && !savedTag) { + if (!query?.length && !savedTag && !fromPeerId && !isFromFilterMode) { handleClose(); return; } setQuery(''); setIsLoading(false); + setIsFromFilterMode(false); + setMemberSearchQuery(''); resetMiddleSearch(); focusInput(); }); @@ -321,9 +350,23 @@ const MiddleSearch: FC = ({ return; } + if (isGroupChat && newQuery.toLowerCase().startsWith(FROM_FILTER_PREFIX) && !fromPeerId && !isFromFilterMode) { + setIsFromFilterMode(true); + const memberQuery = newQuery.slice(FROM_FILTER_PREFIX.length).trim(); + setMemberSearchQuery(memberQuery); + return; + } + + // When in from filter mode (selecting a member), update member search query + if (isFromFilterMode && !fromPeerId) { + setMemberSearchQuery(newQuery); + return; + } + + // Normal query update (including when fromPeerId is set - query is separate) setQuery(newQuery); - if (!newQuery) { + if (!newQuery && !fromPeerId) { setIsLoading(false); resetMiddleSearch(); shouldCancelSearchRef.current = true; @@ -331,10 +374,10 @@ const MiddleSearch: FC = ({ }); useEffect(() => { - if (query) { + if (isActive && (query || fromPeerId)) { handleSearch(); } - }, [query]); + }, [isActive, query, fromPeerId]); useEffect(() => { setIsLoading(Boolean(fetchingQuery)); @@ -391,8 +434,16 @@ const MiddleSearch: FC = ({ const [viewportIds, getMore, viewportOffset = 0] = useInfiniteScroll(handleSearch, foundIds); + const [viewportMemberIds, getMoreMembers] = useInfiniteScroll( + undefined, + hasMemberDropdown ? memberSearchResults : undefined, + ); + + const displayedMembers = (hasMemberResults + && (hasMemberDropdown ? viewportMemberIds : memberSearchResults.slice(0, INLINE_MEMBER_COUNT))) || undefined; + const viewportResults = useMemo(() => { - if ((!query && !savedTag) || !viewportIds?.length) { + if ((!query && !savedTag && !fromPeerId) || !viewportIds?.length) { return MEMO_EMPTY_ARRAY; } const global = getGlobal(); @@ -418,7 +469,16 @@ const MiddleSearch: FC = ({ senderPeer, }; }).filter(Boolean); - }, [query, savedTag, viewportIds, isSavedMessages]); + }, [query, savedTag, fromPeerId, viewportIds, isSavedMessages]); + + const areResultsEmpty = Boolean( + (query || fromPeerId) && !isLoading && !viewportResults.length && !isOnlyHash && !hasMemberResults, + ); + const hasResultsPlaceholder = areResultsEmpty || isOnlyHash; + const hasResultsDropdown = isActive && (isViewAsList || !isMobile) && (isFocused || isNonFocusedDropdownForced) + && Boolean( + hasResultsContainer || hasResultsPlaceholder || savedTags || hasMemberResults, + ); const handleMessageClick = useLastCallback((message: ApiMessage) => { const searchResultKey = getSearchResultKey(message); @@ -444,13 +504,33 @@ const MiddleSearch: FC = ({ markFocused(); }); - const handleKeyDown = useKeyboardListNavigation(containerRef, hasResultsContainer, (index) => { - const foundResult = viewportResults?.[index === -1 ? 0 : index]; - if (foundResult) { - handleMessageClick(foundResult.message); - setFocusedIndex(index + viewportOffset); - } - }, `.${RESULT_ITEM_CLASS_NAME}`, true); + const handleKeyDown = useKeyboardListNavigation( + containerRef, + hasResultsContainer || hasMemberResults, + (index) => { + const actualIndex = index === -1 ? 0 : index; + const membersCount = displayedMembers?.length || 0; + + // Member selection + if (actualIndex < membersCount && displayedMembers) { + const memberId = displayedMembers[actualIndex]; + if (memberId) { + handleSelectFromMember(memberId); + } + return; + } + + // Message selection + const messageIndex = actualIndex - membersCount; + const foundResult = viewportResults?.[messageIndex]; + if (foundResult) { + handleMessageClick(foundResult.message); + setFocusedIndex(messageIndex + viewportOffset); + } + }, + `.${RESULT_ITEM_CLASS_NAME}`, + true, + ); const updateSearchParams = useLastCallback((update: Partial) => { updateMiddleSearch({ chatId: chat!.id, threadId, update }); @@ -474,6 +554,18 @@ const MiddleSearch: FC = ({ }); const handleDeleteTag = useLastCallback(() => { + if (fromPeerId) { + updateSearchParams({ fromPeerId: undefined }); + setMemberSearchQuery(''); + return; + } + + if (isFromFilterMode) { + setIsFromFilterMode(false); + setMemberSearchQuery(''); + return; + } + if (isHashtagQuery) { updateSearchParams({ isHashtag: false }); return; @@ -489,6 +581,19 @@ const MiddleSearch: FC = ({ setIsViewAsList(true); }); + const handleFromFilterClick = useLastCallback(() => { + setIsFromFilterMode(true); + setMemberSearchQuery(''); + focusInput(); + }); + + const handleSelectFromMember = useLastCallback((peerId: string) => { + updateMiddleSearch({ chatId: chat!.id, threadId, update: { fromPeerId: peerId } }); + setMemberSearchQuery(''); + setQuery(''); + focusInput(); + }); + const handleFocusOlder = useLastCallback(() => { if (searchType !== 'chat') return; markBlurred(); @@ -551,11 +656,25 @@ const MiddleSearch: FC = ({ return undefined; } + function renderMemberItem(peerId: string) { + const peer = selectPeer(getGlobal(), peerId); + if (!peer) return undefined; + + return ( + + ); + } + function renderDropdown() { return (
{!isMobile &&
} - {hasSavedTags && !isHashtagQuery && ( + {hasSavedTags && !isHashtagQuery && !hasMemberDropdown && (
= ({ })}
)} - {isHashtagQuery && ( + {isHashtagQuery && !hasMemberDropdown && (
@@ -589,16 +708,21 @@ const MiddleSearch: FC = ({ {renderTypeTag('channels')}
)} - {hasResultsContainer && ( + {Boolean(hasResultsContainer || hasMemberResults) && ( + {isMemberSearchLoading && hasMemberDropdown && } + {displayedMembers?.map(renderMemberItem)} + {hasMemberDropdown && !isMemberSearchLoading && !memberSearchResults?.length && ( + {oldLang('NoResult')} + )} {areResultsEmpty && ( {oldLang('NoResultFoundFor', query)} @@ -609,7 +733,7 @@ const MiddleSearch: FC = ({ {oldLang('HashtagSearchPlaceholder')} )} - {viewportResults?.map(({ + {!hasMemberDropdown && viewportResults?.map(({ message, senderPeer, messageChat, searchResultKey, }, i) => ( = ({ className={RESULT_ITEM_CLASS_NAME} query={query} message={message} - senderPeer={senderPeer} + peer={senderPeer} messageChat={messageChat} shouldShowChat={isHashtagQuery} isActive={focusedIndex - viewportOffset === i} @@ -654,7 +778,7 @@ const MiddleSearch: FC = ({ )} = ({ /> )} {isHashtagQuery &&
#
} + {(isFromFilterMode || fromPeerId) && ( +
+ {lang('SearchFilterFrom')} + {fromPeerId && } +
+ )}
{!isMobile && renderDropdown()} {!isMobile && (
+ {isGroupChat && !isFromFilterMode && !fromPeerId && ( +