Add From: Me to advanced search filters (#11126)
This commit is contained in:
@@ -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'
|
||||
|
||||
|
||||
@@ -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
-6
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user