feat(search): in:, before:/after:, has:, is:pinned operators (#106)
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 10m39s

Typed operators alongside the existing from:, each one setting the filter the
buttons already set (so typing and clicking end in the same state, shown in
the filter bar):

  in:general  in:"voice lounge"   room by name / alias / id (unique partial
                                  name ok); a room outside this page's scope
                                  switches to all rooms
  after:2026-09-01  before:7d     date range; relative h/d/w/m/y
  has:link | image | video | file contains-link / media filters
  is:pinned                       pinned-only filter

- searchOperators.ts: pure parser + room resolver (unit-tested); quoted
  values; keys case-insensitive; only at a word start, so URLs and times
  aren't mistaken for operators; anything it doesn't understand stays in the
  searched text with a notice listing the valid operators.
- Notices: no matching room; operators with no words or from: ("Add a word to
  search for, or a from:@user.") — then the text is kept in the box.
- from:bob now resolves to a known @bob:server (this homeserver first); the
  old path sent "@bob", which matched nobody unless picked from autocomplete.

Verified in Chromium on a local Synapse: `raid in:"Thread Lab" from:bob`
sets rooms=!…Thread Lab, senders=@bob:localhost and finds bob's message;
dates, an unknown room, operators-only and has:gif give the expected params
and notices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-25 12:54:10 -04:00
co-authored by Claude Opus 5.5
parent 06bb896a00
commit 75951f9040
4 changed files with 342 additions and 1 deletions
@@ -50,6 +50,7 @@ import { clearAll as clearSearchCache } from '../../utils/searchCache';
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput';
import { ParsedSearch, resolveRoomOperand } from './searchOperators';
import { SearchFilters } from './SearchFilters';
import { VirtualTile } from '../../components/virtualizer';
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
@@ -205,6 +206,8 @@ export function MessageSearch({
// currently pinned in their room (`m.room.pinned_events`). Server-unaffected.
const [pinnedOnly, setPinnedOnly] = useState(false);
const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
// [Gitea #106] Why a typed operator couldn't be applied (unknown room, …).
const [operatorNotice, setOperatorNotice] = useState<string>();
const [searchParams, setSearchParams] = useSearchParams();
const searchPathSearchParams = useSearchPathSearchParams(searchParams);
@@ -400,6 +403,94 @@ export function MessageSearch({
setRecentSearches((prev) => addRecentSearch(prev, term));
};
// [Gitea #106] Typed operators → the same filters the buttons set.
const handleQuery = (parsed: ParsedSearch, raw: string): boolean => {
const notices: string[] = [];
const roomChoices = allRooms.map((roomId) => {
const room = mx.getRoom(roomId);
return {
roomId,
name: room?.name ?? '',
aliases: [room?.getCanonicalAlias(), ...(room?.getAltAliases() ?? [])].filter(
(a): a is string => !!a,
),
};
});
const roomIds: string[] = [];
parsed.rooms.forEach((value) => {
const id = resolveRoomOperand(value, roomChoices);
if (id) {
if (!roomIds.includes(id)) roomIds.push(id);
} else notices.push(`No room matches “in:${value}”.`);
});
const mergedSenders = [...(searchParamsSenders ?? [])];
// from:bob → a known @bob:server (this homeserver first), as autocomplete would.
const myServer = mx.getUserId()?.split(':')[1];
const resolveUser = (id: string): string => {
if (id.includes(':')) return id;
const local = id.slice(1).toLowerCase();
const found = new Set<string>();
mx.getRooms().forEach((room) =>
room.getMembers().forEach((m) => {
if (m.userId.slice(1).split(':')[0].toLowerCase() === local) found.add(m.userId);
}),
);
const list = [...found];
return list.find((u) => u.split(':')[1] === myServer) ?? list[0] ?? id;
};
parsed.senders.map(resolveUser).forEach((id) => {
if (!mergedSenders.includes(id)) mergedSenders.push(id);
});
if (!parsed.term && mergedSenders.length === 0) {
notices.push('Add a word to search for, or a from:@user.');
}
if (parsed.unknown.length > 0) {
notices.push(
`Not a search filter: ${parsed.unknown.join(', ')} (searched as text). Try in:, from:, before:, after:, has:link/image/video/file, is:pinned.`,
);
}
setOperatorNotice(notices.length > 0 ? notices.join(' ') : undefined);
if (!parsed.term && mergedSenders.length === 0) return false;
setSearchParams((prevParams) => {
const p = new URLSearchParams(prevParams);
p.delete('term');
p.append('term', parsed.term);
if (parsed.senders.length > 0) {
p.delete('senders');
p.append('senders', encodeSearchParamValueArray(mergedSenders));
}
if (roomIds.length > 0) {
p.delete('rooms');
p.append('rooms', encodeSearchParamValueArray(roomIds));
// A room outside this page's own scope needs the all-rooms scope.
if (allowGlobal && roomIds.some((id) => !rooms.includes(id))) {
p.delete('global');
p.append('global', 'true');
}
}
if (parsed.fromTs !== undefined) {
p.delete('fromTs');
p.append('fromTs', String(parsed.fromTs));
}
if (parsed.toTs !== undefined) {
p.delete('toTs');
p.append('toTs', String(parsed.toTs));
}
if (parsed.containsUrl) {
p.delete('containsUrl');
p.append('containsUrl', 'true');
}
return p;
});
if (parsed.msgTypes.length > 0) {
setMsgTypeFilters((prev) => [...new Set([...prev, ...parsed.msgTypes])]);
}
if (parsed.pinnedOnly) setPinnedOnly(true);
setRecentSearches((prev) => addRecentSearch(prev, raw));
return true;
};
const handleRecentSearch = (term: string) => {
if (searchInputRef.current) {
searchInputRef.current.value = term;
@@ -548,10 +639,16 @@ export function MessageSearch({
onSearch={handleSearch}
onReset={handleSearchClear}
onSenderAdd={handleSenderAdd}
onQuery={handleQuery}
recentSearches={recentSearches}
onRecentSearch={handleRecentSearch}
onClearRecentSearches={handleClearRecentSearches}
/>
{operatorNotice && (
<Text size="T200" role="status" style={{ color: color.Warning.Main }}>
{operatorNotice}
</Text>
)}
<SearchFilters
defaultRoomsFilterName={defaultRoomsFilterName}
allowGlobal={allowGlobal}
@@ -582,7 +679,7 @@ export function MessageSearch({
<PageHero
icon={<Icon size="600" src={Icons.Message} />}
title="Search Messages"
subTitle="Find helpful messages in your community by searching with related keywords, or type from:@user to see all messages from someone."
subTitle="Search by keyword. Narrow it with from:@user, in:room, after:2026-09-01 or before:7d, has:link / has:image, and is:pinned."
/>
</PageHeroSection>
</PageHeroEmpty>