import { useState, useEffect, useRef, useMemo, useCallback, forwardRef, useImperativeHandle } from 'react'; import { useUIContext, useLoginStore } from 'tuikit-atomicx-react/chat'; import { i18next } from '@tencentcloud/uikit-base-component-react'; import { defaultUserAvatar } from '../../../../constant'; import styles from './MentionSuggestion.module.scss'; import type { GroupMember } from 'tuikit-atomicx-react/chat'; import type { SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion'; interface MentionSuggestionProps extends SuggestionProps { query: string; onVisibilityChange?: (visible: boolean) => void; } interface MentionSuggestionRef { onKeyDown: (props: SuggestionKeyDownProps) => boolean; } const MSG_AT_ALL = '__kImSDK_MesssageAtALL__'; const MentionSuggestion = forwardRef((props, ref) => { const { query, command, onVisibilityChange } = props; const [selectedIndex, setSelectedIndex] = useState(0); const [isLoading, setIsLoading] = useState(false); const listboxRef = useRef(null); const sentinelRef = useRef(null); const observerRef = useRef(null); const prevFilteredItemsLengthRef = useRef(0); const prevQueryRef = useRef(''); // Get state from UIContext and LoginStore const { activeConversationID, memberList, hasMoreMembers, loadMoreMembers, } = useUIContext(); const { loginUserInfo } = useLoginStore(); const currentUserID = loginUserInfo?.userID; const groupID = activeConversationID?.startsWith('GROUP') ? activeConversationID.slice(5) : undefined; const memberCount = memberList.length; // Load members on mount if empty // Compute available members (exclude self + add @all) const availableMembers = useMemo[]>(() => { if (!memberList || !currentUserID) { return []; } const excludeSelfMembers: Partial[] = memberList .filter(member => member.userID !== currentUserID) .map(member => ({ ...member, nick: member.nickname || member.userID, avatar: member.avatarURL ?? '', })); if (memberCount > 2) { excludeSelfMembers.unshift({ userID: MSG_AT_ALL, nick: i18next.t('MessageInput.at_all_members'), avatar: '/at_all_members.png', }); } return excludeSelfMembers; }, [memberList, currentUserID, memberCount]); const hasMore = hasMoreMembers; // Filter members by query const filteredItems = useMemo(() => { const normalizedQuery = query.toLowerCase().trim(); if (!normalizedQuery) { return availableMembers; } return availableMembers.filter(item => item.nick!.toLowerCase().includes(normalizedQuery), ); }, [query, availableMembers]); const selectedMember = useMemo(() => filteredItems[selectedIndex] || null, [filteredItems, selectedIndex]); const showSentinel = useMemo(() => hasMore || isLoading || filteredItems.length === 0, [hasMore, isLoading, filteredItems.length]); // Load more members const loadMore = useCallback(async () => { if (isLoading || !hasMore || !groupID) { return; } setIsLoading(true); try { await loadMoreMembers(); } catch (error) { // eslint-disable-next-line no-console console.error('[MentionSuggestion] Failed to load more members:', error); } finally { setIsLoading(false); } }, [isLoading, hasMore, groupID, loadMoreMembers]); // Auto-load when items are few + Control panel visibility useEffect(() => { const normalizedQuery = query.toLowerCase().trim(); // 1. Control panel visibility (hide/show, not destroy) if (!normalizedQuery) { // No query - show panel onVisibilityChange?.(true); } else if (filteredItems.length === 0 && !hasMore) { // Has query but no results and no more to load - hide panel onVisibilityChange?.(false); } else { // Has results or still loading - show panel onVisibilityChange?.(true); } // 2. Auto load if items are few if (filteredItems.length < 10 && hasMore && !isLoading && normalizedQuery) { loadMore(); } }, [query, filteredItems.length, hasMore, isLoading, loadMore, onVisibilityChange]); // Scroll to first item helper const scrollToFirst = useCallback(() => { if (listboxRef.current && listboxRef.current.children[0]) { (listboxRef.current.children[0] as HTMLElement).scrollIntoView({ block: 'nearest' }); } }, []); // Reset selection when filtered items change (but not when loading more) useEffect(() => { const currentLength = filteredItems.length; const prevLength = prevFilteredItemsLengthRef.current; const currentQuery = query.toLowerCase().trim(); const prevQuery = prevQueryRef.current; // Check if query changed (this is a search/filter operation) const isQueryChange = currentQuery !== prevQuery; // Only reset if it's a filter change (query changed, length decreased, or became empty) // Don't reset if it's loading more (length increased without query change) if (isQueryChange || currentLength < prevLength || currentLength === 0) { setSelectedIndex(0); // Scroll to first item when resetting requestAnimationFrame(() => { scrollToFirst(); }); } else if (currentLength > prevLength) { // If loading more, keep the current selection valid // Ensure selectedIndex is still within bounds setSelectedIndex(prev => (prev >= currentLength ? currentLength - 1 : prev)); } prevFilteredItemsLengthRef.current = currentLength; prevQueryRef.current = currentQuery; }, [filteredItems.length, query, scrollToFirst]); // Setup IntersectionObserver for lazy loading useEffect(() => { if (!showSentinel || !listboxRef.current || !sentinelRef.current) { return undefined; } // Cleanup previous observer if (observerRef.current) { observerRef.current.disconnect(); } observerRef.current = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { loadMore(); } }); }, { root: listboxRef.current, rootMargin: '0px', threshold: 0.1, }, ); observerRef.current.observe(sentinelRef.current); return () => { if (observerRef.current) { observerRef.current.disconnect(); observerRef.current = null; } }; }, [showSentinel, loadMore]); // Handle member selection const handleSelectMember = useCallback((index: number) => { const member = filteredItems[index]; if (member) { command({ id: member.userID, label: member.nick, mentionSuggestionChar: '@' }); } }, [filteredItems, command]); // Scroll to selected item // scrollToIndex: pass the target index explicitly to avoid closure stale value issue const scrollToSelected = useCallback((scrollToIndex: number) => { if (!listboxRef.current) { return; } const selectedItem = listboxRef.current.children[scrollToIndex] as HTMLElement; if (!selectedItem) { return; } // 1. Ensure the selected member is visible selectedItem.scrollIntoView({ block: 'nearest' }); // 2. If it's the last item and there's more data, ensure the loading indicator (sentinel) is also visible // This provides visual feedback that more items are being loaded if (scrollToIndex === filteredItems.length - 1 && hasMore && sentinelRef.current) { sentinelRef.current.scrollIntoView({ block: 'nearest' }); } }, [filteredItems.length, hasMore]); // Keyboard event handler const onKeyDown = useCallback((keyDownProps: SuggestionKeyDownProps): boolean => { const { event } = keyDownProps; if (event.key === 'ArrowDown') { event.preventDefault(); const nextIndex = Math.min(selectedIndex + 1, filteredItems.length - 1); setSelectedIndex(nextIndex); // Pass nextIndex explicitly to avoid stale closure scrollToSelected(nextIndex); // If reached the last item and has more data to load, trigger load if (nextIndex === filteredItems.length - 1 && hasMore && !isLoading) { loadMore(); } return true; } if (event.key === 'ArrowUp') { event.preventDefault(); const prevIndex = Math.max(selectedIndex - 1, 0); setSelectedIndex(prevIndex); // Pass prevIndex explicitly to avoid stale closure scrollToSelected(prevIndex); return true; } if (event.key === 'Enter') { event.preventDefault(); if (selectedMember) { handleSelectMember(selectedIndex); return true; } } if (event.key === 'Escape') { return true; } return false; }, [selectedIndex, filteredItems.length, hasMore, isLoading, selectedMember, handleSelectMember, scrollToSelected, loadMore]); // Expose onKeyDown to parent via ref useImperativeHandle(ref, () => ({ onKeyDown, }), [onKeyDown]); return (
{/* Member items */} {filteredItems.map((item, index) => (
handleSelectMember(index)} onMouseEnter={() => setSelectedIndex(index)} > {item.avatar !== '/at_all_members.png' && ( {item.nick { e.currentTarget.src = defaultUserAvatar; }} /> )} {item.nick || item.userID}
))} {/* Sentinel element for loading more / showing states */} {showSentinel && (
{/* Loading state */} {hasMore && (
{i18next.t('MessageInput.loading')}
)} {/* No results after search */} {filteredItems.length === 0 && !hasMore && (
{i18next.t('MessageInput.no_matching_members')}
)}
)}
); }); MentionSuggestion.displayName = 'MentionSuggestion'; export default MentionSuggestion;