From 6056aa9632a2a2096985f432d7ed3001d5bcb10f Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 28 May 2026 22:30:38 -0400 Subject: [PATCH] fix: search bar layout, autocomplete FocusTrap, and from: regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs introduced in af3155e1: 1. Layout: extra Box wrapper around Input wasn't stretching to full width. Removed the wrapper — Input is now a direct PopOut child, restoring its original full-width flex behaviour. 2. FocusTrap: the autocomplete dropdown had a FocusTrap that immediately deactivated because the search input (outside the trap) was focused. Removed the FocusTrap entirely; onMouseDown+preventDefault on each suggestion item already prevents input blur on click, and onBlur with a 120ms delay handles dismissal when clicking truly outside. 3. from: regex: @ was required (from:@user) but users naturally type from:user without it. Updated FROM_REGEX and FROM_TYPING_REGEX to make @ optional; userId construction already prepends @ if missing. Co-Authored-By: Claude Sonnet 4.6 --- .../features/message-search/SearchInput.tsx | 217 ++++++++---------- 1 file changed, 102 insertions(+), 115 deletions(-) diff --git a/src/app/features/message-search/SearchInput.tsx b/src/app/features/message-search/SearchInput.tsx index 2ff72faaf..ad52384e2 100644 --- a/src/app/features/message-search/SearchInput.tsx +++ b/src/app/features/message-search/SearchInput.tsx @@ -4,7 +4,6 @@ import React, { RefObject, useCallback, useMemo, - useRef, useState, } from 'react'; import { @@ -22,17 +21,15 @@ import { Spinner, Text, } from 'folds'; -import FocusTrap from 'focus-trap-react'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { UserAvatar } from '../../components/user-avatar'; -import { stopPropagation } from '../../utils/keyboard'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -// Matches "from:@anything" anywhere in the query (no trailing space required) -const FROM_REGEX = /from:@([^\s]*)/gi; -// For detecting active typing: matches the trailing "from:@..." at cursor end -const FROM_TYPING_REGEX = /from:@([^\s]*)$/i; +// Matches "from:@?anything" anywhere — @ is optional so "from:jared" also works +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 SearchInputProps = { active?: boolean; @@ -52,7 +49,6 @@ export function SearchInput({ }: SearchInputProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const inputWrapRef = useRef(null); const [fromQuery, setFromQuery] = useState(''); const [autocompleteAnchor, setAutocompleteAnchor] = useState(); @@ -85,7 +81,6 @@ export function SearchInput({ } closeAutocomplete(); onSenderAdd?.(userId); - // Return focus to input so user can keep typing searchInputRef.current?.focus(); }, [searchInputRef, onSenderAdd, closeAutocomplete], @@ -96,13 +91,19 @@ export function SearchInput({ const match = FROM_TYPING_REGEX.exec(value); if (match) { setFromQuery(match[1]); - const rect = inputWrapRef.current?.getBoundingClientRect(); + // Anchor the dropdown to the input element itself + const rect = searchInputRef.current?.getBoundingClientRect(); if (rect) setAutocompleteAnchor(rect); } else { closeAutocomplete(); } }; + // Close autocomplete when input loses focus, but delay so item clicks fire first + const handleBlur = () => { + setTimeout(closeAutocomplete, 120); + }; + const handleSearchSubmit: FormEventHandler = (evt) => { evt.preventDefault(); const { searchInput } = evt.target as HTMLFormElement & { @@ -111,17 +112,18 @@ export function SearchInput({ const rawValue = searchInput.value.trim(); - // Extract any from:@userId fragments and turn them into sender filters + // Extract from:user fragments and convert to sender filters 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); }); const searchTerm = rawValue.replace(FROM_REGEX, '').replace(/\s+/g, ' ').trim() || undefined; - // Clean up the from: fragments from the visible input + // Remove the from: fragments from the visible input if (fromMatches.length > 0 && searchInputRef.current) { searchInputRef.current.value = searchTerm ?? ''; } @@ -130,6 +132,54 @@ 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 ( @@ -139,110 +189,47 @@ export function SearchInput({ position="Bottom" align="Start" offset={4} - content={ - 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 - } + content={autocompleteContent} > - - - ) : ( - - ) - } - after={ - active ? ( - } - onClick={onReset} - > - Clear - - ) : ( - - Enter - - ) - } - /> - + + ) : ( + + ) + } + after={ + active ? ( + } + onClick={onReset} + > + Clear + + ) : ( + + Enter + + ) + } + /> );