Add advanced search UI (#10992)

This commit is contained in:
DS Boyce
2026-06-26 16:16:48 -07:00
committed by GitHub
parent c5b3810229
commit 248290e0b3
36 changed files with 3929 additions and 261 deletions
@@ -0,0 +1,199 @@
import {describe, expect, it} from '@jest/globals'
import {
buildSearchPostsV2Filters,
extractSearchPostsParams,
} from '#/state/queries/search-posts-params'
describe(`extractSearchPostsParams`, () => {
const tests: {
name: string
input: string
output: ReturnType<typeof extractSearchPostsParams>
}[] = [
{
name: `passes bare text through untouched`,
input: `hello world`,
output: {q: `hello world`},
},
{
name: `lifts from: into author and strips it from q`,
input: `cats from:alice`,
output: {q: `cats`, author: `alice`},
},
{
name: `lifts to: into mentions (alias) and strips it from q`,
input: `cats to:alice`,
output: {q: `cats`, mentions: `alice`},
},
{
name: `accumulates multiple hashtags into tag[]`,
input: `#cats #dogs`,
output: {q: ``, tag: [`cats`, `dogs`]},
},
{
name: `keeps quoted phrases in q`,
input: `"no clues" from:alice`,
output: {q: `"no clues"`, author: `alice`},
},
{
name: `keeps OR groups in q`,
input: `(cats OR dogs) lang:en`,
output: {q: `(cats OR dogs)`, lang: `en`},
},
{
name: `extracts a valid since date`,
input: `cats since:2024-01-01`,
output: {q: `cats`, since: `2024-01-01`},
},
{
name: `leaves an invalid since date in q`,
input: `cats since:garbage`,
output: {q: `cats since:garbage`},
},
{
name: `leaves unsupported operators in q`,
input: `cats replies:only media:true`,
output: {q: `cats replies:only media:true`},
},
{
name: `lifts all supported operators at once`,
input: `term from:alice mentions:bob domain:bsky.app url:bsky.app/x lang:en since:2024-01-01 until:2024-02-01 #tag`,
output: {
q: `term`,
author: `alice`,
mentions: `bob`,
domain: `bsky.app`,
url: `bsky.app/x`,
lang: `en`,
since: `2024-01-01`,
until: `2024-02-01`,
tag: [`tag`],
},
},
{
name: `keeps the first value for a repeated singular operator`,
input: `from:alice from:bob`,
output: {q: ``, author: `alice`},
},
// CJK (and other space-free scripts) carries no whitespace, so the
// whitespace-based tokenizer must keep it intact as bare query text.
{
name: `passes bare CJK text through untouched`,
input: `東京`,
output: {q: `東京`},
},
{
name: `lifts an operator from a CJK query and keeps the CJK text`,
input: `東京 from:alice`,
output: {q: `東京`, author: `alice`},
},
{
name: `treats a whole CJK phrase as a single token`,
input: `寿司 ラーメン`,
output: {q: `寿司 ラーメン`},
},
{
name: `lifts a CJK hashtag into tag[]`,
input: `#日本 ramen`,
output: {q: `ramen`, tag: [`日本`]},
},
]
it.each(tests)(`$name`, ({input, output}) => {
expect(extractSearchPostsParams(input)).toEqual(output)
})
})
describe(`buildSearchPostsV2Filters`, () => {
it(`maps embedded operators alone into v2 plural params`, () => {
expect(
buildSearchPostsV2Filters({
author: `alice`,
domain: `bsky.app`,
lang: `en`,
tag: [`cats`],
}),
).toEqual({
authors: [`alice`],
domains: [`bsky.app`],
languages: [`en`],
hashtags: [`cats`],
})
})
it(`maps dialog filters alone, including v2-only booleans`, () => {
expect(
buildSearchPostsV2Filters(
{},
{author: `bob carol`, media: `true`, replies: `none`},
),
).toEqual({
authors: [`bob`, `carol`],
hasMedia: true,
excludeReplies: true,
})
})
it(`unions list values from both sources without clobbering`, () => {
expect(
buildSearchPostsV2Filters(
{author: `alice`, tag: [`cats`]},
{author: `bob carol`, tag: `dogs`},
),
).toEqual({
authors: [`alice`, `bob`, `carol`],
hashtags: [`cats`, `dogs`],
})
})
it(`dedupes overlapping values across sources`, () => {
expect(
buildSearchPostsV2Filters({author: `alice`}, {author: `alice bob`}),
).toEqual({
authors: [`alice`, `bob`],
})
})
it(`prefers the dialog filter for scalar fields, falling back to embedded`, () => {
expect(
buildSearchPostsV2Filters(
{lang: `en`, since: `2024-01-01`},
{lang: `ja`},
),
).toEqual({
languages: [`ja`],
since: `2024-01-01T00:00:00Z`,
})
})
it(`normalizes date-only since/until to midnight UTC timestamps`, () => {
expect(
buildSearchPostsV2Filters({}, {since: `2024-01-01`, until: `2024-02-01`}),
).toEqual({
since: `2024-01-01T00:00:00Z`,
until: `2024-02-01T00:00:00Z`,
})
})
it(`leaves a timestamp with an explicit time component unchanged`, () => {
expect(
buildSearchPostsV2Filters({}, {until: `2024-02-01T12:30:00Z`}),
).toEqual({
until: `2024-02-01T12:30:00Z`,
})
})
it(`passes exclude lists through from dialog filters`, () => {
expect(
buildSearchPostsV2Filters(
{author: `alice`},
{excludeAuthor: `bob carol`, excludeTag: `spam`},
),
).toEqual({
authors: [`alice`],
excludeAuthors: [`bob`, `carol`],
excludeHashtags: [`spam`],
})
})
})
+239
View File
@@ -0,0 +1,239 @@
/*
* Pure helpers for lifting structured `app.bsky.feed.searchPosts` params out of
* a free-text query. Kept free of React Native imports so it can be unit
* tested in isolation (the search-posts query hook re-exports these).
*/
import {type AppBskyFeedSearchPostsV2} from '@atproto/api'
import {
filtersToApiParams,
type SearchFilters,
} from '#/screens/Search/searchParams'
const DATE_RE = /^\d{4}-\d{2}-\d{2}/
export type ExtractedSearchParams = {
q: string
author?: string
mentions?: string
domain?: string
url?: string
lang?: string
since?: string
until?: string
tag?: string[]
}
/**
* Splits a query into whitespace-delimited tokens, keeping quoted phrases
* ("a b") and parenthesized OR groups ((a OR b)) intact so they pass through to
* `q` untouched. Shared with the advanced-search dialog's parser (view layer),
* which imports it from here so the two stay in sync.
*/
export function tokenizeQuery(raw: string): string[] {
const tokens: string[] = []
let i = 0
const n = raw.length
while (i < n) {
if (/\s/.test(raw[i])) {
i++
continue
}
const start = i
if (raw[i] === '(') {
let depth = 0
while (i < n) {
if (raw[i] === '(') depth++
else if (raw[i] === ')') {
depth--
if (depth === 0) {
i++
break
}
}
i++
}
tokens.push(raw.slice(start, i))
continue
}
let buf = ''
while (i < n && !/\s/.test(raw[i]) && raw[i] !== '(') {
if (raw[i] === '"') {
buf += raw[i++]
while (i < n && raw[i] !== '"') buf += raw[i++]
if (i < n) buf += raw[i++]
} else {
buf += raw[i++]
}
}
if (buf) tokens.push(buf)
}
return tokens
}
/**
* Lifts the operators that `app.bsky.feed.searchPosts` accepts as structured
* params out of the free-text query, so the backend filters on them directly.
* Recognized operators are stripped from `q`; everything else (free text,
* quoted phrases, OR groups, negations, and unsupported operators like
* `replies:`, `media:`) is left in `q` verbatim. Singular params keep the first
* value seen; `tag` accumulates (the lexicon AND-matches multiple tags).
*/
export function extractSearchPostsParams(query: string): ExtractedSearchParams {
const result: ExtractedSearchParams = {q: ''}
const remaining: string[] = []
const tags: string[] = []
for (const token of tokenizeQuery(query)) {
if (token.startsWith('#') && token.length > 1 && !token.includes(':')) {
tags.push(token.slice(1))
continue
}
const colonIdx = token.indexOf(':')
if (colonIdx === -1) {
remaining.push(token)
continue
}
const op = token.slice(0, colonIdx)
const value = token.slice(colonIdx + 1)
if (!value) {
remaining.push(token)
continue
}
switch (op) {
case 'from':
result.author ??= value
break
case 'mentions':
case 'to':
result.mentions ??= value
break
case 'domain':
result.domain ??= value
break
case 'url':
result.url ??= value
break
case 'lang':
result.lang ??= value
break
case 'since':
if (DATE_RE.test(value)) result.since ??= value
else remaining.push(token)
break
case 'until':
if (DATE_RE.test(value)) result.until ??= value
else remaining.push(token)
break
default:
// Unsupported operator (to:, replies:, media:, etc.) - keep in q.
remaining.push(token)
}
}
if (tags.length) result.tag = tags
result.q = remaining.join(' ')
return result
}
/**
* Concatenates two optional value lists, dropping empties and duplicates while
* preserving order. Used to union the back-compat operators embedded in the
* query string with the explicit dialog filters so neither source clobbers the
* other.
*/
function mergeList(a?: string[], b?: string[]): string[] | undefined {
const merged = [...new Set([...(a ?? []), ...(b ?? [])])]
return merged.length ? merged : undefined
}
/**
* Builds the `app.bsky.feed.searchPostsV2` query params (minus q/limit/cursor/
* sort, which the caller owns) from the operators embedded in the query string
* plus the structured advanced-search dialog filters. The two sources are
* merged rather than overriding each other: list fields union their values, and
* scalar fields prefer the explicit dialog filter, falling back to the embedded
* operator. v2 renames v1's singular operators to plural arrays, and `lang` to
* `language`.
*/
export function buildSearchPostsV2Filters(
embedded: Omit<ExtractedSearchParams, 'q'>,
filters?: SearchFilters,
): AppBskyFeedSearchPostsV2.QueryParams {
const apiFilters = filters ? filtersToApiParams(filters) : {}
const params: AppBskyFeedSearchPostsV2.QueryParams = {}
const authors = mergeList(
embedded.author ? [embedded.author] : undefined,
apiFilters.authors,
)
if (authors) params.authors = authors
const mentions = mergeList(
embedded.mentions ? [embedded.mentions] : undefined,
apiFilters.mentions,
)
if (mentions) params.mentions = mentions
const domains = mergeList(
embedded.domain ? [embedded.domain] : undefined,
apiFilters.domains,
)
if (domains) params.domains = domains
const urls = mergeList(
embedded.url ? [embedded.url] : undefined,
apiFilters.urls,
)
if (urls) params.urls = urls
const hashtags = mergeList(embedded.tag, apiFilters.hashtags)
if (hashtags) params.hashtags = hashtags
const language = apiFilters.language ?? embedded.lang
// TODO At the moment, the language selector is single-select. -dsb
if (language) params.languages = [language]
const since = parseTimestamp(apiFilters.since ?? embedded.since)
if (since) params.since = since
const until = parseTimestamp(apiFilters.until ?? embedded.until)
if (until) params.until = until
/*
* Exclude lists have no embedded query-string source (operators like `from:`
* are always include), so they pass straight through from the dialog filters.
*/
if (apiFilters.excludeAuthors)
params.excludeAuthors = apiFilters.excludeAuthors
if (apiFilters.excludeMentions)
params.excludeMentions = apiFilters.excludeMentions
if (apiFilters.excludeDomains)
params.excludeDomains = apiFilters.excludeDomains
if (apiFilters.excludeUrls) params.excludeUrls = apiFilters.excludeUrls
if (apiFilters.excludeHashtags)
params.excludeHashtags = apiFilters.excludeHashtags
if (apiFilters.hasMedia) params.hasMedia = true
if (apiFilters.hasVideo) params.hasVideo = true
if (apiFilters.following) params.following = true
if (apiFilters.excludeReplies) params.excludeReplies = true
if (apiFilters.repliesOnly) params.repliesOnly = true
return params
}
/**
* Consistent with timestamp parsing in @atproto/api. Only the date is used; the
* time is appended here since the lexicon expects a datetime value.
*/
const parseTimestamp = (value: string | undefined): string | undefined => {
if (!value) return undefined
const date = new Date(value)
if (isNaN(date.getTime())) return undefined
return date.toISOString().split('.')[0] + 'Z'
}
+174
View File
@@ -0,0 +1,174 @@
import {useCallback, useMemo, useRef} from 'react'
import {type AppBskyFeedSearchPostsV2, moderatePost} from '@atproto/api'
import {
type InfiniteData,
type QueryKey,
useInfiniteQuery,
} from '@tanstack/react-query'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAgent} from '#/state/session'
import {type SearchFilters} from '#/screens/Search/searchParams'
import {
buildSearchPostsV2Filters,
extractSearchPostsParams,
} from './search-posts-params'
/**
* V2 search shares the `'search-posts'` query-key root with the original hook
* (src/state/queries/search-posts.ts) so the shadow-cache generators there -
* findAllPostsInQueryData / findAllProfilesInQueryData - discover V2 results
* too. This module is only used behind the AdvancedSearchV2Enable gate; the
* original hook is unchanged.
*/
const searchPostsQueryKeyRoot = 'search-posts'
const searchPostsV2QueryKey = ({
query,
sort,
filters,
}: {
query: string
sort?: string
filters?: SearchFilters
}) => [searchPostsQueryKeyRoot, query, sort, filters]
export function useSearchPostsV2Query({
query,
sort,
enabled,
filters,
}: {
query: string
sort?: 'top' | 'latest'
enabled?: boolean
filters?: SearchFilters
}) {
const agent = useAgent()
const moderationOpts = useModerationOpts()
const selectArgs = useMemo(
() => ({
isSearchingSpecificUser: /from:(\w+)/.test(query) || !!filters?.author,
moderationOpts,
}),
[query, filters?.author, moderationOpts],
)
const lastRun = useRef<{
data: InfiniteData<AppBskyFeedSearchPostsV2.OutputSchema>
args: typeof selectArgs
result: InfiniteData<AppBskyFeedSearchPostsV2.OutputSchema>
} | null>(null)
return useInfiniteQuery<
AppBskyFeedSearchPostsV2.OutputSchema,
Error,
InfiniteData<AppBskyFeedSearchPostsV2.OutputSchema>,
QueryKey,
string | undefined
>({
queryKey: searchPostsV2QueryKey({query, sort, filters}),
queryFn: async ({pageParam}) => {
/*
* Operators embedded in the query string (e.g. for back-compat links) are
* merged with the explicit structured filters from the advanced search
* dialog; see buildSearchPostsV2Filters for how the two sources combine.
*/
const {q, ...embedded} = extractSearchPostsParams(query)
const builtFilters = buildSearchPostsV2Filters(embedded, filters)
/*
* v2 defaults to a recent-post window; the Latest tab keeps that, while
* Top searches the full index. But an explicit since/until date filter
* must search the full index too, otherwise the recent window would
* silently override the user's date range and return nothing for older
* dates.
*/
const hasDateFilter = !!(builtFilters.since || builtFilters.until)
const res = await agent.app.bsky.feed.searchPostsV2({
...builtFilters,
query: q,
limit: 25,
cursor: pageParam,
/*
* v2 calls the recency sort 'recent'; the rest of the app still uses
* the v1 'latest' label.
*/
sort: sort === 'latest' ? 'recent' : sort,
allTime: sort !== 'latest' || hasDateFilter,
})
return res.data
},
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
enabled: enabled ?? !!moderationOpts,
select: useCallback(
(data: InfiniteData<AppBskyFeedSearchPostsV2.OutputSchema>) => {
const {moderationOpts, isSearchingSpecificUser} = selectArgs
/*
* If a user applies the `from:<user>` filter, don't apply any
* moderation. Note that if we add any more filtering logic below, we
* may need to adjust this.
*/
if (isSearchingSpecificUser) {
return data
}
/*
* Keep track of the last run and whether we can reuse some already
* selected pages from there.
*/
let reusedPages = []
if (lastRun.current) {
const {
data: lastData,
args: lastArgs,
result: lastResult,
} = lastRun.current
let canReuse = true
for (let key in selectArgs) {
if (selectArgs.hasOwnProperty(key)) {
if (
(selectArgs as Record<string, unknown>)[key] !==
(lastArgs as Record<string, unknown>)[key]
) {
// Can't do reuse anything if any input has changed.
canReuse = false
break
}
}
}
if (canReuse) {
for (let i = 0; i < data.pages.length; i++) {
if (data.pages[i] && lastData.pages[i] === data.pages[i]) {
reusedPages.push(lastResult.pages[i])
continue
}
// Stop as soon as pages stop matching up.
break
}
}
}
const result = {
...data,
pages: [
...reusedPages,
...data.pages.slice(reusedPages.length).map(page => {
return {
...page,
posts: page.posts.filter(post => {
const mod = moderatePost(post, moderationOpts!)
return !mod.ui('contentList').filter
}),
}
}),
],
}
lastRun.current = {data, result, args: selectArgs}
return result
},
[selectArgs],
),
})
}
+4 -1
View File
@@ -97,7 +97,10 @@ export function useSearchPostsQuery({
let canReuse = true
for (let key in selectArgs) {
if (selectArgs.hasOwnProperty(key)) {
if ((selectArgs as any)[key] !== (lastArgs as any)[key]) {
if (
(selectArgs as Record<string, unknown>)[key] !==
(lastArgs as Record<string, unknown>)[key]
) {
// Can't do reuse anything if any input has changed.
canReuse = false
break