import type { FC } from '../../../lib/teact/teact'; import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, } from '../../../lib/teact/teact'; import { getActions, getGlobal, withGlobal } from '../../../global'; import type { ApiChat, ApiMessage, ApiReaction, ApiReactionKey, ApiSavedReactionTag, } from '../../../api/types'; import type { CustomPeer, MiddleSearchParams, MiddleSearchType, ThreadId, } from '../../../types'; import { ANONYMOUS_USER_ID } from '../../../config'; import { requestMeasure, requestMutation, requestNextMutation } from '../../../lib/fasterdom/fasterdom'; import { getIsSavedDialog, getReactionKey, isChatGroup, isSameReaction, isSystemBot, } from '../../../global/helpers'; import { selectChat, selectChatMessage, selectCurrentMessageList, selectCurrentMiddleSearch, selectForwardedSender, selectIsChatWithSelf, selectIsCurrentUserPremium, selectMonoforumChannel, selectPeer, selectSender, selectTabState, } from '../../../global/selectors'; import { IS_IOS } from '../../../util/browser/windowEnvironment'; import buildClassName from '../../../util/buildClassName'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; import { getDayStartAt } from '../../../util/dates/dateFormat'; import focusEditableElement from '../../../util/focusEditableElement'; import focusNoScroll from '../../../util/focusNoScroll'; import { getSearchResultKey, parseSearchResultKey, type SearchResultKey } from '../../../util/keys/searchResultKey'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { debounce, fastRaf } from '../../../util/schedulers'; import { useClickOutside } from '../../../hooks/events/useOutsideClick'; import useAppLayout from '../../../hooks/useAppLayout'; import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps'; import useFlag from '../../../hooks/useFlag'; import useHistoryBack from '../../../hooks/useHistoryBack'; import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; 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'; import styles from './MiddleSearch.module.scss'; export type OwnProps = { isActive: boolean; }; type StateProps = { chat?: ApiChat; monoforumChat?: ApiChat; threadId?: ThreadId; requestedQuery?: string; savedTags?: Record; savedTag?: ApiReaction; totalCount?: number; lastSearchQuery?: string; foundIds?: SearchResultKey[]; isHistoryCalendarOpen?: boolean; isCurrentUserPremium?: boolean; isSavedMessages?: boolean; fetchingQuery?: string; isHashtagQuery?: boolean; searchType?: MiddleSearchType; currentUserId?: string; fromPeerId?: string; isGroupChat?: boolean; }; const CHANNELS_PEER: CustomPeer = { isCustomPeer: true, avatarIcon: 'channel-filled', titleKey: 'SearchPublicPosts', }; 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); const MiddleSearch: FC = ({ isActive, chat, monoforumChat, threadId, requestedQuery, savedTags, savedTag, totalCount, lastSearchQuery, foundIds, isHistoryCalendarOpen, isCurrentUserPremium, isSavedMessages, fetchingQuery, isHashtagQuery, searchType = 'chat', currentUserId, fromPeerId, isGroupChat, }) => { const { updateMiddleSearch, resetMiddleSearch, performMiddleSearch, focusMessage, closeMiddleSearch, openHistoryCalendar, openPremiumModal, loadSavedReactionTags, } = getActions(); const ref = useRef(); const inputRef = useRef(); const containerRef = useRef(); const shouldCancelSearchRef = useRef(false); const { isMobile } = useAppLayout(); const oldLang = useOldLang(); const lang = useLang(); const [query, setQuery] = useState(requestedQuery || ''); const [focusedIndex, setFocusedIndex] = useState(0); const canFocusNewer = foundIds && focusedIndex > 0; const canFocusOlder = foundIds && focusedIndex < foundIds.length - 1; const [isFullyHidden, setIsFullyHidden] = useState(!isActive); const hiddenTimerRef = useRef(); const maybeLongPressActiveRef = useRef(true); 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; // Ignore clicks inside modals if ((event.target as HTMLElement).closest('.Modal')) return; markBlurred(); }); useClickOutside([ref], handleClickOutside); const hasResultsContainer = Boolean(((query || fromPeerId) && foundIds) || isHashtagQuery); const isOnlyHash = isHashtagQuery && !query; const isNonFocusedDropdownForced = searchType === 'myChats' || searchType === 'channels'; const hasMemberResults = !isHashtagQuery && Boolean(memberSearchResults?.length); const hasQueryData = Boolean((query && !isOnlyHash) || savedTag || fromPeerId); const hasNavigationButtons = searchType === 'chat' && Boolean(foundIds?.length); const handleClose = useLastCallback(() => { closeMiddleSearch(); }); const focusInput = useLastCallback(() => { requestMeasure(() => { focusNoScroll(inputRef.current); }); }); const blurInput = useLastCallback(() => { inputRef.current?.blur(); }); // Fix for iOS keyboard useEffect(() => { const { visualViewport } = window; if (!visualViewport) { return undefined; } const mainEl = document.getElementById('Main') as HTMLDivElement; const handleResize = () => { const { activeElement } = document; if (activeElement && (activeElement === inputRef.current)) { const { pageTop, height } = visualViewport; requestMutation(() => { mainEl.style.transform = `translateY(${pageTop}px)`; mainEl.style.height = `${height}px`; document.documentElement.scrollTop = pageTop; }); } else { requestMutation(() => { mainEl.style.transform = ''; mainEl.style.height = ''; }); } }; visualViewport.addEventListener('resize', handleResize); return () => { visualViewport.removeEventListener('resize', handleResize); }; }, []); // Reset focus on query result useEffect(() => { setFocusedIndex(-1); }, [lastSearchQuery]); // Disable native up/down buttons on iOS useLayoutEffect(() => { if (!IS_IOS) return; Array.from(document.querySelectorAll('input')).forEach((input) => { input.disabled = Boolean(isActive && input !== inputRef.current); }); }, [isActive]); // Blur on exit useEffect(() => { if (!isActive) { inputRef.current!.blur(); setIsViewAsList(true); setFocusedIndex(0); setQuery(''); setIsFromFilterMode(false); setMemberSearchQuery(''); hiddenTimerRef.current = window.setTimeout(() => setIsFullyHidden(true), HIDE_TIMEOUT); } else { setIsFullyHidden(false); clearTimeout(hiddenTimerRef.current); } }, [isActive]); useEffect(() => { if (!requestedQuery || !chat?.id) return; setQuery(requestedQuery); updateMiddleSearch({ chatId: chat.id, threadId, update: { requestedQuery: undefined } }); setIsLoading(true); requestNextMutation(() => { const input = inputRef.current; if (!input) return; focusEditableElement(input, true, true); markFocused(); }); }, [chat?.id, requestedQuery, threadId]); useEffectWithPrevDeps(([prevIsActive]) => { if (isActive !== prevIsActive && !query && lastSearchQuery) { setQuery(lastSearchQuery); // Restore query when returning back } }, [isActive, lastSearchQuery, query]); useEffectWithPrevDeps(([prevIsCalendarOpen]) => { if (!isActive || prevIsCalendarOpen === isHistoryCalendarOpen) return; if (isHistoryCalendarOpen) { blurInput(); markBlurred(); } else { focusInput(); } }, [isHistoryCalendarOpen, isActive]); const handleReset = useLastCallback(() => { if (!query?.length && !savedTag && !fromPeerId && !isFromFilterMode) { handleClose(); return; } setQuery(''); setIsLoading(false); setIsFromFilterMode(false); setMemberSearchQuery(''); resetMiddleSearch(); focusInput(); }); useEffect(() => (isActive ? captureEscKeyListener(handleReset) : undefined), [isActive, handleClose]); const savedTagsArray = useMemo(() => { if (!savedTags) return undefined; return Object.values(savedTags); }, [savedTags]); const hasSavedTags = Boolean(savedTagsArray?.length); const areSavedTagsDisabled = hasSavedTags && !isCurrentUserPremium; useEffect(() => { if (isSavedMessages && isActive) loadSavedReactionTags(); }, [isSavedMessages, isActive]); const handleSearch = useLastCallback(() => { const chatId = chat?.id; if (!chatId) { return; } runDebouncedForSearch(() => { if (shouldCancelSearchRef.current) return; performMiddleSearch({ chatId, threadId, query }); }); }); const handleQueryChange = useLastCallback((newQuery: string) => { shouldCancelSearchRef.current = false; if (newQuery.startsWith('#') && !isHashtagQuery) { updateMiddleSearch({ chatId: chat!.id, threadId, update: { isHashtag: true } }); setQuery(newQuery.slice(1)); handleSearch(); 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 && !fromPeerId) { setIsLoading(false); resetMiddleSearch(); shouldCancelSearchRef.current = true; } }); useEffect(() => { if (isActive && (query || fromPeerId)) { handleSearch(); } }, [isActive, query, fromPeerId]); useEffect(() => { setIsLoading(Boolean(fetchingQuery)); }, [fetchingQuery]); useEffect(() => { if (!foundIds?.length) return; const isClose = foundIds.length - focusedIndex < FOCUSED_SEARCH_TRIGGER_OFFSET; if (isClose) { handleSearch(); } }, [focusedIndex, foundIds?.length]); useEffect(() => { if (!isActive) return undefined; maybeLongPressActiveRef.current = true; function focus() { inputRef.current?.focus(); markFocused(); fastRaf(() => { maybeLongPressActiveRef.current = false; }); } function removeListeners() { window.removeEventListener('touchend', focus); window.removeEventListener('mouseup', focus); fastRaf(() => { maybeLongPressActiveRef.current = false; }); } window.addEventListener('touchend', focus); window.addEventListener('mouseup', focus); window.addEventListener('touchstart', removeListeners); window.addEventListener('mousedown', removeListeners); return () => { removeListeners(); window.removeEventListener('touchstart', removeListeners); window.removeEventListener('mousedown', removeListeners); }; }, [isActive]); useHistoryBack({ isActive, onBack: handleClose, }); 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 && !fromPeerId) || !viewportIds?.length) { return MEMO_EMPTY_ARRAY; } const global = getGlobal(); return viewportIds.map((searchResultKey) => { const [chatId, id] = parseSearchResultKey(searchResultKey); const message = selectChatMessage(global, chatId, id); if (!message) { return undefined; } const originalSender = (isSavedMessages || isSystemBot(chatId) || chatId === ANONYMOUS_USER_ID) ? selectForwardedSender(global, message) : undefined; const messageSender = selectSender(global, message); const messageChat = selectChat(global, message.chatId); const senderPeer = originalSender || messageSender; return { searchResultKey, message, messageChat, senderPeer, }; }).filter(Boolean); }, [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); const index = foundIds?.indexOf(searchResultKey) || 0; const realIndex = index + viewportOffset; setFocusedIndex(realIndex); if (searchType === 'chat') { setIsViewAsList(false); } focusMessage({ chatId: message.chatId, messageId: message.id, threadId: !isHashtagQuery ? threadId : undefined, }); markBlurred(); }); const handleTriggerViewStyle = useLastCallback(() => { setIsViewAsList((prev) => !prev); markFocused(); }); 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 }); handleSearch(); }); const activateSearchTag = useLastCallback((tag: ApiReaction) => { if (areSavedTagsDisabled) { openPremiumModal({ initialSection: 'saved_tags', }); return; } updateSearchParams({ savedTag: tag }); }); const removeSearchSavedTag = useLastCallback(() => { updateSearchParams({ savedTag: undefined }); }); const handleDeleteTag = useLastCallback(() => { if (fromPeerId) { updateSearchParams({ fromPeerId: undefined }); setMemberSearchQuery(''); return; } if (isFromFilterMode) { setIsFromFilterMode(false); setMemberSearchQuery(''); return; } if (isHashtagQuery) { updateSearchParams({ isHashtag: false }); return; } if (savedTag) { removeSearchSavedTag(); } }); const handleChangeSearchType = useLastCallback((type: MiddleSearchType) => { updateSearchParams({ type }); 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(); blurInput(); if (foundIds) { const newFocusIndex = focusedIndex + 1; const [chatId, messageId] = parseSearchResultKey(foundIds[newFocusIndex]); focusMessage({ chatId, messageId, threadId }); setFocusedIndex(newFocusIndex); } }); const handleFocusNewer = useLastCallback(() => { if (searchType !== 'chat') return; markBlurred(); blurInput(); if (foundIds) { const newFocusIndex = focusedIndex - 1; const [chatId, messageId] = parseSearchResultKey(foundIds[newFocusIndex]); focusMessage({ chatId, messageId, threadId }); setFocusedIndex(newFocusIndex); } }); function renderTypeTag(type: MiddleSearchType, isForTag?: boolean) { const isSelected = !isForTag && searchType === type; switch (type) { case 'chat': return ( ); case 'myChats': return ( ); case 'channels': return ( ); } return undefined; } function renderMemberItem(peerId: string) { const peer = selectPeer(getGlobal(), peerId); if (!peer) return undefined; return ( ); } function renderDropdown() { return (
{!isMobile &&
} {hasSavedTags && !isHashtagQuery && !hasMemberDropdown && (
{savedTagsArray.map((tag) => { const isChosen = isSameReaction(tag.reaction, savedTag); return ( ); })}
)} {isHashtagQuery && !hasMemberDropdown && (
{renderTypeTag('chat')} {renderTypeTag('myChats')} {renderTypeTag('channels')}
)} {Boolean(hasResultsContainer || hasMemberResults) && ( {isMemberSearchLoading && hasMemberDropdown && } {displayedMembers?.map(renderMemberItem)} {hasMemberDropdown && !isMemberSearchLoading && !memberSearchResults?.length && ( {oldLang('NoResult')} )} {areResultsEmpty && ( {oldLang('NoResultFoundFor', query)} )} {isOnlyHash && ( {oldLang('HashtagSearchPlaceholder')} )} {!hasMemberDropdown && viewportResults?.map(({ message, senderPeer, messageChat, searchResultKey, }, i) => ( ))} )}
); } return (
{!isMobile && ( )}
{savedTag && ( )} {isHashtagQuery &&
#
} {(isFromFilterMode || fromPeerId) && (
{lang('SearchFilterFrom')} {fromPeerId && }
)}
{!isMobile && renderDropdown()}
{!isMobile && (
{isGroupChat && !isFromFilterMode && !fromPeerId && (
)}
{isMobile && renderDropdown()} {isMobile && (
{isGroupChat && !isFromFilterMode && !fromPeerId && ( )} {hasNavigationButtons && !hasResultsDropdown && (
)}
)}
); }; export default memo(withGlobal( (global): Complete => { const currentMessageList = selectCurrentMessageList(global); if (!currentMessageList) { return {} as Complete; } const { chatId, threadId } = currentMessageList; const chat = selectChat(global, chatId); if (!chat) { return {} as Complete; } const { requestedQuery, savedTag, results, fetchingQuery, isHashtag, type, fromPeerId, } = selectCurrentMiddleSearch(global) || {}; const { totalCount, foundIds, query: lastSearchQuery } = results || {}; const currentUserId = global.currentUserId; const isSavedMessages = selectIsChatWithSelf(global, chatId); const isSavedDialog = getIsSavedDialog(chatId, threadId, currentUserId); const savedTags = isSavedMessages && !isSavedDialog ? global.savedReactionTags?.byKey : undefined; const monoforumChat = selectMonoforumChannel(global, chatId); return { chat, monoforumChat, requestedQuery, totalCount, threadId, foundIds, isHistoryCalendarOpen: Boolean(selectTabState(global).historyCalendarSelectedAt), savedTags, savedTag, isCurrentUserPremium: selectIsCurrentUserPremium(global), isSavedMessages, fetchingQuery, isHashtagQuery: isHashtag, currentUserId, searchType: type, lastSearchQuery, fromPeerId, isGroupChat: isChatGroup(chat), }; }, )(MiddleSearch));