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 <[email protected]>
This commit is contained in:
2026-09-05 16:52:37 -04:00
co-authored by Claude Opus 5
parent 4ef7e514c1
commit 70ee40aea5
4 changed files with 256 additions and 7 deletions
+1 -1
Submodule nobilis updated: dda57419e7...c6c6a07177
+38
View File
@@ -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.
*
@@ -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<string, string>
}
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<Record<string, string>>({})
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<number | null>(null)
const box = useRef<HTMLDivElement>(null)
const searchButton = useRef<HTMLSpanElement>(null)
const searchInput = useRef<HTMLInputElement>(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<Message[]>('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<Message[]>('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<DiscordSearch>('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
<Icon name="search" size={16} />
<input
autoFocus
ref={searchInput}
type="search"
value={query}
placeholder={`Search ${buffer.name}`}
@@ -653,6 +708,42 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
/>
</div>
{/* 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 && (
<div className="search-filters">
<div className="small muted">Filters</div>
{filterMenu.map((f) => (
<button
key={f.key}
type="button"
className="search-filter"
onClick={() => {
// Replaces the half-typed name rather than appending to
// it, so picking "from" after typing "fr" does not leave
// "fr from:".
const words = query.split(/\s+/)
words[words.length - 1] = `${f.key}:`
setQuery(words.join(' '))
searchInput.current?.focus()
}}
>
<Icon name={f.icon} size={16} />
<span className="search-filter-text">
<span>{f.label}</span>
<span className="small muted">{f.hint}</span>
</span>
</button>
))}
</div>
)}
{/* 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
</div>
)}
{/* 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 && (
<div className="small muted">
Discord is still indexing this server. Try again in a minute.
</div>
)}
{results && (
<div className="search-results">
<div className="search-results-head small muted">
@@ -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'}`}
</div>
{results.map((m) => (
<button key={m.id} type="button" className="search-result" onClick={() => void jumpToResult(m)}>
+105
View File
@@ -0,0 +1,105 @@
/**
* The filters a search box takes, as Discord's own does.
*
* Typed rather than picked from menus, because that is how anybody who
* searches a Discord server already works: `from:someone has:link` is muscle
* memory, and a client that only offered a dropdown would be slower for
* exactly the people who search most. The menu is still there - it writes the
* token into the box for the people who do not know the words yet.
*/
export interface ParsedSearch {
/** What is left once the filters are taken out: the words to look for. */
text: string
from?: string
mentions?: string
in?: string
has?: string
/** Unix seconds, from a plain YYYY-MM-DD. */
before?: number
after?: number
/** Anything typed as `word:` that is not a filter, left in the text. */
unknown: string[]
}
/** The filters this understands, in the order the menu offers them. */
export const FILTERS = [
{ key: 'from', label: 'From a specific user', hint: 'from: user', icon: 'person' },
{ key: 'in', label: 'Sent in a specific channel', hint: 'in: channel', icon: 'tag' },
{ key: 'has', label: 'Includes a specific type of data', hint: 'has: link, embed or file', icon: 'attach_file' },
{ key: 'mentions', label: 'Mentions a specific user', hint: 'mentions: user', icon: 'alternate_email' },
{ key: 'before', label: 'Sent before a date', hint: 'before: 2026-01-31', icon: 'event' },
{ key: 'after', label: 'Sent after a date', hint: 'after: 2026-01-01', icon: 'event' }
] as const
const KNOWN = new Set<string>(FILTERS.map((f) => f.key))
/** Midnight of a plain date, in seconds, or nothing if it is not one. */
function day(text: string): number | undefined {
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return undefined
const at = Date.parse(`${text}T00:00:00`)
return Number.isNaN(at) ? undefined : Math.floor(at / 1000)
}
/**
* Splits a typed query into filters and the words around them.
*
* A `word:` that is not a filter stays in the text rather than being dropped:
* people search for URLs and for times of day, and "https://x" and "12:30"
* are not badly-spelled filters.
*/
export function parseSearch(input: string): ParsedSearch {
const out: ParsedSearch = { text: '', unknown: [] }
const words: string[] = []
// Quoted values hold together, so a channel or a name with a space in it
// can still be asked for.
for (const token of input.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []) {
const at = token.indexOf(':')
const key = at > 0 ? token.slice(0, at).toLowerCase() : ''
if (!KNOWN.has(key)) {
if (at > 0 && /^[a-z]+$/.test(key)) out.unknown.push(key)
words.push(token)
continue
}
const value = token.slice(at + 1).replace(/^"|"$/g, '')
if (!value) continue
if (key === 'before' || key === 'after') {
const when = day(value)
// A date that is not one is not a filter; leave it in the words rather
// than searching a range nobody asked for.
if (when === undefined) words.push(token)
else out[key] = when
continue
}
out[key as 'from' | 'in' | 'has' | 'mentions'] = value
}
out.text = words.join(' ')
return out
}
/** Whether anything was actually asked for. */
export function hasQuery(parsed: ParsedSearch): boolean {
return !!(
parsed.text.trim() ||
parsed.from ||
parsed.in ||
parsed.has ||
parsed.mentions ||
parsed.before ||
parsed.after
)
}
/**
* Which filter the caret is in the middle of typing, for the menu to answer.
*
* Everything when the box is empty or the last word is bare, the matching ones
* while a filter name is half typed - which is the moment the menu is
* genuinely useful - and none once a value is being typed after the colon.
*/
export function suggestions(input: string): (typeof FILTERS)[number][] {
const last = input.split(/\s+/).pop() ?? ''
if (last.includes(':')) return []
const partial = last.toLowerCase()
if (!partial) return [...FILTERS]
return FILTERS.filter((f) => f.key.startsWith(partial))
}