import React, { ChangeEventHandler, FormEventHandler, RefObject, useCallback, useMemo, useRef, useState, } from 'react'; import { Avatar, Box, Chip, config, Icon, Icons, Input, Menu, MenuItem, Spinner, Text, } from 'folds'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { getMxIdLocalPart, getMxIdServer, mxcUrlToHttp } from '../../utils/matrix'; import { UserAvatar } from '../../components/user-avatar'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; // Matches "from:@?anything" anywhere — @ is optional const FROM_REGEX = /from:@?([^\s:][^\s]*)/gi; // Matches trailing "from:@?..." so autocomplete fires as the user types const FROM_TYPING_REGEX = /from:@?([^\s]*)$/i; type UserSuggestion = { userId: string; displayName?: string; avatarUrl?: string; }; type SearchInputProps = { active?: boolean; loading?: boolean; searchInputRef: RefObject; onSearch: (term: string) => void; onReset: () => void; onSenderAdd?: (userId: string) => void; recentSearches?: string[]; onRecentSearch?: (term: string) => void; onClearRecentSearches?: () => void; }; export function SearchInput({ active, loading, searchInputRef, onSearch, onReset, onSenderAdd, recentSearches, onRecentSearch, onClearRecentSearches, }: SearchInputProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const inputWrapRef = useRef(null); const [fromQuery, setFromQuery] = useState(''); const [showSuggestions, setShowSuggestions] = useState(false); const [focused, setFocused] = useState(false); const [inputEmpty, setInputEmpty] = useState(true); // Collect users from room member lists, scored for relevance. // Score: same homeserver → +1000, each shared room → +1. // This ensures @user:matrix.lotusguild.org ranks above @user:matrix.org. const allUsers = useMemo(() => { const myId = mx.getUserId(); const myServer = myId ? getMxIdServer(myId) : undefined; const map = new Map(); mx.getRooms().forEach((room) => { room.getMembers().forEach((member) => { if (member.userId === myId) return; const existing = map.get(member.userId); if (existing) { existing.score += 1; // +1 per additional shared room } else { const homeserver = getMxIdServer(member.userId); map.set(member.userId, { userId: member.userId, displayName: member.name ?? undefined, avatarUrl: member.getMxcAvatarUrl() ?? undefined, score: myServer && homeserver === myServer ? 1000 : 1, }); } }); }); return Array.from(map.values()).sort((a, b) => b.score - a.score); }, [mx]); const suggestedUsers = useMemo(() => { if (!showSuggestions) return []; const q = fromQuery.toLowerCase(); return allUsers .filter( (u) => u.userId.toLowerCase().includes(q) || (u.displayName ?? '').toLowerCase().includes(q), ) .slice(0, 8); }, [allUsers, fromQuery, showSuggestions]); const closeAutocomplete = useCallback(() => { setFromQuery(''); setShowSuggestions(false); }, []); const handleUserSelect = useCallback( (userId: string) => { if (searchInputRef.current) { searchInputRef.current.value = searchInputRef.current.value .replace(FROM_TYPING_REGEX, '') .replace(/\s+/g, ' ') .trim(); } closeAutocomplete(); onSenderAdd?.(userId); searchInputRef.current?.focus(); }, [searchInputRef, onSenderAdd, closeAutocomplete], ); const handleChange: ChangeEventHandler = (evt) => { const value = evt.target.value; setInputEmpty(value.trim() === ''); const match = FROM_TYPING_REGEX.exec(value); if (match) { setFromQuery(match[1]); setShowSuggestions(true); } else { closeAutocomplete(); } }; const handleFocus = () => { setFocused(true); }; // Close autocomplete when input loses focus — delay so item clicks fire first const handleBlur = () => { setTimeout(() => { closeAutocomplete(); setFocused(false); }, 150); }; const handleRecentClick = (term: string) => { if (searchInputRef.current) { searchInputRef.current.value = term; } setInputEmpty(false); onRecentSearch?.(term); }; const handleSearchSubmit: FormEventHandler = (evt) => { evt.preventDefault(); const { searchInput } = evt.target as HTMLFormElement & { searchInput: HTMLInputElement; }; const rawValue = searchInput.value.trim(); // Extract from:user fragments and convert to sender filters const fromMatches = [...rawValue.matchAll(FROM_REGEX)]; fromMatches.forEach((match) => { const raw = match[1]; const userId = raw.startsWith('@') ? raw : `@${raw}`; onSenderAdd?.(userId); }); const searchTerm = rawValue.replace(FROM_REGEX, '').replace(/\s+/g, ' ').trim() || undefined; // Remove the from: fragments from the visible input if (fromMatches.length > 0 && searchInputRef.current) { searchInputRef.current.value = searchTerm ?? ''; } // Always trigger search when senders were extracted, even with no body text if (fromMatches.length > 0 || searchTerm) { onSearch(searchTerm ?? ''); } closeAutocomplete(); }; return ( Search {/* Relative wrapper so the absolute dropdown positions against the input */} ) : ( ) } after={ active ? ( } onClick={onReset} > Clear ) : ( Enter ) } /> {suggestedUsers.length > 0 && (
{suggestedUsers.map((user) => { const displayName = user.displayName ?? getMxIdLocalPart(user.userId); const avatarUrl = user.avatarUrl ? (mxcUrlToHttp(mx, user.avatarUrl, useAuthentication, 32, 32, 'crop') ?? undefined) : undefined; return ( e.preventDefault()} onClick={() => handleUserSelect(user.userId)} before={ } /> } > {displayName} {user.userId} ); })}
)} {focused && inputEmpty && suggestedUsers.length === 0 && recentSearches && recentSearches.length > 0 && (
Recent searches e.preventDefault()} onClick={() => onClearRecentSearches?.()} > Clear {recentSearches.map((term) => ( } onMouseDown={(e: React.MouseEvent) => e.preventDefault()} onClick={() => handleRecentClick(term)} > {term} ))}
)}
); }