Files
cinny/src/app/features/message-search/SearchInput.tsx
T

237 lines
7.1 KiB
TypeScript
Raw Normal View History

import React, {
ChangeEventHandler,
FormEventHandler,
RefObject,
useCallback,
useMemo,
useState,
} from 'react';
import {
Avatar,
Box,
Chip,
config,
Icon,
Icons,
Input,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
} from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
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
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;
loading?: boolean;
searchInputRef: RefObject<HTMLInputElement>;
onSearch: (term: string) => void;
onReset: () => void;
onSenderAdd?: (userId: string) => void;
};
export function SearchInput({
active,
loading,
searchInputRef,
onSearch,
onReset,
onSenderAdd,
}: SearchInputProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [fromQuery, setFromQuery] = useState('');
const [autocompleteAnchor, setAutocompleteAnchor] = useState<RectCords>();
const allUsers = useMemo(() => mx.getUsers(), [mx]);
const suggestedUsers = useMemo(() => {
const q = fromQuery.toLowerCase();
return allUsers
.filter(
(u) =>
u.userId !== mx.getUserId() &&
(u.userId.toLowerCase().includes(q) || (u.displayName ?? '').toLowerCase().includes(q)),
)
.slice(0, 8);
}, [allUsers, fromQuery, mx]);
const closeAutocomplete = useCallback(() => {
setFromQuery('');
setAutocompleteAnchor(undefined);
}, []);
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<HTMLInputElement> = (evt) => {
const value = evt.target.value;
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);
} else {
closeAutocomplete();
}
};
// Close autocomplete when input loses focus, but delay so item clicks fire first
const handleBlur = () => {
setTimeout(closeAutocomplete, 120);
};
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (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];
// 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;
// Remove the from: fragments from the visible input
if (fromMatches.length > 0 && searchInputRef.current) {
searchInputRef.current.value = searchTerm ?? '';
}
if (searchTerm) onSearch(searchTerm);
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}
>
<Input
ref={searchInputRef}
style={{ paddingRight: config.space.S300 }}
name="searchInput"
autoFocus
size="500"
variant="Background"
placeholder="Search messages or type from:@user to filter by sender"
autoComplete="off"
onChange={handleChange}
onBlur={handleBlur}
before={
active && loading ? (
<Spinner variant="Secondary" size="200" />
) : (
<Icon size="200" src={Icons.Search} />
)
}
after={
active ? (
<Chip
key="resetButton"
type="reset"
variant="Secondary"
size="400"
radii="Pill"
outlined
after={<Icon size="50" src={Icons.Cross} />}
onClick={onReset}
>
<Text size="B300">Clear</Text>
</Chip>
) : (
<Chip type="submit" variant="Primary" size="400" radii="Pill" outlined>
<Text size="B300">Enter</Text>
</Chip>
)
}
/>
</PopOut>
</Box>
);
}