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
@@ -24,6 +24,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart, getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
import { UserAvatar } from '../../components/user-avatar';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ParsedSearch, hasOperators, parseSearchQuery } from './searchOperators';
// Matches "from:@?anything" anywhere — @ is optional
const FROM_REGEX = /from:@?([^\s:][^\s]*)/gi;
@@ -43,6 +44,9 @@ type SearchInputProps = {
onSearch: (term: string) => void;
onReset: () => void;
onSenderAdd?: (userId: string) => void;
/** [Gitea #106] Receives in:/before:/after:/has:/is: (and from:) as filters. */
/** Returns false when the query wasn't run, so the text is kept as typed. */
onQuery?: (parsed: ParsedSearch, raw: string) => boolean;
recentSearches?: string[];
onRecentSearch?: (term: string) => void;
onClearRecentSearches?: () => void;
@@ -54,6 +58,7 @@ export function SearchInput({
onSearch,
onReset,
onSenderAdd,
onQuery,
recentSearches,
onRecentSearch,
onClearRecentSearches,
@@ -167,6 +172,19 @@ export function SearchInput({
const rawValue = searchInput.value.trim();
if (onQuery) {
const parsed = parseSearchQuery(rawValue);
if (!parsed.term && !hasOperators(parsed)) return;
// Recognised operators become filters (chips / controls); drop them
// from the visible input like from: always did.
const ran = onQuery(parsed, rawValue);
if (ran && hasOperators(parsed) && searchInputRef.current) {
searchInputRef.current.value = parsed.term;
}
closeAutocomplete();
return;
}
// Extract from:user fragments and convert to sender filters
const fromMatches = [...rawValue.matchAll(FROM_REGEX)];
fromMatches.forEach((match) => {