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:
@@ -4,6 +4,7 @@ import React, {
|
|||||||
RefObject,
|
RefObject,
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -16,8 +17,6 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
PopOut,
|
|
||||||
RectCords,
|
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
@@ -26,11 +25,17 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
|||||||
import { UserAvatar } from '../../components/user-avatar';
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
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;
|
const FROM_REGEX = /from:@?([^\s:][^\s]*)/gi;
|
||||||
// Matches trailing "from:@?..." so autocomplete fires as the user types
|
// Matches trailing "from:@?..." so autocomplete fires as the user types
|
||||||
const FROM_TYPING_REGEX = /from:@?([^\s]*)$/i;
|
const FROM_TYPING_REGEX = /from:@?([^\s]*)$/i;
|
||||||
|
|
||||||
|
type UserSuggestion = {
|
||||||
|
userId: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type SearchInputProps = {
|
type SearchInputProps = {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -49,26 +54,44 @@ export function SearchInput({
|
|||||||
}: SearchInputProps) {
|
}: SearchInputProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const inputWrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [fromQuery, setFromQuery] = useState('');
|
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(() => {
|
const suggestedUsers = useMemo(() => {
|
||||||
|
if (!showSuggestions) return [];
|
||||||
const q = fromQuery.toLowerCase();
|
const q = fromQuery.toLowerCase();
|
||||||
return allUsers
|
return allUsers
|
||||||
.filter(
|
.filter(
|
||||||
(u) =>
|
(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);
|
.slice(0, 8);
|
||||||
}, [allUsers, fromQuery, mx]);
|
}, [allUsers, fromQuery, showSuggestions]);
|
||||||
|
|
||||||
const closeAutocomplete = useCallback(() => {
|
const closeAutocomplete = useCallback(() => {
|
||||||
setFromQuery('');
|
setFromQuery('');
|
||||||
setAutocompleteAnchor(undefined);
|
setShowSuggestions(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleUserSelect = useCallback(
|
const handleUserSelect = useCallback(
|
||||||
@@ -91,17 +114,15 @@ export function SearchInput({
|
|||||||
const match = FROM_TYPING_REGEX.exec(value);
|
const match = FROM_TYPING_REGEX.exec(value);
|
||||||
if (match) {
|
if (match) {
|
||||||
setFromQuery(match[1]);
|
setFromQuery(match[1]);
|
||||||
// Anchor the dropdown to the input element itself
|
setShowSuggestions(true);
|
||||||
const rect = searchInputRef.current?.getBoundingClientRect();
|
|
||||||
if (rect) setAutocompleteAnchor(rect);
|
|
||||||
} else {
|
} else {
|
||||||
closeAutocomplete();
|
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 = () => {
|
const handleBlur = () => {
|
||||||
setTimeout(closeAutocomplete, 120);
|
setTimeout(closeAutocomplete, 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
@@ -116,7 +137,6 @@ export function SearchInput({
|
|||||||
const fromMatches = [...rawValue.matchAll(FROM_REGEX)];
|
const fromMatches = [...rawValue.matchAll(FROM_REGEX)];
|
||||||
fromMatches.forEach((match) => {
|
fromMatches.forEach((match) => {
|
||||||
const raw = match[1];
|
const raw = match[1];
|
||||||
// Ensure the user ID starts with @
|
|
||||||
const userId = raw.startsWith('@') ? raw : `@${raw}`;
|
const userId = raw.startsWith('@') ? raw : `@${raw}`;
|
||||||
onSenderAdd?.(userId);
|
onSenderAdd?.(userId);
|
||||||
});
|
});
|
||||||
@@ -132,65 +152,12 @@ export function SearchInput({
|
|||||||
closeAutocomplete();
|
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 (
|
return (
|
||||||
<Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}>
|
<Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}>
|
||||||
<span data-spacing-node />
|
<span data-spacing-node />
|
||||||
<Text size="L400">Search</Text>
|
<Text size="L400">Search</Text>
|
||||||
<PopOut
|
{/* Relative wrapper so the absolute dropdown positions against the input */}
|
||||||
anchor={autocompleteAnchor}
|
<Box ref={inputWrapRef} direction="Column" style={{ position: 'relative' }}>
|
||||||
position="Bottom"
|
|
||||||
align="Start"
|
|
||||||
offset={4}
|
|
||||||
content={autocompleteContent}
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
style={{ paddingRight: config.space.S300 }}
|
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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user