Add From: Me to advanced search filters (#11126)

This commit is contained in:
DS Boyce
2026-07-22 15:01:24 -07:00
committed by GitHub
parent 850765bc8d
commit 77bbe8d8a0
12 changed files with 249 additions and 44 deletions
+3 -1
View File
@@ -43,6 +43,7 @@ let SearchResults = ({
query, query,
filters, filters,
hasFilters, hasFilters,
fromMe,
activeTab, activeTab,
onPageSelected, onPageSelected,
headerHeight, headerHeight,
@@ -50,6 +51,7 @@ let SearchResults = ({
query: string query: string
filters: SearchFilters filters: SearchFilters
hasFilters: boolean hasFilters: boolean
fromMe: boolean
activeTab: number activeTab: number
onPageSelected: (page: number) => void onPageSelected: (page: number) => void
headerHeight: number headerHeight: number
@@ -61,7 +63,7 @@ let SearchResults = ({
* to people and feeds too, so it must not hide those tabs (which would also * to people and feeds too, so it must not hide those tabs (which would also
* regress the non-v2 legacy language dropdown). Other filters are post-only. * regress the non-v2 legacy language dropdown). Other filters are post-only.
*/ */
const hasPostFilters = hasPostOnlyFilters(filters) const hasPostFilters = hasPostOnlyFilters(filters) || fromMe
const activePage = hasPostFilters && activeTab > 1 ? 0 : activeTab const activePage = hasPostFilters && activeTab > 1 ? 0 : activeTab
const tabShape = hasPostFilters ? 'filtered' : 'plain' const tabShape = hasPostFilters ? 'filtered' : 'plain'
+29 -11
View File
@@ -32,6 +32,7 @@ import {
unstableCacheProfileView, unstableCacheProfileView,
useProfilesQuery, useProfilesQuery,
} from '#/state/queries/profile' } from '#/state/queries/profile'
import {extractFromMe} from '#/state/queries/search-posts-params'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import { import {
countActiveFilters, countActiveFilters,
@@ -123,8 +124,18 @@ export function SearchScreenShell({
const tabParam = (route.params as {q?: string; tab?: TabParam})?.tab const tabParam = (route.params as {q?: string; tab?: TabParam})?.tab
const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam)) const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam))
/*
* A raw `from:me` operator stays visible in the search input. Submitting the
* advanced dialog promotes it to a structured `from=me` filter and removes
* it from `q`; the API layer reconstructs the operator for post search.
*/
const {query, fromMe, filters, setFilters, hasFilters} = useQueryManager({
initialQuery: queryParam,
fixedParams,
})
// Query terms // Query terms
const [searchText, setSearchText] = useState<string>(queryParam) const [searchText, setSearchText] = useState<string>(query)
const searchTextRef = useRef(searchText) const searchTextRef = useRef(searchText)
const updateSearchText = useCallback((text: string) => { const updateSearchText = useCallback((text: string) => {
searchTextRef.current = text searchTextRef.current = text
@@ -200,10 +211,6 @@ export function SearchScreenShell({
[accountHistory, setAccountHistory], [accountHistory, setAccountHistory],
) )
const {query, filters, setFilters, hasFilters} = useQueryManager({
initialQuery: queryParam,
fixedParams,
})
const showFilters = Boolean((query || hasFilters) && !showAutocomplete) const showFilters = Boolean((query || hasFilters) && !showAutocomplete)
const onChangeLang = useCallback( const onChangeLang = useCallback(
@@ -233,13 +240,13 @@ export function SearchScreenShell({
useEffect(() => { useEffect(() => {
if (IS_NATIVE) { if (IS_NATIVE) {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
updateSearchText(queryParam) updateSearchText(query)
} }
}, [queryParam, updateSearchText]) }, [query, updateSearchText])
useFocusEffect( useFocusEffect(
useNonReactiveCallback(() => { useNonReactiveCallback(() => {
if (IS_WEB) { if (IS_WEB) {
updateSearchText(queryParam) updateSearchText(query)
} }
}), }),
) )
@@ -329,11 +336,12 @@ export function SearchScreenShell({
]) ])
const onSubmit = (source: 'typed' | 'autocomplete') => () => { const onSubmit = (source: 'typed' | 'autocomplete') => () => {
const nextQuery = searchTextRef.current
ax.metric('search:query', { ax.metric('search:query', {
source, source,
filterCount: countActiveFilters(filters), filterCount: countActiveFilters(filters),
}) })
navigateToItem(searchTextRef.current) navigateToItem(nextQuery)
} }
const onSubmitAdvanced = useCallback( const onSubmitAdvanced = useCallback(
@@ -644,6 +652,7 @@ export function SearchScreenShell({
query={query} query={query}
filters={filters} filters={filters}
hasFilters={hasFilters} hasFilters={hasFilters}
fromMe={fromMe}
headerHeight={headerHeight} headerHeight={headerHeight}
focusSearchInput={focusSearchInput} focusSearchInput={focusSearchInput}
/> />
@@ -695,6 +704,7 @@ let SearchScreenInner = ({
query, query,
filters, filters,
hasFilters, hasFilters,
fromMe,
headerHeight, headerHeight,
focusSearchInput, focusSearchInput,
}: { }: {
@@ -703,6 +713,7 @@ let SearchScreenInner = ({
query: string query: string
filters: SearchFilters filters: SearchFilters
hasFilters: boolean hasFilters: boolean
fromMe: boolean
headerHeight: number headerHeight: number
focusSearchInput: (tab?: TabParam) => void focusSearchInput: (tab?: TabParam) => void
}): React.ReactNode => { }): React.ReactNode => {
@@ -719,6 +730,7 @@ let SearchScreenInner = ({
query={query} query={query}
filters={filters} filters={filters}
hasFilters={hasFilters} hasFilters={hasFilters}
fromMe={fromMe}
activeTab={activeTab} activeTab={activeTab}
headerHeight={headerHeight} headerHeight={headerHeight}
onPageSelected={onPageSelected} onPageSelected={onPageSelected}
@@ -769,8 +781,13 @@ function useQueryManager({
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const route = useRoute() const route = useRoute()
// Free text only - structured filters live in sibling route params now. // A raw Me operator remains part of the query until the advanced dialog
// promotes it to the structured `from` filter.
const query = initialQuery const query = initialQuery
const fromMe = useMemo(
() => extractFromMe(initialQuery).fromMe,
[initialQuery],
)
const filters = useMemo(() => { const filters = useMemo(() => {
const fromRoute = readSearchFilters(route.params as Record<string, unknown>) const fromRoute = readSearchFilters(route.params as Record<string, unknown>)
@@ -802,11 +819,12 @@ function useQueryManager({
return useMemo( return useMemo(
() => ({ () => ({
query, query,
fromMe,
filters, filters,
setFilters, setFilters,
hasFilters: hasActiveFilters(filters), hasFilters: hasActiveFilters(filters),
}), }),
[query, filters, setFilters], [query, fromMe, filters, setFilters],
) )
} }
@@ -4,6 +4,7 @@ import {
countActiveFilters, countActiveFilters,
definedFilterParams, definedFilterParams,
filtersToApiParams, filtersToApiParams,
hasActiveFilters,
hasPostOnlyFilters, hasPostOnlyFilters,
parseHistoryEntry, parseHistoryEntry,
readSearchFilters, readSearchFilters,
@@ -55,6 +56,14 @@ describe(`searchParams`, () => {
}) })
}) })
describe(`hasActiveFilters`, () => {
it(`includes the structured Me author filter`, () => {
expect(hasActiveFilters({})).toBe(false)
expect(hasActiveFilters({from: 'me'})).toBe(true)
expect(hasActiveFilters({author: 'alice'})).toBe(true)
})
})
describe(`definedFilterParams`, () => { describe(`definedFilterParams`, () => {
it(`omits absent keys entirely`, () => { it(`omits absent keys entirely`, () => {
expect(definedFilterParams({author: 'alice'})).toEqual({author: 'alice'}) expect(definedFilterParams({author: 'alice'})).toEqual({author: 'alice'})
@@ -130,8 +139,9 @@ describe(`searchParams`, () => {
}) })
describe(`countActiveFilters`, () => { describe(`countActiveFilters`, () => {
it(`counts each set filter key once`, () => { it(`counts each structured filter key once`, () => {
expect(countActiveFilters({})).toBe(0) expect(countActiveFilters({})).toBe(0)
expect(countActiveFilters({from: 'me'})).toBe(1)
expect( expect(
countActiveFilters({author: 'alice bob', domain: 'bsky.app'}), countActiveFilters({author: 'alice bob', domain: 'bsky.app'}),
).toBe(2) ).toBe(2)
@@ -152,6 +162,14 @@ describe(`searchParams`, () => {
}) })
}) })
it(`round-trips a promoted Me-only search`, () => {
const stored = serializeHistoryEntry('', {from: 'me'})
expect(parseHistoryEntry(stored)).toEqual({
q: '',
filters: {from: 'me'},
})
})
it(`round-trips query + filters`, () => { it(`round-trips query + filters`, () => {
const filters = { const filters = {
author: 'alice', author: 'alice',
@@ -7,20 +7,21 @@ import {
ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon, ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon,
} from '#/components/icons/Chevron' } from '#/components/icons/Chevron'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
import {type FollowingFilter} from './utils' import {type FromFilter} from './utils'
export function FollowingDropdown({ export function FromDropdown({
value, value,
onChange, onChange,
}: { }: {
value: FollowingFilter value: FromFilter
onChange: (value: FollowingFilter) => void onChange: (value: FromFilter) => void
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
const options: {value: FollowingFilter; label: string}[] = [ const options: {value: FromFilter; label: string}[] = [
{value: 'anyone', label: l`Anyone`}, {value: 'anyone', label: l`Anyone`},
{value: 'following', label: l`People you follow`}, {value: 'following', label: l`People I follow`},
{value: 'me', label: l`Me`},
] ]
const currentLabel = options.find(o => o.value === value)?.label ?? l`Anyone` const currentLabel = options.find(o => o.value === value)?.label ?? l`Anyone`
@@ -73,12 +73,68 @@ describe(`AdvancedSearchDialog serialize/parse`, () => {
expect(state.until).toBe('2024-02-01') expect(state.until).toBe('2024-02-01')
}) })
it(`keeps from:me in the query box rather than lifting it into a row`, () => { it(`lifts from:me typed into the query box into the "me" following filter`, () => {
const state = parseAdvancedSearch('from:me', {}) const state = parseAdvancedSearch('cats from:me', {})
expect(state.query).toBe('from:me') expect(state.query).toBe('cats')
expect(state.following).toBe('me')
expect(state.filters.find(f => f.field === 'authors')).toBeUndefined() expect(state.filters.find(f => f.field === 'authors')).toBeUndefined()
}) })
it(`promotes the "me" filter from q to a structured filter`, () => {
const out = serializeAdvancedSearch({
...emptySerializeState,
query: 'cats',
following: 'me',
})
expect(out.q).toBe('cats')
expect(out.filters.from).toBe('me')
expect(out.filters.following).toBeUndefined()
})
it(`strips from:me typed after selecting Me`, () => {
const out = serializeAdvancedSearch({
...emptySerializeState,
query: 'cats from:me',
following: 'me',
})
expect(out.q).toBe('cats')
expect(out.filters.from).toBe('me')
})
it(`promotes from:me typed after the dialog opens`, () => {
const out = serializeAdvancedSearch({
...emptySerializeState,
query: 'cats from:me',
})
expect(out.q).toBe('cats')
expect(out.filters.from).toBe('me')
})
it(`keeps a quoted from:me as query text`, () => {
const out = serializeAdvancedSearch({
...emptySerializeState,
query: 'cats "from:me"',
})
expect(out.q).toBe('cats "from:me"')
expect(out.filters.from).toBeUndefined()
})
it(`round-trips cats from:me through the "me" following filter`, () => {
const state = parseAdvancedSearch('cats from:me', {})
const out = serializeAdvancedSearch({
...emptySerializeState,
query: state.query,
following: state.following,
})
expect(out.q).toBe('cats')
expect(out.filters.from).toBe('me')
expect(out.filters.following).toBeUndefined()
})
it(`parses the structured Me filter into the From dropdown`, () => {
expect(parseAdvancedSearch('cats', {from: 'me'}).following).toBe('me')
})
it(`merges a query-box operator with the matching filter param`, () => { it(`merges a query-box operator with the matching filter param`, () => {
const state = parseAdvancedSearch('hi from:bob', {author: 'alice'}) const state = parseAdvancedSearch('hi from:bob', {author: 'alice'})
expect(state.query).toBe('hi') expect(state.query).toBe('hi')
@@ -22,12 +22,12 @@ import {SearchLanguageDropdown} from '../SearchLanguageDropdown'
import {ClearableDateField, DEFAULT_DATE} from './ClearableDateField' import {ClearableDateField, DEFAULT_DATE} from './ClearableDateField'
import {ClearableInput} from './ClearableInput' import {ClearableInput} from './ClearableInput'
import {FilterBlock} from './FilterBlock' import {FilterBlock} from './FilterBlock'
import {FollowingDropdown} from './FollowingDropdown' import {FromDropdown} from './FromDropdown'
import {MediaDropdown} from './MediaDropdown' import {MediaDropdown} from './MediaDropdown'
import {RepliesDropdown} from './RepliesDropdown' import {RepliesDropdown} from './RepliesDropdown'
import { import {
type AdvancedFilter, type AdvancedFilter,
type FollowingFilter, type FromFilter,
makeFilter, makeFilter,
type MediaFilter, type MediaFilter,
parseAdvancedSearch, parseAdvancedSearch,
@@ -124,7 +124,7 @@ function DialogInner({
const [media, setMedia] = useState<MediaFilter>(parsed.media) const [media, setMedia] = useState<MediaFilter>(parsed.media)
const [replies, setReplies] = useState<RepliesFilter>(parsed.replies) const [replies, setReplies] = useState<RepliesFilter>(parsed.replies)
const [following, setFollowing] = useState<FollowingFilter>(parsed.following) const [following, setFollowing] = useState<FromFilter>(parsed.following)
/* /*
* The date picker requires a valid date, so these always hold one. The * The date picker requires a valid date, so these always hold one. The
@@ -389,7 +389,9 @@ function DialogInner({
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
a.mb_sm, a.mb_sm,
]}> ]}>
<Trans>Include</Trans> <Trans comment="Include search results with or without replies">
Include
</Trans>
</Text> </Text>
<View style={[a.flex_row]}> <View style={[a.flex_row]}>
<RepliesDropdown value={replies} onChange={setReplies} /> <RepliesDropdown value={replies} onChange={setReplies} />
@@ -403,10 +405,12 @@ function DialogInner({
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
a.mb_sm, a.mb_sm,
]}> ]}>
<Trans>From</Trans> <Trans comment="Filter search results by a specific post author">
From
</Trans>
</Text> </Text>
<View style={[a.flex_row]}> <View style={[a.flex_row]}>
<FollowingDropdown value={following} onChange={setFollowing} /> <FromDropdown value={following} onChange={setFollowing} />
</View> </View>
</View> </View>
</View> </View>
@@ -1,4 +1,5 @@
import { import {
extractFromMe,
extractSearchPostsParams, extractSearchPostsParams,
tokenizeQuery, tokenizeQuery,
} from '#/state/queries/search-posts-params' } from '#/state/queries/search-posts-params'
@@ -14,10 +15,11 @@ export type RepliesFilter = 'all' | 'none' | 'only'
export type MediaFilter = 'all' | 'media' | 'video' export type MediaFilter = 'all' | 'media' | 'video'
/** /**
* Whether to limit results to authors the user follows. Serializes into the * Which authors to limit results to. 'following' serializes into the
* `following` sibling param ('following' -> following:true, anyone -> unset). * `following` sibling param (following=true); 'me' serializes into the `from`
* sibling param (from=me); 'anyone' leaves both unset.
*/ */
export type FollowingFilter = 'anyone' | 'following' export type FromFilter = 'anyone' | 'following' | 'me'
export type FilterField = 'authors' | 'mentions' | 'domains' | 'urls' | 'tags' export type FilterField = 'authors' | 'mentions' | 'domains' | 'urls' | 'tags'
@@ -93,7 +95,7 @@ export type DialogState = {
language: string language: string
replies: RepliesFilter replies: RepliesFilter
media: MediaFilter media: MediaFilter
following: FollowingFilter following: FromFilter
since: string since: string
until: string until: string
filters: AdvancedFilter[] filters: AdvancedFilter[]
@@ -141,18 +143,27 @@ function isSimpleWord(word: string): boolean {
* populate "none of these words" - but only when their contents are simple * populate "none of these words" - but only when their contents are simple
* words. Anything that wouldn't round-trip (embedded quotes, a negated phrase * words. Anything that wouldn't round-trip (embedded quotes, a negated phrase
* like -"a b", etc.) is left verbatim in the main "all of these words" query * like -"a b", etc.) is left verbatim in the main "all of these words" query
* text instead of parsed. * text instead of parsed. A bare `from:me` is pulled out into the `fromMe`
* flag, which drives the "Me" author filter (the backend resolves `me` to the
* viewer, so it never becomes a structured `author` value).
*/ */
function parseFreeText(raw: string): { function parseFreeText(raw: string): {
query: string query: string
exactPhrase: string exactPhrase: string
negatedWords: string negatedWords: string
fromMe: boolean
} { } {
const queryParts: string[] = [] const queryParts: string[] = []
const negatedWords: string[] = [] const negatedWords: string[] = []
let exactPhrase = '' let exactPhrase = ''
let fromMe = false
for (const token of tokenizeQuery(raw)) { for (const token of tokenizeQuery(raw)) {
// from:me -> "Me" author filter rather than free text.
if (token === 'from:me') {
fromMe = true
continue
}
// "phrase" -> "exact phrase", only if it has no inner quote. // "phrase" -> "exact phrase", only if it has no inner quote.
if (token.startsWith('"') && token.endsWith('"') && token.length > 1) { if (token.startsWith('"') && token.endsWith('"') && token.length > 1) {
const inner = token.slice(1, -1) const inner = token.slice(1, -1)
@@ -176,6 +187,7 @@ function parseFreeText(raw: string): {
query: queryParts.join(' '), query: queryParts.join(' '),
exactPhrase, exactPhrase,
negatedWords: negatedWords.join(' '), negatedWords: negatedWords.join(' '),
fromMe,
} }
} }
@@ -208,7 +220,7 @@ export function parseAdvancedSearch(
* can be expressed as operators; exclude rows come solely from filter params. * can be expressed as operators; exclude rows come solely from filter params.
*/ */
const lifted = extractSearchPostsParams(q) const lifted = extractSearchPostsParams(q)
const freeText = parseFreeText(lifted.q) const {fromMe, ...freeText} = parseFreeText(lifted.q)
const includeValues: Record<FilterField, string> = { const includeValues: Record<FilterField, string> = {
authors: mergeValues(filters.author, lifted.author), authors: mergeValues(filters.author, lifted.author),
@@ -255,12 +267,24 @@ export function parseAdvancedSearch(
const since = filters.since ?? lifted.since const since = filters.since ?? lifted.since
const until = filters.until ?? lifted.until const until = filters.until ?? lifted.until
/*
* A raw `from:me` operator and the structured `from=me` filter both map to
* the "Me" author filter. The raw operator is promoted to the structured
* filter when the dialog is submitted.
*/
let following: FromFilter = 'anyone'
if (fromMe || filters.from === 'me') {
following = 'me'
} else if (filters.following === 'true') {
following = 'following'
}
return { return {
...freeText, ...freeText,
language: lang, language: lang,
replies, replies,
media, media,
following: filters.following === 'true' ? 'following' : 'anyone', following,
since: since && isValidDate(since) ? since : '', since: since && isValidDate(since) ? since : '',
until: until && isValidDate(until) ? until : '', until: until && isValidDate(until) ? until : '',
filters: filterRows, filters: filterRows,
@@ -279,7 +303,7 @@ export function serializeAdvancedSearch(state: {
language: string language: string
replies: RepliesFilter replies: RepliesFilter
media: MediaFilter media: MediaFilter
following: FollowingFilter following: FromFilter
dateSince: string dateSince: string
dateSinceActive: boolean dateSinceActive: boolean
dateUntil: string dateUntil: string
@@ -352,7 +376,21 @@ export function serializeAdvancedSearch(state: {
if (state.replies === 'only') filters.replies = 'only' if (state.replies === 'only') filters.replies = 'only'
if (state.media === 'media') filters.media = 'true' if (state.media === 'media') filters.media = 'true'
else if (state.media === 'video') filters.video = 'true' else if (state.media === 'video') filters.video = 'true'
if (state.following === 'following') filters.following = 'true'
return {q: parts.join(' '), filters} /*
* Re-parse the final text because a user can type `from:me` after the dialog
* has opened. Advanced submission removes every bare token from the search
* input and promotes it to the structured From filter. Quoted `"from:me"`
* remains ordinary query text.
*/
const {q, fromMe} = extractFromMe(parts.join(' '))
if (state.following === 'following') filters.following = 'true'
else if (state.following === 'me' || fromMe) filters.from = 'me'
/*
* Submitting the dialog promotes a raw `from:me` operator to `from=me`, so it
* leaves the search text and is represented by the From dropdown. The query
* hook reconstructs the backend operator at the API boundary.
*/
return {q, filters}
} }
+6 -2
View File
@@ -31,6 +31,8 @@ export type SearchFilters = {
video?: string video?: string
/** 'true' */ /** 'true' */
following?: string following?: string
/** 'me' */
from?: string
} }
export const FILTER_PARAM_KEYS = [ export const FILTER_PARAM_KEYS = [
@@ -51,6 +53,7 @@ export const FILTER_PARAM_KEYS = [
'media', 'media',
'video', 'video',
'following', 'following',
'from',
] as const ] as const
/** /**
@@ -76,13 +79,14 @@ export function readSearchFilters(
} }
export function hasActiveFilters(filters: SearchFilters): boolean { export function hasActiveFilters(filters: SearchFilters): boolean {
return FILTER_PARAM_KEYS.some(key => filters[key]) return countActiveFilters(filters) > 0
} }
/** /**
* Number of active filter params, used for the "[+N filters]" pill in search * Number of active filter params, used for the "[+N filters]" pill in search
* history. Each set key counts once (a multi-value field like author counts as * history. Each set key counts once (a multi-value field like author counts as
* one filter regardless of how many handles it holds). * one filter regardless of how many handles it holds). Raw query operators do
* not count until the advanced dialog promotes them to structured params.
*/ */
export function countActiveFilters(filters: SearchFilters): number { export function countActiveFilters(filters: SearchFilters): number {
return FILTER_PARAM_KEYS.filter(key => filters[key]).length return FILTER_PARAM_KEYS.filter(key => filters[key]).length
@@ -156,7 +156,9 @@ export function Inner({
<> <>
<Divider /> <Divider />
<Text style={[a.font_semi_bold, a.text_md]}> <Text style={[a.font_semi_bold, a.text_md]}>
<Trans>From</Trans> <Trans comment="Filter who you receive notifications from">
From
</Trans>
</Text> </Text>
<Toggle.Group <Toggle.Group
type="radio" type="radio"
@@ -1,7 +1,9 @@
import {describe, expect, it} from '@jest/globals' import {describe, expect, it} from '@jest/globals'
import { import {
appendFromMe,
buildSearchPostsV2Filters, buildSearchPostsV2Filters,
extractFromMe,
extractSearchPostsParams, extractSearchPostsParams,
} from '#/state/queries/search-posts-params' } from '#/state/queries/search-posts-params'
@@ -139,6 +141,39 @@ describe(`extractSearchPostsParams`, () => {
}) })
}) })
describe(`extractFromMe / appendFromMe`, () => {
it(`strips a bare from:me token and reports it`, () => {
expect(extractFromMe(`cats from:me`)).toEqual({q: `cats`, fromMe: true})
expect(extractFromMe(`from:me`)).toEqual({q: ``, fromMe: true})
})
it(`reports fromMe false when the token is absent`, () => {
expect(extractFromMe(`cats from:alice`)).toEqual({
q: `cats from:alice`,
fromMe: false,
})
})
it(`leaves a quoted from:me in the query text`, () => {
expect(extractFromMe(`"from:me"`)).toEqual({q: `"from:me"`, fromMe: false})
})
it(`re-appends the token only when the filter is active`, () => {
expect(appendFromMe(`cats`, true)).toBe(`cats from:me`)
expect(appendFromMe(`cats`, false)).toBe(`cats`)
expect(appendFromMe(``, true)).toBe(`from:me`)
})
it(`does not duplicate an existing from:me token`, () => {
expect(appendFromMe(`cats from:me`, true)).toBe(`cats from:me`)
})
it(`round-trips through extract and append`, () => {
const {q, fromMe} = extractFromMe(`cats from:me`)
expect(appendFromMe(q, fromMe)).toBe(`cats from:me`)
})
})
describe(`buildSearchPostsV2Filters`, () => { describe(`buildSearchPostsV2Filters`, () => {
it(`maps embedded operators alone into v2 plural params`, () => { it(`maps embedded operators alone into v2 plural params`, () => {
expect( expect(
+24
View File
@@ -83,6 +83,30 @@ export function tokenizeQuery(raw: string): string[] {
return tokens return tokens
} }
/**
* Splits a bare `from:me` token out of a query. The "Me" author filter always
* travels inside `q` as a `from:me` token (the backend resolves `me` to the
* viewer), but the UI never shows it as text: the search input strips it for
* display and the advanced-search dialog represents it in the From dropdown.
* Tokenization keeps quoted phrases intact, so a `from:me` inside quotes stays
* in the query text.
*/
export function extractFromMe(query: string): {q: string; fromMe: boolean} {
const tokens = tokenizeQuery(query)
const kept = tokens.filter(token => token !== 'from:me')
return {q: kept.join(' '), fromMe: kept.length !== tokens.length}
}
/**
* Re-appends the `from:me` token when the "Me" author filter is active.
* Idempotent: a query that already carries a bare `from:me` is returned as-is.
*/
export function appendFromMe(query: string, fromMe: boolean): string {
if (!fromMe) return query
if (tokenizeQuery(query).includes('from:me')) return query
return query ? `${query} from:me` : 'from:me'
}
/** /**
* Lifts the operators that `app.bsky.feed.searchPosts` accepts as structured * 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. * params out of the free-text query, so the backend filters on them directly.
+6 -3
View File
@@ -16,6 +16,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {type SearchFilters} from '#/screens/Search/searchParams' import {type SearchFilters} from '#/screens/Search/searchParams'
import { import {
appendFromMe,
buildSearchPostsV2Filters, buildSearchPostsV2Filters,
extractSearchPostsParams, extractSearchPostsParams,
} from './search-posts-params' } from './search-posts-params'
@@ -51,10 +52,11 @@ export function useSearchPostsV2Query({
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const selectArgs = useMemo( const selectArgs = useMemo(
() => ({ () => ({
isSearchingSpecificUser: /from:(\w+)/.test(query) || !!filters?.author, isSearchingSpecificUser:
/from:(\w+)/.test(query) || !!filters?.author || filters?.from === 'me',
moderationOpts, moderationOpts,
}), }),
[query, filters?.author, moderationOpts], [query, filters?.author, filters?.from, moderationOpts],
) )
const lastRun = useRef<{ const lastRun = useRef<{
data: InfiniteData<AppBskyFeedSearchPostsV2.OutputSchema> data: InfiniteData<AppBskyFeedSearchPostsV2.OutputSchema>
@@ -78,9 +80,10 @@ export function useSearchPostsV2Query({
*/ */
const {q, ...embedded} = extractSearchPostsParams(query) const {q, ...embedded} = extractSearchPostsParams(query)
const builtFilters = buildSearchPostsV2Filters(embedded, filters) const builtFilters = buildSearchPostsV2Filters(embedded, filters)
const finalQuery = appendFromMe(q, filters?.from === 'me')
const res = await agent.app.bsky.feed.searchPostsV2({ const res = await agent.app.bsky.feed.searchPostsV2({
...builtFilters, ...builtFilters,
query: q, query: finalQuery,
limit: 25, limit: 25,
cursor: pageParam, cursor: pageParam,
/* /*