From 70ee40aea50d2ddf03e3618a6f90bdc06ced89da Mon Sep 17 00:00:00 2001 From: Salastil Date: Sat, 5 Sep 2026 16:52:37 -0400 Subject: [PATCH] A search box that takes filters, and teaches them The filters are typed - from:, in:, has:, mentions:, before:, after: - because that is how anybody who searches a Discord server already works. The menu under the box is for everybody else: picking one writes the token into the box rather than setting something hidden, so the next search is typed straight out and the menu stops being needed. The parser leaves alone what only looks like a filter. A URL has a colon in it and so does a time of day, and "before:soon" is not a date - all three stay in the words rather than becoming a filter nobody asked for. A filtered search skips the local pass rather than showing results that ignore half of what was asked, the count says what the server found rather than what it sent, and a server Discord is still indexing says so instead of reading as "nothing found". Co-Authored-By: Claude Opus 5 --- nobilis | 2 +- src/renderer/src/app.css | 38 ++++++ .../src/components/ConversationTools.tsx | 118 +++++++++++++++++- src/renderer/src/lib/searchfilters.ts | 105 ++++++++++++++++ 4 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/lib/searchfilters.ts diff --git a/nobilis b/nobilis index dda5741..c6c6a07 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit dda57419e75eb56f4de1ab217e34976232075d69 +Subproject commit c6c6a0717700a47c9f616755b1ae2de20b901b01 diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index fe76ecd..b362d27 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -766,6 +766,44 @@ border-radius: var(--corner-radius-small); } +/* + * The filter menu under a search box. + * + * A list of what can be asked for, in the words that ask for it: picking one + * writes the token into the box rather than applying a hidden setting, so the + * next search is typed straight out and the menu becomes unnecessary. + */ +.search-filters { + display: flex; + flex-direction: column; + gap: 2px; + padding-bottom: var(--spacing-xs); + border-bottom: 1px solid var(--outline-low); +} + +.search-filter { + display: flex; + align-items: center; + gap: var(--spacing-s); + padding: var(--spacing-xs) var(--spacing-s); + border: none; + border-radius: var(--corner-radius-small); + background: none; + color: var(--surface-text); + text-align: left; + cursor: pointer; +} + +.search-filter:hover { + background: var(--surface-container-highest); +} + +.search-filter-text { + display: flex; + flex-direction: column; + min-width: 0; +} + /* * A conversation that is missing its middle, said in the log itself. * diff --git a/src/renderer/src/components/ConversationTools.tsx b/src/renderer/src/components/ConversationTools.tsx index b82b48a..191ecc3 100644 --- a/src/renderer/src/components/ConversationTools.tsx +++ b/src/renderer/src/components/ConversationTools.tsx @@ -4,10 +4,20 @@ import { AddToConversation } from './AddToConversation' import { HeaderPopover } from './HeaderPopover' import { useChat, useStore } from '../state/hooks' import { bufferDisplayName, classes, formatFullTime } from '../lib/util' +import { hasQuery, parseSearch, suggestions } from '../lib/searchfilters' import type { BufferEntry, LiveCard } from '../state/store' import type { Message } from '../../../shared/wire' /** What the homeserver answered, alongside this window's own results. */ +/** What Discord's own search hands back. */ +interface DiscordSearch { + results: Message[] + total: number + /** The server is still being indexed, so an empty answer means "not yet". */ + indexing: boolean + channelNames: Record +} + interface MatrixSearch { results: Message[] /** Names for the rooms hits came from, so a cross-room result reads. */ @@ -380,10 +390,19 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem const [scope, setScope] = useState<'room' | 'account'>('room') const [roomNames, setRoomNames] = useState>({}) const [encrypted, setEncrypted] = useState(false) + /** Discord is still building this server's index and has nothing yet. */ + const [indexing, setIndexing] = useState(false) + /** How many the server found, which is usually far more than it sent. */ + const [total, setTotal] = useState(null) const box = useRef(null) const searchButton = useRef(null) + const searchInput = useRef(null) const isDiscord = buffer.accountId.startsWith('discord:') + // What is still worth offering, given what has been typed so far. Empty + // once a filter's value is being typed, since the menu has nothing to add + // to a name half written. + const filterMenu = suggestions(query) const isMatrix = buffer.accountId.startsWith('matrix:') const stream = useChat((s) => s.kickStreams)[buffer.id] const isDm = buffer.kind === 'dm' @@ -395,22 +414,57 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem // a word on the way to typing it; a short pause after typing stops is both // cheaper and what the results are actually wanted for. useEffect(() => { - const text = query.trim() - if (!text) { + const parsed = parseSearch(query) + if (!hasQuery(parsed)) { setResults(null) setRoomNames({}) setEncrypted(false) + setIndexing(false) + setTotal(null) return } + const text = parsed.text.trim() setSearching(true) const timer = setTimeout(() => { // Local first, and shown as soon as it answers: this window's own copy // is instant and offline, and waiting for a round trip before showing // any of it would make every search feel like the network. - void window.moho - .rpc('searchMessages', { bufferId: buffer.id, query: text, limit: 50 }) + // + // Filters are Discord's to apply, so a filtered search skips the local + // pass rather than showing results that ignore half of what was asked. + const filtered = isDiscord && !!(parsed.from || parsed.in || parsed.has || parsed.mentions || parsed.before || parsed.after) + void (filtered ? Promise.resolve([] as Message[]) : window.moho.rpc('searchMessages', { bufferId: buffer.id, query: text, limit: 50 })) .then((local) => { setResults(local) + if (isDiscord) { + // The server's own index, which covers every channel in the + // guild - including the ones this window has never opened. + return window.moho + .rpc('searchDiscordMessages', { + bufferId: buffer.id, + query: text, + ...(parsed.from ? { from: parsed.from } : {}), + ...(parsed.in ? { in: parsed.in } : {}), + ...(parsed.has ? { has: parsed.has } : {}), + ...(parsed.mentions ? { mentions: parsed.mentions } : {}), + ...(parsed.before ? { before: parsed.before } : {}), + ...(parsed.after ? { after: parsed.after } : {}) + }) + .then((answer) => { + setRoomNames(answer.channelNames || {}) + setIndexing(!!answer.indexing) + setTotal(answer.total) + const seen = new Set(local.map((m) => m.id)) + setResults([...local, ...answer.results.filter((m) => !seen.has(m.id))]) + }) + .catch((e: Error) => { + // A filter that could not be resolved is the common one here, + // and it names what went wrong - worth showing rather than + // leaving a list that quietly ignored it. + store.toast('error', e.message) + if (filtered) setResults([]) + }) + } if (!isMatrix) return // Then the homeserver, which has what this client never downloaded // - including everything said before this account joined. @@ -440,7 +494,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem .finally(() => setSearching(false)) }, 250) return () => clearTimeout(timer) - }, [query, buffer.id, store, isMatrix, scope]) + }, [query, buffer.id, store, isMatrix, isDiscord, scope]) // A viewer count that never moves is worse than none, and Kick pushes a // stream starting and stopping but never the count. The followed channels @@ -634,6 +688,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem + {/* The filters, written into the box rather than applied from the + menu. Discord's own search works this way and its users type + `from:` from memory; this is for everybody else, and it teaches + the words by leaving them in the box. + + Only where a server will honour them: local search matches text + and nothing else, so offering "has: file" on IRC would be + offering a filter that quietly does nothing. */} + {isDiscord && filterMenu.length > 0 && ( +
+
Filters
+ {filterMenu.map((f) => ( + + ))} +
+ )} + {/* Where to look. Only on Matrix, because it is the only protocol here whose server will answer the question - and the room is often exactly what somebody searching has forgotten. */} @@ -685,6 +776,16 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem )} + {/* Discord answers a search of a server it has not finished + indexing with an empty result and a note that it is working; + silence there reads as "nothing was found", which is worse than + a wait nobody was told about. */} + {indexing && ( +
+ Discord is still indexing this server. Try again in a minute. +
+ )} + {results && (
@@ -692,7 +793,12 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem ? 'Searching…' : jumping ? 'Loading older messages…' - : `${results.length} ${results.length === 1 ? 'result' : 'results'}`} + : total !== null && total > results.length + ? // What the server found, not what it sent: a search + // answering "25 results" when there are thousands is + // a wrong answer to "how much is there". + `${results.length} of ${total.toLocaleString()} results` + : `${results.length} ${results.length === 1 ? 'result' : 'results'}`}
{results.map((m) => (