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,
filters,
hasFilters,
fromMe,
activeTab,
onPageSelected,
headerHeight,
@@ -50,6 +51,7 @@ let SearchResults = ({
query: string
filters: SearchFilters
hasFilters: boolean
fromMe: boolean
activeTab: number
onPageSelected: (page: number) => void
headerHeight: number
@@ -61,7 +63,7 @@ let SearchResults = ({
* 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.
*/
const hasPostFilters = hasPostOnlyFilters(filters)
const hasPostFilters = hasPostOnlyFilters(filters) || fromMe
const activePage = hasPostFilters && activeTab > 1 ? 0 : activeTab
const tabShape = hasPostFilters ? 'filtered' : 'plain'
+29 -11
View File
@@ -32,6 +32,7 @@ import {
unstableCacheProfileView,
useProfilesQuery,
} from '#/state/queries/profile'
import {extractFromMe} from '#/state/queries/search-posts-params'
import {useSession} from '#/state/session'
import {
countActiveFilters,
@@ -123,8 +124,18 @@ export function SearchScreenShell({
const tabParam = (route.params as {q?: string; tab?: TabParam})?.tab
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
const [searchText, setSearchText] = useState<string>(queryParam)
const [searchText, setSearchText] = useState<string>(query)
const searchTextRef = useRef(searchText)
const updateSearchText = useCallback((text: string) => {
searchTextRef.current = text
@@ -200,10 +211,6 @@ export function SearchScreenShell({
[accountHistory, setAccountHistory],
)
const {query, filters, setFilters, hasFilters} = useQueryManager({
initialQuery: queryParam,
fixedParams,
})
const showFilters = Boolean((query || hasFilters) && !showAutocomplete)
const onChangeLang = useCallback(
@@ -233,13 +240,13 @@ export function SearchScreenShell({
useEffect(() => {
if (IS_NATIVE) {
// eslint-disable-next-line react-hooks/set-state-in-effect
updateSearchText(queryParam)
updateSearchText(query)
}
}, [queryParam, updateSearchText])
}, [query, updateSearchText])
useFocusEffect(
useNonReactiveCallback(() => {
if (IS_WEB) {
updateSearchText(queryParam)
updateSearchText(query)
}
}),
)
@@ -329,11 +336,12 @@ export function SearchScreenShell({
])
const onSubmit = (source: 'typed' | 'autocomplete') => () => {
const nextQuery = searchTextRef.current
ax.metric('search:query', {
source,
filterCount: countActiveFilters(filters),
})
navigateToItem(searchTextRef.current)
navigateToItem(nextQuery)
}
const onSubmitAdvanced = useCallback(
@@ -644,6 +652,7 @@ export function SearchScreenShell({
query={query}
filters={filters}
hasFilters={hasFilters}
fromMe={fromMe}
headerHeight={headerHeight}
focusSearchInput={focusSearchInput}
/>
@@ -695,6 +704,7 @@ let SearchScreenInner = ({
query,
filters,
hasFilters,
fromMe,
headerHeight,
focusSearchInput,
}: {
@@ -703,6 +713,7 @@ let SearchScreenInner = ({
query: string
filters: SearchFilters
hasFilters: boolean
fromMe: boolean
headerHeight: number
focusSearchInput: (tab?: TabParam) => void
}): React.ReactNode => {
@@ -719,6 +730,7 @@ let SearchScreenInner = ({
query={query}
filters={filters}
hasFilters={hasFilters}
fromMe={fromMe}
activeTab={activeTab}
headerHeight={headerHeight}
onPageSelected={onPageSelected}
@@ -769,8 +781,13 @@ function useQueryManager({
const navigation = useNavigation<NavigationProp>()
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 fromMe = useMemo(
() => extractFromMe(initialQuery).fromMe,
[initialQuery],
)
const filters = useMemo(() => {
const fromRoute = readSearchFilters(route.params as Record<string, unknown>)
@@ -802,11 +819,12 @@ function useQueryManager({
return useMemo(
() => ({
query,
fromMe,
filters,
setFilters,
hasFilters: hasActiveFilters(filters),
}),
[query, filters, setFilters],
[query, fromMe, filters, setFilters],
)
}
@@ -4,6 +4,7 @@ import {
countActiveFilters,
definedFilterParams,
filtersToApiParams,
hasActiveFilters,
hasPostOnlyFilters,
parseHistoryEntry,
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`, () => {
it(`omits absent keys entirely`, () => {
expect(definedFilterParams({author: 'alice'})).toEqual({author: 'alice'})
@@ -130,8 +139,9 @@ describe(`searchParams`, () => {
})
describe(`countActiveFilters`, () => {
it(`counts each set filter key once`, () => {
it(`counts each structured filter key once`, () => {
expect(countActiveFilters({})).toBe(0)
expect(countActiveFilters({from: 'me'})).toBe(1)
expect(
countActiveFilters({author: 'alice bob', domain: 'bsky.app'}),
).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`, () => {
const filters = {
author: 'alice',
@@ -7,20 +7,21 @@ import {
ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon,
} from '#/components/icons/Chevron'
import * as Menu from '#/components/Menu'
import {type FollowingFilter} from './utils'
import {type FromFilter} from './utils'
export function FollowingDropdown({
export function FromDropdown({
value,
onChange,
}: {
value: FollowingFilter
onChange: (value: FollowingFilter) => void
value: FromFilter
onChange: (value: FromFilter) => void
}) {
const {t: l} = useLingui()
const options: {value: FollowingFilter; label: string}[] = [
const options: {value: FromFilter; label: string}[] = [
{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`
@@ -73,12 +73,68 @@ describe(`AdvancedSearchDialog serialize/parse`, () => {
expect(state.until).toBe('2024-02-01')
})
it(`keeps from:me in the query box rather than lifting it into a row`, () => {
const state = parseAdvancedSearch('from:me', {})
expect(state.query).toBe('from:me')
it(`lifts from:me typed into the query box into the "me" following filter`, () => {
const state = parseAdvancedSearch('cats from:me', {})
expect(state.query).toBe('cats')
expect(state.following).toBe('me')
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`, () => {
const state = parseAdvancedSearch('hi from:bob', {author: 'alice'})
expect(state.query).toBe('hi')
@@ -22,12 +22,12 @@ import {SearchLanguageDropdown} from '../SearchLanguageDropdown'
import {ClearableDateField, DEFAULT_DATE} from './ClearableDateField'
import {ClearableInput} from './ClearableInput'
import {FilterBlock} from './FilterBlock'
import {FollowingDropdown} from './FollowingDropdown'
import {FromDropdown} from './FromDropdown'
import {MediaDropdown} from './MediaDropdown'
import {RepliesDropdown} from './RepliesDropdown'
import {
type AdvancedFilter,
type FollowingFilter,
type FromFilter,
makeFilter,
type MediaFilter,
parseAdvancedSearch,
@@ -124,7 +124,7 @@ function DialogInner({
const [media, setMedia] = useState<MediaFilter>(parsed.media)
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
@@ -389,7 +389,9 @@ function DialogInner({
t.atoms.text_contrast_medium,
a.mb_sm,
]}>
<Trans>Include</Trans>
<Trans comment="Include search results with or without replies">
Include
</Trans>
</Text>
<View style={[a.flex_row]}>
<RepliesDropdown value={replies} onChange={setReplies} />
@@ -403,10 +405,12 @@ function DialogInner({
t.atoms.text_contrast_medium,
a.mb_sm,
]}>
<Trans>From</Trans>
<Trans comment="Filter search results by a specific post author">
From
</Trans>
</Text>
<View style={[a.flex_row]}>
<FollowingDropdown value={following} onChange={setFollowing} />
<FromDropdown value={following} onChange={setFollowing} />
</View>
</View>
</View>
@@ -1,4 +1,5 @@
import {
extractFromMe,
extractSearchPostsParams,
tokenizeQuery,
} from '#/state/queries/search-posts-params'
@@ -14,10 +15,11 @@ export type RepliesFilter = 'all' | 'none' | 'only'
export type MediaFilter = 'all' | 'media' | 'video'
/**
* Whether to limit results to authors the user follows. Serializes into the
* `following` sibling param ('following' -> following:true, anyone -> unset).
* Which authors to limit results to. 'following' serializes into the
* `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'
@@ -93,7 +95,7 @@ export type DialogState = {
language: string
replies: RepliesFilter
media: MediaFilter
following: FollowingFilter
following: FromFilter
since: string
until: string
filters: AdvancedFilter[]
@@ -141,18 +143,27 @@ function isSimpleWord(word: string): boolean {
* populate "none of these words" - but only when their contents are simple
* 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
* 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): {
query: string
exactPhrase: string
negatedWords: string
fromMe: boolean
} {
const queryParts: string[] = []
const negatedWords: string[] = []
let exactPhrase = ''
let fromMe = false
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.
if (token.startsWith('"') && token.endsWith('"') && token.length > 1) {
const inner = token.slice(1, -1)
@@ -176,6 +187,7 @@ function parseFreeText(raw: string): {
query: queryParts.join(' '),
exactPhrase,
negatedWords: negatedWords.join(' '),
fromMe,
}
}
@@ -208,7 +220,7 @@ export function parseAdvancedSearch(
* can be expressed as operators; exclude rows come solely from filter params.
*/
const lifted = extractSearchPostsParams(q)
const freeText = parseFreeText(lifted.q)
const {fromMe, ...freeText} = parseFreeText(lifted.q)
const includeValues: Record<FilterField, string> = {
authors: mergeValues(filters.author, lifted.author),
@@ -255,12 +267,24 @@ export function parseAdvancedSearch(
const since = filters.since ?? lifted.since
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 {
...freeText,
language: lang,
replies,
media,
following: filters.following === 'true' ? 'following' : 'anyone',
following,
since: since && isValidDate(since) ? since : '',
until: until && isValidDate(until) ? until : '',
filters: filterRows,
@@ -279,7 +303,7 @@ export function serializeAdvancedSearch(state: {
language: string
replies: RepliesFilter
media: MediaFilter
following: FollowingFilter
following: FromFilter
dateSince: string
dateSinceActive: boolean
dateUntil: string
@@ -352,7 +376,21 @@ export function serializeAdvancedSearch(state: {
if (state.replies === 'only') filters.replies = 'only'
if (state.media === 'media') filters.media = '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
/** 'true' */
following?: string
/** 'me' */
from?: string
}
export const FILTER_PARAM_KEYS = [
@@ -51,6 +53,7 @@ export const FILTER_PARAM_KEYS = [
'media',
'video',
'following',
'from',
] as const
/**
@@ -76,13 +79,14 @@ export function readSearchFilters(
}
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
* 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 {
return FILTER_PARAM_KEYS.filter(key => filters[key]).length
@@ -156,7 +156,9 @@ export function Inner({
<>
<Divider />
<Text style={[a.font_semi_bold, a.text_md]}>
<Trans>From</Trans>
<Trans comment="Filter who you receive notifications from">
From
</Trans>
</Text>
<Toggle.Group
type="radio"
@@ -1,7 +1,9 @@
import {describe, expect, it} from '@jest/globals'
import {
appendFromMe,
buildSearchPostsV2Filters,
extractFromMe,
extractSearchPostsParams,
} 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`, () => {
it(`maps embedded operators alone into v2 plural params`, () => {
expect(
+24
View File
@@ -83,6 +83,30 @@ export function tokenizeQuery(raw: string): string[] {
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
* 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 {type SearchFilters} from '#/screens/Search/searchParams'
import {
appendFromMe,
buildSearchPostsV2Filters,
extractSearchPostsParams,
} from './search-posts-params'
@@ -51,10 +52,11 @@ export function useSearchPostsV2Query({
const moderationOpts = useModerationOpts()
const selectArgs = useMemo(
() => ({
isSearchingSpecificUser: /from:(\w+)/.test(query) || !!filters?.author,
isSearchingSpecificUser:
/from:(\w+)/.test(query) || !!filters?.author || filters?.from === 'me',
moderationOpts,
}),
[query, filters?.author, moderationOpts],
[query, filters?.author, filters?.from, moderationOpts],
)
const lastRun = useRef<{
data: InfiniteData<AppBskyFeedSearchPostsV2.OutputSchema>
@@ -78,9 +80,10 @@ export function useSearchPostsV2Query({
*/
const {q, ...embedded} = extractSearchPostsParams(query)
const builtFilters = buildSearchPostsV2Filters(embedded, filters)
const finalQuery = appendFromMe(q, filters?.from === 'me')
const res = await agent.app.bsky.feed.searchPostsV2({
...builtFilters,
query: q,
query: finalQuery,
limit: 25,
cursor: pageParam,
/*