feat(search): support from:username with no body text

Typing "from:jared" with no additional text now shows all cached
messages from that user across all rooms.

- SearchInput: call onSearch() even when only from: tokens were
  extracted (no remaining body text), passing an empty string term
- useLocalMessageSearch: introduce senderOnlyMode (empty term +
  senders set) which searches ALL rooms instead of encrypted-only,
  and skips text matching — just filters by sender
- MessageSearch: define hasActiveSearch / senderOnlyMode flags;
  use them to enable local search and fix placeholder/loading/results
  conditions; adapt local results section header and description

Server-side search is skipped in sender-only mode (Matrix search API
requires a search_term); results come from the local event cache.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 00:16:24 -04:00
parent 6f9bdc4d50
commit 9c690fbdfb
3 changed files with 96 additions and 61 deletions
@@ -213,12 +213,21 @@ export function MessageSearch({
[msgSearchParams.rooms, searchPathSearchParams.global, allRooms, rooms], [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. // cacheVersion in deps so it re-runs after "Load more" paginates new events.
const localResult = useMemo(() => { const localResult = useMemo(() => {
if (!msgSearchParams.term) return null; if (!hasActiveSearch) return null;
return searchLocalMessages({ return searchLocalMessages({
term: msgSearchParams.term, term: msgSearchParams.term ?? '',
roomIds: localSearchRooms, roomIds: localSearchRooms,
senders: msgSearchParams.senders, senders: msgSearchParams.senders,
}); });
@@ -404,19 +413,19 @@ export function MessageSearch({
/> />
</Box> </Box>
{!msgSearchParams.term && status === 'pending' && ( {!hasActiveSearch && (
<PageHeroEmpty> <PageHeroEmpty>
<PageHeroSection> <PageHeroSection>
<PageHero <PageHero
icon={<Icon size="600" src={Icons.Message} />} icon={<Icon size="600" src={Icons.Message} />}
title="Search Messages" 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."
/> />
</PageHeroSection> </PageHeroSection>
</PageHeroEmpty> </PageHeroEmpty>
)} )}
{msgSearchParams.term && groups.length === 0 && status === 'success' && ( {hasActiveSearch && !senderOnlyMode && groups.length === 0 && status === 'success' && (
<Box direction="Column" gap="200"> <Box direction="Column" gap="200">
<Box <Box
className={ContainerColor({ variant: 'Warning' })} className={ContainerColor({ variant: 'Warning' })}
@@ -450,7 +459,7 @@ export function MessageSearch({
</Box> </Box>
)} )}
{((msgSearchParams.term && status === 'pending') || {((!senderOnlyMode && msgSearchParams.term && status === 'pending') ||
(groups.length > 0 && vItems.length === 0)) && ( (groups.length > 0 && vItems.length === 0)) && (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
{[...Array(8).keys()].map((key) => ( {[...Array(8).keys()].map((key) => (
@@ -460,6 +469,7 @@ export function MessageSearch({
)} )}
{msgSearchParams.term && {msgSearchParams.term &&
!senderOnlyMode &&
localResult && localResult &&
localResult.encryptedRoomsCount > 0 && localResult.encryptedRoomsCount > 0 &&
vItems.length > 0 && ( vItems.length > 0 && (
@@ -524,20 +534,24 @@ export function MessageSearch({
</Box> </Box>
)} )}
{localResult && localResult.encryptedRoomsCount > 0 && ( {localResult && (senderOnlyMode ? localResult.groups.length > 0 : localResult.encryptedRoomsCount > 0) && (
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
<Box direction="Column" gap="200"> <Box direction="Column" gap="200">
<Box alignItems="Center" gap="200"> <Box alignItems="Center" gap="200">
<Icon size="200" src={Icons.Lock} /> <Icon size="200" src={senderOnlyMode ? Icons.User : Icons.Lock} />
<Text size="H5">Encrypted Rooms</Text> <Text size="H5">{senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms'}</Text>
<Text size="T200" style={{ opacity: 0.55 }}> {!senderOnlyMode && (
{`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`} <Text size="T200" style={{ opacity: 0.55 }}>
</Text> {`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`}
</Text>
)}
</Box> </Box>
<Text size="T300" priority="300"> <Text size="T300" priority="300">
{localResult.groups.length > 0 {senderOnlyMode
? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.` ? `Showing locally cached messages from this user across all rooms. Open more rooms or load history below to extend coverage.`
: `No matches in your local cache. Load messages below to search further back.`} : 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.`}
</Text> </Text>
<Line size="300" variant="Surface" /> <Line size="300" variant="Surface" />
</Box> </Box>
@@ -158,7 +158,10 @@ export function SearchInput({
searchInputRef.current.value = searchTerm ?? ''; 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(); closeAutocomplete();
}; };
@@ -31,12 +31,16 @@ export const useLocalMessageSearch = () => {
const search = useCallback( const search = useCallback(
({ term, roomIds, senders }: LocalSearchParams): LocalSearchResult => { ({ term, roomIds, senders }: LocalSearchParams): LocalSearchResult => {
const trimmedTerm = term.trim(); 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 }; return { groups: [], encryptedRoomsCount: 0, searchedRoomsCount: 0 };
} }
const termLower = trimmedTerm.toLowerCase(); const termLower = trimmedTerm.toLowerCase();
const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
const groups: ResultGroup[] = []; const groups: ResultGroup[] = [];
let encryptedRoomsCount = 0; let encryptedRoomsCount = 0;
let searchedRoomsCount = 0; let searchedRoomsCount = 0;
@@ -46,9 +50,12 @@ export const useLocalMessageSearch = () => {
if (!room) continue; if (!room) continue;
const isEncrypted = !!room.currentState.getStateEvents(EventType.RoomEncryption, ''); 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 const events = room
.getUnfilteredTimelineSet() .getUnfilteredTimelineSet()
@@ -63,13 +70,16 @@ export const useLocalMessageSearch = () => {
for (let i = 0; i < events.length; i += 1) { for (let i = 0; i < events.length; i += 1) {
const event = events[i]; const event = events[i];
const evType = event.getType(); // In sender-only mode: include all message types; skip non-message events
const isMessage = evType === EventType.RoomMessage; if (event.getType() !== EventType.RoomMessage) {
const isSticker = evType === 'm.sticker'; if (senderOnlyMode) continue;
const isPoll = const evType = event.getType();
evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start'; 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.isDecryptionFailure()) continue;
if (event.isRedacted()) continue; if (event.isRedacted()) continue;
if (senderSet && !senderSet.has(event.getSender() ?? '')) continue; if (senderSet && !senderSet.has(event.getSender() ?? '')) continue;
@@ -77,41 +87,49 @@ export const useLocalMessageSearch = () => {
// getContent() returns decrypted plaintext regardless of encryption // getContent() returns decrypted plaintext regardless of encryption
const content = event.getContent(); const content = event.getContent();
let body = ''; // Sender-only mode: no text filter needed
let formattedBody = ''; if (!senderOnlyMode) {
if (isMessage || isSticker) { const evType = event.getType();
body = (content.body as string | undefined) ?? ''; const isSticker = evType === 'm.sticker';
formattedBody = (content.formatted_body as string | undefined) ?? ''; const isPoll =
} else { evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
// 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<Record<string, unknown>>)
.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 ( let body = '';
!body.toLowerCase().includes(termLower) && let formattedBody = '';
!formattedBody.toLowerCase().includes(termLower) if (!isPoll) {
) body = (content.body as string | undefined) ?? '';
continue; 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<Record<string, unknown>>)
.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 // Build a synthetic IEventWithRoomId using decrypted content so the
// existing SearchResultGroup renderer works without modification. // existing SearchResultGroup renderer works without modification.