From 75951f90403dac697a8328125ac768f10aaf371d Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 25 Sep 2026 12:54:10 -0400 Subject: [PATCH] feat(search): in:, before:/after:, has:, is:pinned operators (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../features/message-search/MessageSearch.tsx | 99 ++++++++++++- .../features/message-search/SearchInput.tsx | 18 +++ .../message-search/searchOperators.test.ts | 86 +++++++++++ .../message-search/searchOperators.ts | 140 ++++++++++++++++++ 4 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 src/app/features/message-search/searchOperators.test.ts create mode 100644 src/app/features/message-search/searchOperators.ts diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index e91553575..0c3e2d1d9 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -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(); 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(); + 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 && ( + + {operatorNotice} + + )} } 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." /> diff --git a/src/app/features/message-search/SearchInput.tsx b/src/app/features/message-search/SearchInput.tsx index e6a06db30..8dc44becd 100644 --- a/src/app/features/message-search/SearchInput.tsx +++ b/src/app/features/message-search/SearchInput.tsx @@ -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) => { diff --git a/src/app/features/message-search/searchOperators.test.ts b/src/app/features/message-search/searchOperators.test.ts new file mode 100644 index 000000000..48070f12a --- /dev/null +++ b/src/app/features/message-search/searchOperators.test.ts @@ -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); + }); +}); diff --git a/src/app/features/message-search/searchOperators.ts b/src/app/features/message-search/searchOperators.ts new file mode 100644 index 000000000..4578b2a06 --- /dev/null +++ b/src/app/features/message-search/searchOperators.ts @@ -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 = { + 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 = { + 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; +}