From 64029d9e8e07a261cc0e20a41259fd65bdfd6fb1 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 28 May 2026 22:48:55 -0400 Subject: [PATCH] fix: search bar size and from: autocomplete actually working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes identified: 1. Layout: PopOut renders a Fragment, but the Fragment's children are not true flex items of the parent Box in practice — the Input lost its full-width stretch. Replaced PopOut with a relative-positioned Box wrapper + absolute-positioned dropdown div (top:100%, left:0, right:0). Input is now a direct flex child of the wrapper and stretches normally. 2. Autocomplete empty: mx.getUsers() returns almost nothing with lazy member loading enabled — only users seen in presence events, not room members. Switched to iterating mx.getRooms() + room.getMembers() and deduplicating by userId, which covers everyone in every room. Also: removed PopOut/FocusTrap/RectCords imports no longer needed. Co-Authored-By: Claude Sonnet 4.6 --- .../features/message-search/SearchInput.tsx | 166 ++++++++++-------- 1 file changed, 95 insertions(+), 71 deletions(-) diff --git a/src/app/features/message-search/SearchInput.tsx b/src/app/features/message-search/SearchInput.tsx index ad52384e2..a1b76e9c3 100644 --- a/src/app/features/message-search/SearchInput.tsx +++ b/src/app/features/message-search/SearchInput.tsx @@ -4,6 +4,7 @@ import React, { RefObject, useCallback, useMemo, + useRef, useState, } from 'react'; import { @@ -16,8 +17,6 @@ import { Input, Menu, MenuItem, - PopOut, - RectCords, Spinner, Text, } from 'folds'; @@ -26,11 +25,17 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { UserAvatar } from '../../components/user-avatar'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -// Matches "from:@?anything" anywhere — @ is optional so "from:jared" also works +// 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; @@ -49,26 +54,44 @@ export function SearchInput({ }: SearchInputProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); + const inputWrapRef = useRef(null); const [fromQuery, setFromQuery] = useState(''); - const [autocompleteAnchor, setAutocompleteAnchor] = useState(); + const [showSuggestions, setShowSuggestions] = useState(false); - const allUsers = useMemo(() => mx.getUsers(), [mx]); + // Collect users from room member lists — more reliable than mx.getUsers() + // which is sparse with lazy member loading enabled. + const allUsers = useMemo(() => { + const map = new Map(); + const myId = mx.getUserId(); + mx.getRooms().forEach((room) => { + room.getMembers().forEach((member) => { + if (member.userId !== myId && !map.has(member.userId)) { + map.set(member.userId, { + userId: member.userId, + displayName: member.name ?? undefined, + avatarUrl: member.getMxcAvatarUrl() ?? undefined, + }); + } + }); + }); + return Array.from(map.values()); + }, [mx]); const suggestedUsers = useMemo(() => { + if (!showSuggestions) return []; const q = fromQuery.toLowerCase(); return allUsers .filter( (u) => - u.userId !== mx.getUserId() && - (u.userId.toLowerCase().includes(q) || (u.displayName ?? '').toLowerCase().includes(q)), + u.userId.toLowerCase().includes(q) || (u.displayName ?? '').toLowerCase().includes(q), ) .slice(0, 8); - }, [allUsers, fromQuery, mx]); + }, [allUsers, fromQuery, showSuggestions]); const closeAutocomplete = useCallback(() => { setFromQuery(''); - setAutocompleteAnchor(undefined); + setShowSuggestions(false); }, []); const handleUserSelect = useCallback( @@ -91,17 +114,15 @@ export function SearchInput({ const match = FROM_TYPING_REGEX.exec(value); if (match) { setFromQuery(match[1]); - // Anchor the dropdown to the input element itself - const rect = searchInputRef.current?.getBoundingClientRect(); - if (rect) setAutocompleteAnchor(rect); + setShowSuggestions(true); } else { closeAutocomplete(); } }; - // Close autocomplete when input loses focus, but delay so item clicks fire first + // Close autocomplete when input loses focus — delay so item clicks fire first const handleBlur = () => { - setTimeout(closeAutocomplete, 120); + setTimeout(closeAutocomplete, 150); }; const handleSearchSubmit: FormEventHandler = (evt) => { @@ -116,7 +137,6 @@ export function SearchInput({ const fromMatches = [...rawValue.matchAll(FROM_REGEX)]; fromMatches.forEach((match) => { const raw = match[1]; - // Ensure the user ID starts with @ const userId = raw.startsWith('@') ? raw : `@${raw}`; onSenderAdd?.(userId); }); @@ -132,65 +152,12 @@ export function SearchInput({ closeAutocomplete(); }; - const autocompleteContent = - 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} - - - - ); - })} - - - ) : undefined; - return ( Search - + {/* Relative wrapper so the absolute dropdown positions against the input */} + - + {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} + + + + ); + })} + + +
+ )} +
); }