import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { SearchType as CoreSearchType } from 'tuikit-atomicx-react/chat'; import { useUIKit } from '@tencentcloud/uikit-base-component-react'; import cs from 'classnames'; import { SearchStore, useContactStore, useGroupStore, useChatContext, } from 'tuikit-atomicx-react/chat'; import { SearchType, VariantType, defaultTypeLabels } from '../../types/search'; import { debounce } from '../../utils'; import { Dialog } from '../../baseComp'; import styles from './Search.module.scss'; import DefaultSearchAdvanced from './SearchAdvanced'; import DefaultSearchBar from './SearchBar'; import DefaultSearchResults from './SearchResults'; import type { SearchResultItemType, SearchProps, SearchTabType, ResultItemProps, SearchParamsMap, } from '../../types/search'; import type { SearchOption, } from 'tuikit-atomicx-react/chat'; // Map UI SearchType (lower-cased) to AtomicXCore SearchType. // Both share the same string values ('user' | 'group' | 'message') so the cast is safe. const coreSearchTypeMap: Record = { [SearchType.USER]: CoreSearchType.User, [SearchType.GROUP]: CoreSearchType.Group, [SearchType.MESSAGE]: CoreSearchType.Message, [SearchType.CHAT_MESSAGE]: CoreSearchType.Message, }; export interface SearchHandle { setKeyword: (k: string) => Promise; setSelectedType: (type: SearchTabType, skipSearch?: boolean) => Promise; triggerMessageDetail: (conversationID: string) => void; } export const Search = forwardRef(({ SearchBar = DefaultSearchBar, SearchResults = DefaultSearchResults, SearchAdvanced = DefaultSearchAdvanced, onKeywordChange, onError, debounceTime = 300, variant = VariantType.MINI, autoFocus = false, ...props }, ref) => { // --- AtomicXCore stores --- const store = SearchStore.create(); // Separate store instance for CHAT_MESSAGE sub-search (conversation-scoped). // Always created to satisfy Hooks rules; only used when needed. const chatMessageStore = SearchStore.create(); const { getContactInfo } = useContactStore(); const { getGroupInfo, joinedGroupList, loadJoinedGroups } = useGroupStore(); const { direction, t } = useUIKit(); // EMBEDDED variant reads activeConversation from the AtomicXCore Channel // (framework-agnostic source of truth), exposed via the React useUIContext hook. const { activeConversation } = useChatContext(); // --- Component-managed state --- const [searchValue, setSearchValue] = useState(''); const [selectedSearchType, setSelectedSearchType] = useState('all'); const [searchAdvancedParams, setSearchAdvancedParams] = useState< Map >(new Map()); const [error, setError] = useState(null); const [isSearchingLocal, setIsSearchingLocal] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [isChatMessageSearching, setIsChatMessageSearching] = useState(false); const [isExactSearching, setIsExactSearching] = useState(false); // EXACT mode results (precise userID / groupID lookup). const [exactUserResults, setExactUserResults] = useState([]); const [exactGroupResults, setExactGroupResults] = useState([]); // Conversation cursor for the right-hand CHAT_MESSAGE detail panel. // Set when the user explicitly clicks a conversation in MESSAGE results // (or by external imperative API). Cleared whenever the main MESSAGE search // is re-issued (tab switch / advanced filter change / new keyword), so the // stale CHAT_MESSAGE panel doesn't linger when the active conversation is // no longer present in the new MESSAGE list. const [chatMessageActiveID, setChatMessageActiveID] = useState(undefined); // Latest searchValue ref, used by debounced callbacks. const searchValueRef = useRef(''); searchValueRef.current = searchValue; // Refs mirroring the latest selectedSearchType / searchAdvancedParams. // These are used by `handleTabChange` / `handleAdvancedChange` to trigger an // immediate search with the just-applied values, bypassing React's async // state-commit window. Without these refs, the search fired right after // setState would close over the *previous* state. const selectedSearchTypeRef = useRef('all'); selectedSearchTypeRef.current = selectedSearchType; const searchAdvancedParamsRef = useRef< Map >(new Map()); searchAdvancedParamsRef.current = searchAdvancedParams; const isLoading = isSearchingLocal || isLoadingMore || isChatMessageSearching || isExactSearching; const keyword = searchValue; const openSearchUnableDialog = useCallback(() => { Dialog.open({ title: t('Search.tip'), content: ( ), }); }, [t]); // --- Reset selectedSearchType for MINI / EXACT variants --- useEffect(() => { if (variant === VariantType.MINI || variant === VariantType.EXACT) { setSelectedSearchType('all'); } }, [variant]); // --- Build SearchOption from variant + advancedParams + selectedSearchType --- // Reads selectedSearchType / searchAdvancedParams via refs so it always sees // the latest values, even when called inside the same microtask as the // setState that just updated them (see `handleTabChange` / `handleAdvancedChange`). const buildSearchOption = useCallback((): SearchOption => { const option: SearchOption = {}; const currentParams = searchAdvancedParamsRef.current; const currentSelected = selectedSearchTypeRef.current; if (variant === VariantType.EMBEDDED) { option.searchScope = [CoreSearchType.Message]; const conversationID = activeConversation?.conversationID || (currentParams.get(SearchType.CHAT_MESSAGE) as any)?.conversationID; if (conversationID) { option.messageFilter = { conversationID }; } } else if (variant === VariantType.EXACT) { return option; } else { // MINI / STANDARD if (currentSelected === 'all') { option.searchScope = [CoreSearchType.User, CoreSearchType.Group, CoreSearchType.Message]; if (props.enableLocalSearch) { // When enableLocalSearch is enabled, skip cloud group search; // groups will be searched locally from joinedGroupList instead. option.searchScope = option.searchScope.filter(t => t !== CoreSearchType.Group); } } else if (currentSelected === SearchType.MESSAGE) { option.searchScope = [CoreSearchType.Message]; const msgParams = currentParams.get(SearchType.MESSAGE) as any; if (msgParams) { option.messageFilter = { senderUserIDList: msgParams.senderUserIDList, messageTypeList: msgParams.messageTypeList, searchTimePosition: msgParams.timePosition, searchTimePeriod: msgParams.timePeriod, }; } } else if (currentSelected === SearchType.USER) { option.searchScope = [CoreSearchType.User]; const userParams = currentParams.get(SearchType.USER) as any; if (userParams) { option.userFilter = { gender: userParams.gender, minBirthday: userParams.minBirthday, maxBirthday: userParams.maxBirthday, }; } } else if (currentSelected === SearchType.GROUP) { if (props.enableLocalSearch) { // When enableLocalSearch is enabled, skip cloud group search; // groups will be searched locally from joinedGroupList instead. option.searchScope = []; } else { option.searchScope = [CoreSearchType.Group]; } } } return option; }, [variant, activeConversation]); // --- Execute main search --- // --- Ensure joinedGroupList is loaded before filtering group results --- const ensureJoinedGroupsLoaded = useCallback(async () => { if (!props.enableLocalSearch) return; if (joinedGroupList.length > 0) return; await loadJoinedGroups(); }, [props.enableLocalSearch, joinedGroupList, loadJoinedGroups]); const doSearch = useCallback(async (searchKeyword: string) => { const trimmed = searchKeyword.trim(); if (!trimmed) { return; } try { setError(null); setIsSearchingLocal(true); // Clear the CHAT_MESSAGE cursor before issuing a new main search: // the previous active conversation may be absent from the new MESSAGE // results (e.g. after a time-range filter change), in which case the // right-hand detail panel must not linger. setChatMessageActiveID(undefined); await ensureJoinedGroupsLoaded(); await store.search([trimmed], buildSearchOption()); } catch (err) { setError(err as Error); if ((err as any).code === 'SearchUnable') { openSearchUnableDialog(); } onError?.(err as Error); } finally { setIsSearchingLocal(false); } }, [store, buildSearchOption, onError, ensureJoinedGroupsLoaded, openSearchUnableDialog]); // --- Execute EXACT search (precise userID / groupID lookup) --- const doExactSearch = useCallback(async (searchKeyword: string) => { const trimmed = searchKeyword.trim(); if (!trimmed) { return; } try { setIsExactSearching(true); setError(null); setExactUserResults([]); setExactGroupResults([]); const [users, group] = await Promise.allSettled([ getContactInfo([trimmed]), getGroupInfo(trimmed), ]); if (users.status === 'fulfilled' && users.value.length > 0) { setExactUserResults(users.value.map(u => ({ profile: u }))); } if (group.status === 'fulfilled' && group.value?.groupID) { const g = group.value; setExactGroupResults([{ groupInfo: { groupID: g.groupID, groupName: g.groupName, groupAvatarURL: g.avatarURL, groupType: g.groupType, memberCount: g.memberCount, }, }]); } } catch (err) { setError(err as Error); } finally { setIsExactSearching(false); } }, [getContactInfo, getGroupInfo]); // --- Execute conversation-scoped CHAT_MESSAGE sub-search --- const doChatMessageSearch = useCallback(async (conversationID: string) => { const kw = searchValueRef.current.trim(); if (!kw || !conversationID) { return; } try { setIsChatMessageSearching(true); // Pin this conversation as the active CHAT_MESSAGE cursor so the right-hand // detail panel becomes visible. setChatMessageActiveID(conversationID); // Read from ref instead of state to ensure we get the latest value, // since this is called inside an async .then() after a setState. const msgParams = searchAdvancedParamsRef.current.get(SearchType.MESSAGE) as any; await chatMessageStore.search([kw], { searchScope: [CoreSearchType.Message], messageFilter: { conversationID, senderUserIDList: msgParams?.senderUserIDList, messageTypeList: msgParams?.messageTypeList, searchTimePosition: msgParams?.timePosition, searchTimePeriod: msgParams?.timePeriod, }, }); } catch (err) { console.error('[Search] chatMessageSearch error', err); } finally { setIsChatMessageSearching(false); } }, [chatMessageStore, searchAdvancedParams]); // --- Convert flat store state to Map for SearchResults --- const results = useMemo(() => { const map = new Map(); // EXACT mode: use precise lookup results instead of cloud search. if (variant === VariantType.EXACT) { if (exactUserResults.length > 0) { map.set(SearchType.USER, { resultList: exactUserResults, hasMore: false, totalCount: exactUserResults.length, cursor: '', params: {}, } as any); } if (exactGroupResults.length > 0) { map.set(SearchType.GROUP, { resultList: exactGroupResults, hasMore: false, totalCount: exactGroupResults.length, cursor: '', params: {}, } as any); } return map; } if (store.userList.length > 0 || store.userTotalCount > 0) { map.set(SearchType.USER, { resultList: store.userList.map(profile => ({ profile })) as any, hasMore: store.hasMoreUsers, totalCount: store.userTotalCount, cursor: '', params: {}, } as any); } // GROUP results if (props.enableLocalSearch) { // When enableLocalSearch is enabled, search joined groups locally // by keyword instead of using cloud search results (which may be paginated // and miss joined groups on later pages). const kw = searchValue.trim().toLowerCase(); if (kw) { const localGroups = joinedGroupList.filter(g => (g.groupName?.toLowerCase().includes(kw) || g.groupID?.toLowerCase().includes(kw)), ); if (localGroups.length > 0) { map.set(SearchType.GROUP, { resultList: localGroups.map(groupInfo => ({ groupInfo })) as any, hasMore: false, totalCount: localGroups.length, cursor: '', params: {}, } as any); } } } else if (store.groupList.length > 0 || store.groupTotalCount > 0) { map.set(SearchType.GROUP, { resultList: store.groupList.map(groupInfo => ({ groupInfo })) as any, hasMore: store.hasMoreGroups, totalCount: store.groupTotalCount, cursor: '', params: {}, } as any); } // MESSAGE results from main store (global search, grouped by conversation). if (store.messageResults.length > 0 || store.messageResultTotalCount > 0) { map.set(SearchType.MESSAGE, { resultList: store.messageResults as any, hasMore: store.hasMoreMessageResults, totalCount: store.messageResultTotalCount, cursor: '', params: {}, } as any); } // CHAT_MESSAGE results from chatMessageStore (conversation-scoped sub-search). // Only expose them while a conversation is actively pinned via // `chatMessageActiveID`; clearing the cursor (e.g. on a new main search) // hides the stale right-hand detail panel even if the underlying store // still holds the previous results. if ( chatMessageActiveID && ( chatMessageStore.messageResults.length > 0 || chatMessageStore.messageResultTotalCount > 0 ) ) { map.set(SearchType.CHAT_MESSAGE, { resultList: chatMessageStore.messageResults as any, hasMore: chatMessageStore.hasMoreMessageResults, totalCount: chatMessageStore.messageResultTotalCount, cursor: '', params: {}, } as any); } return map; }, [ variant, exactUserResults, exactGroupResults, store.userList, store.userTotalCount, store.hasMoreUsers, store.groupList, store.groupTotalCount, store.hasMoreGroups, store.messageResults, store.messageResultTotalCount, store.hasMoreMessageResults, chatMessageStore.messageResults, chatMessageStore.messageResultTotalCount, chatMessageStore.hasMoreMessageResults, chatMessageActiveID, props.enableLocalSearch, joinedGroupList, ]); // --- Debounced search --- // The debounced wrapper is created ONCE and kept stable across renders. // It forwards to a ref pointing to the latest callback, so it always uses // up-to-date `variant` / `activeConversation` / `doSearch` etc. without // being recreated. // // Why this matters: a `useMemo`-based debounced fn that recreates on dep // change combined with a cleanup-on-unmount effect would cancel the timer // scheduled by the user's keystroke as soon as the next render rebuilds it, // making the first keystroke never trigger a search. const latestSearchCbRef = useRef<(searchKeyword: string) => Promise>( async () => {}, ); latestSearchCbRef.current = async (searchKeyword: string) => { if (variant === VariantType.EMBEDDED) { const conversationID = activeConversation?.conversationID; if (conversationID) { await doChatMessageSearch(conversationID); } } else { await doSearch(searchKeyword); } }; const debouncedSearch = useMemo( () => debounce((searchKeyword: string) => latestSearchCbRef.current(searchKeyword), debounceTime), [debounceTime], ); useEffect(() => () => { debouncedSearch.cancel?.(); }, [debouncedSearch]); // --- Reset embedded state when active conversation changes --- const prevActiveConvIDRef = useRef(undefined); useEffect(() => { const newID = activeConversation?.conversationID; const oldID = prevActiveConvIDRef.current; prevActiveConvIDRef.current = newID; if (variant === VariantType.EMBEDDED && newID !== oldID) { setSearchValue(''); debouncedSearch.cancel?.(); setError(null); setSearchAdvancedParams(new Map()); setSelectedSearchType('all'); setExactUserResults([]); setExactGroupResults([]); setChatMessageActiveID(undefined); onKeywordChange?.(''); } }, [activeConversation?.conversationID, variant]); // --- Event handlers --- const handleInputChange = useCallback((e: React.ChangeEvent) => { const { value } = e.target; setSearchValue(value); onKeywordChange?.(value); if (variant !== VariantType.EXACT) { debouncedSearch(value); } }, [variant, debouncedSearch, onKeywordChange]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') { debouncedSearch.cancel?.(); if (variant === VariantType.EMBEDDED) { const conversationID = activeConversation?.conversationID; if (conversationID) { doChatMessageSearch(conversationID); } } else if (variant === VariantType.EXACT) { doExactSearch(searchValue); } else { doSearch(searchValue); } } }, [ variant, debouncedSearch, activeConversation, doChatMessageSearch, doExactSearch, doSearch, searchValue, ]); const handleClear = useCallback(() => { setSearchValue(''); debouncedSearch.cancel?.(); setError(null); setSearchAdvancedParams(new Map()); setSelectedSearchType('all'); setExactUserResults([]); setExactGroupResults([]); setChatMessageActiveID(undefined); onKeywordChange?.(''); }, [debouncedSearch, onKeywordChange]); const handleAdvancedChange = useCallback(( type: SearchType, params: SearchParamsMap[SearchType], ) => { // Compute the next map synchronously and write both state and ref so that // any immediate doSearch() picks up the just-applied params. const prev = searchAdvancedParamsRef.current; const currentParams: any = prev.get(type) || {}; const next = new Map(prev); next.set(type, { ...currentParams, ...params }); searchAdvancedParamsRef.current = next; setSearchAdvancedParams(next); if (searchValueRef.current) { // In STANDARD mode, when advanced filter changes and the right-hand // CHAT_MESSAGE panel is showing, re-trigger the conversation-scoped // search so the right panel updates too (not just the left list). // doSearch will clear chatMessageActiveID, so we capture it first // and re-trigger after doSearch completes. const prevActiveConvID = variant === VariantType.STANDARD ? chatMessageStore.messageResults[0]?.conversationID : undefined; doSearch(searchValueRef.current).then(() => { if (prevActiveConvID) { doChatMessageSearch(prevActiveConvID); } }); } }, [doSearch, doChatMessageSearch, variant, chatMessageStore]); const handleResultItemClick = useCallback(( item: SearchResultItemType, type: SearchType, ) => { if (props.onResultItemClick) { props.onResultItemClick(item, type); } if (type === SearchType.MESSAGE) { // User clicked a conversation in MESSAGE results → trigger CHAT_MESSAGE sub-search. // Only in STANDARD mode: MINI delegates to ConversationSearch which switches // to STANDARD and calls triggerMessageDetail, so we skip here to avoid an // extra search call (and potential rate-limit hit) on the MINI instance. if (variant === VariantType.STANDARD) { const conversationID = (item as any).conversationID ?? ''; if (conversationID) { doChatMessageSearch(conversationID); } } } // CHAT_MESSAGE clicks are handled by parent (ConversationSearch) and Message component. }, [props, doChatMessageSearch, variant]); const handleViewMore = useCallback((type: SearchType) => { if (variant === VariantType.STANDARD && type !== SearchType.CHAT_MESSAGE) { setSelectedSearchType(type); } // CHAT_MESSAGE load-more uses the chatMessageStore. if (type === SearchType.CHAT_MESSAGE) { setIsLoadingMore(true); chatMessageStore.searchMore(CoreSearchType.Message).finally(() => { setIsLoadingMore(false); }); return; } const coreType = coreSearchTypeMap[type]; if (coreType) { setIsLoadingMore(true); store.searchMore(coreType).finally(() => { setIsLoadingMore(false); }); } }, [variant, store, chatMessageStore]); const handleTabChange = useCallback((tab: SearchTabType) => { // Sync ref synchronously so the immediate doSearch() picks up the new tab. selectedSearchTypeRef.current = tab; setSelectedSearchType(tab); if (searchValueRef.current) { doSearch(searchValueRef.current); } }, [doSearch]); const stopClickPropagation = (e: { stopPropagation: () => void }) => { e.stopPropagation(); }; // --- Imperative API for parent components (e.g. ConversationSearch) --- // Mirrors Vue3's defineExpose({ setKeyword, setSelectedType, triggerMessageDetail }). useImperativeHandle(ref, () => ({ setKeyword: async (k: string) => { // Sync ref immediately so subsequent imperative calls (setSelectedType / // triggerMessageDetail) in the same microtask see the new keyword. searchValueRef.current = k; setSearchValue(k); onKeywordChange?.(k); if (k.trim()) { debouncedSearch.cancel?.(); await doSearch(k); } }, setSelectedType: async (type: SearchTabType, skipSearch = false) => { selectedSearchTypeRef.current = type; setSelectedSearchType(type); if (!skipSearch && searchValueRef.current) { await doSearch(searchValueRef.current); } }, triggerMessageDetail: (conversationID: string) => { if (conversationID) { doChatMessageSearch(conversationID); } }, }), [debouncedSearch, doSearch, doChatMessageSearch, onKeywordChange]); return (
> } variant={variant} />
); }); Search.displayName = 'Search'; export default React.memo(Search);