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 { Crosshair, ChevronRight } from 'lucide-react'; import { SearchStore, SearchType as CoreSearchType, GroupStore, } from 'tuikit-atomicx-react/chat'; import { Dialog } from '../../../baseComp/Dialog'; import { Input } from '../../../baseComp/Input'; import { Popup } from '../../../baseComp/Popup'; import { debounce } from '../../../utils'; import { HighlightText } from '../shared/HighlightText'; import { SearchResultRow } from '../shared/SearchResultRow'; import { handleSearchError } from '../shared/utils/handleSearchError'; import { renderSearchResultItem } from '../shared/renderSearchResultItem'; import { StandardSearch } from '../StandardSearch'; import styles from './MiniSearch.module.scss'; import type { InputRef } from '../../../baseComp/Input'; import type { PopupOpenChangeDetails } from '../../../baseComp/Popup'; import type { StandardSearchResult, StandardSearchTab } from '../StandardSearch'; import type { SearchResultItemComponent } from '../shared/renderSearchResultItem'; import type { GroupSearchInfo, GroupInfo } from 'tuikit-atomicx-react/chat'; import { SearchType as UISearchType } from '../../../types/search'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- // Derive result element types directly from the store's reactive state, // so they always match what search() actually returns (no drift, no import // resolution issues for named types). type MiniStoreAPI = ReturnType; type UserResult = MiniStoreAPI['userList'][number]; type GroupResult = MiniStoreAPI['groupList'][number]; type MessageResult = MiniStoreAPI['messageResults'][number]; export type MiniSearchResultType = 'user' | 'group' | 'message'; /** * Discriminated union emitted by onResultItemClick. * `data` uses the raw store/SDK types — consumers narrow via `result.type`. */ export type MiniSearchResult = | { type: 'user'; data: UserResult } | { type: 'group'; data: GroupResult } | { type: 'message'; data: MessageResult }; export interface MiniSearchProps { /** Debounce delay in milliseconds. Defaults to 500. */ debounceTime?: number; /** * Max preview rows per section. Defaults align with WeCom (企业微信): * user 3, group 7, message 3. Partial overrides are merged per type. */ maxPreviewCount?: Partial>; onKeywordChange?: (keyword: string) => void; onResultItemClick?: (result: MiniSearchResult) => void; /** "View more" for a section — reserved for STANDARD mode integration. */ onViewMore?: (type: MiniSearchResultType) => void; autoFocus?: boolean; enableLocalSearch?: boolean; SearchResultItem?: SearchResultItemComponent | undefined; className?: string; style?: React.CSSProperties; } // MINI searches contacts / groups / messages, but not group members. const MINI_SEARCH_SCOPE = [ CoreSearchType.User, CoreSearchType.Group, CoreSearchType.Message, ]; // Default preview rows per section, aligned with WeCom (企业微信). const DEFAULT_PREVIEW_COUNT: Record = { user: 3, group: 7, message: 3, }; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function MiniSearch({ debounceTime = 500, maxPreviewCount, onKeywordChange, onResultItemClick, onViewMore, enableLocalSearch = true, SearchResultItem, className, style, }: MiniSearchProps) { const { t } = useUIKit(); // Ref to the imperative handle; the Input container element is used // as the Popup anchor so the panel aligns tightly to the input box. const inputRef = useRef(null); // Dedicated reactive store instance for this MiniSearch. const store = SearchStore.create(); const groupStore = GroupStore(); const [keyword, setKeyword] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [hasSearched, setHasSearched] = useState(false); const [popupOpen, setPopupOpen] = useState(false); const [standardOpen, setStandardOpen] = useState(false); const [standardTab, setStandardTab] = useState('all'); const [standardConversationID, setStandardConversationID] = useState(undefined); // 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. const isComposingRef = useRef(false); const openStandardReasonRef = useRef<'by_standard' | 'by_mini'>('by_mini'); // Mirrors `keyword`, but updated SYNCHRONOUSLY (refs aren't batched/deferred // like state). Needed because Input's clear button calls onClear() then // immediately calls focus() in the same tick — that native focus event // reaches our onFocus handler before React has re-rendered with the // just-cleared keyword, so the `keyword` *closure* in handleFocus would // still see the stale pre-clear value. Reading this ref instead always // reflects the true current value at the moment focus fires. const keywordRef = useRef(''); useEffect(() => { // init joined group list if (groupStore.joinedGroupList.length === 0) { groupStore.loadJoinedGroups(); } }, []); // Single write path for `keyword` — always updates the state (for // rendering) and the ref (for synchronous reads, see keywordRef above) // together, so the two can never drift out of sync by accident. const setKeywordSynced = useCallback((val: string) => { setKeyword(val); keywordRef.current = val; }, []); const doSearch = useCallback(async (kw: string) => { const trimmed = kw.trim(); if (!trimmed) { return; } setIsLoading(true); setError(null); setHasSearched(true); try { // keepStaleResultsWhileLoading: this component renders the store's // live slices directly (no local snapshot), so the store itself must // keep the previous results visible until the new ones land. await store.search([trimmed], { searchScope: MINI_SEARCH_SCOPE, keepStaleResultsWhileLoading: true }); // store.userList / groupList / messageResults are reactive. } catch (err) { setError(handleSearchError(t, err)); } finally { setIsLoading(false); } }, [store, t]); // Keep latest doSearch reachable without recreating the debounce instance, // avoiding stale pending timers when store/deps change between renders. const latestDoSearchRef = useRef(doSearch); latestDoSearchRef.current = doSearch; const debouncedSearch = useRef( debounce((kw: string) => latestDoSearchRef.current(kw), debounceTime), ); useEffect(() => () => { debouncedSearch.current.cancel?.(); }, []); // Full reset — only appropriate when the keyword becomes truly empty // (there is nothing meaningful left to keep showing as "stale"). const clearResults = useCallback(() => { setHasSearched(false); setError(null); }, []); const handleChange = useCallback((e: React.ChangeEvent) => { const val = e.target.value; setKeywordSynced(val); onKeywordChange?.(val); // Skip searching while the IME is composing; wait for compositionend. if (isComposingRef.current) { return; } if (val.trim()) { // Deliberately do NOT clear hasSearched 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 slices — this avoids the panel // collapsing to the quick-actions fallback (and shrinking in height) // 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?.(); clearResults(); } }, [clearResults, onKeywordChange, setKeywordSynced]); 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; setKeywordSynced(val); onKeywordChange?.(val); if (val.trim()) { debouncedSearch.current(val); } else { debouncedSearch.current.cancel?.(); clearResults(); } }, [clearResults, onKeywordChange, setKeywordSynced]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) { // Empty keyword: no-op, avoid accidentally opening the panel/Standard. if (!keyword.trim()) { return; } debouncedSearch.current.cancel?.(); setPopupOpen(true); doSearch(keyword); return; } if (e.key === 'Escape') { // Collapse the panel and blur the input so the search box loses focus. setPopupOpen(false); inputRef.current?.blur(); } }, [doSearch, keyword]); const handleClear = useCallback(() => { setKeywordSynced(''); clearResults(); debouncedSearch.current.cancel?.(); onKeywordChange?.(''); }, [clearResults, onKeywordChange, setKeywordSynced]); const handleFocus = useCallback(() => { setPopupOpen(true); // Focusing an input that already has text is a deliberate "search again" // gesture: fire the search immediately, bypassing the debounce so the // user isn't kept waiting on results they already asked for once. // // Deliberately read `keywordRef` here, NOT the `keyword` state. Our // Input's built-in clear button calls onClear() then focus() // synchronously in the same tick, so this handler can run before React // re-renders with the post-clear `keyword` — the `keyword` closure would // still be the pre-clear value and would wrongly re-trigger a search // for the just-cleared text. `keywordRef` is updated synchronously // alongside every `setKeyword` call, so it's always current here. const currentKeyword = keywordRef.current; if (currentKeyword.trim()) { debouncedSearch.current.cancel?.(); doSearch(currentKeyword); } }, [doSearch]); // Upgrade to STANDARD mode: open the Dialog rendering StandardSearch against // the SAME store instance, so existing results render immediately without a // re-search. The current keyword / target tab are one-shot open params. const openStandard = useCallback((tab: StandardSearchTab = 'all', conversationID?: string) => { setStandardTab(tab); setStandardConversationID(conversationID); setStandardOpen(true); setPopupOpen(false); }, []); const handleQuickAction = useCallback(() => { openStandardReasonRef.current = 'by_standard'; openStandard('all'); }, [openStandard]); const handleViewMore = useCallback((type: MiniSearchResultType) => { if (onViewMore) { onViewMore(type); return; } openStandardReasonRef.current = 'by_mini'; openStandard(type); }, [onViewMore, openStandard]); const handleStandardOpenChange = useCallback((open: boolean) => { setStandardOpen(open); }, []); // Bridge: StandardSearch emits its own result type; forward to the consumer's // MiniSearchResult handler (both share the same underlying store data types). // Additionally, close the StandardSearch dialog when a contact/group result // is clicked; message results stay inside to drive the right-pane detail. const handleStandardResultClick = useCallback((result: StandardSearchResult) => { onResultItemClick?.(result); if (result.type === 'user' || result.type === 'group') { setStandardOpen(false); } }, [onResultItemClick]); // After a message is located from the STANDARD dialog's detail pane, close // the dialog so the user lands on the now-active conversation's chat view. const handleStandardLocateMessage = useCallback(() => { setStandardOpen(false); }, []); const handlePopupOpenChange = useCallback( (open: boolean, details: PopupOpenChangeDetails) => { if (open) { return; } // Base UI fires a dismiss (`outside-press` / `focus-out`) on the very // interaction that focuses the input, because the input is only the // anchor and lives OUTSIDE the popover tree. Veto that spurious close // while the interaction stays within the Input container (the Popup // anchor); clicks outside the Input — including on the search bar's // padding — close the popup normally so the input loses focus too. const { reason } = details; if (reason === 'outside-press' || reason === 'focus-out') { const container = inputRef.current?.container; const { target } = details.event as Event & { target: EventTarget | null }; const insideByEvent = target instanceof Node && container?.contains(target); const insideByFocus = container?.contains(document.activeElement); if (insideByEvent || insideByFocus) { details.cancel(); return; } } clearResults(); setPopupOpen(false); }, [clearResults], ); // Reactive slices from the store. With keepStaleResultsWhileLoading passed // to store.search() 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(keywordRef.current.toLowerCase()) || (group.groupName || '').toLowerCase().includes(keywordRef.current.toLowerCase())) : []; const messages = store.messageResults; const hasAnyResult = users.length > 0 || groups.length > 0 || 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; // Per-type preview rows: consumer overrides merge onto the WeCom defaults. const previewCount: Record = { ...DEFAULT_PREVIEW_COUNT, ...maxPreviewCount, }; const renderSection = ( type: MiniSearchResultType, title: string, totalCount: number, rows: React.ReactNode, ) => (
{title}
{rows} {totalCount > previewCount[type] && (
handleViewMore(type)} > {t('Search.view_more')}
)}
); return (
{/* Search bar */}
} onClear={handleClear} value={keyword} onChange={handleChange} onFocus={handleFocus} onKeyDown={handleKeyDown} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} placeholder={t('Search.search')} autoFocus={false} enterKeyHint="search" />
{/* Floating result panel anchored to the native input element */} {/* Fallback / empty-state quick actions. This is the panel's default view whenever there's no error, no active loading, and no completed search — which covers both the true empty-input state AND the brief window while a just-typed keyword is pending its debounce timer. Keeping this as the fallback (instead of gating on `!keyword.trim()`) is what prevents the panel from going blank while the user types. */} {!error && !isLoading && !hasSearched && (
e.preventDefault()} onClick={() => handleQuickAction()} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); } }} > {t('Search.global_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')}
)} {/* Results — the store keeps its previous slices 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 panel shrinking. */} {!error && hasAnyResult && hasSearched && (
{/* Subtle in-place indicator for a revalidating (non-initial) search. */} {/* {isLoading && (
{t('Search.loading')}
)} */} {users.length > 0 && renderSection( 'user', t('Search.contacts'), store.userTotalCount, users.slice(0, previewCount.user).map(user => renderSearchResultItem( SearchResultItem, { keyword, data: { profile: user }, type: UISearchType.USER, onClick: () => { onResultItemClick?.({ type: 'user', data: user }); setPopupOpen(false); }, }, } onClick={() => { onResultItemClick?.({ type: 'user', data: user }); setPopupOpen(false); }} />, )), )} {(enableLocalSearch ? localGroups.length > 0 : groups.length > 0) && renderSection( 'group', t('Search.groups'), enableLocalSearch ? localGroups.length : store.groupTotalCount, (enableLocalSearch ? localGroups : groups).slice(0, previewCount.group).map(group => renderSearchResultItem( SearchResultItem, { keyword, data: { groupInfo: group as GroupSearchInfo }, type: UISearchType.GROUP, onClick: () => { onResultItemClick?.({ type: 'group', data: group as unknown as GroupSearchInfo }); setPopupOpen(false); }, }, } {...(group.groupID && { subtitle: `ID: ${group.groupID}` })} onClick={() => { onResultItemClick?.({ type: 'group', data: group as unknown as GroupSearchInfo }); setPopupOpen(false); }} />, )), )} {messages.length > 0 && renderSection( 'message', t('Search.chat_history'), store.messageResultTotalCount, messages.slice(0, previewCount.message).map(item => renderSearchResultItem( SearchResultItem, { keyword, data: item, type: UISearchType.MESSAGE, onClick: () => { openStandardReasonRef.current = 'by_mini'; openStandard('message', item.conversationID); onResultItemClick?.({ type: 'message', data: item }); }, }, { openStandardReasonRef.current = 'by_mini'; openStandard('message', item.conversationID); onResultItemClick?.({ type: 'message', data: item }); }} />, )), )}
)}
{/* STANDARD mode: full search in a Dialog, sharing this store instance so already-fetched results render on mount without re-searching. finalFocus={false}: this Dialog's "opener" is the search input used purely as a Popover anchor, not a real trigger button — Base UI's default return-focus behavior would otherwise refocus it on close and immediately reopen the MINI popup via handleFocus. */}
); } export default MiniSearch;