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.