fix: search bar size and from: autocomplete actually working

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 22:48:55 -04:00
parent 6056aa9632
commit 64029d9e8e
+95 -71
View File
@@ -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<HTMLDivElement>(null);
const [fromQuery, setFromQuery] = useState('');
const [autocompleteAnchor, setAutocompleteAnchor] = useState<RectCords>();
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<UserSuggestion[]>(() => {
const map = new Map<string, UserSuggestion>();
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<HTMLFormElement> = (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 ? (
<Menu variant="Surface" style={{ minWidth: '260px', maxWidth: '340px' }}>
<Box direction="Column" style={{ padding: `${config.space.S100} 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 (
<MenuItem
key={user.userId}
variant="Surface"
size="300"
radii="0"
// Prevent input blur when clicking a suggestion
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
onClick={() => handleUserSelect(user.userId)}
before={
<Avatar size="200" radii="Pill">
<UserAvatar
userId={user.userId}
src={avatarUrl}
alt={displayName ?? user.userId}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Box direction="Column" style={{ minWidth: 0 }}>
<Text size="T300" truncate>
{displayName}
</Text>
<Text
size="T200"
truncate
style={{ opacity: 0.6, fontFamily: 'monospace', fontSize: '0.75em' }}
>
{user.userId}
</Text>
</Box>
</MenuItem>
);
})}
</Box>
</Menu>
) : undefined;
return (
<Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}>
<span data-spacing-node />
<Text size="L400">Search</Text>
<PopOut
anchor={autocompleteAnchor}
position="Bottom"
align="Start"
offset={4}
content={autocompleteContent}
>
{/* Relative wrapper so the absolute dropdown positions against the input */}
<Box ref={inputWrapRef} direction="Column" style={{ position: 'relative' }}>
<Input
ref={searchInputRef}
style={{ paddingRight: config.space.S300 }}
@@ -230,7 +197,64 @@ export function SearchInput({
)
}
/>
</PopOut>
{suggestedUsers.length > 0 && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 999,
marginTop: config.space.S100,
}}
>
<Menu variant="Surface" style={{ width: '100%' }}>
<Box direction="Column" style={{ padding: `${config.space.S100} 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 (
<MenuItem
key={user.userId}
variant="Surface"
size="300"
radii="0"
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
onClick={() => handleUserSelect(user.userId)}
before={
<Avatar size="200" radii="Pill">
<UserAvatar
userId={user.userId}
src={avatarUrl}
alt={displayName ?? user.userId}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Box direction="Column" style={{ minWidth: 0 }}>
<Text size="T300" truncate>
{displayName}
</Text>
<Text
size="T200"
truncate
style={{ opacity: 0.6, fontFamily: 'monospace', fontSize: '0.75em' }}
>
{user.userId}
</Text>
</Box>
</MenuItem>
);
})}
</Box>
</Menu>
</div>
)}
</Box>
</Box>
);
}