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
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:
co-authored by
Claude Opus 5.5
parent
06bb896a00
commit
75951f9040
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user