import type React from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { IconSearch, useUIKit, } from '@tencentcloud/uikit-base-component-react'; import cs from 'classnames'; import { SearchStore, SearchType as CoreSearchType, useChatContext, } from 'tuikit-atomicx-react/chat'; import { Input } from '../../../baseComp/Input'; import { useChatUIState } from '../../../context/useChatUIState'; import { SearchType } from '../../../types/search'; import { debounce } from '../../../utils'; import { SearchHeaderH5 } from '../shared/SearchHeaderH5'; import { SearchMessageList } from '../shared/SearchMessageList'; import { handleSearchError } from '../shared/utils/handleSearchError'; import styles from './EmbeddedSearchH5.module.scss'; import type { MessageInfo } from 'tuikit-atomicx-react/chat'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface EmbeddedSearchH5Props { /** Debounce delay in milliseconds. Defaults to 1000. */ debounceTime?: number; className?: string; style?: React.CSSProperties; autoFocus?: boolean; onBack?: () => void; /** Fired when a message row is tapped (H5 host typically leaves the search view). */ onResultItemClick?: (message: MessageInfo, type: SearchType) => void; } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function EmbeddedSearchH5({ debounceTime = 1000, className, style, autoFocus = false, onBack, onResultItemClick, }: EmbeddedSearchH5Props) { const { t } = useUIKit(); const { activeConversation, setActiveConversation } = useChatContext(); const { setPendingLocateMessage } = useChatUIState(); // SearchStore.create() in React context = useSearchStore() hook. // store.messageResults / hasMoreMessageResults / messageResultTotalCount // are reactive: when store._setState() fires, useSyncExternalStore triggers // a re-render and the component reads the latest values automatically. // No need to call store.getState() manually. const store = SearchStore.create(); const [keyword, setKeyword] = useState(''); const [isLoading, setIsLoading] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [error, setError] = useState(null); // Distinguishes "not searched yet" from "searched but got zero results". const [hasSearched, setHasSearched] = useState(false); // Track conversation ID to auto-reset when the active conversation changes. const activeConversationIDRef = useRef(undefined); // Whether the IME is currently composing (e.g. selecting pinyin candidates). // While composing, keystrokes should update the displayed value but must not // trigger a search; the search is fired once on compositionend. const isComposingRef = useRef(false); const resetState = useCallback(() => { setKeyword(''); setIsLoading(false); setIsLoadingMore(false); setError(null); setHasSearched(false); }, []); useEffect(() => { const nextID = activeConversation?.conversationID; if (nextID !== activeConversationIDRef.current) { activeConversationIDRef.current = nextID; resetState(); } }, [activeConversation?.conversationID, resetState]); const doSearch = useCallback(async (kw: string) => { const trimmed = kw.trim(); const conversationID = activeConversation?.conversationID; if (!trimmed || !conversationID) { return; } setIsLoading(true); setError(null); setHasSearched(true); try { // keepStaleResultsWhileLoading: this component renders the store's // live slice directly (no local snapshot), so the store itself must // keep the previous results visible until the new ones land. await store.search([trimmed], { searchScope: [CoreSearchType.Message], messageFilter: { conversationID }, keepStaleResultsWhileLoading: true, }); // No getState() needed — store.messageResults is already reactive. } catch (err) { setError(handleSearchError(t, err)); } finally { setIsLoading(false); } }, [store, activeConversation?.conversationID, t]); // Always points to the latest doSearch without recreating the debounce instance. // This prevents stale pending timers caused by debounce recreation on store/deps change. const latestDoSearchRef = useRef(doSearch); latestDoSearchRef.current = doSearch; // Stable debounce — created once for the lifetime of the component. const debouncedSearch = useRef( debounce((kw: string) => latestDoSearchRef.current(kw), debounceTime), ); useEffect(() => () => { debouncedSearch.current.cancel?.(); }, []); const handleChange = useCallback((e: React.ChangeEvent) => { const val = e.target.value; // Always update the displayed value so the input reflects what the user // types, including in-progress IME composition (e.g. raw pinyin). setKeyword(val); // Skip searching while the IME is composing; wait for compositionend. if (isComposingRef.current) { return; } if (val.trim()) { // Deliberately do NOT clear hasSearched/error here. The previous // result set stays on screen (stale-while-revalidate, backed by the // store's keepStaleResultsWhileLoading option) until the new debounced // search resolves and overwrites the store's slice — this avoids the // list collapsing to a loading/empty state on every keystroke while // the user keeps refining a keyword. debouncedSearch.current(val); } else { // Keyword cleared to empty: nothing to revalidate against, fall back. debouncedSearch.current.cancel?.(); setHasSearched(false); setError(null); } }, []); const handleCompositionStart = useCallback(() => { isComposingRef.current = true; }, []); const handleCompositionEnd = useCallback((e: React.CompositionEvent) => { isComposingRef.current = false; const val = e.currentTarget.value; setKeyword(val); if (val.trim()) { debouncedSearch.current(val); } else { debouncedSearch.current.cancel?.(); setHasSearched(false); setError(null); } }, []); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) { debouncedSearch.current.cancel?.(); doSearch(keyword); } }, [doSearch, keyword]); const handleClear = useCallback(() => { setKeyword(''); setHasSearched(false); setError(null); debouncedSearch.current.cancel?.(); }, []); const handleLoadMore = useCallback(async () => { if (!store.hasMoreMessageResults || isLoadingMore) { return; } setIsLoadingMore(true); try { await store.searchMore(CoreSearchType.Message); // store.messageResults auto-updates via useSyncExternalStore subscription. } finally { setIsLoadingMore(false); } }, [store, isLoadingMore]); const handleLocateMessage = useCallback((message: MessageInfo) => { const conversationID = activeConversation?.conversationID; if (!conversationID || !message.msgID) { return; } setPendingLocateMessage(message); setActiveConversation(conversationID); onResultItemClick?.(message, SearchType.CHAT_MESSAGE); }, [activeConversation?.conversationID, setPendingLocateMessage, setActiveConversation, onResultItemClick]); // Flatten MessageSearchResultItem[] → MessageInfo[]. With // keepStaleResultsWhileLoading passed to store.search() above, this keeps // its previous value while a revalidating search is in flight instead of // being reset to empty first. Memoized on `store.messageResults` (a stable // reactive reference) so its identity only changes when the store's data // actually changes. const messages = useMemo( () => store.messageResults.flatMap(item => item.messageList ?? []), [store.messageResults], ); // Total message count for the current conversation search, surfaced from the // first result slice. Prefer this over `store.messageResultTotalCount` to // stay consistent with the Vue3 implementation. const messageCount = store.messageResults[0]?.messageCount; const hasAnyResult = messages.length > 0; // Only a genuine "no results" state once loading has actually settled — // while revalidating (isLoading with prior results still in the store) we // keep showing those results instead of flashing this empty state. const isEmpty = hasSearched && !isLoading && !error && !hasAnyResult; // First-ever search still in flight, with nothing to show yet: this is the // only case that should render the plain "loading" status view. const isInitialLoading = isLoading && !hasAnyResult; return (
{ resetState(); onBack?.(); }} /> {/* Search bar */}
} clearable value={keyword} onChange={handleChange} onKeyDown={handleKeyDown} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} onClear={handleClear} placeholder={t('Search.search_messages')} autoFocus={autoFocus} enterKeyHint="search" />
{/* State views */} {/* True first-load spinner — nothing in the store yet to fall back on. */} {isInitialLoading && !error && (
{t('Search.loading')}
)} {error && (
{error}
)} {isEmpty && (
{t('Search.no_result')}
)} {/* Result count */} {hasSearched && !error && Number(messageCount) > 0 && (
{t('Search.found_messages', { count: messages.length })}
)} {/* Message list — the store keeps its previous slice visible while a revalidating search is in flight (isLoading true while typing on top of an existing keyword), so this keeps the previous rows on screen instead of the list disappearing. */} {!error && hasAnyResult && hasSearched && (
)}
); } export default EmbeddedSearchH5;