diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx
index 27e0d47e1..3bf91759f 100644
--- a/src/app/features/message-search/MessageSearch.tsx
+++ b/src/app/features/message-search/MessageSearch.tsx
@@ -19,6 +19,7 @@ import { useRooms } from '../../state/hooks/roomList';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { mDirectAtom } from '../../state/mDirectList';
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
+import { useLocalMessageSearch } from './useLocalMessageSearch';
import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput';
import { SearchFilters } from './SearchFilters';
@@ -95,6 +96,19 @@ export function MessageSearch({
}, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]);
const searchMessages = useMessageSearch(msgSearchParams);
+ const searchLocalMessages = useLocalMessageSearch();
+
+ // The rooms actually in scope for this search (mirrors server-side logic)
+ const localSearchRooms = useMemo(
+ () => msgSearchParams.rooms ?? (searchPathSearchParams.global === 'true' ? allRooms : rooms),
+ [msgSearchParams.rooms, searchPathSearchParams.global, allRooms, rooms],
+ );
+
+ // Run synchronous client-side search over encrypted rooms immediately
+ const localResult = useMemo(() => {
+ if (!msgSearchParams.term) return null;
+ return searchLocalMessages(localSearchRooms, msgSearchParams.term);
+ }, [searchLocalMessages, localSearchRooms, msgSearchParams.term]);
const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
enabled: !!msgSearchParams.term,
@@ -237,16 +251,36 @@ export function MessageSearch({
)}
{msgSearchParams.term && groups.length === 0 && status === 'success' && (
-
-
-
- No results found for {`"${msgSearchParams.term}"`}
-
+
+
+
+
+ No results found for {`"${msgSearchParams.term}"`}
+
+
+ {localResult &&
+ localResult.encryptedRoomsCount > 0 &&
+ localResult.groups.length === 0 && (
+
+
+
+ {`${localResult.encryptedRoomsCount} encrypted room${localResult.encryptedRoomsCount !== 1 ? 's' : ''} in scope — the server cannot search E2EE messages. `}
+ {localResult.searchedRoomsCount > 0
+ ? `No matches found in your locally cached messages from ${localResult.searchedRoomsCount} room${localResult.searchedRoomsCount !== 1 ? 's' : ''}.`
+ : `Open those rooms to cache messages locally, then search again.`}
+
+
+ )}
)}
@@ -259,6 +293,23 @@ export function MessageSearch({
)}
+ {msgSearchParams.term &&
+ localResult &&
+ localResult.encryptedRoomsCount > 0 &&
+ vItems.length > 0 && (
+
+
+
+ {`${localResult.encryptedRoomsCount} encrypted room${localResult.encryptedRoomsCount !== 1 ? 's' : ''} in scope — server results are from unencrypted rooms only. Encrypted room results appear below from your local cache.`}
+
+
+ )}
+
{vItems.length > 0 && (
@@ -307,6 +358,41 @@ export function MessageSearch({
)}
+ {localResult && localResult.groups.length > 0 && (
+
+
+
+
+ Encrypted Rooms
+
+
+ {`Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Only recently viewed messages are available.`}
+
+
+
+
+ {localResult.groups.map((group) => {
+ const groupRoom = mx.getRoom(group.roomId);
+ if (!groupRoom) return null;
+ return (
+
+ );
+ })}
+
+
+ )}
+
{error && (
{
+ const mx = useMatrixClient();
+
+ const search = useCallback(
+ (roomIds: string[], term: string): LocalSearchResult => {
+ const trimmedTerm = term.trim();
+ if (!trimmedTerm) {
+ return { groups: [], encryptedRoomsCount: 0, searchedRoomsCount: 0 };
+ }
+
+ const termLower = trimmedTerm.toLowerCase();
+ const groups: ResultGroup[] = [];
+ let encryptedRoomsCount = 0;
+ let searchedRoomsCount = 0;
+
+ for (const roomId of roomIds) {
+ const room = mx.getRoom(roomId);
+ if (!room) continue;
+
+ const isEncrypted = !!room.currentState.getStateEvents(EventType.RoomEncryption, '');
+ if (!isEncrypted) continue;
+
+ encryptedRoomsCount += 1;
+
+ const events = room.getLiveTimeline().getEvents();
+ if (events.length === 0) continue;
+
+ searchedRoomsCount += 1;
+
+ const items: ResultItem[] = [];
+
+ for (let i = 0; i < events.length; i += 1) {
+ const event = events[i];
+
+ if (event.getType() !== EventType.RoomMessage) continue;
+ if (event.isDecryptionFailure()) continue;
+ if (event.isRedacted()) continue;
+
+ // getContent() returns decrypted plaintext regardless of encryption
+ const content = event.getContent();
+ const body = (content.body as string | undefined) ?? '';
+ const formattedBody = (content.formatted_body as string | undefined) ?? '';
+
+ 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.
+ const syntheticEvent = {
+ room_id: roomId,
+ event_id: event.getId() ?? '',
+ type: event.getType(),
+ sender: event.getSender() ?? '',
+ origin_server_ts: event.getTs(),
+ content,
+ unsigned: event.getUnsigned(),
+ };
+
+ items.push({
+ rank: 0,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ event: syntheticEvent as any,
+ context: {
+ events_before: [],
+ events_after: [],
+ profile_info: {},
+ },
+ });
+ }
+
+ if (items.length > 0) {
+ items.sort((a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0));
+ groups.push({ roomId, items });
+ }
+ }
+
+ return { groups, encryptedRoomsCount, searchedRoomsCount };
+ },
+ [mx],
+ );
+
+ return search;
+};
diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts
index 5eb79da89..048188665 100644
--- a/src/app/features/message-search/useMessageSearch.ts
+++ b/src/app/features/message-search/useMessageSearch.ts
@@ -80,7 +80,7 @@ export const useMessageSearch = (params: MessageSearchParams) => {
highlights: [],
groups: [],
};
- const limit = 20;
+ const limit = 50;
const requestBody: ISearchRequestBody = {
search_categories: {