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:
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user