Search: Add sender filter (#6578)

This commit is contained in:
Alexander Zinchuk 2026-01-20 12:01:16 +01:00
parent 3eb69cefb1
commit 224670674a
13 changed files with 339 additions and 54 deletions

View File

@ -1476,6 +1476,7 @@ export async function searchMessagesInChat({
offsetId, offsetId,
addOffset, addOffset,
limit, limit,
fromPeer,
}: { }: {
peer: ApiPeer; peer: ApiPeer;
isSavedDialog?: boolean; isSavedDialog?: boolean;
@ -1488,6 +1489,7 @@ export async function searchMessagesInChat({
limit: number; limit: number;
minDate?: number; minDate?: number;
maxDate?: number; maxDate?: number;
fromPeer?: ApiPeer;
}): Promise<SearchResults | undefined> { }): Promise<SearchResults | undefined> {
let filter; let filter;
switch (type) { switch (type) {
@ -1519,6 +1521,7 @@ export async function searchMessagesInChat({
} }
const inputPeer = buildInputPeer(peer.id, peer.accessHash); const inputPeer = buildInputPeer(peer.id, peer.accessHash);
const inputFromPeer = fromPeer ? buildInputPeer(fromPeer.id, fromPeer.accessHash) : undefined;
const result = await invokeRequest(new GramJs.messages.Search({ const result = await invokeRequest(new GramJs.messages.Search({
peer: isSavedDialog ? new GramJs.InputPeerSelf() : inputPeer, peer: isSavedDialog ? new GramJs.InputPeerSelf() : inputPeer,
@ -1527,6 +1530,7 @@ export async function searchMessagesInChat({
topMsgId: threadId !== MAIN_THREAD_ID && !isSavedDialog ? Number(threadId) : undefined, topMsgId: threadId !== MAIN_THREAD_ID && !isSavedDialog ? Number(threadId) : undefined,
filter, filter,
q: query, q: query,
fromId: inputFromPeer,
minDate: minDate ?? DEFAULT_PRIMITIVES.INT, minDate: minDate ?? DEFAULT_PRIMITIVES.INT,
maxDate: maxDate ?? DEFAULT_PRIMITIVES.INT, maxDate: maxDate ?? DEFAULT_PRIMITIVES.INT,
maxId: DEFAULT_PRIMITIVES.INT, maxId: DEFAULT_PRIMITIVES.INT,

View File

@ -1711,6 +1711,7 @@
"GroupChatsSearchContext" = "Group Chats"; "GroupChatsSearchContext" = "Group Chats";
"ChannelsSearchContext" = "Channels"; "ChannelsSearchContext" = "Channels";
"SearchContextCaption" = "From {type}"; "SearchContextCaption" = "From {type}";
"SearchFilterFrom" = "From:";
"FolderLinkTitleDescription" = "Anyone with this link can add {folder} folder and {chats} selected below."; "FolderLinkTitleDescription" = "Anyone with this link can add {folder} folder and {chats} selected below.";
"FolderLinkTitleDescriptionChats_one" = "the chat"; "FolderLinkTitleDescriptionChats_one" = "the chat";
"FolderLinkTitleDescriptionChats_other" = "the {count} chats"; "FolderLinkTitleDescriptionChats_other" = "the {count} chats";

View File

@ -19,10 +19,12 @@ import {
getMainUsername, getMainUsername,
isAnonymousForwardsChat, isAnonymousForwardsChat,
isAnonymousOwnMessage, isAnonymousOwnMessage,
isChatChannel,
isSystemBot, isSystemBot,
} from '../../../global/helpers'; } from '../../../global/helpers';
import { isApiPeerUser } from '../../../global/helpers/peers'; import { isApiPeerUser } from '../../../global/helpers/peers';
import { import {
selectChat,
selectForwardedSender, selectForwardedSender,
selectIsChatWithSelf, selectIsChatWithSelf,
selectSender, selectSender,
@ -58,6 +60,7 @@ type StateProps = {
isChatWithSelf?: boolean; isChatWithSelf?: boolean;
isRepliesChat?: boolean; isRepliesChat?: boolean;
isAnonymousForwards?: boolean; isAnonymousForwards?: boolean;
isChannel?: boolean;
}; };
const SenderGroupContainer: FC<OwnProps & StateProps> = ({ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
@ -72,9 +75,10 @@ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
isChatWithSelf, isChatWithSelf,
isRepliesChat, isRepliesChat,
isAnonymousForwards, isAnonymousForwards,
isChannel,
canPost, canPost,
}) => { }) => {
const { openChat, updateInsertingPeerIdMention } = getActions(); const { openChat, updateInsertingPeerIdMention, openMiddleSearch } = getActions();
const { forwardInfo } = message; const { forwardInfo } = message;
@ -115,6 +119,14 @@ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
} }
}); });
const handleSearchMessages = useLastCallback(() => {
if (!avatarPeer) {
return;
}
openMiddleSearch({ fromPeerId: avatarPeer.id });
});
const handleAvatarClick = useLastCallback(() => { const handleAvatarClick = useLastCallback(() => {
handleOpenChat(); handleOpenChat();
}); });
@ -142,7 +154,8 @@ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
const getLayout = useLastCallback(() => ({ withPortal: true })); const getLayout = useLastCallback(() => ({ withPortal: true }));
const canMention = canPost && avatarPeer && (isAvatarPeerUser || Boolean(getMainUsername(avatarPeer))); 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() { function renderContextMenu() {
return ( return (
@ -176,6 +189,14 @@ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
{lang('ContextMenuItemMention')} {lang('ContextMenuItemMention')}
</MenuItem> </MenuItem>
)} )}
{canSearch && (
<MenuItem
icon="search"
onClick={handleSearchMessages}
>
{lang('Search')}
</MenuItem>
)}
</> </>
</Menu> </Menu>
); );
@ -221,6 +242,7 @@ export default memo(withGlobal<OwnProps>(
} = ownProps; } = ownProps;
const { chatId } = message; const { chatId } = message;
const chat = selectChat(global, chatId);
const isChatWithSelf = selectIsChatWithSelf(global, chatId); const isChatWithSelf = selectIsChatWithSelf(global, chatId);
const isSystemBotChat = isSystemBot(chatId); const isSystemBotChat = isSystemBot(chatId);
const isAnonymousForwards = isAnonymousForwardsChat(chatId); const isAnonymousForwards = isAnonymousForwardsChat(chatId);
@ -237,6 +259,7 @@ export default memo(withGlobal<OwnProps>(
isChatWithSelf, isChatWithSelf,
isRepliesChat: isSystemBotChat, isRepliesChat: isSystemBotChat,
isAnonymousForwards, isAnonymousForwards,
isChannel: chat && isChatChannel(chat),
}; };
}, },
)(SenderGroupContainer)); )(SenderGroupContainer));

View File

@ -202,6 +202,21 @@
color: var(--color-text-secondary); 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 { .searchTypes {
overflow-x: scroll; overflow-x: scroll;
display: flex; display: flex;
@ -235,6 +250,12 @@
} }
} }
.icons {
display: flex;
gap: 0.25rem;
align-items: center;
}
.footer { .footer {
pointer-events: auto; pointer-events: auto;
@ -306,3 +327,9 @@
transform: translateY(0); transform: translateY(0);
} }
} }
.memberItem {
:global(.ListItem-button) {
padding: 0.375rem 0.75rem !important;
}
}

View File

@ -16,7 +16,7 @@ import type {
import { ANONYMOUS_USER_ID } from '../../../config'; import { ANONYMOUS_USER_ID } from '../../../config';
import { requestMeasure, requestMutation, requestNextMutation } from '../../../lib/fasterdom/fasterdom'; import { requestMeasure, requestMutation, requestNextMutation } from '../../../lib/fasterdom/fasterdom';
import { import {
getIsSavedDialog, getReactionKey, isSameReaction, isSystemBot, getIsSavedDialog, getReactionKey, isChatGroup, isSameReaction, isSystemBot,
} from '../../../global/helpers'; } from '../../../global/helpers';
import { import {
selectChat, selectChat,
@ -27,6 +27,7 @@ import {
selectIsChatWithSelf, selectIsChatWithSelf,
selectIsCurrentUserPremium, selectIsCurrentUserPremium,
selectMonoforumChannel, selectMonoforumChannel,
selectPeer,
selectSender, selectSender,
selectTabState, selectTabState,
} from '../../../global/selectors'; } from '../../../global/selectors';
@ -50,11 +51,13 @@ import useKeyboardListNavigation from '../../../hooks/useKeyboardListNavigation'
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
import usePeerSearch, { prepareChatMemberSearch } from '../../../hooks/usePeerSearch';
import Avatar from '../../common/Avatar'; import Avatar from '../../common/Avatar';
import PeerChip from '../../common/PeerChip'; import PeerChip from '../../common/PeerChip';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import InfiniteScroll from '../../ui/InfiniteScroll'; import InfiniteScroll from '../../ui/InfiniteScroll';
import Loading from '../../ui/Loading';
import SearchInput from '../../ui/SearchInput'; import SearchInput from '../../ui/SearchInput';
import SavedTagButton from '../message/reactions/SavedTagButton'; import SavedTagButton from '../message/reactions/SavedTagButton';
import MiddleSearchResult from './MiddleSearchResult'; import MiddleSearchResult from './MiddleSearchResult';
@ -82,6 +85,8 @@ type StateProps = {
isHashtagQuery?: boolean; isHashtagQuery?: boolean;
searchType?: MiddleSearchType; searchType?: MiddleSearchType;
currentUserId?: string; currentUserId?: string;
fromPeerId?: string;
isGroupChat?: boolean;
}; };
const CHANNELS_PEER: CustomPeer = { const CHANNELS_PEER: CustomPeer = {
@ -92,6 +97,8 @@ const CHANNELS_PEER: CustomPeer = {
const FOCUSED_SEARCH_TRIGGER_OFFSET = 5; const FOCUSED_SEARCH_TRIGGER_OFFSET = 5;
const HIDE_TIMEOUT = 200; const HIDE_TIMEOUT = 200;
const RESULT_ITEM_CLASS_NAME = 'MiddleSearchResult'; const RESULT_ITEM_CLASS_NAME = 'MiddleSearchResult';
const FROM_FILTER_PREFIX = 'from:';
const INLINE_MEMBER_COUNT = 5;
const runDebouncedForSearch = debounce((cb) => cb(), 200, false); const runDebouncedForSearch = debounce((cb) => cb(), 200, false);
@ -113,6 +120,8 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
isHashtagQuery, isHashtagQuery,
searchType = 'chat', searchType = 'chat',
currentUserId, currentUserId,
fromPeerId,
isGroupChat,
}) => { }) => {
const { const {
updateMiddleSearch, updateMiddleSearch,
@ -146,6 +155,28 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
const [isFocused, markFocused, markBlurred] = useFlag(); const [isFocused, markFocused, markBlurred] = useFlag();
const [isViewAsList, setIsViewAsList] = useState(true); const [isViewAsList, setIsViewAsList] = useState(true);
const [isLoading, setIsLoading] = useState(false); 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) => { const handleClickOutside = useLastCallback((event: MouseEvent) => {
if (maybeLongPressActiveRef.current) return; if (maybeLongPressActiveRef.current) return;
@ -155,17 +186,11 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
}); });
useClickOutside([ref], handleClickOutside); useClickOutside([ref], handleClickOutside);
const hasResultsContainer = Boolean((query && foundIds) || isHashtagQuery); const hasResultsContainer = Boolean(((query || fromPeerId) && foundIds) || isHashtagQuery);
const isOnlyHash = isHashtagQuery && !query; const isOnlyHash = isHashtagQuery && !query;
const areResultsEmpty = Boolean(query && foundIds && !foundIds.length && !isLoading && !isOnlyHash);
const hasResultsPlaceholder = areResultsEmpty || isOnlyHash;
const isNonFocusedDropdownForced = searchType === 'myChats' || searchType === 'channels'; const isNonFocusedDropdownForced = searchType === 'myChats' || searchType === 'channels';
const hasResultsDropdown = isActive && (isViewAsList || !isMobile) && (isFocused || isNonFocusedDropdownForced) const hasMemberResults = !isHashtagQuery && Boolean(memberSearchResults?.length);
&& Boolean( const hasQueryData = Boolean((query && !isOnlyHash) || savedTag || fromPeerId);
hasResultsContainer || hasResultsPlaceholder || savedTags,
);
const hasQueryData = Boolean((query && !isOnlyHash) || savedTag);
const hasNavigationButtons = searchType === 'chat' && Boolean(foundIds?.length); const hasNavigationButtons = searchType === 'chat' && Boolean(foundIds?.length);
const handleClose = useLastCallback(() => { const handleClose = useLastCallback(() => {
@ -236,6 +261,8 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
setIsViewAsList(true); setIsViewAsList(true);
setFocusedIndex(0); setFocusedIndex(0);
setQuery(''); setQuery('');
setIsFromFilterMode(false);
setMemberSearchQuery('');
hiddenTimerRef.current = window.setTimeout(() => setIsFullyHidden(true), HIDE_TIMEOUT); hiddenTimerRef.current = window.setTimeout(() => setIsFullyHidden(true), HIDE_TIMEOUT);
} else { } else {
setIsFullyHidden(false); setIsFullyHidden(false);
@ -274,13 +301,15 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
}, [isHistoryCalendarOpen, isActive]); }, [isHistoryCalendarOpen, isActive]);
const handleReset = useLastCallback(() => { const handleReset = useLastCallback(() => {
if (!query?.length && !savedTag) { if (!query?.length && !savedTag && !fromPeerId && !isFromFilterMode) {
handleClose(); handleClose();
return; return;
} }
setQuery(''); setQuery('');
setIsLoading(false); setIsLoading(false);
setIsFromFilterMode(false);
setMemberSearchQuery('');
resetMiddleSearch(); resetMiddleSearch();
focusInput(); focusInput();
}); });
@ -321,9 +350,23 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
return; 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); setQuery(newQuery);
if (!newQuery) { if (!newQuery && !fromPeerId) {
setIsLoading(false); setIsLoading(false);
resetMiddleSearch(); resetMiddleSearch();
shouldCancelSearchRef.current = true; shouldCancelSearchRef.current = true;
@ -331,10 +374,10 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
}); });
useEffect(() => { useEffect(() => {
if (query) { if (isActive && (query || fromPeerId)) {
handleSearch(); handleSearch();
} }
}, [query]); }, [isActive, query, fromPeerId]);
useEffect(() => { useEffect(() => {
setIsLoading(Boolean(fetchingQuery)); setIsLoading(Boolean(fetchingQuery));
@ -391,8 +434,16 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
const [viewportIds, getMore, viewportOffset = 0] = useInfiniteScroll(handleSearch, foundIds); 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(() => { const viewportResults = useMemo(() => {
if ((!query && !savedTag) || !viewportIds?.length) { if ((!query && !savedTag && !fromPeerId) || !viewportIds?.length) {
return MEMO_EMPTY_ARRAY; return MEMO_EMPTY_ARRAY;
} }
const global = getGlobal(); const global = getGlobal();
@ -418,7 +469,16 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
senderPeer, senderPeer,
}; };
}).filter(Boolean); }).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 handleMessageClick = useLastCallback((message: ApiMessage) => {
const searchResultKey = getSearchResultKey(message); const searchResultKey = getSearchResultKey(message);
@ -444,13 +504,33 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
markFocused(); markFocused();
}); });
const handleKeyDown = useKeyboardListNavigation(containerRef, hasResultsContainer, (index) => { const handleKeyDown = useKeyboardListNavigation(
const foundResult = viewportResults?.[index === -1 ? 0 : index]; containerRef,
if (foundResult) { hasResultsContainer || hasMemberResults,
handleMessageClick(foundResult.message); (index) => {
setFocusedIndex(index + viewportOffset); const actualIndex = index === -1 ? 0 : index;
} const membersCount = displayedMembers?.length || 0;
}, `.${RESULT_ITEM_CLASS_NAME}`, true);
// 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<MiddleSearchParams>) => { const updateSearchParams = useLastCallback((update: Partial<MiddleSearchParams>) => {
updateMiddleSearch({ chatId: chat!.id, threadId, update }); updateMiddleSearch({ chatId: chat!.id, threadId, update });
@ -474,6 +554,18 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
}); });
const handleDeleteTag = useLastCallback(() => { const handleDeleteTag = useLastCallback(() => {
if (fromPeerId) {
updateSearchParams({ fromPeerId: undefined });
setMemberSearchQuery('');
return;
}
if (isFromFilterMode) {
setIsFromFilterMode(false);
setMemberSearchQuery('');
return;
}
if (isHashtagQuery) { if (isHashtagQuery) {
updateSearchParams({ isHashtag: false }); updateSearchParams({ isHashtag: false });
return; return;
@ -489,6 +581,19 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
setIsViewAsList(true); 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(() => { const handleFocusOlder = useLastCallback(() => {
if (searchType !== 'chat') return; if (searchType !== 'chat') return;
markBlurred(); markBlurred();
@ -551,11 +656,25 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
return undefined; return undefined;
} }
function renderMemberItem(peerId: string) {
const peer = selectPeer(getGlobal(), peerId);
if (!peer) return undefined;
return (
<MiddleSearchResult
key={`member-${peerId}`}
className={RESULT_ITEM_CLASS_NAME}
peer={peer}
onClick={handleSelectFromMember}
/>
);
}
function renderDropdown() { function renderDropdown() {
return ( return (
<div className={buildClassName(styles.dropdown, !hasResultsDropdown && styles.dropdownHidden)}> <div className={buildClassName(styles.dropdown, !hasResultsDropdown && styles.dropdownHidden)}>
{!isMobile && <div className={styles.separator} />} {!isMobile && <div className={styles.separator} />}
{hasSavedTags && !isHashtagQuery && ( {hasSavedTags && !isHashtagQuery && !hasMemberDropdown && (
<div <div
className={buildClassName( className={buildClassName(
styles.savedTags, styles.savedTags,
@ -580,7 +699,7 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
})} })}
</div> </div>
)} )}
{isHashtagQuery && ( {isHashtagQuery && !hasMemberDropdown && (
<div <div
className={buildClassName(styles.searchTypes, 'no-scrollbar')} className={buildClassName(styles.searchTypes, 'no-scrollbar')}
> >
@ -589,16 +708,21 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
{renderTypeTag('channels')} {renderTypeTag('channels')}
</div> </div>
)} )}
{hasResultsContainer && ( {Boolean(hasResultsContainer || hasMemberResults) && (
<InfiniteScroll <InfiniteScroll
ref={containerRef} ref={containerRef}
className={buildClassName(styles.results, 'custom-scroll')} className={buildClassName(styles.results, 'custom-scroll')}
items={viewportResults} items={hasMemberDropdown ? displayedMembers : viewportResults}
itemSelector={`.${RESULT_ITEM_CLASS_NAME}`} itemSelector={`.${RESULT_ITEM_CLASS_NAME}`}
preloadBackwards={0} preloadBackwards={0}
onLoadMore={getMore} onLoadMore={hasMemberDropdown ? getMoreMembers : getMore}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
{isMemberSearchLoading && hasMemberDropdown && <Loading />}
{displayedMembers?.map(renderMemberItem)}
{hasMemberDropdown && !isMemberSearchLoading && !memberSearchResults?.length && (
<span className={styles.placeholder}>{oldLang('NoResult')}</span>
)}
{areResultsEmpty && ( {areResultsEmpty && (
<span key="nothing" className={styles.placeholder}> <span key="nothing" className={styles.placeholder}>
{oldLang('NoResultFoundFor', query)} {oldLang('NoResultFoundFor', query)}
@ -609,7 +733,7 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
{oldLang('HashtagSearchPlaceholder')} {oldLang('HashtagSearchPlaceholder')}
</span> </span>
)} )}
{viewportResults?.map(({ {!hasMemberDropdown && viewportResults?.map(({
message, senderPeer, messageChat, searchResultKey, message, senderPeer, messageChat, searchResultKey,
}, i) => ( }, i) => (
<MiddleSearchResult <MiddleSearchResult
@ -618,7 +742,7 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
className={RESULT_ITEM_CLASS_NAME} className={RESULT_ITEM_CLASS_NAME}
query={query} query={query}
message={message} message={message}
senderPeer={senderPeer} peer={senderPeer}
messageChat={messageChat} messageChat={messageChat}
shouldShowChat={isHashtagQuery} shouldShowChat={isHashtagQuery}
isActive={focusedIndex - viewportOffset === i} isActive={focusedIndex - viewportOffset === i}
@ -654,7 +778,7 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
)} )}
<SearchInput <SearchInput
ref={inputRef} ref={inputRef}
value={query} value={isFromFilterMode && !fromPeerId ? memberSearchQuery : query}
className={buildClassName( className={buildClassName(
styles.input, styles.input,
hasResultsDropdown && styles.withDropdown, hasResultsDropdown && styles.withDropdown,
@ -687,11 +811,27 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
/> />
)} )}
{isHashtagQuery && <div className={styles.hash}>#</div>} {isHashtagQuery && <div className={styles.hash}>#</div>}
{(isFromFilterMode || fromPeerId) && (
<div className={styles.fromTag}>
{lang('SearchFilterFrom')}
{fromPeerId && <PeerChip peerId={fromPeerId} forceShowSelf canClose onClick={handleDeleteTag} />}
</div>
)}
</div> </div>
{!isMobile && renderDropdown()} {!isMobile && renderDropdown()}
</SearchInput> </SearchInput>
{!isMobile && ( {!isMobile && (
<div className={styles.icons}> <div className={styles.icons}>
{isGroupChat && !isFromFilterMode && !fromPeerId && (
<Button
round
size="smaller"
color="translucent"
onClick={handleFromFilterClick}
ariaLabel={oldLang('FilterByUser')}
iconName="user"
/>
)}
<Button <Button
round round
size="smaller" size="smaller"
@ -706,6 +846,16 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
{isMobile && renderDropdown()} {isMobile && renderDropdown()}
{isMobile && ( {isMobile && (
<div className={styles.footer}> <div className={styles.footer}>
{isGroupChat && !isFromFilterMode && !fromPeerId && (
<Button
round
size="smaller"
color="translucent"
onClick={handleFromFilterClick}
ariaLabel={oldLang('FilterByUser')}
iconName="user"
/>
)}
<Button <Button
round round
size="smaller" size="smaller"
@ -779,7 +929,7 @@ export default memo(withGlobal<OwnProps>(
} }
const { const {
requestedQuery, savedTag, results, fetchingQuery, isHashtag, type, requestedQuery, savedTag, results, fetchingQuery, isHashtag, type, fromPeerId,
} = selectCurrentMiddleSearch(global) || {}; } = selectCurrentMiddleSearch(global) || {};
const { totalCount, foundIds, query: lastSearchQuery } = results || {}; const { totalCount, foundIds, query: lastSearchQuery } = results || {};
@ -808,6 +958,8 @@ export default memo(withGlobal<OwnProps>(
currentUserId, currentUserId,
searchType: type, searchType: type,
lastSearchQuery, lastSearchQuery,
fromPeerId,
isGroupChat: isChatGroup(chat),
}; };
}, },
)(MiddleSearch)); )(MiddleSearch));

View File

@ -2,7 +2,8 @@ import { memo } from '../../../lib/teact/teact';
import type { ApiChat, ApiMessage, ApiPeer } from '../../../api/types'; import type { ApiChat, ApiMessage, ApiPeer } from '../../../api/types';
import { getMessageSenderName } from '../../../global/helpers/peers'; import { getGroupStatus, getMainUsername } from '../../../global/helpers';
import { getMessageSenderName, isApiPeerChat } from '../../../global/helpers/peers';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';
@ -18,13 +19,13 @@ import styles from './MiddleSearchResult.module.scss';
type OwnProps = { type OwnProps = {
isActive?: boolean; isActive?: boolean;
message: ApiMessage; message?: ApiMessage;
senderPeer?: ApiPeer; peer?: ApiPeer;
messageChat?: ApiChat; messageChat?: ApiChat;
shouldShowChat?: boolean; shouldShowChat?: boolean;
query?: string; query?: string;
className?: string; className?: string;
onClick: (message: ApiMessage) => void; onClick: (clickArg: any) => void;
}; };
const TRUNCATE_LENGTH = 200; const TRUNCATE_LENGTH = 200;
@ -32,7 +33,7 @@ const TRUNCATE_LENGTH = 200;
const MiddleSearchResult = ({ const MiddleSearchResult = ({
isActive, isActive,
message, message,
senderPeer, peer,
messageChat, messageChat,
shouldShowChat, shouldShowChat,
query, query,
@ -40,11 +41,47 @@ const MiddleSearchResult = ({
onClick, onClick,
}: OwnProps) => { }: OwnProps) => {
const lang = useLang(); const lang = useLang();
if (peer && !message) {
const username = getMainUsername(peer);
const handlePeerClick = () => {
onClick(peer.id);
};
return (
<div
role="button"
tabIndex={0}
className={buildClassName(styles.root, isActive && styles.active, className)}
onClick={handlePeerClick}
>
<Avatar
className={styles.avatar}
peer={peer}
size="medium"
/>
<div className={styles.info}>
<div className={styles.topRow}>
<FullNameTitle peer={peer} withEmojiStatus />
</div>
{(isApiPeerChat(peer) || username) && (
<div className={styles.subtitle} dir="auto">
{username
? `@${username}`
: getGroupStatus(lang, peer as ApiChat)}
</div>
)}
</div>
</div>
);
}
if (!message) return undefined;
const hiddenForwardTitle = message.forwardInfo?.hiddenUserName; const hiddenForwardTitle = message.forwardInfo?.hiddenUserName;
const senderPeer = shouldShowChat ? messageChat : peer;
const peer = shouldShowChat ? messageChat : senderPeer; const senderName = shouldShowChat && peer ? getMessageSenderName(lang, message.chatId, peer) : undefined;
const senderName = shouldShowChat && senderPeer ? getMessageSenderName(lang, message.chatId, senderPeer) : undefined;
const handleClick = useLastCallback(() => { const handleClick = useLastCallback(() => {
onClick(message); onClick(message);
@ -59,13 +96,13 @@ const MiddleSearchResult = ({
> >
<Avatar <Avatar
className={styles.avatar} className={styles.avatar}
peer={peer} peer={senderPeer}
text={hiddenForwardTitle} text={hiddenForwardTitle}
size="medium" size="medium"
/> />
<div className={styles.info}> <div className={styles.info}>
<div className={styles.topRow}> <div className={styles.topRow}>
{(peer && <FullNameTitle peer={peer} withEmojiStatus />) || hiddenForwardTitle} {(senderPeer && <FullNameTitle peer={senderPeer} withEmojiStatus />) || hiddenForwardTitle}
<LastMessageMeta className={styles.meta} message={message} /> <LastMessageMeta className={styles.meta} message={message} />
</div> </div>
<div className={styles.subtitle} dir="auto"> <div className={styles.subtitle} dir="auto">

View File

@ -64,16 +64,17 @@ addActionHandler('performMiddleSearch', async (global, actions, payload): Promis
currentSearch = selectCurrentMiddleSearch(global, tabId)!; currentSearch = selectCurrentMiddleSearch(global, tabId)!;
const { const {
results, savedTag, type, isHashtag, results, savedTag, type, isHashtag, fromPeerId,
} = currentSearch; } = currentSearch;
const shouldReuseParams = results?.query === query; const shouldReuseParams = results?.query === query;
const fromPeer = fromPeerId ? selectPeer(global, fromPeerId) : undefined;
const offsetId = shouldReuseParams ? results?.nextOffsetId : undefined; const offsetId = shouldReuseParams ? results?.nextOffsetId : undefined;
const offsetRate = shouldReuseParams ? results?.nextOffsetRate : undefined; const offsetRate = shouldReuseParams ? results?.nextOffsetRate : undefined;
const offsetPeerId = shouldReuseParams ? results?.nextOffsetPeerId : undefined; const offsetPeerId = shouldReuseParams ? results?.nextOffsetPeerId : undefined;
const offsetPeer = shouldReuseParams && offsetPeerId ? selectChat(global, offsetPeerId) : undefined; const offsetPeer = shouldReuseParams && offsetPeerId ? selectChat(global, offsetPeerId) : undefined;
const shouldHaveQuery = isHashtag || !savedTag; const shouldHaveQuery = isHashtag || (!savedTag && !fromPeerId);
if (shouldHaveQuery && !query) { if (shouldHaveQuery && !query) {
global = updateMiddleSearch(global, realChatId, threadId, { global = updateMiddleSearch(global, realChatId, threadId, {
fetchingQuery: undefined, fetchingQuery: undefined,
@ -98,6 +99,7 @@ addActionHandler('performMiddleSearch', async (global, actions, payload): Promis
offsetId, offsetId,
isSavedDialog, isSavedDialog,
savedTag, savedTag,
fromPeer,
}); });
} }

View File

@ -12,13 +12,13 @@ import {
import { selectCurrentMessageList } from '../../selectors'; import { selectCurrentMessageList } from '../../selectors';
addActionHandler('openMiddleSearch', (global, actions, payload): ActionReturnType => { addActionHandler('openMiddleSearch', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {}; const { fromPeerId, tabId = getCurrentTabId() } = payload || {};
const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {};
if (!chatId || !threadId) { if (!chatId || !threadId) {
return undefined; return undefined;
} }
return updateMiddleSearch(global, chatId, threadId, {}, tabId); return updateMiddleSearch(global, chatId, threadId, { fromPeerId }, tabId);
}); });
addActionHandler('closeMiddleSearch', (global, actions, payload): ActionReturnType => { addActionHandler('closeMiddleSearch', (global, actions, payload): ActionReturnType => {

View File

@ -77,7 +77,11 @@ export function updateMiddleSearch<T extends GlobalState>(
updated.type = 'chat'; updated.type = 'chat';
} }
if (currentSearch && (currentSearch.type !== updated.type || currentSearch.savedTag !== updated.savedTag)) { if (currentSearch && (
currentSearch.type !== updated.type
|| currentSearch.savedTag !== updated.savedTag
|| currentSearch.fromPeerId !== updated.fromPeerId
)) {
updated.results = undefined; updated.results = undefined;
} }

View File

@ -312,7 +312,9 @@ export interface ActionPayloads {
}; };
// Message search // Message search
openMiddleSearch: WithTabId | undefined; openMiddleSearch: {
fromPeerId?: string;
} & WithTabId | undefined;
closeMiddleSearch: WithTabId | undefined; closeMiddleSearch: WithTabId | undefined;
updateMiddleSearch: { updateMiddleSearch: {
chatId: string; chatId: string;

View File

@ -1,7 +1,11 @@
import { useState } from '../lib/teact/teact'; import { useState } from '../lib/teact/teact';
import { getGlobal } from '../global';
import type { ApiChat } from '../api/types'; import type { ApiChat } from '../api/types';
import { isChatBasicGroup, isChatSuperGroup } from '../global/helpers';
import { filterPeersByQuery } from '../global/helpers/peers';
import { selectChatFullInfo } from '../global/selectors';
import { callApi } from '../api/gramjs'; import { callApi } from '../api/gramjs';
import useAsync from './useAsync'; import useAsync from './useAsync';
import useDebouncedMemo from './useDebouncedMemo'; import useDebouncedMemo from './useDebouncedMemo';
@ -20,13 +24,40 @@ export async function peerGlobalSearch(query: string) {
export function prepareChatMemberSearch(chat: ApiChat) { export function prepareChatMemberSearch(chat: ApiChat) {
return async (query: string) => { return async (query: string) => {
const trimmedQuery = query.trim();
// For basic groups, filter from cached members in fullInfo
if (isChatBasicGroup(chat)) {
const global = getGlobal();
const fullInfo = selectChatFullInfo(global, chat.id);
const memberIds = fullInfo?.members?.map((m) => m.userId) || [];
if (!trimmedQuery) {
return memberIds;
}
return filterPeersByQuery({ ids: memberIds, query: trimmedQuery, type: 'user' });
}
// For supergroups/channels, use API
const searchResult = await callApi('fetchMembers', { const searchResult = await callApi('fetchMembers', {
chat, chat,
memberFilter: 'search', memberFilter: trimmedQuery ? 'search' : 'recent',
query, query: trimmedQuery,
}); });
return searchResult?.members?.map((member) => member.userId) || []; const memberIds = searchResult?.members?.map((member) => member.userId) || [];
if (!isChatSuperGroup(chat)) {
return memberIds;
}
if (!trimmedQuery) {
return [...memberIds, chat.id];
}
const chatMatches = filterPeersByQuery({ ids: [chat.id], query: trimmedQuery, type: 'chat' });
return [...memberIds, ...chatMatches];
}; };
} }

View File

@ -411,6 +411,7 @@ export type MiddleSearchParams = {
requestedQuery?: string; requestedQuery?: string;
savedTag?: ApiReaction; savedTag?: ApiReaction;
isHashtag?: boolean; isHashtag?: boolean;
fromPeerId?: string;
fetchingQuery?: string; fetchingQuery?: string;
type: MiddleSearchType; type: MiddleSearchType;
results?: MiddleSearchResults; results?: MiddleSearchResults;

View File

@ -1398,6 +1398,7 @@ export interface LangPair {
'PrivateChatsSearchContext': undefined; 'PrivateChatsSearchContext': undefined;
'GroupChatsSearchContext': undefined; 'GroupChatsSearchContext': undefined;
'ChannelsSearchContext': undefined; 'ChannelsSearchContext': undefined;
'SearchFilterFrom': undefined;
'FolderLinkSubtitleNew': undefined; 'FolderLinkSubtitleNew': undefined;
'FolderLinkSubtitleAlready': undefined; 'FolderLinkSubtitleAlready': undefined;
'FolderLinkAddFolder': undefined; 'FolderLinkAddFolder': undefined;