diff --git a/src/screens/Search/SearchResults.tsx b/src/screens/Search/SearchResults.tsx index 2d6079e2fd..cb7ac53d09 100644 --- a/src/screens/Search/SearchResults.tsx +++ b/src/screens/Search/SearchResults.tsx @@ -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' diff --git a/src/screens/Search/Shell.tsx b/src/screens/Search/Shell.tsx index 681ee25273..8148dcad1a 100644 --- a/src/screens/Search/Shell.tsx +++ b/src/screens/Search/Shell.tsx @@ -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(queryParam) + const [searchText, setSearchText] = useState(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() 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) @@ -802,11 +819,12 @@ function useQueryManager({ return useMemo( () => ({ query, + fromMe, filters, setFilters, hasFilters: hasActiveFilters(filters), }), - [query, filters, setFilters], + [query, fromMe, filters, setFilters], ) } diff --git a/src/screens/Search/__tests__/searchParams.test.ts b/src/screens/Search/__tests__/searchParams.test.ts index a78acbd44e..8741ef2588 100644 --- a/src/screens/Search/__tests__/searchParams.test.ts +++ b/src/screens/Search/__tests__/searchParams.test.ts @@ -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', diff --git a/src/screens/Search/components/AdvancedSearchDialog/FollowingDropdown.tsx b/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx similarity index 85% rename from src/screens/Search/components/AdvancedSearchDialog/FollowingDropdown.tsx rename to src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx index 26957d95a0..ee7e0a8c01 100644 --- a/src/screens/Search/components/AdvancedSearchDialog/FollowingDropdown.tsx +++ b/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx @@ -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` diff --git a/src/screens/Search/components/AdvancedSearchDialog/__tests__/utils.test.ts b/src/screens/Search/components/AdvancedSearchDialog/__tests__/utils.test.ts index b30d04fb90..cea695c909 100644 --- a/src/screens/Search/components/AdvancedSearchDialog/__tests__/utils.test.ts +++ b/src/screens/Search/components/AdvancedSearchDialog/__tests__/utils.test.ts @@ -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') diff --git a/src/screens/Search/components/AdvancedSearchDialog/index.tsx b/src/screens/Search/components/AdvancedSearchDialog/index.tsx index 3276ee402c..b62620ffe3 100644 --- a/src/screens/Search/components/AdvancedSearchDialog/index.tsx +++ b/src/screens/Search/components/AdvancedSearchDialog/index.tsx @@ -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(parsed.media) const [replies, setReplies] = useState(parsed.replies) - const [following, setFollowing] = useState(parsed.following) + const [following, setFollowing] = useState(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, ]}> - Include + + Include + @@ -403,10 +405,12 @@ function DialogInner({ t.atoms.text_contrast_medium, a.mb_sm, ]}> - From + + From + - + diff --git a/src/screens/Search/components/AdvancedSearchDialog/utils.ts b/src/screens/Search/components/AdvancedSearchDialog/utils.ts index 25d21f9972..f5109f5eb0 100644 --- a/src/screens/Search/components/AdvancedSearchDialog/utils.ts +++ b/src/screens/Search/components/AdvancedSearchDialog/utils.ts @@ -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 = { 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} } diff --git a/src/screens/Search/searchParams.ts b/src/screens/Search/searchParams.ts index c3a4d21d83..f2eba8f6a3 100644 --- a/src/screens/Search/searchParams.ts +++ b/src/screens/Search/searchParams.ts @@ -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 diff --git a/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx b/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx index 8be51b93d7..73fbdac8d5 100644 --- a/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx +++ b/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx @@ -156,7 +156,9 @@ export function Inner({ <> - From + + From + { }) }) +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( diff --git a/src/state/queries/search-posts-params.ts b/src/state/queries/search-posts-params.ts index dcfd9aa4b5..c2769b2295 100644 --- a/src/state/queries/search-posts-params.ts +++ b/src/state/queries/search-posts-params.ts @@ -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. diff --git a/src/state/queries/search-posts-v2.ts b/src/state/queries/search-posts-v2.ts index f4784ba5d8..e33328b5a0 100644 --- a/src/state/queries/search-posts-v2.ts +++ b/src/state/queries/search-posts-v2.ts @@ -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 @@ -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, /*