import type React from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { IconSearch, useUIKit, } from '@tencentcloud/uikit-base-component-react'; import cs from 'classnames'; import { SearchStore, SearchType as CoreSearchType, useChatContext, GroupStore, } from 'tuikit-atomicx-react/chat'; import { Input } from '../../../baseComp/Input'; import { useChatUIState } from '../../../context/useChatUIState'; import { debounce } from '../../../utils'; import { HighlightText } from '../shared/HighlightText'; import { SearchMessageList } from '../shared/SearchMessageList'; import { SearchResultRow } from '../shared/SearchResultRow'; import { handleSearchError } from '../shared/utils/handleSearchError'; import { renderSearchResultItem } from '../shared/renderSearchResultItem'; import styles from './StandardSearch.module.scss'; import type { MessageInfo, GroupSearchInfo, GroupInfo, } from 'tuikit-atomicx-react/chat'; import type { SearchResultItemComponent } from '../shared/renderSearchResultItem'; import { SearchType as UISearchType } from '../../../types/search'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type MiniStoreAPI = ReturnType; type UserResult = MiniStoreAPI['userList'][number]; type GroupResult = MiniStoreAPI['groupList'][number]; type MessageResult = MiniStoreAPI['messageResults'][number]; /** Left-panel tabs. `all` aggregates user + group + message sections. */ export type StandardSearchTab = 'all' | 'user' | 'group' | 'message'; /** Message time-range filter. `all` means no time constraint. */ type MessageTimeFilter = 'all' | 'today' | 'last3days' | 'last7days'; /** Discriminated union emitted by onResultItemClick; consumers narrow via `type`. */ export type StandardSearchResult = | { type: 'user'; data: UserResult } | { type: 'group'; data: GroupResult } | { type: 'message'; data: MessageResult }; export interface StandardSearchProps { /** * Shared store instance (e.g. owned by MiniSearch). When provided, results * already in the store are rendered on mount without re-searching. When * omitted, an internal store is created for standalone use. */ store?: MiniStoreAPI | undefined; /** Initial keyword (one-shot). The component owns `keyword` afterwards. */ initialKeyword?: string; /** Initial active tab. */ initialTab?: StandardSearchTab; /** * Open with this conversation pre-selected in the message detail pane * (entry: clicking a message row in MINI mode). */ initialConversationID?: string; /** Debounce delay in milliseconds. Defaults to 300. */ debounceTime?: number; onKeywordChange?: (keyword: string) => void; onResultItemClick?: (result: StandardSearchResult) => void; /** * Fired after a message in the right-pane detail list is located (i.e. the * active conversation has been switched and the message queued to jump * to). Consumers embedding StandardSearch inside a modal (e.g. MiniSearch) * typically use this to close the modal. */ onLocateMessage?: (message: MessageInfo) => void; /** * When true, group results are filtered locally from the current user's * joined group list instead of hitting the server-side group search. * Mirrors MiniSearch's `enableLocalSearch`. Defaults to true. */ enableLocalSearch?: boolean; SearchResultItem?: SearchResultItemComponent | undefined; className?: string; style?: React.CSSProperties; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- // The store always searches the FULL scope; tabs are a pure view concern. // searchMore(type) appends into the matching slice, so switching tabs never // needs a re-search. const ALL_SCOPE = [CoreSearchType.User, CoreSearchType.Group, CoreSearchType.Message]; const TAB_ORDER: StandardSearchTab[] = ['all', 'user', 'group', 'message']; const TAB_I18N_KEY: Record = { all: 'Search.tab_all', user: 'Search.contacts', group: 'Search.groups', message: 'Search.chat_history', }; // One day in seconds, for searchTimePeriod. const DAY_SECONDS = 24 * 60 * 60; const TIME_FILTER_ORDER: MessageTimeFilter[] = ['all', 'today', 'last3days', 'last7days']; const TIME_FILTER_I18N_KEY: Record = { all: 'Search.time_all', today: 'Search.time_today', last3days: 'Search.time_last_3_days', last7days: 'Search.time_last_7_days', }; // searchTimePeriod per filter; `all` → 0 (no constraint). const TIME_FILTER_PERIOD: Record = { all: 0, today: DAY_SECONDS, last3days: 3 * DAY_SECONDS, last7days: 7 * DAY_SECONDS, }; // In the `all` tab each section shows a short preview; single tabs show the // full (paginated) list. Preview counts align with WeCom (企业微信): // contacts 3, groups 7, chat history 3 — anything beyond is behind "view more". const ALL_TAB_PREVIEW_COUNT: Record, number> = { user: 3, group: 7, message: 3, }; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function StandardSearch({ store: storeProp, initialKeyword = '', initialTab = 'all', initialConversationID, debounceTime = 500, onKeywordChange, onResultItemClick, onLocateMessage, enableLocalSearch = true, SearchResultItem, className, style, }: StandardSearchProps) { const { t } = useUIKit(); const { setActiveConversation } = useChatContext(); const { setPendingLocateMessage } = useChatUIState(); // Store instances (three max): // 1. props.store — shared from MiniSearch, already populated (no re-search). // 2. internalStore — standalone fallback when no store is passed. // 3. scopedStore — dedicated to in-conversation message detail (right pane). const internalStore = SearchStore.create(); const store = storeProp ?? internalStore; const scopedStore = SearchStore.create(); // Joined-group list, used for local group filtering when enableLocalSearch. const groupStore = GroupStore(); const [keyword, setKeyword] = useState(initialKeyword); const [activeTab, setActiveTab] = useState(initialTab); const [isLoading, setIsLoading] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [error, setError] = useState(null); const [hasSearched, setHasSearched] = useState(!!storeProp); // Right pane: selected conversation + its own loading/error lifecycle. const [activeConversationID, setActiveConversationID] = useState( initialConversationID, ); const [isDetailLoading, setIsDetailLoading] = useState(false); const [isDetailLoadingMore, setIsDetailLoadingMore] = useState(false); const [detailError, setDetailError] = useState(null); const [detailSearched, setDetailSearched] = useState(false); // Message time filter (advanced option for the message tab). const [timeFilter, setTimeFilter] = useState('all'); // Latest-value refs so stable callbacks (debounce / event handlers created // once) can read current values across timer boundaries. const latestKeywordRef = useRef(keyword); latestKeywordRef.current = keyword; const activeConversationIDRef = useRef(activeConversationID); activeConversationIDRef.current = activeConversationID; // Whether the IME is currently composing (e.g. selecting pinyin candidates). // While composing, keystrokes update the displayed value but must not trigger // a search; the search fires once on compositionend. Mirrors MiniSearch. const isComposingRef = useRef(false); // Message filter carrying a time range (and optionally a conversation scope). // `filter` is passed explicitly so an event handler can search with a value // it just picked, without waiting for the state commit. `all` → no constraint. const buildMessageFilter = useCallback(( filter: MessageTimeFilter, conversationID?: string, ) => ({ ...(conversationID && { conversationID }), searchTimePosition: filter === 'all' ? 0 : Math.floor(Date.now() / 1000), searchTimePeriod: TIME_FILTER_PERIOD[filter], }), []); // Full-scope search on the shared store. When a conversation is selected, // the right pane re-searches with the same (new) keyword. // `filterOverride` lets the time-filter handler search with the value it just // picked; other callers use the committed state. const doSearch = useCallback(async (kw: string, filterOverride?: MessageTimeFilter) => { const trimmed = kw.trim(); if (!trimmed) { return; } const filter = filterOverride ?? timeFilter; setIsLoading(true); setError(null); setHasSearched(true); try { // keepStaleResultsWhileLoading: this component reads store slices // directly, so the store itself must keep previous results visible // until the new ones land instead of clearing them up front. await store.search([trimmed], { searchScope: ALL_SCOPE, messageFilter: buildMessageFilter(filter), keepStaleResultsWhileLoading: true, }); // store slices are reactive; no manual state sync needed. } catch (err) { setError(handleSearchError(t, err)); } finally { setIsLoading(false); } const conversationID = activeConversationIDRef.current; if (conversationID) { setIsDetailLoading(true); setDetailError(null); setDetailSearched(true); try { await scopedStore.search([trimmed], { searchScope: [CoreSearchType.Message], messageFilter: buildMessageFilter(filter, conversationID), keepStaleResultsWhileLoading: true, }); } catch (err) { setDetailError(handleSearchError(t, err)); } finally { setIsDetailLoading(false); } } }, [store, scopedStore, buildMessageFilter, timeFilter, t]); // Keep latest doSearch reachable without recreating the debounce instance, // avoiding stale pending timers when deps change between renders. const latestDoSearchRef = useRef(doSearch); latestDoSearchRef.current = doSearch; const debouncedSearch = useRef( debounce((kw: string) => latestDoSearchRef.current(kw), debounceTime), ); // Dedicated debounce for the time-range filter buttons. Without this, // quickly clicking through today / last3days / last7days fires one SDK // search request per click and can trip the SDK's rate limiting. const debouncedFilterSearch = useRef( debounce( (kw: string, filter: MessageTimeFilter) => latestDoSearchRef.current(kw, filter), debounceTime, ), ); useEffect(() => () => { debouncedSearch.current.cancel?.(); debouncedFilterSearch.current.cancel?.(); }, []); // Select a conversation and load its messages into the right pane. // Single funnel for all three entries: // 1. MINI message-row click (via initialConversationID on mount) // 2. `all` tab message-row click (also switches to the message tab) // 3. message tab message-row click const selectConversation = useCallback((conversationID: string) => { setActiveConversationID(conversationID); activeConversationIDRef.current = conversationID; const trimmed = latestKeywordRef.current.trim(); if (!trimmed) { return; } setIsDetailLoading(true); setDetailError(null); setDetailSearched(true); scopedStore.search([trimmed], { searchScope: [CoreSearchType.Message], messageFilter: buildMessageFilter(timeFilter, conversationID), keepStaleResultsWhileLoading: true, }).catch((err) => { setDetailError(handleSearchError(t, err)); }).finally(() => { setIsDetailLoading(false); }); }, [scopedStore, buildMessageFilter, timeFilter, t]); // Time filter change: a user interaction, so the search runs here directly // with the value just picked — no state-commit wait, no ref mirroring. const handleTimeFilterChange = useCallback((next: MessageTimeFilter) => { if (next === timeFilter) { return; } setTimeFilter(next); const kw = latestKeywordRef.current; if (kw.trim()) { debouncedSearch.current.cancel?.(); // Debounced (not immediate): rapid successive clicks only fire the // last-picked filter's search once the clicking settles down. debouncedFilterSearch.current(kw, next); } }, [timeFilter]); // Mount-only: MINI entry pre-selects the conversation. const mountedRef = useRef(false); useEffect(() => { if (mountedRef.current || !initialConversationID) { return; } mountedRef.current = true; setActiveTab('message'); selectConversation(initialConversationID); }, [initialConversationID, selectConversation]); useEffect(() => { // init joined group list if (groupStore.joinedGroupList.length === 0) { groupStore.loadJoinedGroups(); } }, []); const handleChange = useCallback((e: React.ChangeEvent) => { const val = e.target.value; setKeyword(val); onKeywordChange?.(val); // Skip searching while the IME is composing; wait for compositionend. if (isComposingRef.current) { return; } setError(null); if (val.trim()) { // Keep hasSearched true so the previous result set stays visible until // the debounced search overwrites the store (stale-while-revalidate). debouncedSearch.current(val); } else { setHasSearched(false); debouncedSearch.current.cancel?.(); } }, [onKeywordChange]); const handleCompositionStart = useCallback(() => { isComposingRef.current = true; }, []); const handleCompositionEnd = useCallback((e: React.CompositionEvent) => { isComposingRef.current = false; // The composed text is now committed to the input; trigger the search. const val = e.currentTarget.value; setKeyword(val); onKeywordChange?.(val); setError(null); if (val.trim()) { debouncedSearch.current(val); } else { setHasSearched(false); debouncedSearch.current.cancel?.(); } }, [onKeywordChange]); // Tabs are a pure view switch — the store holds full-scope data already. const handleTabChange = useCallback((tab: StandardSearchTab) => { setActiveTab(tab); }, []); const handleClear = useCallback(() => { setKeyword(''); setHasSearched(false); setError(null); debouncedSearch.current.cancel?.(); onKeywordChange?.(''); }, [onKeywordChange]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { // Guard against the Enter that confirms an IME candidate selection — // that keystroke must not be treated as "submit search". if (e.key === 'Enter' && !e.nativeEvent.isComposing) { debouncedSearch.current.cancel?.(); doSearch(keyword); } }, [doSearch, keyword]); const handleLoadMore = useCallback(async (type: CoreSearchType) => { if (isLoadingMore) { return; } setIsLoadingMore(true); try { await store.searchMore(type); } finally { setIsLoadingMore(false); } }, [store, isLoadingMore]); const handleDetailLoadMore = useCallback(async () => { if (isDetailLoadingMore) { return; } setIsDetailLoadingMore(true); try { await scopedStore.searchMore(CoreSearchType.Message); } finally { setIsDetailLoadingMore(false); } }, [scopedStore, isDetailLoadingMore]); // Right-pane "locate to chat": switch the active conversation to the one // being viewed and queue the message to jump to. Uses activeConversationID // (the conversation picked in the message tab), not the app-wide active // conversation — StandardSearch's detail pane may show any conversation. const handleLocateMessage = useCallback((message: MessageInfo) => { const conversationID = activeConversationIDRef.current; if (!conversationID || !message.msgID) { return; } setPendingLocateMessage(message); setActiveConversation(conversationID); onLocateMessage?.(message); }, [setPendingLocateMessage, setActiveConversation, onLocateMessage]); // Message-row click (entries 2 & 3): ensure message tab, then select. const handleMessageItemClick = useCallback((conversationID: string) => { if (activeTab !== 'message') { setActiveTab('message'); } selectConversation(conversationID); }, [activeTab, selectConversation]); // Reactive slices from the shared store. With keepStaleResultsWhileLoading // passed to every search() call above, these keep their previous values // while a revalidating search is in flight instead of being reset to // empty first. const users = store.userList; const groups = store.groupList; const localGroups = enableLocalSearch ? groupStore.joinedGroupList.filter(group => group.groupID.toLowerCase().includes(keyword.toLowerCase()) || (group.groupName || '').toLowerCase().includes(keyword.toLowerCase())) : []; // The group list actually rendered: local (joined-group filter) or server search results. const visibleGroups = enableLocalSearch ? localGroups : groups; const messages = store.messageResults; // Right pane: flatten the scoped store's per-conversation buckets. const detailMessages = scopedStore.messageResults.flatMap(item => item.messageList ?? []); const showAll = activeTab === 'all'; const showUsers = (showAll || activeTab === 'user') && hasSearched && users.length > 0; const showGroups = (showAll || activeTab === 'group') && hasSearched && visibleGroups.length > 0; const showMessages = (showAll || activeTab === 'message') && hasSearched && messages.length > 0; const isEmpty = hasSearched && !isLoading && !error && !showUsers && !showGroups && !showMessages; const renderSection = ( tab: StandardSearchTab, title: string, totalCount: number, hasMore: boolean, coreType: CoreSearchType, rows: React.ReactNode, ) => (
{title}
{rows} {showAll && tab !== 'all' && totalCount > ALL_TAB_PREVIEW_COUNT[tab] && (
handleTabChange(tab)} > {t('Search.view_more')}
)} {!showAll && hasMore && (
)}
); const renderMessageRow = (item: MessageResult) => renderSearchResultItem( SearchResultItem, { keyword, data: item, type: UISearchType.MESSAGE, onClick: () => handleMessageItemClick(item.conversationID), }, handleMessageItemClick(item.conversationID)} />, ); return (
{/* Search bar */}
} clearable value={keyword} onChange={handleChange} onKeyDown={handleKeyDown} onClear={handleClear} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} placeholder={t('Search.search')} enterKeyHint="search" />
{/* Tabs */}
{TAB_ORDER.map(tab => ( ))}
{/* Advanced options for chat message: time-range filter */} {activeTab === 'message' && (
{t('Search.select_time')} {TIME_FILTER_ORDER.map(filter => ( ))}
)} {/* State views */} {isLoading && (
{t('Search.loading')}
)} {error && (
{error}
)} {isEmpty && (
{t('Search.no_result')}
)} {/* Results: message tab renders a split view, others a single column. */} {!isLoading && !error && (showUsers || showGroups || showMessages) && ( activeTab === 'message' ? (
{messages.map(renderMessageRow)} {store.hasMoreMessageResults && (
)}
{activeConversationID ? ( ) : (
{t('Search.select_conversation')}
)}
) : (
{showUsers && renderSection( 'user', t('Search.contacts'), store.userTotalCount, store.hasMoreUsers, CoreSearchType.User, (showAll ? users.slice(0, ALL_TAB_PREVIEW_COUNT.user) : users).map(user => renderSearchResultItem( SearchResultItem, { keyword, data: { profile: user }, type: UISearchType.USER, onClick: () => onResultItemClick?.({ type: 'user', data: user }), }, } onClick={() => onResultItemClick?.({ type: 'user', data: user })} />, )), )} {showGroups && renderSection( 'group', t('Search.groups'), enableLocalSearch ? localGroups.length : store.groupTotalCount, enableLocalSearch ? false : store.hasMoreGroups, CoreSearchType.Group, (showAll ? visibleGroups.slice(0, ALL_TAB_PREVIEW_COUNT.group) : visibleGroups).map(group => renderSearchResultItem( SearchResultItem, { keyword, data: { groupInfo: group as GroupSearchInfo }, type: UISearchType.GROUP, onClick: () => onResultItemClick?.({ type: 'group', data: group as unknown as GroupResult }), }, } {...(group.groupID && { subtitle: `ID: ${group.groupID}` })} onClick={() => onResultItemClick?.({ type: 'group', data: group as unknown as GroupResult })} />, )), )} {showMessages && renderSection( 'message', t('Search.chat_history'), store.messageResultTotalCount, store.hasMoreMessageResults, CoreSearchType.Message, (showAll ? messages.slice(0, ALL_TAB_PREVIEW_COUNT.message) : messages).map(renderMessageRow), )}
) )}
); } export default StandardSearch;