feat(search): has:image/file/video filters + recent searches
- Add three msgtype toggle chips (Images/Files/Video) to the search filter bar, mirroring the existing "Has link" chip. The Matrix search API can't filter by msgtype server-side, so results are post-filtered client-side (union match on event.content.msgtype, dropping now-empty groups); the server request is unchanged. Visible count may be lower than the server total — inherent to client-side filtering. - Recent searches: last 10 distinct terms persisted via a new state/recentSearches.ts (atomWithStorage, error-safe, mirrors scheduledMessages). Shown as clickable chips when the search input is focused + empty, with a Clear affordance; clicking re-runs the search. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,9 @@ type SearchInputProps = {
|
||||
onSearch: (term: string) => void;
|
||||
onReset: () => void;
|
||||
onSenderAdd?: (userId: string) => void;
|
||||
recentSearches?: string[];
|
||||
onRecentSearch?: (term: string) => void;
|
||||
onClearRecentSearches?: () => void;
|
||||
};
|
||||
export function SearchInput({
|
||||
active,
|
||||
@@ -51,6 +54,9 @@ export function SearchInput({
|
||||
onSearch,
|
||||
onReset,
|
||||
onSenderAdd,
|
||||
recentSearches,
|
||||
onRecentSearch,
|
||||
onClearRecentSearches,
|
||||
}: SearchInputProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
@@ -58,6 +64,8 @@ export function SearchInput({
|
||||
|
||||
const [fromQuery, setFromQuery] = useState('');
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [inputEmpty, setInputEmpty] = useState(true);
|
||||
|
||||
// Collect users from room member lists, scored for relevance.
|
||||
// Score: same homeserver → +1000, each shared room → +1.
|
||||
@@ -121,6 +129,7 @@ export function SearchInput({
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
const value = evt.target.value;
|
||||
setInputEmpty(value.trim() === '');
|
||||
const match = FROM_TYPING_REGEX.exec(value);
|
||||
if (match) {
|
||||
setFromQuery(match[1]);
|
||||
@@ -130,9 +139,24 @@ export function SearchInput({
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setFocused(true);
|
||||
};
|
||||
|
||||
// Close autocomplete when input loses focus — delay so item clicks fire first
|
||||
const handleBlur = () => {
|
||||
setTimeout(closeAutocomplete, 150);
|
||||
setTimeout(() => {
|
||||
closeAutocomplete();
|
||||
setFocused(false);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleRecentClick = (term: string) => {
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.value = term;
|
||||
}
|
||||
setInputEmpty(false);
|
||||
onRecentSearch?.(term);
|
||||
};
|
||||
|
||||
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
@@ -181,6 +205,7 @@ export function SearchInput({
|
||||
placeholder="Search messages or type from:@user to filter by sender"
|
||||
autoComplete="off"
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
before={
|
||||
active && loading ? (
|
||||
@@ -267,6 +292,52 @@ export function SearchInput({
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
{focused &&
|
||||
inputEmpty &&
|
||||
suggestedUsers.length === 0 &&
|
||||
recentSearches &&
|
||||
recentSearches.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" gap="200" style={{ padding: config.space.S300 }}>
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="L400">Recent searches</Text>
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
|
||||
onClick={() => onClearRecentSearches?.()}
|
||||
>
|
||||
<Text size="T200">Clear</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
<Box gap="200" wrap="Wrap">
|
||||
{recentSearches.map((term) => (
|
||||
<Chip
|
||||
key={term}
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
before={<Icon size="50" src={Icons.RecentClock} />}
|
||||
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
|
||||
onClick={() => handleRecentClick(term)}
|
||||
>
|
||||
<Text size="T200">{term}</Text>
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user