diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx
index 13e2c525c..c87155872 100644
--- a/src/app/features/message-search/MessageSearch.tsx
+++ b/src/app/features/message-search/MessageSearch.tsx
@@ -213,12 +213,21 @@ export function MessageSearch({
[msgSearchParams.rooms, searchPathSearchParams.global, allRooms, rooms],
);
- // Run synchronous client-side search over encrypted rooms immediately.
+ // term === undefined → no search started
+ // term === '' → sender-only search (from:user with no body text)
+ // term === 'foo' → normal text search
+ const hasActiveSearch =
+ msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
+ const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length;
+
+ // Run synchronous client-side search immediately.
+ // In text-search mode: covers encrypted rooms only (server handles plaintext).
+ // In sender-only mode: covers all rooms (server has no sender-only search).
// cacheVersion in deps so it re-runs after "Load more" paginates new events.
const localResult = useMemo(() => {
- if (!msgSearchParams.term) return null;
+ if (!hasActiveSearch) return null;
return searchLocalMessages({
- term: msgSearchParams.term,
+ term: msgSearchParams.term ?? '',
roomIds: localSearchRooms,
senders: msgSearchParams.senders,
});
@@ -404,19 +413,19 @@ export function MessageSearch({
/>
- {!msgSearchParams.term && status === 'pending' && (
+ {!hasActiveSearch && (
}
title="Search Messages"
- subTitle="Find helpful messages in your community by searching with related keywords."
+ subTitle="Find helpful messages in your community by searching with related keywords, or type from:@user to see all messages from someone."
/>
)}
- {msgSearchParams.term && groups.length === 0 && status === 'success' && (
+ {hasActiveSearch && !senderOnlyMode && groups.length === 0 && status === 'success' && (
)}
- {((msgSearchParams.term && status === 'pending') ||
+ {((!senderOnlyMode && msgSearchParams.term && status === 'pending') ||
(groups.length > 0 && vItems.length === 0)) && (
{[...Array(8).keys()].map((key) => (
@@ -460,6 +469,7 @@ export function MessageSearch({
)}
{msgSearchParams.term &&
+ !senderOnlyMode &&
localResult &&
localResult.encryptedRoomsCount > 0 &&
vItems.length > 0 && (
@@ -524,20 +534,24 @@ export function MessageSearch({
)}
- {localResult && localResult.encryptedRoomsCount > 0 && (
+ {localResult && (senderOnlyMode ? localResult.groups.length > 0 : localResult.encryptedRoomsCount > 0) && (
-
- Encrypted Rooms
-
- {`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`}
-
+
+ {senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms'}
+ {!senderOnlyMode && (
+
+ {`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`}
+
+ )}
- {localResult.groups.length > 0
- ? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.`
- : `No matches in your local cache. Load messages below to search further back.`}
+ {senderOnlyMode
+ ? `Showing locally cached messages from this user across all rooms. Open more rooms or load history below to extend coverage.`
+ : localResult.groups.length > 0
+ ? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.`
+ : `No matches in your local cache. Load messages below to search further back.`}
diff --git a/src/app/features/message-search/SearchInput.tsx b/src/app/features/message-search/SearchInput.tsx
index 580f7c2cd..d30e2bd1f 100644
--- a/src/app/features/message-search/SearchInput.tsx
+++ b/src/app/features/message-search/SearchInput.tsx
@@ -158,7 +158,10 @@ export function SearchInput({
searchInputRef.current.value = searchTerm ?? '';
}
- if (searchTerm) onSearch(searchTerm);
+ // Always trigger search when senders were extracted, even with no body text
+ if (fromMatches.length > 0 || searchTerm) {
+ onSearch(searchTerm ?? '');
+ }
closeAutocomplete();
};
diff --git a/src/app/features/message-search/useLocalMessageSearch.ts b/src/app/features/message-search/useLocalMessageSearch.ts
index d8534d553..a5cdd6efa 100644
--- a/src/app/features/message-search/useLocalMessageSearch.ts
+++ b/src/app/features/message-search/useLocalMessageSearch.ts
@@ -31,12 +31,16 @@ export const useLocalMessageSearch = () => {
const search = useCallback(
({ term, roomIds, senders }: LocalSearchParams): LocalSearchResult => {
const trimmedTerm = term.trim();
- if (!trimmedTerm) {
+ const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
+
+ // Sender-only mode: no text filter, search all rooms (server can't filter by sender alone)
+ const senderOnlyMode = !trimmedTerm && !!senderSet;
+
+ if (!trimmedTerm && !senderSet) {
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;
@@ -46,9 +50,12 @@ export const useLocalMessageSearch = () => {
if (!room) continue;
const isEncrypted = !!room.currentState.getStateEvents(EventType.RoomEncryption, '');
- if (!isEncrypted) continue;
- encryptedRoomsCount += 1;
+ // Text search: encrypted rooms only — server already covers plaintext rooms
+ // Sender-only: all rooms — server has no sender-only search
+ if (!senderOnlyMode && !isEncrypted) continue;
+
+ if (isEncrypted) encryptedRoomsCount += 1;
const events = room
.getUnfilteredTimelineSet()
@@ -63,13 +70,16 @@ export const useLocalMessageSearch = () => {
for (let i = 0; i < events.length; i += 1) {
const event = events[i];
- const evType = event.getType();
- const isMessage = evType === EventType.RoomMessage;
- const isSticker = evType === 'm.sticker';
- const isPoll =
- evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
+ // In sender-only mode: include all message types; skip non-message events
+ if (event.getType() !== EventType.RoomMessage) {
+ if (senderOnlyMode) continue;
+ const evType = event.getType();
+ const isSticker = evType === 'm.sticker';
+ const isPoll =
+ evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
+ if (!isSticker && !isPoll) continue;
+ }
- if (!isMessage && !isSticker && !isPoll) continue;
if (event.isDecryptionFailure()) continue;
if (event.isRedacted()) continue;
if (senderSet && !senderSet.has(event.getSender() ?? '')) continue;
@@ -77,41 +87,49 @@ export const useLocalMessageSearch = () => {
// getContent() returns decrypted plaintext regardless of encryption
const content = event.getContent();
- let body = '';
- let formattedBody = '';
- if (isMessage || isSticker) {
- body = (content.body as string | undefined) ?? '';
- formattedBody = (content.formatted_body as string | undefined) ?? '';
- } else {
- // Poll — index question text and all answer options
- const poll = (content['m.poll'] ??
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- content['org.matrix.msc3381.poll.start']) as any;
- if (poll) {
- const qBody =
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (poll.question?.['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
- (poll.question?.body as string | undefined) ??
- '';
- const answerBodies = ((poll.answers ?? []) as Array>)
- .map(
- (a) =>
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- ((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (a['org.matrix.msc3381.poll.answer'] as any)?.body ??
- '') as string,
- )
- .join(' ');
- body = `${qBody} ${answerBodies}`.trim();
- }
- }
+ // Sender-only mode: no text filter needed
+ if (!senderOnlyMode) {
+ const evType = event.getType();
+ const isSticker = evType === 'm.sticker';
+ const isPoll =
+ evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
- if (
- !body.toLowerCase().includes(termLower) &&
- !formattedBody.toLowerCase().includes(termLower)
- )
- continue;
+ let body = '';
+ let formattedBody = '';
+ if (!isPoll) {
+ body = (content.body as string | undefined) ?? '';
+ formattedBody = (content.formatted_body as string | undefined) ?? '';
+ } else {
+ // Poll — index question text and all answer options
+ const poll = (content['m.poll'] ??
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ content['org.matrix.msc3381.poll.start']) as any;
+ if (poll) {
+ const qBody =
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (poll.question?.['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
+ (poll.question?.body as string | undefined) ??
+ '';
+ const answerBodies = ((poll.answers ?? []) as Array>)
+ .map(
+ (a) =>
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (a['org.matrix.msc3381.poll.answer'] as any)?.body ??
+ '') as string,
+ )
+ .join(' ');
+ body = `${qBody} ${answerBodies}`.trim();
+ }
+ }
+
+ if (
+ !body.toLowerCase().includes(termLower) &&
+ !formattedBody.toLowerCase().includes(termLower)
+ )
+ continue;
+ }
// Build a synthetic IEventWithRoomId using decrypted content so the
// existing SearchResultGroup renderer works without modification.