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>
@@ -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) => {
@@ -0,0 +1,86 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { parseDateOperand, parseSearchQuery, resolveRoomOperand } from './searchOperators';
const NOW = new Date(2026, 8, 25, 12, 0, 0).getTime();
const DAY = 86_400_000;
describe('parseSearchQuery', () => {
it('plain text stays the term', () => {
const p = parseSearchQuery('loot rules tonight', NOW);
assert.equal(p.term, 'loot rules tonight');
assert.deepEqual(p.senders, []);
});
it('pulls every operator out of the term', () => {
const p = parseSearchQuery(
'raid from:bob in:general after:7d before:2026-09-24 has:link has:image is:pinned plan',
NOW,
);
assert.equal(p.term, 'raid plan');
assert.deepEqual(p.senders, ['@bob']);
assert.deepEqual(p.rooms, ['general']);
assert.equal(p.fromTs, NOW - 7 * DAY);
assert.equal(p.toTs, new Date(2026, 8, 24).getTime());
assert.equal(p.containsUrl, true);
assert.deepEqual(p.msgTypes, ['m.image']);
assert.equal(p.pinnedOnly, true);
assert.deepEqual(p.unknown, []);
});
it('keeps @ on from:, accepts quoted in:, and is case-insensitive on keys', () => {
const p = parseSearchQuery('FROM:@alice:x.org In:"voice lounge" hello', NOW);
assert.deepEqual(p.senders, ['@alice:x.org']);
assert.deepEqual(p.rooms, ['voice lounge']);
assert.equal(p.term, 'hello');
});
it('leaves operators it cannot understand in the term', () => {
const p = parseSearchQuery('has:gif after:someday is:starred hi', NOW);
assert.equal(p.term, 'has:gif after:someday is:starred hi');
assert.deepEqual(p.unknown, ['has:gif', 'after:someday', 'is:starred']);
});
it('does not treat a URL or a mid-word colon as an operator', () => {
const p = parseSearchQuery('see https://in:example.org and 10:30 before-lunch', NOW);
assert.equal(p.term, 'see https://in:example.org and 10:30 before-lunch');
assert.deepEqual(p.rooms, []);
});
it('has: file/video map to the media filters without duplicates', () => {
const p = parseSearchQuery('has:file has:files has:video', NOW);
assert.deepEqual(p.msgTypes, ['m.file', 'm.video']);
assert.equal(p.term, '');
});
});
describe('parseDateOperand', () => {
it('relative units', () => {
assert.equal(parseDateOperand('3h', NOW), NOW - 3 * 3_600_000);
assert.equal(parseDateOperand('2w', NOW), NOW - 14 * DAY);
assert.equal(parseDateOperand('1m', NOW), NOW - 30 * DAY);
});
it('rejects impossible dates and junk', () => {
assert.equal(parseDateOperand('2026-02-30', NOW), undefined);
assert.equal(parseDateOperand('yesterday', NOW), undefined);
});
});
describe('resolveRoomOperand', () => {
const rooms = [
{ roomId: '!a:x', name: 'General', aliases: ['#general:x.org'] },
{ roomId: '!b:x', name: 'Voice Lounge', aliases: [] },
{ roomId: '!c:x', name: 'Off Topic', aliases: ['#offtopic:x.org'] },
];
it('by alias, alias localpart, name, room id', () => {
assert.equal(resolveRoomOperand('#general:x.org', rooms), '!a:x');
assert.equal(resolveRoomOperand('offtopic', rooms), '!c:x');
assert.equal(resolveRoomOperand('voice lounge', rooms), '!b:x');
assert.equal(resolveRoomOperand('!b:x', rooms), '!b:x');
});
it('a unique partial name match, otherwise nothing', () => {
assert.equal(resolveRoomOperand('lounge', rooms), '!b:x');
assert.equal(resolveRoomOperand('o', rooms), undefined);
assert.equal(resolveRoomOperand('nope', rooms), undefined);
});
});
@@ -0,0 +1,140 @@
import type { MsgTypeFilter } from './useMessageSearch';
/**
* [Gitea #106] Discord-style operators typed into the search box. Each one
* maps onto a filter the search already has (sender chips, room picker, date
* range, "contains link", media type, pinned), so typing and clicking end up
* in the same state. Pure and unit-tested.
*
* from:@user sender (existing)
* in:general in:"a b" room, by name or alias (resolved by the caller)
* after:2026-09-01 from the start of that day (local time)
* before:2026-09-01 up to the start of that day
* after:7d before:2w relative: h, d, w, m (30 d), y (365 d) ago
* has:link | image | video | file
* is:pinned
*/
export type ParsedSearch = {
term: string;
senders: string[];
rooms: string[];
fromTs?: number;
toTs?: number;
containsUrl?: boolean;
msgTypes: MsgTypeFilter[];
pinnedOnly?: boolean;
/** Operators we didn't understand, left in the term as typed. */
unknown: string[];
};
const UNIT_MS: Record<string, number> = {
h: 3_600_000,
d: 86_400_000,
w: 7 * 86_400_000,
m: 30 * 86_400_000,
y: 365 * 86_400_000,
};
/** A date operand → timestamp, or undefined if it isn't one. Exported for tests. */
export function parseDateOperand(value: string, now: number): number | undefined {
const rel = /^(\d{1,4})([hdwmy])$/i.exec(value);
if (rel) return now - Number(rel[1]) * UNIT_MS[rel[2].toLowerCase()];
const abs = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value);
if (abs) {
const [y, m, d] = [Number(abs[1]), Number(abs[2]), Number(abs[3])];
const date = new Date(y, m - 1, d);
if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) {
return undefined;
}
return date.getTime();
}
return undefined;
}
const HAS_TYPES: Record<string, MsgTypeFilter> = {
image: 'm.image',
images: 'm.image',
video: 'm.video',
videos: 'm.video',
file: 'm.file',
files: 'm.file',
};
// key:"quoted value" or key:value
const OPERATOR = /(^|\s)(from|in|after|before|has|is):(?:"([^"]*)"|(\S*))/gi;
export function parseSearchQuery(raw: string, now = Date.now()): ParsedSearch {
const out: ParsedSearch = { term: '', senders: [], rooms: [], msgTypes: [], unknown: [] };
const kept: string[] = [];
let last = 0;
raw.replace(
OPERATOR,
(match, lead: string, keyRaw: string, quoted?: string, bare?: string, offset?: number) => {
const key = keyRaw.toLowerCase();
const value = (quoted ?? bare ?? '').trim();
const start = (offset ?? 0) + lead.length;
kept.push(raw.slice(last, start));
last = start + match.length - lead.length;
let understood = value !== '';
if (understood) {
if (key === 'from') {
out.senders.push(value.startsWith('@') ? value : `@${value}`);
} else if (key === 'in') {
out.rooms.push(value);
} else if (key === 'after' || key === 'before') {
const ts = parseDateOperand(value, now);
if (ts === undefined) understood = false;
else if (key === 'after') out.fromTs = ts;
else out.toTs = ts;
} else if (key === 'has') {
const v = value.toLowerCase();
if (v === 'link' || v === 'links' || v === 'url') out.containsUrl = true;
else if (HAS_TYPES[v]) {
if (!out.msgTypes.includes(HAS_TYPES[v])) out.msgTypes.push(HAS_TYPES[v]);
} else understood = false;
} else if (key === 'is') {
if (value.toLowerCase() === 'pinned') out.pinnedOnly = true;
else understood = false;
}
}
if (!understood) {
out.unknown.push(match.trim());
kept.push(match.trim());
}
return match;
},
);
kept.push(raw.slice(last));
out.term = kept.join(' ').replace(/\s+/g, ' ').trim();
return out;
}
/** True when the query contains any operator at all (for the submit path). */
export const hasOperators = (p: ParsedSearch): boolean =>
p.senders.length > 0 ||
p.rooms.length > 0 ||
p.fromTs !== undefined ||
p.toTs !== undefined ||
!!p.containsUrl ||
p.msgTypes.length > 0 ||
!!p.pinnedOnly;
/** Resolve an `in:` value to a room id among `rooms`. Exported for tests. */
export function resolveRoomOperand(
value: string,
rooms: { roomId: string; name: string; aliases: string[] }[],
): string | undefined {
const v = value.toLowerCase().replace(/^#/, '');
const exact = rooms.find(
(r) =>
r.roomId.toLowerCase() === value.toLowerCase() ||
r.aliases.some((a) => {
const al = a.toLowerCase().replace(/^#/, '');
return al === v || al.split(':')[0] === v;
}) ||
r.name.toLowerCase() === v,
);
if (exact) return exact.roomId;
const partial = rooms.filter((r) => r.name.toLowerCase().includes(v));
return partial.length === 1 ? partial[0].roomId : undefined;
}