feat: sender filter for message search with from:@user autocomplete

Type 'from:@name' in the search box to filter by sender — a dropdown
of matching users (avatar + display name + full ID) appears as you type
and selecting one converts it into a removable sender chip in the filter
bar. Multiple senders supported. Also works via manual entry on submit.

- SearchInput: detects trailing 'from:@...' pattern on every keystroke,
  shows PopOut autocomplete from mx.getUsers(), onMouseDown prevents
  input blur when selecting, cleans up fragment after selection
- SearchFilters: selectedSenders/onSelectedSendersChange props, sender
  chips rendered with user icon and X to remove
- useLocalMessageSearch: filters cached events by sender set when senders
  param is provided (encrypted room search respects the filter too)
- MessageSearch: handleSenderAdd deduplicates and writes to ?senders= URL
  param; localResult now passes senders to the local search

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 22:07:53 -04:00
parent e5ae77a99d
commit af3155e169
4 changed files with 300 additions and 45 deletions
@@ -214,9 +214,19 @@ export function MessageSearch({
// eslint-disable-next-line react-hooks/exhaustive-deps
const localResult = useMemo(() => {
if (!msgSearchParams.term) return null;
return searchLocalMessages(localSearchRooms, msgSearchParams.term);
return searchLocalMessages({
term: msgSearchParams.term,
roomIds: localSearchRooms,
senders: msgSearchParams.senders,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchLocalMessages, localSearchRooms, msgSearchParams.term, cacheVersion]);
}, [
searchLocalMessages,
localSearchRooms,
msgSearchParams.term,
msgSearchParams.senders,
cacheVersion,
]);
const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
enabled: !!msgSearchParams.term,
@@ -297,6 +307,29 @@ export function MessageSearch({
});
};
const handleSelectedSendersChange = useCallback(
(newSenders?: string[]) => {
setSearchParams((prevParams) => {
const p = new URLSearchParams(prevParams);
p.delete('senders');
if (newSenders && newSenders.length > 0) {
p.append('senders', encodeSearchParamValueArray(newSenders));
}
return p;
});
},
[setSearchParams],
);
const handleSenderAdd = useCallback(
(userId: string) => {
const current = searchParamsSenders ?? [];
if (current.includes(userId)) return;
handleSelectedSendersChange([...current, userId]);
},
[searchParamsSenders, handleSelectedSendersChange],
);
const lastVItem = vItems[vItems.length - 1];
const lastVItemIndex: number | undefined = lastVItem?.index;
const lastGroupIndex = groups.length - 1;
@@ -332,6 +365,7 @@ export function MessageSearch({
searchInputRef={searchInputRef}
onSearch={handleSearch}
onReset={handleSearchClear}
onSenderAdd={handleSenderAdd}
/>
<SearchFilters
defaultRoomsFilterName={defaultRoomsFilterName}
@@ -343,6 +377,8 @@ export function MessageSearch({
onGlobalChange={handleGlobalChange}
order={msgSearchParams.order}
onOrderChange={handleOrderChange}
selectedSenders={searchParamsSenders}
onSelectedSendersChange={handleSelectedSendersChange}
/>
</Box>