diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx
index 21e3cc19f..9f6079a7c 100644
--- a/src/app/features/message-search/MessageSearch.tsx
+++ b/src/app/features/message-search/MessageSearch.tsx
@@ -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}
/>
diff --git a/src/app/features/message-search/SearchFilters.tsx b/src/app/features/message-search/SearchFilters.tsx
index 0762bcc38..c16f362cc 100644
--- a/src/app/features/message-search/SearchFilters.tsx
+++ b/src/app/features/message-search/SearchFilters.tsx
@@ -29,6 +29,7 @@ import { SearchOrderBy } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { getMxIdLocalPart } from '../../utils/matrix';
import { getRoomIconSrc } from '../../utils/room';
import { factoryRoomIdByAtoZ } from '../../utils/sort';
import {
@@ -335,6 +336,8 @@ type SearchFiltersProps = {
onGlobalChange: (global?: boolean) => void;
order?: string;
onOrderChange: (order?: string) => void;
+ selectedSenders?: string[];
+ onSelectedSendersChange: (senders?: string[]) => void;
};
export function SearchFilters({
defaultRoomsFilterName,
@@ -346,6 +349,8 @@ export function SearchFilters({
order,
onGlobalChange,
onOrderChange,
+ selectedSenders,
+ onSelectedSendersChange,
}: SearchFiltersProps) {
const mx = useMatrixClient();
@@ -403,6 +408,30 @@ export function SearchFilters({
selectedRooms={selectedRooms}
onChange={onSelectedRoomsChange}
/>
+ {selectedSenders && selectedSenders.length > 0 && (
+
+ )}
+ {selectedSenders?.map((userId) => {
+ const user = mx.getUser(userId);
+ const name = user?.displayName ?? getMxIdLocalPart(userId) ?? userId;
+ return (
+ }
+ after={}
+ onClick={() => onSelectedSendersChange(selectedSenders.filter((id) => id !== userId))}
+ >
+ {name}
+
+ );
+ })}
diff --git a/src/app/features/message-search/SearchInput.tsx b/src/app/features/message-search/SearchInput.tsx
index 533eb5fdb..2ff72faaf 100644
--- a/src/app/features/message-search/SearchInput.tsx
+++ b/src/app/features/message-search/SearchInput.tsx
@@ -1,67 +1,249 @@
-import React, { FormEventHandler, RefObject } from 'react';
-import { Box, Text, Input, Icon, Icons, Spinner, Chip, config } from 'folds';
+import React, {
+ ChangeEventHandler,
+ FormEventHandler,
+ RefObject,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import {
+ Avatar,
+ Box,
+ Chip,
+ config,
+ Icon,
+ Icons,
+ Input,
+ Menu,
+ MenuItem,
+ PopOut,
+ RectCords,
+ Spinner,
+ Text,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
+import { UserAvatar } from '../../components/user-avatar';
+import { stopPropagation } from '../../utils/keyboard';
+import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
-type SearchProps = {
+// Matches "from:@anything" anywhere in the query (no trailing space required)
+const FROM_REGEX = /from:@([^\s]*)/gi;
+// For detecting active typing: matches the trailing "from:@..." at cursor end
+const FROM_TYPING_REGEX = /from:@([^\s]*)$/i;
+
+type SearchInputProps = {
active?: boolean;
loading?: boolean;
searchInputRef: RefObject;
onSearch: (term: string) => void;
onReset: () => void;
+ onSenderAdd?: (userId: string) => void;
};
-export function SearchInput({ active, loading, searchInputRef, onSearch, onReset }: SearchProps) {
+export function SearchInput({
+ active,
+ loading,
+ searchInputRef,
+ onSearch,
+ onReset,
+ onSenderAdd,
+}: SearchInputProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const inputWrapRef = useRef(null);
+
+ const [fromQuery, setFromQuery] = useState('');
+ const [autocompleteAnchor, setAutocompleteAnchor] = useState();
+
+ 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);
+ // Return focus to input so user can keep typing
+ searchInputRef.current?.focus();
+ },
+ [searchInputRef, onSenderAdd, closeAutocomplete],
+ );
+
+ const handleChange: ChangeEventHandler = (evt) => {
+ const value = evt.target.value;
+ const match = FROM_TYPING_REGEX.exec(value);
+ if (match) {
+ setFromQuery(match[1]);
+ const rect = inputWrapRef.current?.getBoundingClientRect();
+ if (rect) setAutocompleteAnchor(rect);
+ } else {
+ closeAutocomplete();
+ }
+ };
+
const handleSearchSubmit: FormEventHandler = (evt) => {
evt.preventDefault();
const { searchInput } = evt.target as HTMLFormElement & {
searchInput: HTMLInputElement;
};
- const searchTerm = searchInput.value.trim() || undefined;
- if (searchTerm) {
- onSearch(searchTerm);
+ const rawValue = searchInput.value.trim();
+
+ // Extract any from:@userId fragments and turn them into sender filters
+ const fromMatches = [...rawValue.matchAll(FROM_REGEX)];
+ fromMatches.forEach((match) => {
+ const raw = match[1];
+ const userId = raw.startsWith('@') ? raw : `@${raw}`;
+ onSenderAdd?.(userId);
+ });
+
+ const searchTerm = rawValue.replace(FROM_REGEX, '').replace(/\s+/g, ' ').trim() || undefined;
+
+ // Clean up the from: fragments from the visible input
+ if (fromMatches.length > 0 && searchInputRef.current) {
+ searchInputRef.current.value = searchTerm ?? '';
}
+
+ if (searchTerm) onSearch(searchTerm);
+ closeAutocomplete();
};
return (
Search
-
- ) : (
-
- )
- }
- after={
- active ? (
- }
- onClick={onReset}
+ 0 ? (
+
- Clear
-
- ) : (
-
- Enter
-
- )
+
+
+ ) : undefined
}
- />
+ >
+
+
+ ) : (
+
+ )
+ }
+ after={
+ active ? (
+ }
+ onClick={onReset}
+ >
+ Clear
+
+ ) : (
+
+ Enter
+
+ )
+ }
+ />
+
+
);
}
diff --git a/src/app/features/message-search/useLocalMessageSearch.ts b/src/app/features/message-search/useLocalMessageSearch.ts
index 7fd40cdc8..b7340d269 100644
--- a/src/app/features/message-search/useLocalMessageSearch.ts
+++ b/src/app/features/message-search/useLocalMessageSearch.ts
@@ -3,6 +3,12 @@ import { useCallback } from 'react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { ResultGroup, ResultItem } from './useMessageSearch';
+export type LocalSearchParams = {
+ term: string;
+ roomIds: string[];
+ senders?: string[];
+};
+
export type LocalSearchResult = {
groups: ResultGroup[];
/** How many rooms in scope are encrypted */
@@ -23,13 +29,14 @@ export const useLocalMessageSearch = () => {
const mx = useMatrixClient();
const search = useCallback(
- (roomIds: string[], term: string): LocalSearchResult => {
+ ({ term, roomIds, senders }: LocalSearchParams): LocalSearchResult => {
const trimmedTerm = term.trim();
if (!trimmedTerm) {
return { groups: [], encryptedRoomsCount: 0, searchedRoomsCount: 0 };
}
const termLower = trimmedTerm.toLowerCase();
+ const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
const groups: ResultGroup[] = [];
let encryptedRoomsCount = 0;
let searchedRoomsCount = 0;
@@ -56,6 +63,7 @@ export const useLocalMessageSearch = () => {
if (event.getType() !== EventType.RoomMessage) continue;
if (event.isDecryptionFailure()) continue;
if (event.isRedacted()) continue;
+ if (senderSet && !senderSet.has(event.getSender() ?? '')) continue;
// getContent() returns decrypted plaintext regardless of encryption
const content = event.getContent();