diff --git a/eslint-suppressions.json b/eslint-suppressions.json index dc652d6ea0..152355c62a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -162,11 +162,6 @@ "count": 1 } }, - "src/components/Autocomplete/Autocomplete.tsx": { - "@typescript-eslint/no-floating-promises": { - "count": 1 - } - }, "src/components/Autocomplete/useAutocomplete/index.ts": { "react-hooks/immutability": { "count": 1 @@ -1866,14 +1861,6 @@ "count": 2 } }, - "src/state/queries/search-posts.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - }, - "@typescript-eslint/no-unsafe-member-access": { - "count": 2 - } - }, "src/state/queries/starter-packs.ts": { "@typescript-eslint/require-await": { "count": 1 diff --git a/package.json b/package.json index bb0c86960a..164de28be1 100644 --- a/package.json +++ b/package.json @@ -94,8 +94,8 @@ "update-actions": "pnpm dlx actions-up --min-age 7" }, "dependencies": { - "@atproto/api": "0.20.20", - "@atproto/syntax": "0.6.1", + "@atproto/api": "0.20.22", + "@atproto/syntax": "0.6.3", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@bsky.app/alf": "^0.1.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1ff05dc50..6b17ac5c1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,11 +241,11 @@ importers: .: dependencies: '@atproto/api': - specifier: 0.20.20 - version: 0.20.20 + specifier: 0.20.22 + version: 0.20.22 '@atproto/syntax': - specifier: 0.6.1 - version: 0.6.1 + specifier: 0.6.3 + version: 0.6.3 '@bitdrift/react-native': specifier: ^0.6.8 version: 0.6.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -876,36 +876,32 @@ packages: graphql: optional: true - '@atproto/api@0.20.20': - resolution: {integrity: sha512-+yqxLCvMu36xRxwc8/x0mhptZBC+/cbfPwdzeOtlBUeVi4ZB/5RWg2L4lWWdhBSiQBZw0AgaPg/2zsx4lDFgjg==} + '@atproto/api@0.20.22': + resolution: {integrity: sha512-TdT9ktYc0FMmMZ8HjkMz13QcSCcYiesCGN81mFZd5owQASHVM9GBtAAmzpkrPyZhUkiIjgh/nUHk8xWVu+4dVA==} engines: {node: '>=22'} - '@atproto/common-web@0.5.1': - resolution: {integrity: sha512-nru02gLyzjMQn4ZFgms9ry3kIAKwMaCAvjeFhB9mU6h1ABiSho7aRDbGvnU50CC88TROXuZlgRiEkBjnuiHEtA==} + '@atproto/common-web@0.5.2': + resolution: {integrity: sha512-oO0JEvM7MM7iXMngq6V51IJlzBZFhFJoDjnGnQT75EO94/8Q5hnM/LP+a8KyWjcBcpcSZwQ0bukNv9phZONdGA==} engines: {node: '>=22'} - '@atproto/lex-data@0.1.2': - resolution: {integrity: sha512-NZ4iZvNaqTM6pz+9VRDyEjtozrrf2EDHySNi3Xa8PCzS8gRMCvsnnsvM7r264v4eSqNIDhBB8fr9hfWCHMspXg==} + '@atproto/lex-data@0.1.3': + resolution: {integrity: sha512-ysqMYW6cIKce52/+EIbTa+I4pLqZQSBP9aGImN88vAQtd1oZBKPmut6dQk00xSQ8PW9REJRuIHNSFAF4HD+fsw==} engines: {node: '>=22'} - '@atproto/lex-json@0.1.1': - resolution: {integrity: sha512-FC/NsKHm8TDzWikvf/268T3mMAh6+f31yfCApWC3SvrhWc9c06cSYPp7qzpXcdV0OdB+I5dqHScuTB5OC7HrXg==} + '@atproto/lex-json@0.1.2': + resolution: {integrity: sha512-58nIjoWX0c8T5fcoPPmIaShxKZgfPMhNmrprtR7GuN4PzyMwm59A3CLlKAzzR+vjI3IidEMU+enpLuwUT8L+Nw==} engines: {node: '>=22'} - '@atproto/lexicon@0.7.2': - resolution: {integrity: sha512-LVZcTr5+9Qh0okZnNmfRiIDPfbL/6rRxf4goiQxPxwDzaeUDhn4M209i1drmiitbCO1qRLJA2hHF54yNA75Cig==} + '@atproto/lexicon@0.7.3': + resolution: {integrity: sha512-WP6ct2rjNCKSJN/VFc+8x6JQ7PvRMlfHUlmQM5hzvE3a6nOjm6kwSicjSIV/QldurdRGJ4FCQZ3uZu9Wqt4SxQ==} engines: {node: '>=22'} - '@atproto/syntax@0.6.1': - resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==} + '@atproto/syntax@0.6.3': + resolution: {integrity: sha512-io7Ck4o+40iFXhetHYoEtok2gZ8cWcJ1yRftEHe5AmAF0dSGcYmclbx5GatVew59taw+eSG7Ty5D3llop/0BhA==} engines: {node: '>=22'} - '@atproto/syntax@0.6.2': - resolution: {integrity: sha512-h3njTNFl/jv5kTbDqfamQGOJfGbulqLDuAiLbasKwVdBhFyQanpRLa6vE2WqTwv1XEFWLYNMH0KCVsYwT2+aww==} - engines: {node: '>=22'} - - '@atproto/xrpc@0.8.1': - resolution: {integrity: sha512-sKopRG3an6LN6NfHnRNIEX1fBx3CRtxbNFWQxyse2f86wMcTuXM+/+olN4lVXgFQgv4AWvKTxRXWyuIF2G4YxQ==} + '@atproto/xrpc@0.8.2': + resolution: {integrity: sha512-geLqJwazZuCGnae68KZppciS8DujhGvYtpc/aAj16XpHkUCf5Ds64H1IPrV0uv7SFvd/IAuMZAXZS4GIpfp1iw==} engines: {node: '>=22'} '@babel/code-frame@7.10.4': @@ -9476,56 +9472,51 @@ snapshots: '@0no-co/graphql.web@1.2.0': {} - '@atproto/api@0.20.20': + '@atproto/api@0.20.22': dependencies: - '@atproto/common-web': 0.5.1 - '@atproto/lexicon': 0.7.2 - '@atproto/syntax': 0.6.2 - '@atproto/xrpc': 0.8.1 + '@atproto/common-web': 0.5.2 + '@atproto/lexicon': 0.7.3 + '@atproto/syntax': 0.6.3 + '@atproto/xrpc': 0.8.2 await-lock: 3.0.0 multiformats: 13.4.2 tlds: 1.261.0 zod: 3.25.76 - '@atproto/common-web@0.5.1': + '@atproto/common-web@0.5.2': dependencies: - '@atproto/lex-data': 0.1.2 - '@atproto/lex-json': 0.1.1 - '@atproto/syntax': 0.6.2 + '@atproto/lex-data': 0.1.3 + '@atproto/lex-json': 0.1.2 + '@atproto/syntax': 0.6.3 zod: 3.25.76 - '@atproto/lex-data@0.1.2': + '@atproto/lex-data@0.1.3': dependencies: multiformats: 13.4.2 tslib: 2.8.1 uint8arrays: 5.1.1 unicode-segmenter: 0.14.5 - '@atproto/lex-json@0.1.1': + '@atproto/lex-json@0.1.2': dependencies: - '@atproto/lex-data': 0.1.2 + '@atproto/lex-data': 0.1.3 tslib: 2.8.1 - '@atproto/lexicon@0.7.2': + '@atproto/lexicon@0.7.3': dependencies: - '@atproto/common-web': 0.5.1 - '@atproto/syntax': 0.6.2 + '@atproto/common-web': 0.5.2 + '@atproto/syntax': 0.6.3 multiformats: 13.4.2 zod: 3.25.76 - '@atproto/syntax@0.6.1': + '@atproto/syntax@0.6.3': dependencies: iso-datestring-validator: 2.2.2 tslib: 2.8.1 - '@atproto/syntax@0.6.2': + '@atproto/xrpc@0.8.2': dependencies: - iso-datestring-validator: 2.2.2 - tslib: 2.8.1 - - '@atproto/xrpc@0.8.1': - dependencies: - '@atproto/lexicon': 0.7.2 + '@atproto/lexicon': 0.7.3 zod: 3.25.76 '@babel/code-frame@7.10.4': diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 1526fb426a..3d8d20a856 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -13,6 +13,8 @@ export enum Features { ComposerLanguageDetectionEnable = 'composer:language_detection:enable', PostGalleryEmbedEnable = 'post_gallery_embed:enable', NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable', + SearchV2Enable = 'search_v2:enable', + AdvancedSearchV2Enable = 'advanced_search_v2:enable', AATest = 'aa-test', } diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index e34feae3a5..8416a69b5e 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -775,6 +775,7 @@ export type Events = { 'search:query': { source: 'typed' | 'history' | 'autocomplete' + filterCount: number } 'search:results:loaded': { @@ -799,6 +800,18 @@ export type Events = { position: number } + 'search:advanced:press': { + filterCount: number + } + + 'search:shareLink:press': { + filterCount: number + } + + 'search:addFilter:press': { + filterCount: number + } + 'progressGuide:hide': {} 'progressGuide:followDialog:open': {} diff --git a/src/components/Autocomplete/Autocomplete.tsx b/src/components/Autocomplete/Autocomplete.tsx index f8cbfd6dd4..f49eb6d46a 100644 --- a/src/components/Autocomplete/Autocomplete.tsx +++ b/src/components/Autocomplete/Autocomplete.tsx @@ -44,7 +44,7 @@ export function Autocomplete({ const t = useTheme() const updatePosition = useCallback(() => { - sift.updatePosition() + void sift.updatePosition() }, [sift]) useOnKeyboard('keyboardDidShow', updatePosition) diff --git a/src/components/forms/DateField/index.android.tsx b/src/components/forms/DateField/index.android.tsx index b7f594281a..3b058a2643 100644 --- a/src/components/forms/DateField/index.android.tsx +++ b/src/components/forms/DateField/index.android.tsx @@ -23,6 +23,7 @@ export function DateField({ testID, accessibilityHint, maximumDate, + minimumDate, }: DateFieldProps) { const {i18n} = useLingui() const t = useTheme() @@ -107,6 +108,9 @@ export function DateField({ maximumDate={ maximumDate ? new Date(toSimpleDateString(maximumDate)) : undefined } + minimumDate={ + minimumDate ? new Date(toSimpleDateString(minimumDate)) : undefined + } /> )} diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx index 3211c4d1c7..3c909fb335 100644 --- a/src/components/forms/DateField/index.tsx +++ b/src/components/forms/DateField/index.tsx @@ -35,6 +35,7 @@ export function DateField({ isInvalid, accessibilityHint, maximumDate, + minimumDate, }: DateFieldProps) { const {_, i18n} = useLingui() const t = useTheme() @@ -116,6 +117,11 @@ export function DateField({ ? new Date(toSimpleDateString(maximumDate)) : undefined } + minimumDate={ + minimumDate + ? new Date(toSimpleDateString(minimumDate)) + : undefined + } /> + )} + + + - {showAutocomplete && ( - - )} - {showFilters && !showHeader && ( - + {showFilters && !showHeader && !advancedSearchV2Enabled && ( + )} + + {showFilters && advancedSearchV2Enabled ? ( + + + + + ) : null} @@ -462,11 +618,13 @@ export function SearchScreenShell({ flex: 1, }}> @@ -480,6 +638,8 @@ let SearchScreenInner = ({ setActiveTab, query, queryWithParams, + filters, + hasFilters, headerHeight, focusSearchInput, }: { @@ -487,6 +647,8 @@ let SearchScreenInner = ({ setActiveTab: React.Dispatch> query: string queryWithParams: string + filters: SearchFilters + hasFilters: boolean headerHeight: number focusSearchInput: (tab?: TabParam) => void }): React.ReactNode => { @@ -498,14 +660,15 @@ let SearchScreenInner = ({ setActiveTab(index) } - return queryWithParams ? ( + return query || hasFilters ? ( ) : hasSession ? ( @@ -548,47 +711,51 @@ function useQueryManager({ fixedParams, }: { initialQuery: string - fixedParams?: Params + fixedParams?: SearchFilters }) { - const {query, params: initialParams} = useMemo(() => { - return parseSearchQuery(initialQuery || '') - }, [initialQuery]) - const [prevInitialQuery, setPrevInitialQuery] = useState(initialQuery) - const [lang, setLang] = useState(initialParams.lang || '') + const navigation = useNavigation() + const route = useRoute() - if (initialQuery !== prevInitialQuery) { - // handle new queryParam change (from manual search entry) - setPrevInitialQuery(initialQuery) - setLang(initialParams.lang || '') - } + // Free text only - structured filters live in sibling route params now. + const query = initialQuery - const params = useMemo( - () => ({ - // default stuff - ...initialParams, - // managed stuff - lang, - ...fixedParams, - }), - [lang, initialParams, fixedParams], - ) - const handlers = useMemo( - () => ({ - setLang, - }), - [setLang], + const filters = useMemo(() => { + const fromRoute = readSearchFilters(route.params as Record) + // fixedParams (e.g. ProfileSearch's author) always win and can't be cleared. + return {...fromRoute, ...fixedParams} + }, [route.params, fixedParams]) + + const setFilters = useCallback( + (next: SearchFilters) => { + const merged = {...next, ...fixedParams} + if (IS_WEB) { + /* + * Replace the param set so removed filters drop out of the URL instead + * of serializing as the literal string "undefined". + */ + const nextParams = { + ...withoutFilterParams(route.params as Record), + ...definedFilterParams(merged), + } + // @ts-expect-error route is not typesafe + navigation.replace(route.name, nextParams) + } else { + navigation.setParams(filtersToRouteParams(merged)) + } + }, + [navigation, route, fixedParams], ) - return useMemo(() => { - return { + return useMemo( + () => ({ query, - queryWithParams: makeSearchQuery(query, params), - params: { - ...params, - ...handlers, - }, - } - }, [query, params, handlers]) + queryWithParams: makeSearchQuery(query, filtersToLegacyParams(filters)), + filters, + setFilters, + hasFilters: hasActiveFilters(filters), + }), + [query, filters, setFilters], + ) } function scrollToTopWeb() { @@ -596,3 +763,12 @@ function scrollToTopWeb() { window.scrollTo(0, 0) } } + +function withoutSearchParams( + routeParams: Record | undefined, +): Record { + const parameters = withoutFilterParams(routeParams) + delete parameters.q + delete parameters.tab + return parameters +} diff --git a/src/screens/Search/__tests__/searchParams.test.ts b/src/screens/Search/__tests__/searchParams.test.ts new file mode 100644 index 0000000000..fd12fd2466 --- /dev/null +++ b/src/screens/Search/__tests__/searchParams.test.ts @@ -0,0 +1,213 @@ +import {describe, expect, it} from '@jest/globals' + +import { + countActiveFilters, + definedFilterParams, + filtersToApiParams, + filtersToLegacyParams, + hasPostOnlyFilters, + parseHistoryEntry, + readSearchFilters, + serializeHistoryEntry, + withoutFilterParams, +} from '#/screens/Search/searchParams' + +describe(`searchParams`, () => { + describe(`readSearchFilters`, () => { + it(`reads present string filters`, () => { + expect( + readSearchFilters({q: 'cats', author: 'alice', domain: 'bsky.app'}), + ).toEqual({author: 'alice', domain: 'bsky.app'}) + }) + + it(`ignores the literal string "undefined"`, () => { + expect( + readSearchFilters({ + q: 'cats', + author: 'alice', + mentions: 'undefined', + domain: 'undefined', + }), + ).toEqual({author: 'alice'}) + }) + + it(`ignores empty and non-string values`, () => { + expect(readSearchFilters({author: '', tag: undefined})).toEqual({}) + }) + }) + + describe(`hasPostOnlyFilters`, () => { + it(`returns false for a lang-only filter (people/feeds tabs stay)`, () => { + expect(hasPostOnlyFilters({lang: 'en'})).toBe(false) + }) + + it(`returns false for no filters`, () => { + expect(hasPostOnlyFilters({})).toBe(false) + }) + + it(`returns true for a post-restricting filter`, () => { + expect(hasPostOnlyFilters({author: 'alice'})).toBe(true) + expect(hasPostOnlyFilters({media: 'true'})).toBe(true) + expect(hasPostOnlyFilters({excludeTag: 'spam'})).toBe(true) + }) + + it(`returns true when lang is combined with a post-only filter`, () => { + expect(hasPostOnlyFilters({lang: 'en', author: 'alice'})).toBe(true) + }) + }) + + describe(`definedFilterParams`, () => { + it(`omits absent keys entirely`, () => { + expect(definedFilterParams({author: 'alice'})).toEqual({author: 'alice'}) + }) + }) + + describe(`withoutFilterParams`, () => { + it(`strips filter keys but keeps q/tab/name`, () => { + expect( + withoutFilterParams({ + q: 'cats', + tab: 'latest', + name: 'alice', + author: 'alice', + domain: 'undefined', + }), + ).toEqual({q: 'cats', tab: 'latest', name: 'alice'}) + }) + }) + + describe(`filtersToLegacyParams`, () => { + it(`maps structured filters back to legacy query operators`, () => { + expect( + filtersToLegacyParams({ + author: 'alice', + mentions: 'bob', + domain: 'bsky.app', + url: 'bsky.app/x', + tag: 'atproto', + lang: 'en', + since: '2024-01-01', + until: '2024-02-01', + media: 'true', + replies: 'none', + }), + ).toEqual({ + from: 'alice', + mentions: 'bob', + domain: 'bsky.app', + url: 'bsky.app/x', + tag: 'atproto', + lang: 'en', + since: '2024-01-01', + until: '2024-02-01', + }) + }) + }) + + describe(`filtersToApiParams`, () => { + it(`splits list fields into arrays and maps v2-only filters`, () => { + expect( + filtersToApiParams({ + author: 'alice bob', + domain: 'bsky.app', + tag: 'atproto bluesky', + lang: 'en', + media: 'true', + replies: 'none', + }), + ).toEqual({ + authors: ['alice', 'bob'], + domains: ['bsky.app'], + hashtags: ['atproto', 'bluesky'], + language: 'en', + hasMedia: true, + excludeReplies: true, + }) + }) + + it(`maps video/following and repliesOnly`, () => { + expect( + filtersToApiParams({ + video: 'true', + following: 'true', + replies: 'only', + }), + ).toEqual({ + hasVideo: true, + following: true, + repliesOnly: true, + }) + }) + + it(`maps exclude* keys to v2 exclude* arrays`, () => { + expect( + filtersToApiParams({ + excludeAuthor: 'alice bob', + excludeMentions: 'carol', + excludeDomain: 'spam.com', + excludeUrl: 'spam.com/x', + excludeTag: 'nsfw promo', + }), + ).toEqual({ + excludeAuthors: ['alice', 'bob'], + excludeMentions: ['carol'], + excludeDomains: ['spam.com'], + excludeUrls: ['spam.com/x'], + excludeHashtags: ['nsfw', 'promo'], + }) + }) + }) + + describe(`countActiveFilters`, () => { + it(`counts each set filter key once`, () => { + expect(countActiveFilters({})).toBe(0) + expect( + countActiveFilters({author: 'alice bob', domain: 'bsky.app'}), + ).toBe(2) + }) + }) + + describe(`search history serialize/parse`, () => { + it(`stores a filter-less search as a plain string`, () => { + expect(serializeHistoryEntry('cats', {})).toBe('cats') + }) + + it(`stores a filtered search as JSON`, () => { + const stored = serializeHistoryEntry('cats', {author: 'alice'}) + expect(stored).not.toBe('cats') + expect(parseHistoryEntry(stored)).toEqual({ + q: 'cats', + filters: {author: 'alice'}, + }) + }) + + it(`round-trips query + filters`, () => { + const filters = { + author: 'alice', + tag: 'black orange', + since: '2024-01-01', + } + const stored = serializeHistoryEntry('cats', filters) + expect(parseHistoryEntry(stored)).toEqual({q: 'cats', filters}) + }) + + it(`reads a legacy plain-string entry as a query with no filters`, () => { + expect(parseHistoryEntry('plain old search')).toEqual({ + q: 'plain old search', + filters: {}, + }) + }) + + it(`treats malformed JSON as a plain query without throwing`, () => { + expect(parseHistoryEntry('{not valid json')).toEqual({ + q: '{not valid json', + filters: {}, + }) + }) + + it(`treats a JSON value lacking a string q as a plain query`, () => { + const weird = JSON.stringify({foo: 'bar'}) + expect(parseHistoryEntry(weird)).toEqual({q: weird, filters: {}}) + }) + }) +}) diff --git a/src/screens/Search/components/AdvancedSearchDialog/AutocompleteInput/index.native.tsx b/src/screens/Search/components/AdvancedSearchDialog/AutocompleteInput/index.native.tsx new file mode 100644 index 0000000000..8b7bba24d2 --- /dev/null +++ b/src/screens/Search/components/AdvancedSearchDialog/AutocompleteInput/index.native.tsx @@ -0,0 +1,153 @@ +import {useState} from 'react' +import {Pressable, View} from 'react-native' +import {useLingui} from '@lingui/react/macro' + +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {atoms as a, useTheme} from '#/alf' +import {type AutocompleteItem, useAutocomplete} from '#/components/Autocomplete' +import {Button, ButtonIcon} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import * as ProfileCard from '#/components/ProfileCard' +import { + appendSelection, + type AutocompleteInputProps, + lastTokenOf, +} from './shared' + +/** + * Native: Sift's popover positioning doesn't anchor, so the result list is + * drawn as an absolutely-positioned overlay directly above the input. Anchoring + * above (rather than below) keeps it clear of the on-screen keyboard, and + * overlaying (rather than rendering inline) means it doesn't push the focused + * input down under the keyboard. See index.tsx for the web (floating) variant. + * The typeahead matches the last space-delimited token; selecting a result + * completes that token and leaves a trailing space so the next value can be + * typed. + */ +export function AutocompleteInput({ + label, + value, + placeholder, + onChangeText, + onSubmitEditing, +}: AutocompleteInputProps) { + const t = useTheme() + const {t: l} = useLingui() + const [focused, setFocused] = useState(false) + + const lastToken = lastTokenOf(value) + const {items} = useAutocomplete({type: 'profile', query: lastToken}) + + const showDropdown = focused && lastToken.length > 0 && items.length > 0 + + function selectItem(item: AutocompleteItem) { + const next = appendSelection(value, lastToken, item) + if (next !== null) onChangeText(next) + } + + return ( + + + setFocused(true)} + onBlur={() => setFocused(false)} + onSubmitEditing={onSubmitEditing} + /> + + + {value.length > 0 && ( + + + + )} + + {showDropdown && } + + ) +} + +function OverlayList({ + items, + onSelect, +}: { + items: AutocompleteItem[] + onSelect: (item: AutocompleteItem) => void +}) { + const t = useTheme() + const moderationOpts = useModerationOpts() + + if (!moderationOpts) return null + + /* + * The list sits above the input, so reverse it to put the most relevant + * result (first) at the bottom, nearest the text the user is typing. + */ + const ordered = [...items].reverse() + + return ( + + {ordered.map((item, index) => { + if (item.type !== 'profile') return null + return ( + onSelect(item)} + style={({pressed}) => [ + a.py_sm, + a.px_md, + index !== 0 && a.border_t, + t.atoms.border_contrast_low, + pressed && t.atoms.bg_contrast_25, + ]}> + + + + + + ) + })} + + ) +} diff --git a/src/screens/Search/components/AdvancedSearchDialog/AutocompleteInput/index.tsx b/src/screens/Search/components/AdvancedSearchDialog/AutocompleteInput/index.tsx new file mode 100644 index 0000000000..aab079ae6c --- /dev/null +++ b/src/screens/Search/components/AdvancedSearchDialog/AutocompleteInput/index.tsx @@ -0,0 +1,134 @@ +import {useEffect, useState} from 'react' +import {View} from 'react-native' +import {useSift} from '@bsky.app/sift' +import {useLingui} from '@lingui/react/macro' + +import {atoms as a, useTheme} from '#/alf' +import { + Autocomplete, + AutocompleteItemProfile, + useAutocomplete, +} from '#/components/Autocomplete' +import {type AutocompleteItemProps} from '#/components/Autocomplete/types' +import {Button, ButtonIcon} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import { + appendSelection, + type AutocompleteInputProps, + lastTokenOf, +} from './shared' + +function renderItem(props: AutocompleteItemProps) { + return +} + +/** + * Web: the result list floats above content via the shared Autocomplete/Sift + * primitives (the same stack the composer/DM mention typeahead uses). See + * index.native.tsx for the inline variant. The typeahead matches the last + * space-delimited token; selecting a result completes that token and leaves a + * trailing space so the next value can be typed. + */ +export function AutocompleteInput({ + label, + value, + placeholder, + onChangeText, + onSubmitEditing, +}: AutocompleteInputProps) { + const t = useTheme() + const {t: l} = useLingui() + const [focused, setFocused] = useState(false) + /* + * The popover is portaled and doesn't follow the page as it scrolls, so hide + * it on scroll. Reset when the user edits or refocuses the input. + */ + const [dismissedByScroll, setDismissedByScroll] = useState(false) + + const sift = useSift({ + offset: a.p_xs.padding, + placement: 'bottom-start', + dynamicWidth: true, + }) + + const lastToken = lastTokenOf(value) + const {items} = useAutocomplete({type: 'profile', query: lastToken}) + + const showDropdown = + focused && !dismissedByScroll && lastToken.length > 0 && items.length > 0 + + useEffect(() => { + if (!showDropdown) return + const onScroll = () => setDismissedByScroll(true) + /* + * Capture phase so scrolls inside the dialog's scroll container (not just + * the window) trigger dismissal. + */ + window.addEventListener('scroll', onScroll, true) + return () => window.removeEventListener('scroll', onScroll, true) + }, [showDropdown]) + + /* + * The anchor ref is applied via inputRef (lands on the inner TextInput); the + * remaining combobox a11y props are spread onto the input. + */ + const {ref: anchorRef, ...comboboxProps} = sift.targetProps + + return ( + + + { + setDismissedByScroll(false) + onChangeText(text) + }} + onFocus={() => { + setDismissedByScroll(false) + setFocused(true) + }} + onBlur={() => setFocused(false)} + onSubmitEditing={onSubmitEditing} + /> + + + {value.length > 0 && ( + + + + )} + + {showDropdown && ( + { + const next = appendSelection(value, lastToken, item) + if (next !== null) onChangeText(next) + }} + onDismiss={() => {}} + /> + )} + + ) +} diff --git a/src/screens/Search/components/AdvancedSearchDialog/AutocompleteInput/shared.ts b/src/screens/Search/components/AdvancedSearchDialog/AutocompleteInput/shared.ts new file mode 100644 index 0000000000..a575336226 --- /dev/null +++ b/src/screens/Search/components/AdvancedSearchDialog/AutocompleteInput/shared.ts @@ -0,0 +1,32 @@ +import {type AutocompleteItem} from '#/components/Autocomplete' + +export type AutocompleteInputProps = { + label: string + value: string + placeholder?: string + onChangeText: (text: string) => void + onSubmitEditing?: () => void +} + +/** + * The typeahead matches the last space-delimited token, so earlier completed + * values are left alone while the user types the next one. + */ +export function lastTokenOf(value: string): string { + return value.split(/\s+/u).pop() ?? '' +} + +/** + * Replaces the in-progress last token with the chosen handle, keeping earlier + * values, and leaves a trailing space to start the next one. The trailing space + * empties the last token, which dismisses the list. + */ +export function appendSelection( + value: string, + lastToken: string, + item: AutocompleteItem, +): string | null { + if (item.type !== 'profile') return null + const prefix = value.slice(0, value.length - lastToken.length) + return `${prefix}${item.profile.handle} ` +} diff --git a/src/screens/Search/components/AdvancedSearchDialog/ClearableDateField.tsx b/src/screens/Search/components/AdvancedSearchDialog/ClearableDateField.tsx new file mode 100644 index 0000000000..6d6d4bcdc5 --- /dev/null +++ b/src/screens/Search/components/AdvancedSearchDialog/ClearableDateField.tsx @@ -0,0 +1,81 @@ +import {View} from 'react-native' +import {useLingui} from '@lingui/react/macro' + +import {atoms as a} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {DateField} from '#/components/forms/DateField' +import {toSimpleDateString} from '#/components/forms/DateField/utils' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' + +/** The date picker requires a valid date, so default to today. */ +export const DEFAULT_DATE = toSimpleDateString(new Date()) + +export function ClearableDateField({ + label, + value, + active, + accessibilityHint, + maximumDate = DEFAULT_DATE, + minimumDate, + onConfirm, + onClear, +}: { + label: string + value: string + active: boolean + accessibilityHint?: string + maximumDate?: string + minimumDate?: string + onConfirm: (value: string) => void + onClear: () => void +}) { + const {t: l} = useLingui() + + return ( + + {}} + onConfirm={onConfirm} + /> + + {active && ( + /* + * Nudged left of the browser's native date-picker icon, which (in + * Firefox especially) can't be hidden via CSS. + */ + + + + )} + + ) +} diff --git a/src/screens/Search/components/AdvancedSearchDialog/ClearableInput.tsx b/src/screens/Search/components/AdvancedSearchDialog/ClearableInput.tsx new file mode 100644 index 0000000000..e6525ae6cd --- /dev/null +++ b/src/screens/Search/components/AdvancedSearchDialog/ClearableInput.tsx @@ -0,0 +1,74 @@ +import {useRef, useState} from 'react' +import {type TextInput, View} from 'react-native' +import {useLingui} from '@lingui/react/macro' + +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' + +/** + * A text input with a clear (X) button inside it on the right. The input stays + * uncontrolled (defaultValue + imperative clear) per the codebase's preference; + * local state only drives whether the clear button is shown. + */ +export function ClearableInput({ + label, + defaultValue, + placeholder, + onChangeText, + onSubmitEditing, +}: { + label: string + defaultValue: string + placeholder?: string + onChangeText: (text: string) => void + onSubmitEditing?: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const inputRef = useRef(null) + const [showClear, setShowClear] = useState(defaultValue.length > 0) + + return ( + + + { + setShowClear(text.length > 0) + onChangeText(text) + }} + onSubmitEditing={onSubmitEditing} + /> + + + {showClear && ( + + + + )} + + ) +} diff --git a/src/screens/Search/components/AdvancedSearchDialog/FilterBlock.tsx b/src/screens/Search/components/AdvancedSearchDialog/FilterBlock.tsx new file mode 100644 index 0000000000..f09e68e233 --- /dev/null +++ b/src/screens/Search/components/AdvancedSearchDialog/FilterBlock.tsx @@ -0,0 +1,182 @@ +import {View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' + +import {atoms as a, platform, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import { + ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, + ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon, +} from '#/components/icons/Chevron' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import * as Menu from '#/components/Menu' +import {AutocompleteInput} from './AutocompleteInput' +import {ClearableInput} from './ClearableInput' +import {useFilterFieldLabels} from './hooks' +import {type AdvancedFilter, FILTER_FIELDS, HANDLE_FIELDS} from './utils' + +export function FilterBlock({ + filter, + onChange, + onRemove, + onSubmitEditing, +}: { + filter: AdvancedFilter + onChange: (patch: Partial) => void + onRemove: () => void + onSubmitEditing?: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const labels = useFilterFieldLabels() + + return ( + + + + + {({props}) => ( + + )} + + + + onChange({mode: 'include'})}> + + Include + + + + onChange({mode: 'exclude'})}> + + Exclude + + + + + + + + + + {({props}) => ( + + )} + + + + {FILTER_FIELDS.map(field => ( + onChange({field, value: ''})}> + {labels[field].title} + + + ))} + + + + + + + + + + {HANDLE_FIELDS.has(filter.field) ? ( + onChange({value: text})} + onSubmitEditing={onSubmitEditing} + /> + ) : ( + onChange({value: text})} + onSubmitEditing={onSubmitEditing} + /> + )} + + ) +} diff --git a/src/screens/Search/components/AdvancedSearchDialog/FollowingDropdown.tsx b/src/screens/Search/components/AdvancedSearchDialog/FollowingDropdown.tsx new file mode 100644 index 0000000000..7d3e56a524 --- /dev/null +++ b/src/screens/Search/components/AdvancedSearchDialog/FollowingDropdown.tsx @@ -0,0 +1,65 @@ +import {Trans, useLingui} from '@lingui/react/macro' + +import {platform} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import { + ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, + ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon, +} from '#/components/icons/Chevron' +import * as Menu from '#/components/Menu' +import {type FollowingFilter} from './utils' + +export function FollowingDropdown({ + value, + onChange, +}: { + value: FollowingFilter + onChange: (value: FollowingFilter) => void +}) { + const {t: l} = useLingui() + + const options: {value: FollowingFilter; label: string}[] = [ + {value: 'everyone', label: l`Everyone`}, + {value: 'following', label: l`People you follow`}, + ] + const currentLabel = + options.find(o => o.value === value)?.label ?? l`Everyone` + + return ( + + + {({props}) => ( + + )} + + + + Filter by author + + + {options.map(option => ( + onChange(option.value)}> + {option.label} + + + ))} + + + + ) +} diff --git a/src/screens/Search/components/AdvancedSearchDialog/MediaDropdown.tsx b/src/screens/Search/components/AdvancedSearchDialog/MediaDropdown.tsx new file mode 100644 index 0000000000..1f4c424c8a --- /dev/null +++ b/src/screens/Search/components/AdvancedSearchDialog/MediaDropdown.tsx @@ -0,0 +1,66 @@ +import {Trans, useLingui} from '@lingui/react/macro' + +import {platform} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import { + ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, + ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon, +} from '#/components/icons/Chevron' +import * as Menu from '#/components/Menu' +import {type MediaFilter} from './utils' + +export function MediaDropdown({ + value, + onChange, +}: { + value: MediaFilter + onChange: (value: MediaFilter) => void +}) { + const {t: l} = useLingui() + + const options: {value: MediaFilter; label: string}[] = [ + {value: 'all', label: l`All posts`}, + {value: 'media', label: l`Only posts with media`}, + {value: 'video', label: l`Only posts with videos`}, + ] + const currentLabel = + options.find(o => o.value === value)?.label ?? l`All posts` + + return ( + + + {({props}) => ( + + )} + + + + Filter by media + + + {options.map(option => ( + onChange(option.value)}> + {option.label} + + + ))} + + + + ) +} diff --git a/src/screens/Search/components/AdvancedSearchDialog/RepliesDropdown.tsx b/src/screens/Search/components/AdvancedSearchDialog/RepliesDropdown.tsx new file mode 100644 index 0000000000..52f352dd08 --- /dev/null +++ b/src/screens/Search/components/AdvancedSearchDialog/RepliesDropdown.tsx @@ -0,0 +1,66 @@ +import {Trans, useLingui} from '@lingui/react/macro' + +import {platform} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import { + ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, + ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon, +} from '#/components/icons/Chevron' +import * as Menu from '#/components/Menu' +import {type RepliesFilter} from './utils' + +export function RepliesDropdown({ + value, + onChange, +}: { + value: RepliesFilter + onChange: (value: RepliesFilter) => void +}) { + const {t: l} = useLingui() + + const options: {value: RepliesFilter; label: string}[] = [ + {value: 'all', label: l`Posts and replies`}, + {value: 'none', label: l`No replies`}, + {value: 'only', label: l`Only replies`}, + ] + const currentLabel = options.find(o => o.value === value)?.label ?? l`All` + + return ( + + + {({props}) => ( + + )} + + + + Include these results + + + {options.map(option => ( + onChange(option.value)}> + {option.label} + + + ))} + + + + ) +} diff --git a/src/screens/Search/components/AdvancedSearchDialog/__tests__/utils.test.ts b/src/screens/Search/components/AdvancedSearchDialog/__tests__/utils.test.ts new file mode 100644 index 0000000000..8822c1337d --- /dev/null +++ b/src/screens/Search/components/AdvancedSearchDialog/__tests__/utils.test.ts @@ -0,0 +1,308 @@ +import {describe, expect, it} from '@jest/globals' + +import { + makeFilter, + parseAdvancedSearch, + serializeAdvancedSearch, +} from '#/screens/Search/components/AdvancedSearchDialog/utils' +import {type SearchFilters} from '#/screens/Search/searchParams' + +const emptySerializeState = { + query: '', + exactPhrase: '', + negatedWords: '', + language: '', + replies: 'all' as const, + media: 'all' as const, + following: 'everyone' as const, + dateSince: '', + dateSinceActive: false, + dateUntil: '', + dateUntilActive: false, + filters: [], +} + +describe(`AdvancedSearchDialog serialize/parse`, () => { + it(`splits free text into q and structured fields into filters`, () => { + const state = parseAdvancedSearch('hello "exact phrase" -spam', { + author: 'alice', + domain: 'bsky.app', + tag: 'atproto bluesky', + lang: 'en', + since: '2024-01-01', + media: 'true', + replies: 'none', + }) + + expect(state.query).toBe('hello') + expect(state.exactPhrase).toBe('exact phrase') + expect(state.negatedWords).toBe('spam') + expect(state.language).toBe('en') + expect(state.since).toBe('2024-01-01') + expect(state.media).toBe('media') + expect(state.replies).toBe('none') + // Handle fields (authors/mentions) get a trailing space on parse so the + // typeahead stays closed until the user types; it's trimmed on serialize. + expect(state.filters.find(f => f.field === 'authors')?.value).toBe('alice ') + expect(state.filters.find(f => f.field === 'domains')?.value).toBe( + 'bsky.app', + ) + expect(state.filters.find(f => f.field === 'tags')?.value).toBe( + 'atproto bluesky', + ) + }) + + it(`lifts operators typed into the query box into include rows`, () => { + const state = parseAdvancedSearch( + 'chat from:bsky.app to:alice domain:example.com url:example.com/x #atproto since:2024-01-01 until:2024-02-01', + {}, + ) + + // Operators are stripped from the free text. + expect(state.query).toBe('chat') + + const findRow = (field: string) => + state.filters.find(f => f.field === field && f.mode === 'include')?.value + // Handle fields get a trailing space on parse (trimmed on serialize). + expect(findRow('authors')).toBe('bsky.app ') + expect(findRow('mentions')).toBe('alice ') + expect(findRow('domains')).toBe('example.com') + expect(findRow('urls')).toBe('example.com/x') + expect(findRow('tags')).toBe('atproto') + expect(state.since).toBe('2024-01-01') + expect(state.until).toBe('2024-02-01') + }) + + it(`merges a query-box operator with the matching filter param`, () => { + const state = parseAdvancedSearch('hi from:bob', {author: 'alice'}) + expect(state.query).toBe('hi') + expect( + state.filters.find(f => f.field === 'authors' && f.mode === 'include') + ?.value, + ).toBe('alice bob ') + }) + + it(`round-trips state -> {q, filters} -> state`, () => { + const q = 'hello "exact phrase" -spam' + const filters: SearchFilters = { + author: 'alice', + domain: 'bsky.app', + url: 'bsky.app/post', + tag: 'atproto bluesky', + lang: 'en', + since: '2024-01-01', + until: '2024-02-01', + media: 'true', + following: 'true', + replies: 'only', + } + + const state = parseAdvancedSearch(q, filters) + const out = serializeAdvancedSearch({ + query: state.query, + exactPhrase: state.exactPhrase, + negatedWords: state.negatedWords, + language: state.language, + replies: state.replies, + media: state.media, + following: state.following, + dateSince: state.since, + dateSinceActive: !!state.since, + dateUntil: state.until, + dateUntilActive: !!state.until, + filters: state.filters, + }) + + expect(out.q).toBe(q) + expect(out.filters).toEqual(filters) + }) + + it(`maps the video media filter to the video param on serialize`, () => { + const out = serializeAdvancedSearch({ + ...emptySerializeState, + media: 'video', + }) + expect(out.filters.video).toBe('true') + expect(out.filters.media).toBeUndefined() + }) + + it(`parses the video param into the video media filter`, () => { + const state = parseAdvancedSearch('', {video: 'true'}) + expect(state.media).toBe('video') + }) + + it(`maps the following filter to the following param on serialize`, () => { + const out = serializeAdvancedSearch({ + ...emptySerializeState, + following: 'following', + }) + expect(out.filters.following).toBe('true') + }) + + it(`leaves the following param unset for everyone`, () => { + const out = serializeAdvancedSearch({ + ...emptySerializeState, + following: 'everyone', + }) + expect(out.filters.following).toBeUndefined() + }) + + it(`parses the following param into the following filter`, () => { + expect(parseAdvancedSearch('', {following: 'true'}).following).toBe( + 'following', + ) + expect(parseAdvancedSearch('', {}).following).toBe('everyone') + }) + + it(`strips redundant markers from filter values on serialize`, () => { + const state = parseAdvancedSearch('', {author: 'alice', tag: 'atproto'}) + // simulate a user typing the marker explicitly + state.filters = state.filters.map(f => + f.field === 'authors' + ? {...f, value: '@alice'} + : f.field === 'tags' + ? {...f, value: '#atproto'} + : f, + ) + const out = serializeAdvancedSearch({ + query: '', + exactPhrase: '', + negatedWords: '', + language: '', + replies: 'all', + media: 'all', + following: 'everyone', + dateSince: '', + dateSinceActive: false, + dateUntil: '', + dateUntilActive: false, + filters: state.filters, + }) + expect(out.filters.author).toBe('alice') + expect(out.filters.tag).toBe('atproto') + }) + + it(`merges multiple filter rows of the same field on serialize`, () => { + const out = serializeAdvancedSearch({ + ...emptySerializeState, + filters: [ + makeFilter('authors', 'alice'), + makeFilter('authors', 'bob carol'), + ], + }) + expect(out.filters.author).toBe('alice bob carol') + }) + + it(`dedupes duplicate values across rows and within a row on serialize`, () => { + const out = serializeAdvancedSearch({ + ...emptySerializeState, + filters: [ + makeFilter('authors', 'alice'), + makeFilter('authors', 'alice bob'), + makeFilter('tags', 'cats cats'), + ], + }) + expect(out.filters.author).toBe('alice bob') + expect(out.filters.tag).toBe('cats') + }) + + it(`collapses a merged param back into a single filter row on parse`, () => { + const state = parseAdvancedSearch('', {author: 'alice bob carol'}) + const authorRows = state.filters.filter(f => f.field === 'authors') + expect(authorRows).toHaveLength(1) + // Handle fields get a trailing space on parse (trimmed on serialize). + expect(authorRows[0].value).toBe('alice bob carol ') + }) + + it(`routes exclude-mode rows to the exclude* params on serialize`, () => { + const out = serializeAdvancedSearch({ + ...emptySerializeState, + filters: [ + makeFilter('authors', 'alice', 'include'), + makeFilter('authors', 'bob', 'exclude'), + makeFilter('tags', 'spam', 'exclude'), + ], + }) + expect(out.filters.author).toBe('alice') + expect(out.filters.excludeAuthor).toBe('bob') + expect(out.filters.excludeTag).toBe('spam') + }) + + it(`merges multiple rows of the same field and mode independently`, () => { + const out = serializeAdvancedSearch({ + ...emptySerializeState, + filters: [ + makeFilter('domains', 'a.com', 'include'), + makeFilter('domains', 'b.com', 'include'), + makeFilter('domains', 'c.com', 'exclude'), + makeFilter('domains', 'd.com', 'exclude'), + ], + }) + expect(out.filters.domain).toBe('a.com b.com') + expect(out.filters.excludeDomain).toBe('c.com d.com') + }) + + it(`builds exclude-mode rows from exclude* params on parse`, () => { + const state = parseAdvancedSearch('', { + author: 'alice', + excludeAuthor: 'bob', + excludeTag: 'spam', + }) + const includeAuthor = state.filters.find( + f => f.field === 'authors' && f.mode === 'include', + ) + const excludeAuthor = state.filters.find( + f => f.field === 'authors' && f.mode === 'exclude', + ) + const excludeTag = state.filters.find( + f => f.field === 'tags' && f.mode === 'exclude', + ) + // Handle fields get a trailing space on parse (trimmed on serialize). + expect(includeAuthor?.value).toBe('alice ') + expect(excludeAuthor?.value).toBe('bob ') + expect(excludeTag?.value).toBe('spam') + }) + + it(`round-trips a mix of include and exclude filters`, () => { + const filters: SearchFilters = { + author: 'alice', + excludeAuthor: 'bob', + domain: 'bsky.app', + excludeTag: 'spam', + } + const state = parseAdvancedSearch('', filters) + const out = serializeAdvancedSearch({ + ...emptySerializeState, + filters: state.filters, + }) + expect(out.filters).toEqual(filters) + }) + + it(`leaves a negated phrase in the query instead of parsing it`, () => { + const state = parseAdvancedSearch('-"exact phrase"', {}) + expect(state.query).toBe('-"exact phrase"') + expect(state.negatedWords).toBe('') + expect(state.exactPhrase).toBe('') + }) + + it(`leaves a phrase with embedded quotes in the query`, () => { + const state = parseAdvancedSearch('"say "hi""', {}) + expect(state.query).toBe('"say "hi""') + expect(state.exactPhrase).toBe('') + }) + + it(`moves quoted negatedWords input to the query verbatim on serialize`, () => { + const out = serializeAdvancedSearch({ + ...emptySerializeState, + query: 'cats', + negatedWords: '"foo" bar', + }) + // "foo" is unexpected -> verbatim; bar is simple -> negated. + expect(out.q).toBe('cats "foo" -bar') + }) + + it(`leaves an OR group in the query instead of parsing it`, () => { + const state = parseAdvancedSearch('hello (cats OR dogs)', {}) + expect(state.query).toBe('hello (cats OR dogs)') + }) +}) diff --git a/src/screens/Search/components/AdvancedSearchDialog/hooks.tsx b/src/screens/Search/components/AdvancedSearchDialog/hooks.tsx new file mode 100644 index 0000000000..37c87f84f2 --- /dev/null +++ b/src/screens/Search/components/AdvancedSearchDialog/hooks.tsx @@ -0,0 +1,65 @@ +import {useState} from 'react' +import {useLingui} from '@lingui/react/macro' + +import {type FilterField} from './utils' + +export function useFilterFieldLabels(): Record< + FilterField, + { + title: string + label: string + } +> { + const {t: l} = useLingui() + + const hashtags = [ + 'bluesky atproto', + l({ + message: 'bloomscrolling booksky', + comment: 'Advanced search: Examples of hashtags', + }), + ] + + // eslint-disable-next-line react/hook-use-state + const [hashtagIndex] = useState(() => + Math.floor(Math.random() * hashtags.length), + ) + + return { + authors: { + title: l({ + message: 'From these people', + comment: 'Advanced search filter', + }), + label: 'bsky.app atproto.com', + }, + mentions: { + title: l({ + message: 'Mentions of these people', + comment: 'Advanced search filter', + }), + label: 'bsky.app atproto.com', + }, + tags: { + title: l({ + message: 'These hashtags', + comment: 'Advanced search filter', + }), + label: hashtags[hashtagIndex], + }, + domains: { + title: l({ + message: 'These domains', + comment: 'Advanced search filter', + }), + label: 'bsky.app atproto.com', + }, + urls: { + title: l({ + message: 'These URLs', + comment: 'Advanced search filter', + }), + label: 'bsky.app atproto.com', + }, + } +} diff --git a/src/screens/Search/components/AdvancedSearchDialog/index.tsx b/src/screens/Search/components/AdvancedSearchDialog/index.tsx new file mode 100644 index 0000000000..bb458ad2bb --- /dev/null +++ b/src/screens/Search/components/AdvancedSearchDialog/index.tsx @@ -0,0 +1,464 @@ +import {useMemo, useRef, useState} from 'react' +import {type ScrollView, View} from 'react-native' +import {Plural, Trans, useLingui} from '@lingui/react/macro' + +import { + countActiveFilters, + FILTER_PARAM_KEYS, + hasActiveFilters, + type SearchFilters, +} from '#/screens/Search/searchParams' +import {atoms as a, native, useBreakpoints, useTheme} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' +import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSliderIcon} from '#/components/icons/SettingsSlider' +import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' +import {IS_WEB} from '#/env' +import {SearchLanguageDropdown} from '../SearchLanguageDropdown' +import {ClearableDateField, DEFAULT_DATE} from './ClearableDateField' +import {ClearableInput} from './ClearableInput' +import {FilterBlock} from './FilterBlock' +import {FollowingDropdown} from './FollowingDropdown' +import {MediaDropdown} from './MediaDropdown' +import {RepliesDropdown} from './RepliesDropdown' +import { + type AdvancedFilter, + type FollowingFilter, + makeFilter, + type MediaFilter, + parseAdvancedSearch, + type RepliesFilter, + serializeAdvancedSearch, +} from './utils' + +const MAX_FILTERS = 20 + +export function AdvancedSearchDialog({ + q, + filters, + onSubmit, +}: { + q: string + filters: SearchFilters + onSubmit: (q: string, filters: SearchFilters) => void +}) { + const ax = useAnalytics() + const {t: l} = useLingui() + const t = useTheme() + const control = Dialog.useDialogControl() + const filtersActive = hasActiveFilters(filters) + const stateKey = useMemo( + () => + JSON.stringify([q, ...FILTER_PARAM_KEYS.map(key => filters[key] ?? '')]), + [q, filters], + ) + + return ( + <> + + + {filtersActive && ( + + )} + + + + + + + + ) +} + +function DialogInner({ + control, + q, + filters: filterParams, + onSubmit, +}: { + control: Dialog.DialogControlProps + q: string + filters: SearchFilters + onSubmit: (q: string, filters: SearchFilters) => void +}) { + const ax = useAnalytics() + const t = useTheme() + const {t: l} = useLingui() + const {gtTablet} = useBreakpoints() + // Two-column layout for the word fields, web-only at the widest breakpoint. + const twoColumn = IS_WEB && gtTablet + + const parsed = useMemo( + () => parseAdvancedSearch(q, filterParams), + [q, filterParams], + ) + + const [query, setQuery] = useState(parsed.query) + const [exactPhrase, setExactPhrase] = useState(parsed.exactPhrase) + const [negatedWords, setNegatedWords] = useState(parsed.negatedWords) + const [language, setLanguage] = useState(parsed.language) + + const [media, setMedia] = useState(parsed.media) + const [replies, setReplies] = useState(parsed.replies) + const [following, setFollowing] = useState(parsed.following) + + /* + * The date picker requires a valid date, so these always hold one. The + * accompanying `active` flags track whether the date is actually part of the + * query, so that a date equal to today (the default) can still be applied. + */ + const [dateSince, setDateSince] = useState(parsed.since || DEFAULT_DATE) + const [dateSinceActive, setDateSinceActive] = useState(!!parsed.since) + const [dateUntil, setDateUntil] = useState(parsed.until || DEFAULT_DATE) + const [dateUntilActive, setDateUntilActive] = useState(!!parsed.until) + + const [filters, setFilters] = useState(parsed.filters) + const scrollRef = useRef(null) + const filtersSectionRef = useRef(null) + + function addFilter() { + if (filters.length >= MAX_FILTERS) return + /* + * New blocks append to the end so the newest sits directly above the + * "Add filter" button, which renders below the list. + */ + setFilters(prev => [...prev, makeFilter('authors')]) + ax.metric('search:addFilter:press', { + filterCount: filters.length + 1, + }) + /* + * Wait for the new block to render, then scroll the bottom of the dialog + * (the new block plus the button beneath it) into view. + */ + requestAnimationFrame(() => { + if (IS_WEB) { + const node = filtersSectionRef.current as unknown as HTMLElement | null + node?.scrollIntoView?.({behavior: 'smooth', block: 'end'}) + } else { + scrollRef.current?.scrollToEnd({animated: true}) + } + }) + } + + function updateFilter(id: string, patch: Partial) { + setFilters(prev => + prev.map(filter => (filter.id === id ? {...filter, ...patch} : filter)), + ) + } + + function removeFilter(id: string) { + setFilters(prev => prev.filter(filter => filter.id !== id)) + } + + function handlePressSearch() { + const {q: nextQ, filters: nextFilters} = serializeAdvancedSearch({ + query, + exactPhrase, + negatedWords, + language, + replies, + media, + following, + dateSince, + dateSinceActive, + dateUntil, + dateUntilActive, + filters, + }) + /* + * Run the submit (navigation + state updates) inside the close callback so + * it doesn't race the sheet's close animation on native. + */ + control.close(() => onSubmit(nextQ, nextFilters)) + } + + function cancelButton() { + return ( + + ) + } + + function searchButton() { + return ( + + ) + } + + return ( + + + Advanced search + + + }> + + + + All of these words + + + + + + + + This exact phrase + + + + + + + None of these words + + + + + + + + + + Since + + { + setDateSince(value) + setDateSinceActive(true) + }} + onClear={() => { + setDateSinceActive(false) + setDateSince(DEFAULT_DATE) + }} + /> + + + + Until + + { + setDateUntil(value) + setDateUntilActive(true) + }} + onClear={() => { + setDateUntilActive(false) + setDateUntil(DEFAULT_DATE) + }} + /> + + + + + + + + Language + + + + + + + + Media + + + + + + + + + + + Include + + + + + + + + From + + + + + + + + + {filters.map(filter => ( + updateFilter(filter.id, patch)} + onSubmitEditing={handlePressSearch} + onRemove={() => removeFilter(filter.id)} + /> + ))} + {filters.length >= MAX_FILTERS && ( + + + You’ve reached the maximum of{' '} + . + Add more values to an existing filter instead of creating new + ones. + + + )} + + + + + ) +} diff --git a/src/screens/Search/components/AdvancedSearchDialog/utils.ts b/src/screens/Search/components/AdvancedSearchDialog/utils.ts new file mode 100644 index 0000000000..95977338c1 --- /dev/null +++ b/src/screens/Search/components/AdvancedSearchDialog/utils.ts @@ -0,0 +1,358 @@ +import { + extractSearchPostsParams, + tokenizeQuery, +} from '#/state/queries/search-posts-params' +import {type SearchFilters} from '#/screens/Search/searchParams' + +export type RepliesFilter = 'all' | 'none' | 'only' + +/** + * Media filter is mutually exclusive: a post matches at most one of these. It + * serializes into the separate media/video sibling params (only one is ever + * set at a time). + */ +export type MediaFilter = 'all' | 'media' | 'video' + +/** + * Whether to limit results to authors the user follows. Serializes into the + * `following` sibling param ('following' -> following:true, everyone -> unset). + */ +export type FollowingFilter = 'everyone' | 'following' + +export type FilterField = 'authors' | 'mentions' | 'domains' | 'urls' | 'tags' + +export const FILTER_FIELDS: FilterField[] = [ + 'authors', + 'mentions', + 'domains', + 'urls', + 'tags', +] + +/** Fields whose values are user handles, so they get handle typeahead. */ +export const HANDLE_FIELDS = new Set(['authors', 'mentions']) + +/** + * Whether a filter includes or excludes matching posts. Exclude is a v2-only + * capability; it serializes into the exclude* sibling params and is dropped on + * the search v1 path. + */ +export type FilterMode = 'include' | 'exclude' + +export type AdvancedFilter = { + id: string + field: FilterField + mode: FilterMode + value: string +} + +/** + * Monotonic counter for filter row ids, owned here so both this module and the + * component create rows through the same source. Imported `let` bindings are + * read-only, so callers must use makeFilter rather than incrementing directly. + */ +let nextFilterId = 0 + +/** Creates a new filter row with a unique id. */ +export function makeFilter( + field: FilterField, + value: string = '', + mode: FilterMode = 'include', +): AdvancedFilter { + return {id: `filter-${nextFilterId++}`, field, mode, value} +} + +/** + * A marker character users commonly type that is redundant with the field's + * stored form, e.g. "#cats" or "@alice"; stripped on serialize so we don't + * store "#cats" or "@alice" in the param value. + */ +const FIELD_MARKERS: Partial> = { + authors: '@', + mentions: '@', + tags: '#', +} + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ + +function isValidDate(value: string): boolean { + if (!DATE_RE.test(value)) return false + const d = new Date(value + 'T00:00:00') + return !isNaN(d.getTime()) +} + +/** + * The dialog's full internal state. Free-text fields (query/exactPhrase/ + * negatedWords) are derived from `q`; everything else comes from the structured + * filter params. + */ +export type DialogState = { + query: string + exactPhrase: string + negatedWords: string + language: string + replies: RepliesFilter + media: MediaFilter + following: FollowingFilter + since: string + until: string + filters: AdvancedFilter[] +} + +const WHITESPACE_RE = /\s+/ + +/** + * Maps a filter field to the structured param key it serializes into, by mode. + * Include rows write the base param; exclude rows write the exclude* sibling. + */ +const FIELD_TO_PARAM: Record< + FilterMode, + Record +> = { + include: { + authors: 'author', + mentions: 'mentions', + domains: 'domain', + urls: 'url', + tags: 'tag', + }, + exclude: { + authors: 'excludeAuthor', + mentions: 'excludeMentions', + domains: 'excludeDomain', + urls: 'excludeUrl', + tags: 'excludeTag', + }, +} + +/** + * A "simple" free-text word - no quotes. Only simple words round-trip cleanly + * through the negated-words field, so anything else is left in (or moved to) + * the main query rather than coerced into a `-word` token it can't represent. + */ +function isSimpleWord(word: string): boolean { + return word.length > 0 && !word.includes('"') +} + +/** + * Parses the free-text portion of a query (`q`) into the dialog's text fields. + * `q` no longer carries structured operators - those arrive separately via + * `filters`. A quoted phrase populates "exact phrase" and -negated terms + * 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. + */ +function parseFreeText(raw: string): { + query: string + exactPhrase: string + negatedWords: string +} { + const queryParts: string[] = [] + const negatedWords: string[] = [] + let exactPhrase = '' + + for (const token of tokenizeQuery(raw)) { + // "phrase" -> "exact phrase", only if it has no inner quote. + if (token.startsWith('"') && token.endsWith('"') && token.length > 1) { + const inner = token.slice(1, -1) + if (!inner.includes('"')) { + exactPhrase = inner + continue + } + } + // -word -> "none of these words", only if the negated word is simple. + if (token.startsWith('-') && token.length > 1 && !token.includes(':')) { + const word = token.slice(1) + if (isSimpleWord(word)) { + negatedWords.push(word) + continue + } + } + queryParts.push(token) + } + + return { + query: queryParts.join(' '), + exactPhrase, + negatedWords: negatedWords.join(' '), + } +} + +/** + * Joins two space-delimited value lists, dropping empties and duplicates while + * preserving order. Used to fold operators lifted from the query text into the + * matching structured filter values without clobbering either source. + */ +function mergeValues(a?: string, b?: string): string { + const seen = new Set() + for (const part of `${a ?? ''} ${b ?? ''}`.split(WHITESPACE_RE)) { + if (part) seen.add(part) + } + return [...seen].join(' ') +} + +/** + * Builds the full dialog state from the free-text `q` plus the structured + * filter params. Operators typed directly into the query box (e.g. `from:`, + * `domain:`, `since:`, `#tag`) are lifted out via extractSearchPostsParams and + * folded into the matching include filters/date fields, and removed from the + * free text - so the dialog shows them as structured rows rather than raw text. + */ +export function parseAdvancedSearch( + q: string, + filters: SearchFilters, +): DialogState { + /* + * Lift recognized operators out of the query text. Only include-mode fields + * can be expressed as operators; exclude rows come solely from filter params. + */ + const lifted = extractSearchPostsParams(q) + const freeText = parseFreeText(lifted.q) + + const includeValues: Record = { + authors: mergeValues(filters.author, lifted.author), + mentions: mergeValues(filters.mentions, lifted.mentions), + domains: mergeValues(filters.domain, lifted.domain), + urls: mergeValues(filters.url, lifted.url), + tags: mergeValues(filters.tag, lifted.tag?.join(' ')), + } + + const filterRows: AdvancedFilter[] = [] + const MODES: FilterMode[] = ['include', 'exclude'] + for (const field of FILTER_FIELDS) { + for (const mode of MODES) { + const value = + mode === 'include' + ? includeValues[field] + : filters[FIELD_TO_PARAM[mode][field]] + if (value) { + /* + * Append a trailing space to handle fields so the typeahead (which + * matches the last token) stays closed until the user types. The space + * is ignored on serialize, which trims/splits on whitespace. + */ + filterRows.push( + makeFilter( + field, + HANDLE_FIELDS.has(field) ? `${value} ` : value, + mode, + ), + ) + } + } + } + + let replies: RepliesFilter = 'all' + if (filters.replies === 'none') replies = 'none' + else if (filters.replies === 'only') replies = 'only' + + let media: MediaFilter = 'all' + if (filters.media === 'true') media = 'media' + else if (filters.video === 'true') media = 'video' + + const lang = filters.lang ?? lifted.lang ?? '' + const since = filters.since ?? lifted.since + const until = filters.until ?? lifted.until + + return { + ...freeText, + language: lang, + replies, + media, + following: filters.following === 'true' ? 'following' : 'everyone', + since: since && isValidDate(since) ? since : '', + until: until && isValidDate(until) ? until : '', + filters: filterRows, + } +} + +/** + * Serializes the dialog state into the free-text `q` string plus the structured + * filter params. Free text (all/exact/none words) goes into `q`; everything + * else becomes a sibling param. + */ +export function serializeAdvancedSearch(state: { + query: string + exactPhrase: string + negatedWords: string + language: string + replies: RepliesFilter + media: MediaFilter + following: FollowingFilter + dateSince: string + dateSinceActive: boolean + dateUntil: string + dateUntilActive: boolean + filters: AdvancedFilter[] +}): {q: string; filters: SearchFilters} { + const parts: string[] = [] + + if (state.query.trim()) { + parts.push(state.query.trim()) + } + + /* + * "exact phrase" -> a quoted token, but only if it's a clean phrase with no + * embedded quotes. Otherwise it can't be safely wrapped, so pass it through + * to the query verbatim. + */ + const exactPhrase = state.exactPhrase.trim() + if (exactPhrase) { + parts.push(exactPhrase.includes('"') ? exactPhrase : `"${exactPhrase}"`) + } + + /* + * "none of these words" -> -negated tokens, but only simple words get the `-` + * prefix. Anything unexpected passes through to the query verbatim. + */ + for (const word of state.negatedWords.trim().split(WHITESPACE_RE)) { + if (!word) continue + const bare = word.replace(/^-+/, '') + if (isSimpleWord(bare)) { + parts.push(`-${bare}`) + } else { + parts.push(word) + } + } + + const filters: SearchFilters = {} + + /* + * Each filter row's value is normalized (marker stripped) into space-joined + * values under its param key. The row's mode selects the include or exclude + * param. Multiple rows of the same field and mode merge into one param, + * deduping values across rows (and within a row) so e.g. two `author: alice` + * rows don't serialize to "alice alice". + */ + const valuesByKey = new Map>() + for (const filter of state.filters) { + const marker = FIELD_MARKERS[filter.field] + const values = filter.value + .trim() + .split(WHITESPACE_RE) + .filter(Boolean) + .map(v => (marker && v.startsWith(marker) ? v.slice(1) : v)) + .filter(Boolean) + if (values.length) { + const key = FIELD_TO_PARAM[filter.mode][filter.field] + const set = valuesByKey.get(key) ?? new Set() + for (const value of values) set.add(value) + valuesByKey.set(key, set) + } + } + for (const [key, set] of valuesByKey) { + filters[key] = [...set].join(' ') + } + + if (state.language) filters.lang = state.language + if (state.dateSinceActive && state.dateSince) filters.since = state.dateSince + if (state.dateUntilActive && state.dateUntil) filters.until = state.dateUntil + if (state.replies === 'none') filters.replies = 'none' + 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} +} diff --git a/src/screens/Search/components/DetectedLanguagesAdmonition.tsx b/src/screens/Search/components/DetectedLanguagesAdmonition.tsx new file mode 100644 index 0000000000..be45e24e65 --- /dev/null +++ b/src/screens/Search/components/DetectedLanguagesAdmonition.tsx @@ -0,0 +1,125 @@ +import {useMemo} from 'react' +import {Trans, useLingui} from '@lingui/react/macro' + +import {augmentSearchQuery} from '#/lib/strings/helpers' +import {codeToLanguageName} from '#/locale/helpers' +import {useLanguagePrefs} from '#/state/preferences/languages' +import {useSearchPostsV2Query} from '#/state/queries/search-posts-v2' +import {useSession} from '#/state/session' +import {type SearchFilters} from '#/screens/Search/searchParams' +import {Admonition} from '#/components/Admonition' +import {createStaticClick, InlineLinkText} from '#/components/Link' + +type DetectedLanguage = {code: string; name: string} + +/** + * Shows a tip naming the languages v2 detected in the query text (for + * CJK/Thai/Arabic scripts), so the user can confirm we parsed the query as they + * intended. Each name is a link that adds its code to the `language` filter and + * re-runs the search. It runs the same v2 query as the active post-results tab; + * react query dedupes on the shared cache key, so this reads the existing + * result rather than triggering a second fetch. + */ +export function DetectedLanguagesAdmonition({ + query, + filters, + sort, + enabled, + onPressLanguage, +}: { + query: string + filters: SearchFilters + sort?: 'top' | 'latest' + enabled: boolean + onPressLanguage: (code: string) => void +}) { + const {appLanguage} = useLanguagePrefs() + const {currentAccount} = useSession() + + const augmentedQuery = useMemo( + () => augmentSearchQuery(query || '', {did: currentAccount?.did}), + [query, currentAccount], + ) + + const {data} = useSearchPostsV2Query({ + query: augmentedQuery, + filters, + sort, + enabled, + }) + + // Detected languages are a per-query value; read them from the first page. + const languages = useMemo(() => { + const codes = data?.pages[0]?.detectedQueryLanguages ?? [] + return codes.map(code => ({ + code, + name: codeToLanguageName(code, appLanguage), + })) + }, [data, appLanguage]) + + // No suggestions to make. + if (languages.length === 0) return null + + // User already chose one of the suggestions. + if (filters.lang && languages.some(({code}) => code === filters.lang)) + return null + + return ( + + + + ) +} + +/** + * Per-count templates so the sentence and list conjunction stay translatable + * while each language name remains an individually pressable link. v2 only + * detects 1-5 languages; 3+ all use the comma-list variant. + */ +function DetectedLanguagesPrompt({ + languages, + onPressLanguage, +}: { + languages: DetectedLanguage[] + onPressLanguage: (code: string) => void +}) { + const {t: l} = useLingui() + + const link = (lang: DetectedLanguage) => ( + onPressLanguage(lang.code))}> + {lang.name} + + ) + + if (languages.length === 1) { + return Are you searching for posts in {link(languages[0])}? + } + + if (languages.length === 2) { + return ( + + Are you searching for posts in {link(languages[0])} or{' '} + {link(languages[1])}? + + ) + } + + const head = languages.slice(0, -1) + const last = languages[languages.length - 1] + return ( + + Are you searching for posts in{' '} + {head.map(lang => ( + + {link(lang)},{' '} + + ))} + or {link(last)}? + + ) +} diff --git a/src/screens/Search/components/SearchHistory.tsx b/src/screens/Search/components/SearchHistory.tsx index f49fadf0d5..91d3264307 100644 --- a/src/screens/Search/components/SearchHistory.tsx +++ b/src/screens/Search/components/SearchHistory.tsx @@ -1,6 +1,6 @@ import {Pressable, ScrollView, View} from 'react-native' import {moderateProfile, type ModerationOpts} from '@atproto/api' -import {Trans, useLingui} from '@lingui/react/macro' +import {Plural, Trans, useLingui} from '@lingui/react/macro' import {createHitslop, HITSLOP_10} from '#/lib/constants' import {makeProfileLink} from '#/lib/routes/links' @@ -9,7 +9,11 @@ import {sanitizeHandle} from '#/lib/strings/handles' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {UserAvatar} from '#/view/com/util/UserAvatar' import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' -import {atoms as a} from '#/alf' +import { + countActiveFilters, + parseHistoryEntry, +} from '#/screens/Search/searchParams' +import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import * as Layout from '#/components/Layout' @@ -35,7 +39,6 @@ export function SearchHistory({ onRemoveProfileClick: (profile: bsky.profile.AnyProfileView) => void }) { const ax = useAnalytics() - const {t: l} = useLingui() const moderationOpts = useModerationOpts() return ( @@ -87,31 +90,26 @@ export function SearchHistory({ {searchHistory.length > 0 && ( - {searchHistory.slice(0, 5).map((historyItem, index) => ( - - { + const {q, filters} = parseHistoryEntry(historyItem) + const filterCount = countActiveFilters(filters) + + return ( + { ax.metric('search:query', { source: 'history', + filterCount, }) onItemClick(historyItem) }} - hitSlop={HITSLOP_10} - style={[a.flex_1, a.py_sm]}> - {historyItem} - - - - ))} + onRemove={() => onRemoveItemClick(historyItem)} + /> + ) + })} )} @@ -119,6 +117,59 @@ export function SearchHistory({ ) } +function SearchHistoryItem({ + q, + filterCount, + onPress, + onRemove, +}: { + q: string + filterCount: number + onPress: () => void + onRemove: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + {q} + + {filterCount > 0 ? ( + + + + + + ) : null} + + + + ) +} + function RecentProfileItem({ profile, moderationOpts, diff --git a/src/screens/Search/components/SearchLanguageDropdown.tsx b/src/screens/Search/components/SearchLanguageDropdown.tsx index dcef23488a..f91dcd605d 100644 --- a/src/screens/Search/components/SearchLanguageDropdown.tsx +++ b/src/screens/Search/components/SearchLanguageDropdown.tsx @@ -14,17 +14,25 @@ import { } from '#/components/icons/Chevron' import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe' import * as Menu from '#/components/Menu' +import {useAnalytics} from '#/analytics' export function SearchLanguageDropdown({ + showIcon = true, value, onChange, }: { + showIcon?: boolean value: string onChange(value: string): void }) { + const ax = useAnalytics() const {_} = useLingui() const {appLanguage, contentLanguages, primaryLanguage} = useLanguagePrefs() + const searchV2Enabled = ax.features.enabled(ax.features.SearchV2Enable) + const advancedSearchV2Enabled = + searchV2Enabled && ax.features.enabled(ax.features.AdvancedSearchV2Enable) + const languages = useMemo(() => { return LANGUAGES.filter( (lang, index, self) => @@ -75,14 +83,28 @@ export function SearchLanguageDropdown({ {...props} label={props.accessibilityLabel} size="small" - color={platform({native: 'primary', default: 'secondary'})} - variant={platform({native: 'ghost', default: 'solid'})} - style={native([ - a.py_sm, - a.px_sm, - {marginRight: tokens.space.sm * -1}, - ])}> - + color={ + advancedSearchV2Enabled + ? 'secondary' + : platform({native: 'primary', default: 'secondary'}) + } + variant={ + advancedSearchV2Enabled + ? 'solid' + : platform({native: 'ghost', default: 'solid'}) + } + style={ + showIcon + ? native([ + a.py_sm, + a.px_sm, + advancedSearchV2Enabled + ? null + : {marginRight: tokens.space.sm * -1}, + ]) + : null + }> + {showIcon ? : null} {currentLanguageLabel} | undefined, +): SearchFilters { + const filters: SearchFilters = {} + if (!routeParams) return filters + for (const key of FILTER_PARAM_KEYS) { + const value = routeParams[key] + /* + * Guard against the literal string "undefined", which can leak into the + * URL if an undefined param value gets serialized. + */ + if (typeof value === 'string' && value && value !== 'undefined') { + filters[key] = value + } + } + return filters +} + +export function hasActiveFilters(filters: SearchFilters): boolean { + return FILTER_PARAM_KEYS.some(key => filters[key]) +} + +/** + * 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). + */ +export function countActiveFilters(filters: SearchFilters): number { + return FILTER_PARAM_KEYS.filter(key => filters[key]).length +} + +export type SearchHistoryEntry = { + q: string + filters: SearchFilters +} + +/** + * Serializes a search (query text + filters) for term-history storage. Searches + * with no filters are stored as plain strings - both for readability and so + * pre-existing term-only history (also plain strings) stays valid. Only + * filtered searches are JSON-encoded. + */ +export function serializeHistoryEntry( + q: string, + filters: SearchFilters, +): string { + if (!hasActiveFilters(filters)) return q + return JSON.stringify({q, filters}) +} + +/** + * Parses a stored term-history entry. Legacy/term-only entries are plain + * strings; filtered entries are JSON objects. Anything that isn't a + * well-formed {q, filters} object is treated as a plain query string, so a bad + * or pre-existing value never throws. + */ +export function parseHistoryEntry(stored: string): SearchHistoryEntry { + try { + const parsed: unknown = JSON.parse(stored) + if ( + parsed && + typeof parsed === 'object' && + typeof (parsed as {q?: unknown}).q === 'string' + ) { + const obj = parsed as {q: string; filters?: Record} + return {q: obj.q, filters: readSearchFilters(obj.filters)} + } + } catch { + // Not JSON - a plain term-only entry. + } + return {q: stored, filters: {}} +} + +/** + * Filter keys that restrict posts specifically, as opposed to `lang`, which + * applies equally to posts, people, and feeds. Used to decide whether the + * People/Feeds search tabs still make sense: a language alone should not hide + * them, but any post-only filter (author, domain, media, etc.) should. + */ +const POST_ONLY_FILTER_KEYS = FILTER_PARAM_KEYS.filter(key => key !== 'lang') + +export function hasPostOnlyFilters(filters: SearchFilters): boolean { + return POST_ONLY_FILTER_KEYS.some(key => filters[key]) +} + +/** + * Expands filters to a route-params object covering every filter key, with + * absent keys set to undefined. Used with navigation.setParams (which merges) + * so filters the user removed are cleared. NOTE: only safe on native - on web, + * undefined values serialize into the URL as the literal string "undefined". + * Use definedFilterParams + a fresh navigation (push/replace) on web instead. + */ +export function filtersToRouteParams( + filters: SearchFilters, +): Record { + const params: Record = {} + for (const key of FILTER_PARAM_KEYS) { + params[key] = filters[key] || undefined + } + return params +} + +/** + * Returns only the filter keys that have a value, omitting the rest entirely. + * Safe for building a fresh URL on web - absent filters never appear. + */ +export function definedFilterParams( + filters: SearchFilters, +): Record { + const params: Record = {} + for (const key of FILTER_PARAM_KEYS) { + const value = filters[key] + if (value) params[key] = value + } + return params +} + +/** + * Strips all filter keys from a route-params object, leaving non-filter params + * (q, tab, name) intact. Use as the base when rebuilding a fresh param set so + * removed filters - and any stale "undefined" strings - drop out. + */ +export function withoutFilterParams( + routeParams: Record | undefined, +): Record { + const base: Record = {...routeParams} + for (const key of FILTER_PARAM_KEYS) { + delete base[key] + } + return base +} + +/** + * Converts structured filters back into the legacy free-text operators used by + * search v1. UI-only filters are intentionally dropped because the old path + * could not apply them. + */ +export function filtersToLegacyParams( + filters: SearchFilters, +): Record { + const params: Record = {} + if (filters.author) params.from = filters.author + if (filters.mentions) params.mentions = filters.mentions + if (filters.domain) params.domain = filters.domain + if (filters.url) params.url = filters.url + if (filters.tag) params.tag = filters.tag + if (filters.lang) params.lang = filters.lang + if (filters.since) params.since = filters.since + if (filters.until) params.until = filters.until + return params +} + +/** + * Maps each multi-value SearchFilters key to its `app.bsky.feed.searchPostsV2` + * param name. Search v1 only honored the first value for the singular lexicon + * params (author/domain/url); v2 accepts every value and renames them to the + * plural forms here. `tag` becomes `hashtags`; `mentions` keeps its name. The + * exclude* keys map to v2's matching exclude* params. + */ +const MULTI_VALUE_KEY_MAP = { + author: 'authors', + mentions: 'mentions', + domain: 'domains', + url: 'urls', + tag: 'hashtags', + excludeAuthor: 'excludeAuthors', + excludeMentions: 'excludeMentions', + excludeDomain: 'excludeDomains', + excludeUrl: 'excludeUrls', + excludeTag: 'excludeHashtags', +} as const + +/** + * Converts filters into structured params for `app.bsky.feed.searchPostsV2`. + * Output property names match the v2 lexicon. List fields are split into arrays + * of values; scalar fields (language/since/until) pass through. The + * boolean/replies filters (media/video/following/replies) are v2-only - v1 had + * no equivalent and dropped them. + */ +export function filtersToApiParams(filters: SearchFilters): { + authors?: string[] + mentions?: string[] + domains?: string[] + urls?: string[] + hashtags?: string[] + excludeAuthors?: string[] + excludeMentions?: string[] + excludeDomains?: string[] + excludeUrls?: string[] + excludeHashtags?: string[] + language?: string + since?: string + until?: string + hasMedia?: boolean + hasVideo?: boolean + following?: boolean + excludeReplies?: boolean + repliesOnly?: boolean +} { + const params: ReturnType = {} + for (const key of Object.keys( + MULTI_VALUE_KEY_MAP, + ) as (keyof typeof MULTI_VALUE_KEY_MAP)[]) { + const raw = filters[key] + if (!raw) continue + const values = raw.split(/\s+/).filter(Boolean) + if (values.length) params[MULTI_VALUE_KEY_MAP[key]] = values + } + if (filters.lang) params.language = filters.lang + if (filters.since) params.since = filters.since + if (filters.until) params.until = filters.until + if (filters.media === 'true') params.hasMedia = true + if (filters.video === 'true') params.hasVideo = true + if (filters.following === 'true') params.following = true + if (filters.replies === 'none') params.excludeReplies = true + else if (filters.replies === 'only') params.repliesOnly = true + return params +} diff --git a/src/state/queries/__tests__/search-posts-params.test.ts b/src/state/queries/__tests__/search-posts-params.test.ts new file mode 100644 index 0000000000..a35f319fea --- /dev/null +++ b/src/state/queries/__tests__/search-posts-params.test.ts @@ -0,0 +1,199 @@ +import {describe, expect, it} from '@jest/globals' + +import { + buildSearchPostsV2Filters, + extractSearchPostsParams, +} from '#/state/queries/search-posts-params' + +describe(`extractSearchPostsParams`, () => { + const tests: { + name: string + input: string + output: ReturnType + }[] = [ + { + name: `passes bare text through untouched`, + input: `hello world`, + output: {q: `hello world`}, + }, + { + name: `lifts from: into author and strips it from q`, + input: `cats from:alice`, + output: {q: `cats`, author: `alice`}, + }, + { + name: `lifts to: into mentions (alias) and strips it from q`, + input: `cats to:alice`, + output: {q: `cats`, mentions: `alice`}, + }, + { + name: `accumulates multiple hashtags into tag[]`, + input: `#cats #dogs`, + output: {q: ``, tag: [`cats`, `dogs`]}, + }, + { + name: `keeps quoted phrases in q`, + input: `"no clues" from:alice`, + output: {q: `"no clues"`, author: `alice`}, + }, + { + name: `keeps OR groups in q`, + input: `(cats OR dogs) lang:en`, + output: {q: `(cats OR dogs)`, lang: `en`}, + }, + { + name: `extracts a valid since date`, + input: `cats since:2024-01-01`, + output: {q: `cats`, since: `2024-01-01`}, + }, + { + name: `leaves an invalid since date in q`, + input: `cats since:garbage`, + output: {q: `cats since:garbage`}, + }, + { + name: `leaves unsupported operators in q`, + input: `cats replies:only media:true`, + output: {q: `cats replies:only media:true`}, + }, + { + name: `lifts all supported operators at once`, + input: `term from:alice mentions:bob domain:bsky.app url:bsky.app/x lang:en since:2024-01-01 until:2024-02-01 #tag`, + output: { + q: `term`, + author: `alice`, + mentions: `bob`, + domain: `bsky.app`, + url: `bsky.app/x`, + lang: `en`, + since: `2024-01-01`, + until: `2024-02-01`, + tag: [`tag`], + }, + }, + { + name: `keeps the first value for a repeated singular operator`, + input: `from:alice from:bob`, + output: {q: ``, author: `alice`}, + }, + // CJK (and other space-free scripts) carries no whitespace, so the + // whitespace-based tokenizer must keep it intact as bare query text. + { + name: `passes bare CJK text through untouched`, + input: `東京`, + output: {q: `東京`}, + }, + { + name: `lifts an operator from a CJK query and keeps the CJK text`, + input: `東京 from:alice`, + output: {q: `東京`, author: `alice`}, + }, + { + name: `treats a whole CJK phrase as a single token`, + input: `寿司 ラーメン`, + output: {q: `寿司 ラーメン`}, + }, + { + name: `lifts a CJK hashtag into tag[]`, + input: `#日本 ramen`, + output: {q: `ramen`, tag: [`日本`]}, + }, + ] + + it.each(tests)(`$name`, ({input, output}) => { + expect(extractSearchPostsParams(input)).toEqual(output) + }) +}) + +describe(`buildSearchPostsV2Filters`, () => { + it(`maps embedded operators alone into v2 plural params`, () => { + expect( + buildSearchPostsV2Filters({ + author: `alice`, + domain: `bsky.app`, + lang: `en`, + tag: [`cats`], + }), + ).toEqual({ + authors: [`alice`], + domains: [`bsky.app`], + languages: [`en`], + hashtags: [`cats`], + }) + }) + + it(`maps dialog filters alone, including v2-only booleans`, () => { + expect( + buildSearchPostsV2Filters( + {}, + {author: `bob carol`, media: `true`, replies: `none`}, + ), + ).toEqual({ + authors: [`bob`, `carol`], + hasMedia: true, + excludeReplies: true, + }) + }) + + it(`unions list values from both sources without clobbering`, () => { + expect( + buildSearchPostsV2Filters( + {author: `alice`, tag: [`cats`]}, + {author: `bob carol`, tag: `dogs`}, + ), + ).toEqual({ + authors: [`alice`, `bob`, `carol`], + hashtags: [`cats`, `dogs`], + }) + }) + + it(`dedupes overlapping values across sources`, () => { + expect( + buildSearchPostsV2Filters({author: `alice`}, {author: `alice bob`}), + ).toEqual({ + authors: [`alice`, `bob`], + }) + }) + + it(`prefers the dialog filter for scalar fields, falling back to embedded`, () => { + expect( + buildSearchPostsV2Filters( + {lang: `en`, since: `2024-01-01`}, + {lang: `ja`}, + ), + ).toEqual({ + languages: [`ja`], + since: `2024-01-01T00:00:00Z`, + }) + }) + + it(`normalizes date-only since/until to midnight UTC timestamps`, () => { + expect( + buildSearchPostsV2Filters({}, {since: `2024-01-01`, until: `2024-02-01`}), + ).toEqual({ + since: `2024-01-01T00:00:00Z`, + until: `2024-02-01T00:00:00Z`, + }) + }) + + it(`leaves a timestamp with an explicit time component unchanged`, () => { + expect( + buildSearchPostsV2Filters({}, {until: `2024-02-01T12:30:00Z`}), + ).toEqual({ + until: `2024-02-01T12:30:00Z`, + }) + }) + + it(`passes exclude lists through from dialog filters`, () => { + expect( + buildSearchPostsV2Filters( + {author: `alice`}, + {excludeAuthor: `bob carol`, excludeTag: `spam`}, + ), + ).toEqual({ + authors: [`alice`], + excludeAuthors: [`bob`, `carol`], + excludeHashtags: [`spam`], + }) + }) +}) diff --git a/src/state/queries/search-posts-params.ts b/src/state/queries/search-posts-params.ts new file mode 100644 index 0000000000..420f814955 --- /dev/null +++ b/src/state/queries/search-posts-params.ts @@ -0,0 +1,239 @@ +/* + * Pure helpers for lifting structured `app.bsky.feed.searchPosts` params out of + * a free-text query. Kept free of React Native imports so it can be unit + * tested in isolation (the search-posts query hook re-exports these). + */ + +import {type AppBskyFeedSearchPostsV2} from '@atproto/api' + +import { + filtersToApiParams, + type SearchFilters, +} from '#/screens/Search/searchParams' + +const DATE_RE = /^\d{4}-\d{2}-\d{2}/ + +export type ExtractedSearchParams = { + q: string + author?: string + mentions?: string + domain?: string + url?: string + lang?: string + since?: string + until?: string + tag?: string[] +} + +/** + * Splits a query into whitespace-delimited tokens, keeping quoted phrases + * ("a b") and parenthesized OR groups ((a OR b)) intact so they pass through to + * `q` untouched. Shared with the advanced-search dialog's parser (view layer), + * which imports it from here so the two stay in sync. + */ +export function tokenizeQuery(raw: string): string[] { + const tokens: string[] = [] + let i = 0 + const n = raw.length + while (i < n) { + if (/\s/.test(raw[i])) { + i++ + continue + } + const start = i + if (raw[i] === '(') { + let depth = 0 + while (i < n) { + if (raw[i] === '(') depth++ + else if (raw[i] === ')') { + depth-- + if (depth === 0) { + i++ + break + } + } + i++ + } + tokens.push(raw.slice(start, i)) + continue + } + let buf = '' + while (i < n && !/\s/.test(raw[i]) && raw[i] !== '(') { + if (raw[i] === '"') { + buf += raw[i++] + while (i < n && raw[i] !== '"') buf += raw[i++] + if (i < n) buf += raw[i++] + } else { + buf += raw[i++] + } + } + if (buf) tokens.push(buf) + } + return tokens +} + +/** + * Lifts the operators that `app.bsky.feed.searchPosts` accepts as structured + * params out of the free-text query, so the backend filters on them directly. + * Recognized operators are stripped from `q`; everything else (free text, + * quoted phrases, OR groups, negations, and unsupported operators like + * `replies:`, `media:`) is left in `q` verbatim. Singular params keep the first + * value seen; `tag` accumulates (the lexicon AND-matches multiple tags). + */ +export function extractSearchPostsParams(query: string): ExtractedSearchParams { + const result: ExtractedSearchParams = {q: ''} + const remaining: string[] = [] + const tags: string[] = [] + + for (const token of tokenizeQuery(query)) { + if (token.startsWith('#') && token.length > 1 && !token.includes(':')) { + tags.push(token.slice(1)) + continue + } + + const colonIdx = token.indexOf(':') + if (colonIdx === -1) { + remaining.push(token) + continue + } + + const op = token.slice(0, colonIdx) + const value = token.slice(colonIdx + 1) + if (!value) { + remaining.push(token) + continue + } + + switch (op) { + case 'from': + result.author ??= value + break + case 'mentions': + case 'to': + result.mentions ??= value + break + case 'domain': + result.domain ??= value + break + case 'url': + result.url ??= value + break + case 'lang': + result.lang ??= value + break + case 'since': + if (DATE_RE.test(value)) result.since ??= value + else remaining.push(token) + break + case 'until': + if (DATE_RE.test(value)) result.until ??= value + else remaining.push(token) + break + default: + // Unsupported operator (to:, replies:, media:, etc.) - keep in q. + remaining.push(token) + } + } + + if (tags.length) result.tag = tags + result.q = remaining.join(' ') + return result +} + +/** + * Concatenates two optional value lists, dropping empties and duplicates while + * preserving order. Used to union the back-compat operators embedded in the + * query string with the explicit dialog filters so neither source clobbers the + * other. + */ +function mergeList(a?: string[], b?: string[]): string[] | undefined { + const merged = [...new Set([...(a ?? []), ...(b ?? [])])] + return merged.length ? merged : undefined +} + +/** + * Builds the `app.bsky.feed.searchPostsV2` query params (minus q/limit/cursor/ + * sort, which the caller owns) from the operators embedded in the query string + * plus the structured advanced-search dialog filters. The two sources are + * merged rather than overriding each other: list fields union their values, and + * scalar fields prefer the explicit dialog filter, falling back to the embedded + * operator. v2 renames v1's singular operators to plural arrays, and `lang` to + * `language`. + */ +export function buildSearchPostsV2Filters( + embedded: Omit, + filters?: SearchFilters, +): AppBskyFeedSearchPostsV2.QueryParams { + const apiFilters = filters ? filtersToApiParams(filters) : {} + const params: AppBskyFeedSearchPostsV2.QueryParams = {} + + const authors = mergeList( + embedded.author ? [embedded.author] : undefined, + apiFilters.authors, + ) + if (authors) params.authors = authors + + const mentions = mergeList( + embedded.mentions ? [embedded.mentions] : undefined, + apiFilters.mentions, + ) + if (mentions) params.mentions = mentions + + const domains = mergeList( + embedded.domain ? [embedded.domain] : undefined, + apiFilters.domains, + ) + if (domains) params.domains = domains + + const urls = mergeList( + embedded.url ? [embedded.url] : undefined, + apiFilters.urls, + ) + if (urls) params.urls = urls + + const hashtags = mergeList(embedded.tag, apiFilters.hashtags) + if (hashtags) params.hashtags = hashtags + + const language = apiFilters.language ?? embedded.lang + // TODO At the moment, the language selector is single-select. -dsb + if (language) params.languages = [language] + + const since = parseTimestamp(apiFilters.since ?? embedded.since) + if (since) params.since = since + + const until = parseTimestamp(apiFilters.until ?? embedded.until) + if (until) params.until = until + + /* + * Exclude lists have no embedded query-string source (operators like `from:` + * are always include), so they pass straight through from the dialog filters. + */ + if (apiFilters.excludeAuthors) + params.excludeAuthors = apiFilters.excludeAuthors + if (apiFilters.excludeMentions) + params.excludeMentions = apiFilters.excludeMentions + if (apiFilters.excludeDomains) + params.excludeDomains = apiFilters.excludeDomains + if (apiFilters.excludeUrls) params.excludeUrls = apiFilters.excludeUrls + if (apiFilters.excludeHashtags) + params.excludeHashtags = apiFilters.excludeHashtags + + if (apiFilters.hasMedia) params.hasMedia = true + if (apiFilters.hasVideo) params.hasVideo = true + if (apiFilters.following) params.following = true + if (apiFilters.excludeReplies) params.excludeReplies = true + if (apiFilters.repliesOnly) params.repliesOnly = true + + return params +} + +/** + * Consistent with timestamp parsing in @atproto/api. Only the date is used; the + * time is appended here since the lexicon expects a datetime value. + */ +const parseTimestamp = (value: string | undefined): string | undefined => { + if (!value) return undefined + const date = new Date(value) + if (isNaN(date.getTime())) return undefined + return date.toISOString().split('.')[0] + 'Z' +} diff --git a/src/state/queries/search-posts-v2.ts b/src/state/queries/search-posts-v2.ts new file mode 100644 index 0000000000..506240e066 --- /dev/null +++ b/src/state/queries/search-posts-v2.ts @@ -0,0 +1,174 @@ +import {useCallback, useMemo, useRef} from 'react' +import {type AppBskyFeedSearchPostsV2, moderatePost} from '@atproto/api' +import { + type InfiniteData, + type QueryKey, + useInfiniteQuery, +} from '@tanstack/react-query' + +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useAgent} from '#/state/session' +import {type SearchFilters} from '#/screens/Search/searchParams' +import { + buildSearchPostsV2Filters, + extractSearchPostsParams, +} from './search-posts-params' + +/** + * V2 search shares the `'search-posts'` query-key root with the original hook + * (src/state/queries/search-posts.ts) so the shadow-cache generators there - + * findAllPostsInQueryData / findAllProfilesInQueryData - discover V2 results + * too. This module is only used behind the AdvancedSearchV2Enable gate; the + * original hook is unchanged. + */ +const searchPostsQueryKeyRoot = 'search-posts' +const searchPostsV2QueryKey = ({ + query, + sort, + filters, +}: { + query: string + sort?: string + filters?: SearchFilters +}) => [searchPostsQueryKeyRoot, query, sort, filters] + +export function useSearchPostsV2Query({ + query, + sort, + enabled, + filters, +}: { + query: string + sort?: 'top' | 'latest' + enabled?: boolean + filters?: SearchFilters +}) { + const agent = useAgent() + const moderationOpts = useModerationOpts() + const selectArgs = useMemo( + () => ({ + isSearchingSpecificUser: /from:(\w+)/.test(query) || !!filters?.author, + moderationOpts, + }), + [query, filters?.author, moderationOpts], + ) + const lastRun = useRef<{ + data: InfiniteData + args: typeof selectArgs + result: InfiniteData + } | null>(null) + + return useInfiniteQuery< + AppBskyFeedSearchPostsV2.OutputSchema, + Error, + InfiniteData, + QueryKey, + string | undefined + >({ + queryKey: searchPostsV2QueryKey({query, sort, filters}), + queryFn: async ({pageParam}) => { + /* + * Operators embedded in the query string (e.g. for back-compat links) are + * merged with the explicit structured filters from the advanced search + * dialog; see buildSearchPostsV2Filters for how the two sources combine. + */ + const {q, ...embedded} = extractSearchPostsParams(query) + const builtFilters = buildSearchPostsV2Filters(embedded, filters) + /* + * v2 defaults to a recent-post window; the Latest tab keeps that, while + * Top searches the full index. But an explicit since/until date filter + * must search the full index too, otherwise the recent window would + * silently override the user's date range and return nothing for older + * dates. + */ + const hasDateFilter = !!(builtFilters.since || builtFilters.until) + const res = await agent.app.bsky.feed.searchPostsV2({ + ...builtFilters, + query: q, + limit: 25, + cursor: pageParam, + /* + * v2 calls the recency sort 'recent'; the rest of the app still uses + * the v1 'latest' label. + */ + sort: sort === 'latest' ? 'recent' : sort, + allTime: sort !== 'latest' || hasDateFilter, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + enabled: enabled ?? !!moderationOpts, + select: useCallback( + (data: InfiniteData) => { + const {moderationOpts, isSearchingSpecificUser} = selectArgs + + /* + * If a user applies the `from:` filter, don't apply any + * moderation. Note that if we add any more filtering logic below, we + * may need to adjust this. + */ + if (isSearchingSpecificUser) { + return data + } + + /* + * Keep track of the last run and whether we can reuse some already + * selected pages from there. + */ + let reusedPages = [] + if (lastRun.current) { + const { + data: lastData, + args: lastArgs, + result: lastResult, + } = lastRun.current + let canReuse = true + for (let key in selectArgs) { + if (selectArgs.hasOwnProperty(key)) { + if ( + (selectArgs as Record)[key] !== + (lastArgs as Record)[key] + ) { + // Can't do reuse anything if any input has changed. + canReuse = false + break + } + } + } + if (canReuse) { + for (let i = 0; i < data.pages.length; i++) { + if (data.pages[i] && lastData.pages[i] === data.pages[i]) { + reusedPages.push(lastResult.pages[i]) + continue + } + // Stop as soon as pages stop matching up. + break + } + } + } + + const result = { + ...data, + pages: [ + ...reusedPages, + ...data.pages.slice(reusedPages.length).map(page => { + return { + ...page, + posts: page.posts.filter(post => { + const mod = moderatePost(post, moderationOpts!) + return !mod.ui('contentList').filter + }), + } + }), + ], + } + + lastRun.current = {data, result, args: selectArgs} + + return result + }, + [selectArgs], + ), + }) +} diff --git a/src/state/queries/search-posts.ts b/src/state/queries/search-posts.ts index 50fade8632..1fc8963caa 100644 --- a/src/state/queries/search-posts.ts +++ b/src/state/queries/search-posts.ts @@ -97,7 +97,10 @@ export function useSearchPostsQuery({ let canReuse = true for (let key in selectArgs) { if (selectArgs.hasOwnProperty(key)) { - if ((selectArgs as any)[key] !== (lastArgs as any)[key]) { + if ( + (selectArgs as Record)[key] !== + (lastArgs as Record)[key] + ) { // Can't do reuse anything if any input has changed. canReuse = false break