Add advanced search UI (#10992)
This commit is contained in:
@@ -162,11 +162,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/Autocomplete/Autocomplete.tsx": {
|
|
||||||
"@typescript-eslint/no-floating-promises": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/Autocomplete/useAutocomplete/index.ts": {
|
"src/components/Autocomplete/useAutocomplete/index.ts": {
|
||||||
"react-hooks/immutability": {
|
"react-hooks/immutability": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -1866,14 +1861,6 @@
|
|||||||
"count": 2
|
"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": {
|
"src/state/queries/starter-packs.ts": {
|
||||||
"@typescript-eslint/require-await": {
|
"@typescript-eslint/require-await": {
|
||||||
"count": 1
|
"count": 1
|
||||||
|
|||||||
+2
-2
@@ -94,8 +94,8 @@
|
|||||||
"update-actions": "pnpm dlx actions-up --min-age 7"
|
"update-actions": "pnpm dlx actions-up --min-age 7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.20.20",
|
"@atproto/api": "0.20.22",
|
||||||
"@atproto/syntax": "0.6.1",
|
"@atproto/syntax": "0.6.3",
|
||||||
"@bitdrift/react-native": "^0.6.8",
|
"@bitdrift/react-native": "^0.6.8",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@bsky.app/alf": "^0.1.14",
|
"@bsky.app/alf": "^0.1.14",
|
||||||
|
|||||||
Generated
+36
-45
@@ -241,11 +241,11 @@ importers:
|
|||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/api':
|
'@atproto/api':
|
||||||
specifier: 0.20.20
|
specifier: 0.20.22
|
||||||
version: 0.20.20
|
version: 0.20.22
|
||||||
'@atproto/syntax':
|
'@atproto/syntax':
|
||||||
specifier: 0.6.1
|
specifier: 0.6.3
|
||||||
version: 0.6.1
|
version: 0.6.3
|
||||||
'@bitdrift/react-native':
|
'@bitdrift/react-native':
|
||||||
specifier: ^0.6.8
|
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)
|
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:
|
graphql:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@atproto/api@0.20.20':
|
'@atproto/api@0.20.22':
|
||||||
resolution: {integrity: sha512-+yqxLCvMu36xRxwc8/x0mhptZBC+/cbfPwdzeOtlBUeVi4ZB/5RWg2L4lWWdhBSiQBZw0AgaPg/2zsx4lDFgjg==}
|
resolution: {integrity: sha512-TdT9ktYc0FMmMZ8HjkMz13QcSCcYiesCGN81mFZd5owQASHVM9GBtAAmzpkrPyZhUkiIjgh/nUHk8xWVu+4dVA==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@atproto/common-web@0.5.1':
|
'@atproto/common-web@0.5.2':
|
||||||
resolution: {integrity: sha512-nru02gLyzjMQn4ZFgms9ry3kIAKwMaCAvjeFhB9mU6h1ABiSho7aRDbGvnU50CC88TROXuZlgRiEkBjnuiHEtA==}
|
resolution: {integrity: sha512-oO0JEvM7MM7iXMngq6V51IJlzBZFhFJoDjnGnQT75EO94/8Q5hnM/LP+a8KyWjcBcpcSZwQ0bukNv9phZONdGA==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@atproto/lex-data@0.1.2':
|
'@atproto/lex-data@0.1.3':
|
||||||
resolution: {integrity: sha512-NZ4iZvNaqTM6pz+9VRDyEjtozrrf2EDHySNi3Xa8PCzS8gRMCvsnnsvM7r264v4eSqNIDhBB8fr9hfWCHMspXg==}
|
resolution: {integrity: sha512-ysqMYW6cIKce52/+EIbTa+I4pLqZQSBP9aGImN88vAQtd1oZBKPmut6dQk00xSQ8PW9REJRuIHNSFAF4HD+fsw==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@atproto/lex-json@0.1.1':
|
'@atproto/lex-json@0.1.2':
|
||||||
resolution: {integrity: sha512-FC/NsKHm8TDzWikvf/268T3mMAh6+f31yfCApWC3SvrhWc9c06cSYPp7qzpXcdV0OdB+I5dqHScuTB5OC7HrXg==}
|
resolution: {integrity: sha512-58nIjoWX0c8T5fcoPPmIaShxKZgfPMhNmrprtR7GuN4PzyMwm59A3CLlKAzzR+vjI3IidEMU+enpLuwUT8L+Nw==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@atproto/lexicon@0.7.2':
|
'@atproto/lexicon@0.7.3':
|
||||||
resolution: {integrity: sha512-LVZcTr5+9Qh0okZnNmfRiIDPfbL/6rRxf4goiQxPxwDzaeUDhn4M209i1drmiitbCO1qRLJA2hHF54yNA75Cig==}
|
resolution: {integrity: sha512-WP6ct2rjNCKSJN/VFc+8x6JQ7PvRMlfHUlmQM5hzvE3a6nOjm6kwSicjSIV/QldurdRGJ4FCQZ3uZu9Wqt4SxQ==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@atproto/syntax@0.6.1':
|
'@atproto/syntax@0.6.3':
|
||||||
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
|
resolution: {integrity: sha512-io7Ck4o+40iFXhetHYoEtok2gZ8cWcJ1yRftEHe5AmAF0dSGcYmclbx5GatVew59taw+eSG7Ty5D3llop/0BhA==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@atproto/syntax@0.6.2':
|
'@atproto/xrpc@0.8.2':
|
||||||
resolution: {integrity: sha512-h3njTNFl/jv5kTbDqfamQGOJfGbulqLDuAiLbasKwVdBhFyQanpRLa6vE2WqTwv1XEFWLYNMH0KCVsYwT2+aww==}
|
resolution: {integrity: sha512-geLqJwazZuCGnae68KZppciS8DujhGvYtpc/aAj16XpHkUCf5Ds64H1IPrV0uv7SFvd/IAuMZAXZS4GIpfp1iw==}
|
||||||
engines: {node: '>=22'}
|
|
||||||
|
|
||||||
'@atproto/xrpc@0.8.1':
|
|
||||||
resolution: {integrity: sha512-sKopRG3an6LN6NfHnRNIEX1fBx3CRtxbNFWQxyse2f86wMcTuXM+/+olN4lVXgFQgv4AWvKTxRXWyuIF2G4YxQ==}
|
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@babel/code-frame@7.10.4':
|
'@babel/code-frame@7.10.4':
|
||||||
@@ -9476,56 +9472,51 @@ snapshots:
|
|||||||
|
|
||||||
'@0no-co/graphql.web@1.2.0': {}
|
'@0no-co/graphql.web@1.2.0': {}
|
||||||
|
|
||||||
'@atproto/api@0.20.20':
|
'@atproto/api@0.20.22':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/common-web': 0.5.1
|
'@atproto/common-web': 0.5.2
|
||||||
'@atproto/lexicon': 0.7.2
|
'@atproto/lexicon': 0.7.3
|
||||||
'@atproto/syntax': 0.6.2
|
'@atproto/syntax': 0.6.3
|
||||||
'@atproto/xrpc': 0.8.1
|
'@atproto/xrpc': 0.8.2
|
||||||
await-lock: 3.0.0
|
await-lock: 3.0.0
|
||||||
multiformats: 13.4.2
|
multiformats: 13.4.2
|
||||||
tlds: 1.261.0
|
tlds: 1.261.0
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
|
|
||||||
'@atproto/common-web@0.5.1':
|
'@atproto/common-web@0.5.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/lex-data': 0.1.2
|
'@atproto/lex-data': 0.1.3
|
||||||
'@atproto/lex-json': 0.1.1
|
'@atproto/lex-json': 0.1.2
|
||||||
'@atproto/syntax': 0.6.2
|
'@atproto/syntax': 0.6.3
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
|
|
||||||
'@atproto/lex-data@0.1.2':
|
'@atproto/lex-data@0.1.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
multiformats: 13.4.2
|
multiformats: 13.4.2
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
uint8arrays: 5.1.1
|
uint8arrays: 5.1.1
|
||||||
unicode-segmenter: 0.14.5
|
unicode-segmenter: 0.14.5
|
||||||
|
|
||||||
'@atproto/lex-json@0.1.1':
|
'@atproto/lex-json@0.1.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/lex-data': 0.1.2
|
'@atproto/lex-data': 0.1.3
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@atproto/lexicon@0.7.2':
|
'@atproto/lexicon@0.7.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/common-web': 0.5.1
|
'@atproto/common-web': 0.5.2
|
||||||
'@atproto/syntax': 0.6.2
|
'@atproto/syntax': 0.6.3
|
||||||
multiformats: 13.4.2
|
multiformats: 13.4.2
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
|
|
||||||
'@atproto/syntax@0.6.1':
|
'@atproto/syntax@0.6.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
iso-datestring-validator: 2.2.2
|
iso-datestring-validator: 2.2.2
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@atproto/syntax@0.6.2':
|
'@atproto/xrpc@0.8.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
iso-datestring-validator: 2.2.2
|
'@atproto/lexicon': 0.7.3
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@atproto/xrpc@0.8.1':
|
|
||||||
dependencies:
|
|
||||||
'@atproto/lexicon': 0.7.2
|
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
|
|
||||||
'@babel/code-frame@7.10.4':
|
'@babel/code-frame@7.10.4':
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export enum Features {
|
|||||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||||
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
|
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
|
||||||
|
SearchV2Enable = 'search_v2:enable',
|
||||||
|
AdvancedSearchV2Enable = 'advanced_search_v2:enable',
|
||||||
|
|
||||||
AATest = 'aa-test',
|
AATest = 'aa-test',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -775,6 +775,7 @@ export type Events = {
|
|||||||
|
|
||||||
'search:query': {
|
'search:query': {
|
||||||
source: 'typed' | 'history' | 'autocomplete'
|
source: 'typed' | 'history' | 'autocomplete'
|
||||||
|
filterCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
'search:results:loaded': {
|
'search:results:loaded': {
|
||||||
@@ -799,6 +800,18 @@ export type Events = {
|
|||||||
position: number
|
position: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
'search:advanced:press': {
|
||||||
|
filterCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
'search:shareLink:press': {
|
||||||
|
filterCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
'search:addFilter:press': {
|
||||||
|
filterCount: number
|
||||||
|
}
|
||||||
|
|
||||||
'progressGuide:hide': {}
|
'progressGuide:hide': {}
|
||||||
'progressGuide:followDialog:open': {}
|
'progressGuide:followDialog:open': {}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function Autocomplete({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const updatePosition = useCallback(() => {
|
const updatePosition = useCallback(() => {
|
||||||
sift.updatePosition()
|
void sift.updatePosition()
|
||||||
}, [sift])
|
}, [sift])
|
||||||
|
|
||||||
useOnKeyboard('keyboardDidShow', updatePosition)
|
useOnKeyboard('keyboardDidShow', updatePosition)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function DateField({
|
|||||||
testID,
|
testID,
|
||||||
accessibilityHint,
|
accessibilityHint,
|
||||||
maximumDate,
|
maximumDate,
|
||||||
|
minimumDate,
|
||||||
}: DateFieldProps) {
|
}: DateFieldProps) {
|
||||||
const {i18n} = useLingui()
|
const {i18n} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
@@ -107,6 +108,9 @@ export function DateField({
|
|||||||
maximumDate={
|
maximumDate={
|
||||||
maximumDate ? new Date(toSimpleDateString(maximumDate)) : undefined
|
maximumDate ? new Date(toSimpleDateString(maximumDate)) : undefined
|
||||||
}
|
}
|
||||||
|
minimumDate={
|
||||||
|
minimumDate ? new Date(toSimpleDateString(minimumDate)) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function DateField({
|
|||||||
isInvalid,
|
isInvalid,
|
||||||
accessibilityHint,
|
accessibilityHint,
|
||||||
maximumDate,
|
maximumDate,
|
||||||
|
minimumDate,
|
||||||
}: DateFieldProps) {
|
}: DateFieldProps) {
|
||||||
const {_, i18n} = useLingui()
|
const {_, i18n} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
@@ -116,6 +117,11 @@ export function DateField({
|
|||||||
? new Date(toSimpleDateString(maximumDate))
|
? new Date(toSimpleDateString(maximumDate))
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
minimumDate={
|
||||||
|
minimumDate
|
||||||
|
? new Date(toSimpleDateString(minimumDate))
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function DateField({
|
|||||||
testID,
|
testID,
|
||||||
accessibilityHint,
|
accessibilityHint,
|
||||||
maximumDate,
|
maximumDate,
|
||||||
|
minimumDate,
|
||||||
}: DateFieldProps) {
|
}: DateFieldProps) {
|
||||||
const handleOnChange = useCallback(
|
const handleOnChange = useCallback(
|
||||||
(e: any) => {
|
(e: any) => {
|
||||||
@@ -68,6 +69,7 @@ export function DateField({
|
|||||||
accessibilityHint={accessibilityHint}
|
accessibilityHint={accessibilityHint}
|
||||||
// @ts-expect-error not typed as <input type="date"> even though it is one
|
// @ts-expect-error not typed as <input type="date"> even though it is one
|
||||||
max={maximumDate ? toSimpleDateString(maximumDate) : undefined}
|
max={maximumDate ? toSimpleDateString(maximumDate) : undefined}
|
||||||
|
min={minimumDate ? toSimpleDateString(minimumDate) : undefined}
|
||||||
/>
|
/>
|
||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ export type DateFieldProps = {
|
|||||||
testID?: string
|
testID?: string
|
||||||
accessibilityHint?: string
|
accessibilityHint?: string
|
||||||
maximumDate?: string | Date
|
maximumDate?: string | Date
|
||||||
|
minimumDate?: string | Date
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-5
@@ -1,10 +1,24 @@
|
|||||||
import {type NavigationState, type PartialState} from '@react-navigation/native'
|
import {type NavigationState, type PartialState} from '@react-navigation/native'
|
||||||
import {type NativeStackNavigationProp} from '@react-navigation/native-stack'
|
import {type NativeStackNavigationProp} from '@react-navigation/native-stack'
|
||||||
|
|
||||||
|
import {type SearchFilters} from '#/screens/Search/searchParams'
|
||||||
import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
|
import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
|
||||||
|
|
||||||
export type {NativeStackScreenProps} from '@react-navigation/native-stack'
|
export type {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The advanced-search filter params are owned by searchParams.ts (the param
|
||||||
|
* model, serialization, and helpers all live there). Re-export the type so the
|
||||||
|
* route params stay in sync with it automatically rather than being a second
|
||||||
|
* hand-maintained copy.
|
||||||
|
*/
|
||||||
|
export type SearchFilterParams = SearchFilters
|
||||||
|
|
||||||
|
export type SearchParams = {
|
||||||
|
q?: string
|
||||||
|
tab?: 'user' | 'profile' | 'feed' | 'latest'
|
||||||
|
} & SearchFilterParams
|
||||||
|
|
||||||
export type CommonNavigatorParams = {
|
export type CommonNavigatorParams = {
|
||||||
NotFound: undefined
|
NotFound: undefined
|
||||||
Lists: undefined
|
Lists: undefined
|
||||||
@@ -19,7 +33,7 @@ export type CommonNavigatorParams = {
|
|||||||
ProfileFollowers: {name: string}
|
ProfileFollowers: {name: string}
|
||||||
ProfileFollows: {name: string}
|
ProfileFollows: {name: string}
|
||||||
ProfileKnownFollowers: {name: string}
|
ProfileKnownFollowers: {name: string}
|
||||||
ProfileSearch: {name: string; q?: string}
|
ProfileSearch: {name: string} & SearchParams
|
||||||
ProfileList: {name: string; rkey: string}
|
ProfileList: {name: string; rkey: string}
|
||||||
PostThread: {name: string; rkey: string}
|
PostThread: {name: string; rkey: string}
|
||||||
PostLikedBy: {name: string; rkey: string}
|
PostLikedBy: {name: string; rkey: string}
|
||||||
@@ -60,7 +74,7 @@ export type CommonNavigatorParams = {
|
|||||||
AppIconSettings: undefined
|
AppIconSettings: undefined
|
||||||
FindContactsSettings: undefined
|
FindContactsSettings: undefined
|
||||||
InviteScanner: undefined
|
InviteScanner: undefined
|
||||||
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
|
Search: SearchParams
|
||||||
Hashtag: {tag: string; author?: string}
|
Hashtag: {tag: string; author?: string}
|
||||||
Topic: {topic: string}
|
Topic: {topic: string}
|
||||||
MessagesConversation: {conversation: string; embed?: string; accept?: true}
|
MessagesConversation: {conversation: string; embed?: string; accept?: true}
|
||||||
@@ -98,7 +112,7 @@ export type HomeTabNavigatorParams = CommonNavigatorParams & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SearchTabNavigatorParams = CommonNavigatorParams & {
|
export type SearchTabNavigatorParams = CommonNavigatorParams & {
|
||||||
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
|
Search: SearchParams
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
|
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
|
||||||
@@ -119,7 +133,7 @@ export type MessagesTabNavigatorParams = CommonNavigatorParams & {
|
|||||||
|
|
||||||
export type FlatNavigatorParams = CommonNavigatorParams & {
|
export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||||
Home: undefined
|
Home: undefined
|
||||||
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
|
Search: SearchParams
|
||||||
Feeds: undefined
|
Feeds: undefined
|
||||||
Notifications: undefined
|
Notifications: undefined
|
||||||
Messages: {
|
Messages: {
|
||||||
@@ -133,7 +147,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
|||||||
HomeTab: undefined
|
HomeTab: undefined
|
||||||
Home: undefined
|
Home: undefined
|
||||||
SearchTab: undefined
|
SearchTab: undefined
|
||||||
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
|
Search: SearchParams
|
||||||
Feeds: undefined
|
Feeds: undefined
|
||||||
NotificationsTab: undefined
|
NotificationsTab: undefined
|
||||||
Notifications: undefined
|
Notifications: undefined
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const ProfileSearchScreen = ({route}: Props) => {
|
|||||||
|
|
||||||
const fixedParams = useMemo(
|
const fixedParams = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
from: profile?.handle ?? name,
|
author: profile?.handle ?? name,
|
||||||
}),
|
}),
|
||||||
[profile?.handle, name],
|
[profile?.handle, name],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {augmentSearchQuery} from '#/lib/strings/helpers'
|
|||||||
import {useActorSearch} from '#/state/queries/actor-search'
|
import {useActorSearch} from '#/state/queries/actor-search'
|
||||||
import {usePopularFeedsSearch} from '#/state/queries/feed'
|
import {usePopularFeedsSearch} from '#/state/queries/feed'
|
||||||
import {useSearchPostsQuery} from '#/state/queries/search-posts'
|
import {useSearchPostsQuery} from '#/state/queries/search-posts'
|
||||||
|
import {useSearchPostsV2Query} from '#/state/queries/search-posts-v2'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||||
import {useCloseAllActiveElements} from '#/state/util'
|
import {useCloseAllActiveElements} from '#/state/util'
|
||||||
@@ -19,6 +20,10 @@ import {TabBar} from '#/view/com/pager/TabBar'
|
|||||||
import {Post} from '#/view/com/post/Post'
|
import {Post} from '#/view/com/post/Post'
|
||||||
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||||
import {List} from '#/view/com/util/List'
|
import {List} from '#/view/com/util/List'
|
||||||
|
import {
|
||||||
|
hasPostOnlyFilters,
|
||||||
|
type SearchFilters,
|
||||||
|
} from '#/screens/Search/searchParams'
|
||||||
import {atoms as a, useTheme, web} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
import * as FeedCard from '#/components/FeedCard'
|
import * as FeedCard from '#/components/FeedCard'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
@@ -32,31 +37,48 @@ import type * as bsky from '#/types/bsky'
|
|||||||
let SearchResults = ({
|
let SearchResults = ({
|
||||||
query,
|
query,
|
||||||
queryWithParams,
|
queryWithParams,
|
||||||
|
filters,
|
||||||
|
hasFilters,
|
||||||
activeTab,
|
activeTab,
|
||||||
onPageSelected,
|
onPageSelected,
|
||||||
headerHeight,
|
headerHeight,
|
||||||
initialPage = 0,
|
|
||||||
}: {
|
}: {
|
||||||
query: string
|
query: string
|
||||||
queryWithParams: string
|
queryWithParams: string
|
||||||
|
filters: SearchFilters
|
||||||
|
hasFilters: boolean
|
||||||
activeTab: number
|
activeTab: number
|
||||||
onPageSelected: (page: number) => void
|
onPageSelected: (page: number) => void
|
||||||
headerHeight: number
|
headerHeight: number
|
||||||
initialPage?: number
|
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
/*
|
||||||
|
* People/Feeds visibility keys off post-only filters: a `lang` filter applies
|
||||||
|
* to people and feeds too, so it must not hide those tabs (which would also
|
||||||
|
* regress the non-v2 legacy language dropdown). Other filters are post-only.
|
||||||
|
*/
|
||||||
|
const hasPostFilters = hasPostOnlyFilters(filters)
|
||||||
|
const activePage = hasPostFilters && activeTab > 1 ? 0 : activeTab
|
||||||
|
const tabShape = hasPostFilters ? 'filtered' : 'plain'
|
||||||
|
|
||||||
const sections = useMemo(() => {
|
const sections = useMemo(() => {
|
||||||
if (!queryWithParams) return []
|
if (!query && !hasFilters) return []
|
||||||
const noParams = queryWithParams === query
|
/*
|
||||||
|
* People and Feeds tabs only make sense without post-restricting filters -
|
||||||
|
* those filters don't apply to actors or feeds.
|
||||||
|
*/
|
||||||
|
const noFilters = !hasPostFilters
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
title: l`Top`,
|
title: l`Top`,
|
||||||
component: (
|
component: (
|
||||||
<SearchScreenPostResults
|
<SearchScreenPostResults
|
||||||
query={queryWithParams}
|
hasFilters={hasFilters}
|
||||||
|
query={query}
|
||||||
|
queryWithParams={queryWithParams}
|
||||||
|
filters={filters}
|
||||||
sort="top"
|
sort="top"
|
||||||
active={activeTab === 0}
|
active={activePage === 0}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -64,35 +86,47 @@ let SearchResults = ({
|
|||||||
title: l`Latest`,
|
title: l`Latest`,
|
||||||
component: (
|
component: (
|
||||||
<SearchScreenPostResults
|
<SearchScreenPostResults
|
||||||
query={queryWithParams}
|
hasFilters={hasFilters}
|
||||||
|
query={query}
|
||||||
|
queryWithParams={queryWithParams}
|
||||||
|
filters={filters}
|
||||||
sort="latest"
|
sort="latest"
|
||||||
active={activeTab === 1}
|
active={activePage === 1}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
noParams && {
|
noFilters && {
|
||||||
title: l`People`,
|
title: l`People`,
|
||||||
component: (
|
component: (
|
||||||
<SearchScreenUserResults query={query} active={activeTab === 2} />
|
<SearchScreenUserResults query={query} active={activePage === 2} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
noParams && {
|
noFilters && {
|
||||||
title: l`Feeds`,
|
title: l`Feeds`,
|
||||||
component: (
|
component: (
|
||||||
<SearchScreenFeedsResults query={query} active={activeTab === 3} />
|
<SearchScreenFeedsResults query={query} active={activePage === 3} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
].filter(Boolean) as {
|
].filter(Boolean) as {
|
||||||
title: string
|
title: string
|
||||||
component: React.ReactNode
|
component: React.ReactNode
|
||||||
}[]
|
}[]
|
||||||
}, [l, query, queryWithParams, activeTab])
|
}, [
|
||||||
|
l,
|
||||||
|
query,
|
||||||
|
queryWithParams,
|
||||||
|
filters,
|
||||||
|
hasFilters,
|
||||||
|
hasPostFilters,
|
||||||
|
activePage,
|
||||||
|
])
|
||||||
|
|
||||||
// There may be fewer tabs after changing the search options.
|
// There may be fewer tabs after changing the search options.
|
||||||
const selectedPage = initialPage > sections.length - 1 ? 0 : initialPage
|
const selectedPage = activePage > sections.length - 1 ? 0 : activePage
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pager
|
<Pager
|
||||||
|
key={tabShape}
|
||||||
onPageSelected={onPageSelected}
|
onPageSelected={onPageSelected}
|
||||||
renderTabBar={props => (
|
renderTabBar={props => (
|
||||||
<Layout.Center style={[a.z_10, web([a.sticky, {top: headerHeight}])]}>
|
<Layout.Center style={[a.z_10, web([a.sticky, {top: headerHeight}])]}>
|
||||||
@@ -164,38 +198,94 @@ function EmptyState({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NoResultsText({
|
function NoResultsText({
|
||||||
|
hasFilters = false,
|
||||||
query,
|
query,
|
||||||
}: {
|
}: {
|
||||||
sort?: 'top' | 'latest' | 'people' | 'feeds'
|
hasFilters?: boolean
|
||||||
query: string
|
query: string
|
||||||
}) {
|
}) {
|
||||||
|
const ax = useAnalytics()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
const searchV2Enabled = ax.features.enabled(ax.features.SearchV2Enable)
|
||||||
|
const advancedSearchV2Enabled =
|
||||||
|
searchV2Enabled && ax.features.enabled(ax.features.AdvancedSearchV2Enable)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text style={[a.text_lg, t.atoms.text_contrast_high]}>
|
<Text style={[a.text_lg, t.atoms.text_contrast_high]}>
|
||||||
<Trans>
|
{hasFilters ? (
|
||||||
No results found for “
|
query ? (
|
||||||
<Text style={[a.text_lg, t.atoms.text, a.font_medium]}>{query}</Text>
|
<Trans>
|
||||||
”.
|
No results found for “
|
||||||
</Trans>
|
<Text style={[a.text_lg, a.font_medium]}>{query}</Text>” with
|
||||||
|
advanced search filters applied.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
No results found for your query with advanced search filters
|
||||||
|
applied.
|
||||||
|
</Trans>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
No results found for “
|
||||||
|
<Text style={[a.text_lg, a.font_medium]}>{query}</Text>”.
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
{'\n\n'}
|
{'\n\n'}
|
||||||
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
|
<Text
|
||||||
<Trans context="english-only-resource">
|
style={[
|
||||||
Try a different search term, or{' '}
|
a.mt_lg,
|
||||||
<InlineLinkText
|
a.text_md,
|
||||||
label={l({
|
a.leading_snug,
|
||||||
message: 'read about how to use search filters',
|
t.atoms.text_contrast_high,
|
||||||
context: 'english-only-resource',
|
]}>
|
||||||
})}
|
{hasFilters ? (
|
||||||
to={urls.website.blog.searchTipsAndTricks}
|
<Trans>Try a different search term or remove some filters.</Trans>
|
||||||
style={[a.text_md, a.leading_snug]}>
|
) : (
|
||||||
read about how to use search filters
|
<Trans>Try a different search term.</Trans>
|
||||||
</InlineLinkText>
|
)}
|
||||||
.
|
</Text>
|
||||||
</Trans>
|
{'\n\n'}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.mt_lg,
|
||||||
|
a.text_md,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]}>
|
||||||
|
{advancedSearchV2Enabled ? (
|
||||||
|
<Trans context="english-only-resource">
|
||||||
|
Learn more about{' '}
|
||||||
|
<InlineLinkText
|
||||||
|
label={l({
|
||||||
|
message: 'Read about how to use advanced search filters',
|
||||||
|
context: 'english-only-resource',
|
||||||
|
})}
|
||||||
|
to={urls.website.blog.searchTipsAndTricks}
|
||||||
|
style={[a.text_md, a.leading_snug]}>
|
||||||
|
how to use advanced search
|
||||||
|
</InlineLinkText>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans context="english-only-resource">
|
||||||
|
Learn more about{' '}
|
||||||
|
<InlineLinkText
|
||||||
|
label={l({
|
||||||
|
message: 'Read about how to use search filters',
|
||||||
|
context: 'english-only-resource',
|
||||||
|
})}
|
||||||
|
to={urls.website.blog.searchTipsAndTricks}
|
||||||
|
style={[a.text_md, a.leading_snug]}>
|
||||||
|
how to use search filters
|
||||||
|
</InlineLinkText>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -213,11 +303,17 @@ type SearchResultSlice =
|
|||||||
}
|
}
|
||||||
|
|
||||||
let SearchScreenPostResults = ({
|
let SearchScreenPostResults = ({
|
||||||
|
hasFilters = false,
|
||||||
query,
|
query,
|
||||||
|
queryWithParams,
|
||||||
|
filters,
|
||||||
sort,
|
sort,
|
||||||
active,
|
active,
|
||||||
}: {
|
}: {
|
||||||
|
hasFilters: boolean
|
||||||
query: string
|
query: string
|
||||||
|
queryWithParams: string
|
||||||
|
filters?: SearchFilters
|
||||||
sort?: 'top' | 'latest'
|
sort?: 'top' | 'latest'
|
||||||
active: boolean
|
active: boolean
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
@@ -227,10 +323,31 @@ let SearchScreenPostResults = ({
|
|||||||
const [isPTR, setIsPTR] = useState(false)
|
const [isPTR, setIsPTR] = useState(false)
|
||||||
const trackPostView = usePostViewTracking('SearchResults')
|
const trackPostView = usePostViewTracking('SearchResults')
|
||||||
|
|
||||||
const augmentedQuery = useMemo(() => {
|
const searchV2Enabled = ax.features.enabled(ax.features.SearchV2Enable)
|
||||||
|
|
||||||
|
const augmentedV2Query = useMemo(() => {
|
||||||
return augmentSearchQuery(query || '', {did: currentAccount?.did})
|
return augmentSearchQuery(query || '', {did: currentAccount?.did})
|
||||||
}, [query, currentAccount])
|
}, [query, currentAccount])
|
||||||
|
const augmentedV1Query = useMemo(() => {
|
||||||
|
return augmentSearchQuery(queryWithParams || '', {did: currentAccount?.did})
|
||||||
|
}, [queryWithParams, currentAccount])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Both hooks are called to keep hook order stable; `enabled` ensures only the
|
||||||
|
* gated one actually fetches. V2 sends structured `filters` as separate
|
||||||
|
* params, V1 keeps the existing single-`q` behavior.
|
||||||
|
*/
|
||||||
|
const v1 = useSearchPostsQuery({
|
||||||
|
query: augmentedV1Query,
|
||||||
|
sort,
|
||||||
|
enabled: active && !searchV2Enabled,
|
||||||
|
})
|
||||||
|
const v2 = useSearchPostsV2Query({
|
||||||
|
query: augmentedV2Query,
|
||||||
|
filters,
|
||||||
|
sort,
|
||||||
|
enabled: active && searchV2Enabled,
|
||||||
|
})
|
||||||
const {
|
const {
|
||||||
isFetched,
|
isFetched,
|
||||||
data: results,
|
data: results,
|
||||||
@@ -240,7 +357,7 @@ let SearchScreenPostResults = ({
|
|||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
} = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active})
|
} = searchV2Enabled ? v2 : v1
|
||||||
|
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const onPullToRefresh = useCallback(async () => {
|
const onPullToRefresh = useCallback(async () => {
|
||||||
@@ -381,7 +498,11 @@ let SearchScreenPostResults = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState messageText={<NoResultsText query={query} />} />
|
<EmptyState
|
||||||
|
messageText={
|
||||||
|
<NoResultsText hasFilters={hasFilters} query={query} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+302
-126
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
import {setStringAsync} from 'expo-clipboard'
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native'
|
import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
@@ -19,7 +21,7 @@ import {useQueryClient} from '@tanstack/react-query'
|
|||||||
import {HITSLOP_10, HITSLOP_20} from '#/lib/constants'
|
import {HITSLOP_10, HITSLOP_20} from '#/lib/constants'
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {MagnifyingGlassIcon} from '#/lib/icons'
|
import {MagnifyingGlassIcon} from '#/lib/icons'
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp, type SearchParams} from '#/lib/routes/types'
|
||||||
import {listenSoftReset} from '#/state/events'
|
import {listenSoftReset} from '#/state/events'
|
||||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||||
import {
|
import {
|
||||||
@@ -28,26 +30,44 @@ import {
|
|||||||
} from '#/state/queries/profile'
|
} from '#/state/queries/profile'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {
|
import {
|
||||||
makeSearchQuery,
|
countActiveFilters,
|
||||||
type Params,
|
definedFilterParams,
|
||||||
parseSearchQuery,
|
filtersToLegacyParams,
|
||||||
} from '#/screens/Search/utils'
|
filtersToRouteParams,
|
||||||
|
hasActiveFilters,
|
||||||
|
parseHistoryEntry,
|
||||||
|
readSearchFilters,
|
||||||
|
type SearchFilters,
|
||||||
|
serializeHistoryEntry,
|
||||||
|
withoutFilterParams,
|
||||||
|
} from '#/screens/Search/searchParams'
|
||||||
|
import {makeSearchQuery} from '#/screens/Search/utils'
|
||||||
import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
|
import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import {SearchInput} from '#/components/forms/SearchInput'
|
import {SearchInput} from '#/components/forms/SearchInput'
|
||||||
|
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
|
||||||
|
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_WEB} from '#/env'
|
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||||
import {account, useStorage} from '#/storage'
|
import {account, useStorage} from '#/storage'
|
||||||
import type * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
|
import {AdvancedSearchDialog} from './components/AdvancedSearchDialog'
|
||||||
import {AutocompleteResults} from './components/AutocompleteResults'
|
import {AutocompleteResults} from './components/AutocompleteResults'
|
||||||
|
import {DetectedLanguagesAdmonition} from './components/DetectedLanguagesAdmonition'
|
||||||
import {SearchHistory} from './components/SearchHistory'
|
import {SearchHistory} from './components/SearchHistory'
|
||||||
import {SearchLanguageDropdown} from './components/SearchLanguageDropdown'
|
import {SearchLanguageDropdown} from './components/SearchLanguageDropdown'
|
||||||
import {Explore} from './Explore'
|
import {Explore} from './Explore'
|
||||||
import {SearchResults} from './SearchResults'
|
import {SearchResults} from './SearchResults'
|
||||||
|
|
||||||
type TabParam = 'user' | 'profile' | 'feed' | 'latest'
|
/**
|
||||||
|
* Derived from the route param so the two can't drift. NonNullable because the
|
||||||
|
* tab param is optional on the route but the handlers below always work with a
|
||||||
|
* concrete value.
|
||||||
|
*/
|
||||||
|
type TabParam = NonNullable<SearchParams['tab']>
|
||||||
|
|
||||||
// Map tab parameter to tab index
|
// Map tab parameter to tab index
|
||||||
function getTabIndex(tabParam?: TabParam) {
|
function getTabIndex(tabParam?: TabParam) {
|
||||||
@@ -74,7 +94,7 @@ export function SearchScreenShell({
|
|||||||
}: {
|
}: {
|
||||||
queryParam: string
|
queryParam: string
|
||||||
testID: string
|
testID: string
|
||||||
fixedParams?: Params
|
fixedParams?: SearchFilters
|
||||||
navButton?: 'back' | 'menu'
|
navButton?: 'back' | 'menu'
|
||||||
inputPlaceholder?: string
|
inputPlaceholder?: string
|
||||||
isExplore?: boolean
|
isExplore?: boolean
|
||||||
@@ -89,6 +109,10 @@ export function SearchScreenShell({
|
|||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const searchV2Enabled = ax.features.enabled(ax.features.SearchV2Enable)
|
||||||
|
const advancedSearchV2Enabled =
|
||||||
|
searchV2Enabled && ax.features.enabled(ax.features.AdvancedSearchV2Enable)
|
||||||
|
|
||||||
// Get tab parameter from route params
|
// Get tab parameter from route params
|
||||||
const tabParam = (route.params as {q?: string; tab?: TabParam})?.tab
|
const tabParam = (route.params as {q?: string; tab?: TabParam})?.tab
|
||||||
const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam))
|
const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam))
|
||||||
@@ -100,6 +124,7 @@ export function SearchScreenShell({
|
|||||||
searchTextRef.current = text
|
searchTextRef.current = text
|
||||||
setSearchText(text)
|
setSearchText(text)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const {data: autocompleteData, isFetching: isAutocompleteFetching} =
|
const {data: autocompleteData, isFetching: isAutocompleteFetching} =
|
||||||
useActorAutocompleteQuery(searchText, true)
|
useActorAutocompleteQuery(searchText, true)
|
||||||
|
|
||||||
@@ -120,8 +145,14 @@ export function SearchScreenShell({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const updateSearchHistory = useCallback(
|
const updateSearchHistory = useCallback(
|
||||||
(item: string) => {
|
(q: string, searchFilters: SearchFilters = {}) => {
|
||||||
if (!item) return
|
if (!q) return
|
||||||
|
/*
|
||||||
|
* Store the query plus any advanced-search filters. Term-only searches
|
||||||
|
* serialize to a plain string (back-compatible with existing history);
|
||||||
|
* filtered searches serialize to JSON. Dedupe on the serialized form.
|
||||||
|
*/
|
||||||
|
const item = serializeHistoryEntry(q, searchFilters)
|
||||||
const newSearchHistory = [
|
const newSearchHistory = [
|
||||||
item,
|
item,
|
||||||
...termHistory.filter(search => search !== item),
|
...termHistory.filter(search => search !== item),
|
||||||
@@ -155,11 +186,19 @@ export function SearchScreenShell({
|
|||||||
[accountHistory, setAccountHistory],
|
[accountHistory, setAccountHistory],
|
||||||
)
|
)
|
||||||
|
|
||||||
const {params, query, queryWithParams} = useQueryManager({
|
const {query, queryWithParams, filters, setFilters, hasFilters} =
|
||||||
initialQuery: queryParam,
|
useQueryManager({
|
||||||
fixedParams,
|
initialQuery: queryParam,
|
||||||
})
|
fixedParams,
|
||||||
const showFilters = Boolean(queryWithParams && !showAutocomplete)
|
})
|
||||||
|
const showFilters = Boolean((query || hasFilters) && !showAutocomplete)
|
||||||
|
|
||||||
|
const onChangeLang = useCallback(
|
||||||
|
(lang: string) => {
|
||||||
|
setFilters({...filters, lang: lang || undefined})
|
||||||
|
},
|
||||||
|
[filters, setFilters],
|
||||||
|
)
|
||||||
|
|
||||||
// web only - measure header height for sticky positioning
|
// web only - measure header height for sticky positioning
|
||||||
const [headerHeight, setHeaderHeight] = useState(0)
|
const [headerHeight, setHeaderHeight] = useState(0)
|
||||||
@@ -172,6 +211,18 @@ export function SearchScreenShell({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* On native, navigating to an already-mounted Search screen with a new `q`
|
||||||
|
* (e.g. from a post hashtag/search link) updates the route param without
|
||||||
|
* remounting, so re-sync the input. Web handles this via the focus effect
|
||||||
|
* below, which fires on back/forward navigation.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (IS_NATIVE) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
updateSearchText(queryParam)
|
||||||
|
}
|
||||||
|
}, [queryParam, updateSearchText])
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useNonReactiveCallback(() => {
|
useNonReactiveCallback(() => {
|
||||||
if (IS_WEB) {
|
if (IS_WEB) {
|
||||||
@@ -182,9 +233,27 @@ export function SearchScreenShell({
|
|||||||
|
|
||||||
const onPressClearQuery = useCallback(() => {
|
const onPressClearQuery = useCallback(() => {
|
||||||
scrollToTopWeb()
|
scrollToTopWeb()
|
||||||
updateSearchText('')
|
/*
|
||||||
|
* Clearing the query also resets any advanced-search filters, then keeps
|
||||||
|
* the input focused so the user can immediately type a new search.
|
||||||
|
*/
|
||||||
|
if (IS_WEB) {
|
||||||
|
/*
|
||||||
|
* Replace the param set so q/tab/filters drop out of the URL instead of
|
||||||
|
* serializing as the literal string "undefined". fixedParams live on the
|
||||||
|
* route already and are preserved by withoutSearchParams.
|
||||||
|
*/
|
||||||
|
const parameters = withoutSearchParams(
|
||||||
|
route.params as Record<string, unknown>,
|
||||||
|
)
|
||||||
|
// @ts-expect-error route is not typesafe
|
||||||
|
navigation.replace(route.name, parameters)
|
||||||
|
} else {
|
||||||
|
updateSearchText('')
|
||||||
|
navigation.setParams({...filtersToRouteParams({}), q: ''})
|
||||||
|
}
|
||||||
textInput.current?.focus()
|
textInput.current?.focus()
|
||||||
}, [updateSearchText])
|
}, [updateSearchText, navigation, route])
|
||||||
|
|
||||||
const onChangeText = useCallback(
|
const onChangeText = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
@@ -195,20 +264,25 @@ export function SearchScreenShell({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const navigateToItem = useCallback(
|
const navigateToItem = useCallback(
|
||||||
(item: string) => {
|
(item: string, itemFilters: SearchFilters = filters) => {
|
||||||
scrollToTopWeb()
|
scrollToTopWeb()
|
||||||
setShowAutocomplete(false)
|
setShowAutocomplete(false)
|
||||||
updateSearchHistory(item)
|
updateSearchHistory(item, itemFilters)
|
||||||
|
|
||||||
if (IS_WEB) {
|
if (IS_WEB) {
|
||||||
|
const nextParams = {
|
||||||
|
...withoutFilterParams(route.params as Record<string, unknown>),
|
||||||
|
...definedFilterParams(itemFilters),
|
||||||
|
q: item,
|
||||||
|
}
|
||||||
// @ts-expect-error route is not typesafe
|
// @ts-expect-error route is not typesafe
|
||||||
navigation.push(route.name, {...route.params, q: item})
|
navigation.push(route.name, nextParams)
|
||||||
} else {
|
} else {
|
||||||
textInput.current?.blur()
|
textInput.current?.blur()
|
||||||
navigation.setParams({q: item})
|
navigation.setParams({...filtersToRouteParams(itemFilters), q: item})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateSearchHistory, navigation, route],
|
[updateSearchHistory, navigation, route, filters],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressCancelSearch = useCallback(() => {
|
const onPressCancelSearch = useCallback(() => {
|
||||||
@@ -216,20 +290,22 @@ export function SearchScreenShell({
|
|||||||
textInput.current?.blur()
|
textInput.current?.blur()
|
||||||
setShowAutocomplete(false)
|
setShowAutocomplete(false)
|
||||||
if (IS_WEB) {
|
if (IS_WEB) {
|
||||||
// Empty params resets the URL to be /search rather than /search?q=
|
/*
|
||||||
// Also clear the tab parameter
|
* Empty params resets the URL to be /search rather than /search?q=
|
||||||
const {
|
* Also clear tab and advanced-search filter parameters.
|
||||||
q: _q,
|
*/
|
||||||
tab: _tab,
|
const parameters = withoutSearchParams(
|
||||||
...parameters
|
route.params as Record<string, unknown>,
|
||||||
} = (route.params ?? {}) as {
|
)
|
||||||
[key: string]: string
|
|
||||||
}
|
|
||||||
// @ts-expect-error route is not typesafe
|
// @ts-expect-error route is not typesafe
|
||||||
navigation.replace(route.name, parameters)
|
navigation.replace(route.name, parameters)
|
||||||
} else {
|
} else {
|
||||||
updateSearchText('')
|
updateSearchText('')
|
||||||
navigation.setParams({q: '', tab: undefined})
|
navigation.setParams({
|
||||||
|
...filtersToRouteParams({}),
|
||||||
|
q: '',
|
||||||
|
tab: undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
setShowAutocomplete,
|
setShowAutocomplete,
|
||||||
@@ -242,10 +318,43 @@ export function SearchScreenShell({
|
|||||||
const onSubmit = (source: 'typed' | 'autocomplete') => () => {
|
const onSubmit = (source: 'typed' | 'autocomplete') => () => {
|
||||||
ax.metric('search:query', {
|
ax.metric('search:query', {
|
||||||
source,
|
source,
|
||||||
|
filterCount: countActiveFilters(filters),
|
||||||
})
|
})
|
||||||
navigateToItem(searchTextRef.current)
|
navigateToItem(searchTextRef.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onSubmitAdvanced = useCallback(
|
||||||
|
(text: string, nextFilters: SearchFilters) => {
|
||||||
|
scrollToTopWeb()
|
||||||
|
setShowAutocomplete(false)
|
||||||
|
updateSearchText(text)
|
||||||
|
updateSearchHistory(text, nextFilters)
|
||||||
|
ax.metric('search:query', {
|
||||||
|
source: 'typed',
|
||||||
|
filterCount: countActiveFilters(nextFilters),
|
||||||
|
})
|
||||||
|
if (IS_WEB) {
|
||||||
|
/*
|
||||||
|
* Build a fresh param set so removed filters drop out of the URL.
|
||||||
|
* Only defined filters are included - undefined values would serialize
|
||||||
|
* as the literal string "undefined".
|
||||||
|
*/
|
||||||
|
const nextParams = {
|
||||||
|
...withoutFilterParams(route.params as Record<string, unknown>),
|
||||||
|
...definedFilterParams(nextFilters),
|
||||||
|
q: text,
|
||||||
|
}
|
||||||
|
// @ts-expect-error route is not typesafe
|
||||||
|
navigation.push(route.name, nextParams)
|
||||||
|
} else {
|
||||||
|
textInput.current?.blur()
|
||||||
|
// setParams merges, so pass undefined for absent keys to clear them.
|
||||||
|
navigation.setParams({...filtersToRouteParams(nextFilters), q: text})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ax, navigation, route, updateSearchText, updateSearchHistory],
|
||||||
|
)
|
||||||
|
|
||||||
const onAutocompleteResultPress = useCallback(() => {
|
const onAutocompleteResultPress = useCallback(() => {
|
||||||
if (IS_WEB) {
|
if (IS_WEB) {
|
||||||
setShowAutocomplete(false)
|
setShowAutocomplete(false)
|
||||||
@@ -256,8 +365,14 @@ export function SearchScreenShell({
|
|||||||
|
|
||||||
const handleHistoryItemClick = useCallback(
|
const handleHistoryItemClick = useCallback(
|
||||||
(item: string) => {
|
(item: string) => {
|
||||||
updateSearchText(item)
|
/*
|
||||||
navigateToItem(item)
|
* History entries may carry advanced-search filters (JSON-encoded);
|
||||||
|
* term-only entries are plain strings. Restore both the query and
|
||||||
|
* filters.
|
||||||
|
*/
|
||||||
|
const {q, filters: itemFilters} = parseHistoryEntry(item)
|
||||||
|
updateSearchText(q)
|
||||||
|
navigateToItem(q, itemFilters)
|
||||||
},
|
},
|
||||||
[navigateToItem, updateSearchText],
|
[navigateToItem, updateSearchText],
|
||||||
)
|
)
|
||||||
@@ -275,20 +390,22 @@ export function SearchScreenShell({
|
|||||||
|
|
||||||
const onSoftReset = useCallback(() => {
|
const onSoftReset = useCallback(() => {
|
||||||
if (IS_WEB) {
|
if (IS_WEB) {
|
||||||
// Empty params resets the URL to be /search rather than /search?q=
|
/*
|
||||||
// Also clear the tab parameter when soft resetting
|
* Empty params resets the URL to be /search rather than /search?q=
|
||||||
const {
|
* Also clear tab and advanced-search filter parameters.
|
||||||
q: _q,
|
*/
|
||||||
tab: _tab,
|
const parameters = withoutSearchParams(
|
||||||
...parameters
|
route.params as Record<string, unknown>,
|
||||||
} = (route.params ?? {}) as {
|
)
|
||||||
[key: string]: string
|
|
||||||
}
|
|
||||||
// @ts-expect-error route is not typesafe
|
// @ts-expect-error route is not typesafe
|
||||||
navigation.replace(route.name, parameters)
|
navigation.replace(route.name, parameters)
|
||||||
} else {
|
} else {
|
||||||
updateSearchText('')
|
updateSearchText('')
|
||||||
navigation.setParams({q: '', tab: undefined})
|
navigation.setParams({
|
||||||
|
...filtersToRouteParams({}),
|
||||||
|
q: '',
|
||||||
|
tab: undefined,
|
||||||
|
})
|
||||||
textInput.current?.focus()
|
textInput.current?.focus()
|
||||||
}
|
}
|
||||||
}, [navigation, route.name, route.params, updateSearchText])
|
}, [navigation, route.name, route.params, updateSearchText])
|
||||||
@@ -327,6 +444,22 @@ export function SearchScreenShell({
|
|||||||
[navigation, route],
|
[navigation, route],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onShareSearch = useCallback(() => {
|
||||||
|
const url = new URL('https://bsky.app')
|
||||||
|
url.pathname = '/search'
|
||||||
|
if (query) url.searchParams.set('q', query)
|
||||||
|
for (const [key, value] of Object.entries(definedFilterParams(filters))) {
|
||||||
|
url.searchParams.set(key, value)
|
||||||
|
}
|
||||||
|
ax.metric('search:shareLink:press', {
|
||||||
|
filterCount: countActiveFilters(filters),
|
||||||
|
})
|
||||||
|
setStringAsync(url.toString()).then(
|
||||||
|
() => Toast.show(l`Copied link to clipboard`, {type: 'success'}),
|
||||||
|
() => Toast.show(l`Failed to copy link`, {type: 'error'}),
|
||||||
|
)
|
||||||
|
}, [ax, query, filters, l])
|
||||||
|
|
||||||
const showHeader = !gtMobile || navButton !== 'menu'
|
const showHeader = !gtMobile || navButton !== 'menu'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -363,10 +496,12 @@ export function SearchScreenShell({
|
|||||||
</Layout.Header.TitleText>
|
</Layout.Header.TitleText>
|
||||||
</Layout.Header.Content>
|
</Layout.Header.Content>
|
||||||
{showFilters ? (
|
{showFilters ? (
|
||||||
<SearchLanguageDropdown
|
advancedSearchV2Enabled ? null : (
|
||||||
value={params.lang}
|
<SearchLanguageDropdown
|
||||||
onChange={params.setLang}
|
value={filters.lang ?? ''}
|
||||||
/>
|
onChange={onChangeLang}
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<Layout.Header.Slot />
|
<Layout.Header.Slot />
|
||||||
)}
|
)}
|
||||||
@@ -375,53 +510,74 @@ export function SearchScreenShell({
|
|||||||
)}
|
)}
|
||||||
<View style={[a.px_lg, a.pt_sm, a.pb_sm, a.overflow_hidden]}>
|
<View style={[a.px_lg, a.pt_sm, a.pb_sm, a.overflow_hidden]}>
|
||||||
<View style={[a.gap_sm]}>
|
<View style={[a.gap_sm]}>
|
||||||
|
{searchV2Enabled && query && !showAutocomplete && (
|
||||||
|
<DetectedLanguagesAdmonition
|
||||||
|
query={query}
|
||||||
|
filters={filters}
|
||||||
|
sort={activeTab === 1 ? 'latest' : 'top'}
|
||||||
|
enabled={activeTab === 0 || activeTab === 1}
|
||||||
|
onPressLanguage={onChangeLang}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs]}>
|
<View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs]}>
|
||||||
<View style={[a.flex_1]}>
|
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
<SearchInput
|
{showAutocomplete && (
|
||||||
ref={textInput}
|
<Button
|
||||||
value={searchText}
|
label={l`Cancel search`}
|
||||||
onFocus={onSearchInputFocus}
|
size="large"
|
||||||
onChangeText={onChangeText}
|
variant="ghost"
|
||||||
onClearText={onPressClearQuery}
|
color="secondary"
|
||||||
onSubmitEditing={onSubmit('typed')}
|
shape="rectangular"
|
||||||
placeholder={
|
style={[a.px_sm]}
|
||||||
inputPlaceholder ?? l`Search for posts, users, or feeds`
|
onPress={onPressCancelSearch}
|
||||||
}
|
hitSlop={HITSLOP_10}>
|
||||||
hitSlop={{...HITSLOP_20, top: 0}}
|
<ButtonIcon icon={ArrowLeftIcon} />
|
||||||
hotkey={true}
|
</Button>
|
||||||
/>
|
)}
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<SearchInput
|
||||||
|
ref={textInput}
|
||||||
|
value={searchText}
|
||||||
|
onFocus={onSearchInputFocus}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
onClearText={onPressClearQuery}
|
||||||
|
onSubmitEditing={onSubmit('typed')}
|
||||||
|
placeholder={inputPlaceholder ?? l`Search`}
|
||||||
|
hitSlop={{...HITSLOP_20, top: 0}}
|
||||||
|
hotkey={true}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{showAutocomplete && (
|
|
||||||
<Button
|
|
||||||
label={l`Cancel search`}
|
|
||||||
size="large"
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
shape="rectangular"
|
|
||||||
style={[a.px_sm]}
|
|
||||||
onPress={onPressCancelSearch}
|
|
||||||
hitSlop={HITSLOP_10}>
|
|
||||||
<ButtonText>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</ButtonText>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{showFilters && !showHeader && (
|
{showFilters && !showHeader && !advancedSearchV2Enabled && (
|
||||||
<View
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
style={[
|
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_between,
|
|
||||||
a.gap_sm,
|
|
||||||
]}>
|
|
||||||
<SearchLanguageDropdown
|
<SearchLanguageDropdown
|
||||||
value={params.lang}
|
value={filters.lang ?? ''}
|
||||||
onChange={params.setLang}
|
onChange={onChangeLang}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showFilters && advancedSearchV2Enabled ? (
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<AdvancedSearchDialog
|
||||||
|
q={searchText}
|
||||||
|
filters={filters}
|
||||||
|
onSubmit={onSubmitAdvanced}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
accessibilityRole="button"
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
shape="round"
|
||||||
|
label={l`Share this search`}
|
||||||
|
onPress={onShareSearch}>
|
||||||
|
<ButtonIcon icon={ShareIcon} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Layout.Center>
|
</Layout.Center>
|
||||||
@@ -462,11 +618,13 @@ export function SearchScreenShell({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
}}>
|
}}>
|
||||||
<SearchScreenInner
|
<SearchScreenInner
|
||||||
key={params.lang}
|
key={filters.lang ?? ''}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
query={query}
|
query={query}
|
||||||
queryWithParams={queryWithParams}
|
queryWithParams={queryWithParams}
|
||||||
|
filters={filters}
|
||||||
|
hasFilters={hasFilters}
|
||||||
headerHeight={headerHeight}
|
headerHeight={headerHeight}
|
||||||
focusSearchInput={focusSearchInput}
|
focusSearchInput={focusSearchInput}
|
||||||
/>
|
/>
|
||||||
@@ -480,6 +638,8 @@ let SearchScreenInner = ({
|
|||||||
setActiveTab,
|
setActiveTab,
|
||||||
query,
|
query,
|
||||||
queryWithParams,
|
queryWithParams,
|
||||||
|
filters,
|
||||||
|
hasFilters,
|
||||||
headerHeight,
|
headerHeight,
|
||||||
focusSearchInput,
|
focusSearchInput,
|
||||||
}: {
|
}: {
|
||||||
@@ -487,6 +647,8 @@ let SearchScreenInner = ({
|
|||||||
setActiveTab: React.Dispatch<React.SetStateAction<number>>
|
setActiveTab: React.Dispatch<React.SetStateAction<number>>
|
||||||
query: string
|
query: string
|
||||||
queryWithParams: string
|
queryWithParams: string
|
||||||
|
filters: SearchFilters
|
||||||
|
hasFilters: boolean
|
||||||
headerHeight: number
|
headerHeight: number
|
||||||
focusSearchInput: (tab?: TabParam) => void
|
focusSearchInput: (tab?: TabParam) => void
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
@@ -498,14 +660,15 @@ let SearchScreenInner = ({
|
|||||||
setActiveTab(index)
|
setActiveTab(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
return queryWithParams ? (
|
return query || hasFilters ? (
|
||||||
<SearchResults
|
<SearchResults
|
||||||
query={query}
|
query={query}
|
||||||
queryWithParams={queryWithParams}
|
queryWithParams={queryWithParams}
|
||||||
|
filters={filters}
|
||||||
|
hasFilters={hasFilters}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
headerHeight={headerHeight}
|
headerHeight={headerHeight}
|
||||||
onPageSelected={onPageSelected}
|
onPageSelected={onPageSelected}
|
||||||
initialPage={activeTab}
|
|
||||||
/>
|
/>
|
||||||
) : hasSession ? (
|
) : hasSession ? (
|
||||||
<Explore focusSearchInput={focusSearchInput} headerHeight={headerHeight} />
|
<Explore focusSearchInput={focusSearchInput} headerHeight={headerHeight} />
|
||||||
@@ -548,47 +711,51 @@ function useQueryManager({
|
|||||||
fixedParams,
|
fixedParams,
|
||||||
}: {
|
}: {
|
||||||
initialQuery: string
|
initialQuery: string
|
||||||
fixedParams?: Params
|
fixedParams?: SearchFilters
|
||||||
}) {
|
}) {
|
||||||
const {query, params: initialParams} = useMemo(() => {
|
const navigation = useNavigation<NavigationProp>()
|
||||||
return parseSearchQuery(initialQuery || '')
|
const route = useRoute()
|
||||||
}, [initialQuery])
|
|
||||||
const [prevInitialQuery, setPrevInitialQuery] = useState(initialQuery)
|
|
||||||
const [lang, setLang] = useState(initialParams.lang || '')
|
|
||||||
|
|
||||||
if (initialQuery !== prevInitialQuery) {
|
// Free text only - structured filters live in sibling route params now.
|
||||||
// handle new queryParam change (from manual search entry)
|
const query = initialQuery
|
||||||
setPrevInitialQuery(initialQuery)
|
|
||||||
setLang(initialParams.lang || '')
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = useMemo(
|
const filters = useMemo(() => {
|
||||||
() => ({
|
const fromRoute = readSearchFilters(route.params as Record<string, unknown>)
|
||||||
// default stuff
|
// fixedParams (e.g. ProfileSearch's author) always win and can't be cleared.
|
||||||
...initialParams,
|
return {...fromRoute, ...fixedParams}
|
||||||
// managed stuff
|
}, [route.params, fixedParams])
|
||||||
lang,
|
|
||||||
...fixedParams,
|
const setFilters = useCallback(
|
||||||
}),
|
(next: SearchFilters) => {
|
||||||
[lang, initialParams, fixedParams],
|
const merged = {...next, ...fixedParams}
|
||||||
)
|
if (IS_WEB) {
|
||||||
const handlers = useMemo(
|
/*
|
||||||
() => ({
|
* Replace the param set so removed filters drop out of the URL instead
|
||||||
setLang,
|
* of serializing as the literal string "undefined".
|
||||||
}),
|
*/
|
||||||
[setLang],
|
const nextParams = {
|
||||||
|
...withoutFilterParams(route.params as Record<string, unknown>),
|
||||||
|
...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 useMemo(
|
||||||
return {
|
() => ({
|
||||||
query,
|
query,
|
||||||
queryWithParams: makeSearchQuery(query, params),
|
queryWithParams: makeSearchQuery(query, filtersToLegacyParams(filters)),
|
||||||
params: {
|
filters,
|
||||||
...params,
|
setFilters,
|
||||||
...handlers,
|
hasFilters: hasActiveFilters(filters),
|
||||||
},
|
}),
|
||||||
}
|
[query, filters, setFilters],
|
||||||
}, [query, params, handlers])
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToTopWeb() {
|
function scrollToTopWeb() {
|
||||||
@@ -596,3 +763,12 @@ function scrollToTopWeb() {
|
|||||||
window.scrollTo(0, 0)
|
window.scrollTo(0, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withoutSearchParams(
|
||||||
|
routeParams: Record<string, unknown> | undefined,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const parameters = withoutFilterParams(routeParams)
|
||||||
|
delete parameters.q
|
||||||
|
delete parameters.tab
|
||||||
|
return parameters
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: {}})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
+153
@@ -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 (
|
||||||
|
<View style={[a.relative]}>
|
||||||
|
<TextField.Root>
|
||||||
|
<Dialog.Input
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
keyboardAppearance={t.scheme}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
style={[a.pr_2xl]}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
|
onSubmitEditing={onSubmitEditing}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
|
||||||
|
{value.length > 0 && (
|
||||||
|
<View
|
||||||
|
style={[a.absolute, a.justify_center, {top: 0, bottom: 0, right: 8}]}>
|
||||||
|
<Button
|
||||||
|
label={l`Clear`}
|
||||||
|
onPress={() => onChangeText('')}
|
||||||
|
size="tiny"
|
||||||
|
color="secondary"
|
||||||
|
shape="round">
|
||||||
|
<ButtonIcon icon={XIcon} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDropdown && <OverlayList items={items} onSelect={selectItem} />}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
/*
|
||||||
|
* Drawn as an overlay anchored just above the input so it doesn't push
|
||||||
|
* the input down under the keyboard. z_10 keeps it above sibling rows.
|
||||||
|
*/
|
||||||
|
a.absolute,
|
||||||
|
a.z_10,
|
||||||
|
a.mb_xs,
|
||||||
|
{left: 0, right: 0, bottom: '100%'},
|
||||||
|
a.border,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.overflow_hidden,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg,
|
||||||
|
]}>
|
||||||
|
{ordered.map((item, index) => {
|
||||||
|
if (item.type !== 'profile') return null
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={item.key}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => 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,
|
||||||
|
]}>
|
||||||
|
<ProfileCard.Header>
|
||||||
|
<ProfileCard.Avatar
|
||||||
|
disabledPreview
|
||||||
|
profile={item.profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
<ProfileCard.NameAndHandle
|
||||||
|
profile={item.profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
</ProfileCard.Header>
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 <AutocompleteItemProfile {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<View style={[a.relative]}>
|
||||||
|
<TextField.Root>
|
||||||
|
<Dialog.Input
|
||||||
|
{...comboboxProps}
|
||||||
|
inputRef={anchorRef}
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
keyboardAppearance={t.scheme}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
style={[a.pr_2xl]}
|
||||||
|
onChangeText={text => {
|
||||||
|
setDismissedByScroll(false)
|
||||||
|
onChangeText(text)
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setDismissedByScroll(false)
|
||||||
|
setFocused(true)
|
||||||
|
}}
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
|
onSubmitEditing={onSubmitEditing}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
|
||||||
|
{value.length > 0 && (
|
||||||
|
<View
|
||||||
|
style={[a.absolute, a.justify_center, {top: 0, bottom: 0, right: 8}]}>
|
||||||
|
<Button
|
||||||
|
label={l`Clear`}
|
||||||
|
onPress={() => onChangeText('')}
|
||||||
|
size="tiny"
|
||||||
|
color="secondary"
|
||||||
|
shape="round">
|
||||||
|
<ButtonIcon icon={XIcon} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDropdown && (
|
||||||
|
<Autocomplete
|
||||||
|
sift={sift}
|
||||||
|
data={items}
|
||||||
|
render={renderItem}
|
||||||
|
onSelect={item => {
|
||||||
|
const next = appendSelection(value, lastToken, item)
|
||||||
|
if (next !== null) onChangeText(next)
|
||||||
|
}}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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} `
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<View style={[a.w_full, a.relative]}>
|
||||||
|
<DateField
|
||||||
|
label={label}
|
||||||
|
/*
|
||||||
|
* An empty value renders the placeholder and leaves the field inactive
|
||||||
|
* until the user confirms a date.
|
||||||
|
*/
|
||||||
|
value={active ? value : ''}
|
||||||
|
placeholder={l({
|
||||||
|
message: 'Any time',
|
||||||
|
comment: 'Placeholder text for a date picker',
|
||||||
|
})}
|
||||||
|
accessibilityHint={accessibilityHint}
|
||||||
|
maximumDate={maximumDate}
|
||||||
|
minimumDate={minimumDate}
|
||||||
|
/*
|
||||||
|
* onChangeDate is required, but activation is driven by onConfirm so
|
||||||
|
* that iOS scroll ticks don't mark the field active.
|
||||||
|
*/
|
||||||
|
onChangeDate={() => {}}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{active && (
|
||||||
|
/*
|
||||||
|
* Nudged left of the browser's native date-picker icon, which (in
|
||||||
|
* Firefox especially) can't be hidden via CSS.
|
||||||
|
*/
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.justify_center,
|
||||||
|
{top: 0, bottom: 0, right: 16},
|
||||||
|
]}>
|
||||||
|
<Button
|
||||||
|
label={l`Clear date`}
|
||||||
|
onPress={onClear}
|
||||||
|
size="tiny"
|
||||||
|
color="secondary"
|
||||||
|
shape="round">
|
||||||
|
<ButtonIcon icon={XIcon} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<TextInput>(null)
|
||||||
|
const [showClear, setShowClear] = useState(defaultValue.length > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.relative]}>
|
||||||
|
<TextField.Root>
|
||||||
|
<Dialog.Input
|
||||||
|
inputRef={inputRef}
|
||||||
|
label={label}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
keyboardAppearance={t.scheme}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
style={[a.pr_2xl]}
|
||||||
|
onChangeText={text => {
|
||||||
|
setShowClear(text.length > 0)
|
||||||
|
onChangeText(text)
|
||||||
|
}}
|
||||||
|
onSubmitEditing={onSubmitEditing}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
|
||||||
|
{showClear && (
|
||||||
|
<View
|
||||||
|
style={[a.absolute, a.justify_center, {top: 0, bottom: 0, right: 8}]}>
|
||||||
|
<Button
|
||||||
|
label={l`Clear`}
|
||||||
|
onPress={() => {
|
||||||
|
inputRef.current?.clear()
|
||||||
|
setShowClear(false)
|
||||||
|
onChangeText('')
|
||||||
|
}}
|
||||||
|
size="tiny"
|
||||||
|
color="secondary"
|
||||||
|
shape="round">
|
||||||
|
<ButtonIcon icon={XIcon} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<AdvancedFilter>) => void
|
||||||
|
onRemove: () => void
|
||||||
|
onSubmitEditing?: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const labels = useFilterFieldLabels()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.gap_sm,
|
||||||
|
a.p_md,
|
||||||
|
a.rounded_md,
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||||
|
<Menu.Root>
|
||||||
|
<Menu.Trigger
|
||||||
|
label={l`Include or exclude matching posts (currently: ${
|
||||||
|
filter.mode === 'exclude'
|
||||||
|
? l({message: 'Exclude', comment: 'Advanced search filter'})
|
||||||
|
: l({message: 'Include', comment: 'Advanced search filter'})
|
||||||
|
})`}>
|
||||||
|
{({props}) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
label={props.accessibilityLabel}
|
||||||
|
size="small"
|
||||||
|
color="secondary">
|
||||||
|
<ButtonText>
|
||||||
|
{filter.mode === 'exclude'
|
||||||
|
? l({message: 'Exclude', comment: 'Advanced search filter'})
|
||||||
|
: l({
|
||||||
|
message: 'Include',
|
||||||
|
comment: 'Advanced search filter',
|
||||||
|
})}
|
||||||
|
</ButtonText>
|
||||||
|
<ButtonIcon
|
||||||
|
icon={platform({
|
||||||
|
native: ChevronUpDownIcon,
|
||||||
|
default: ChevronDownIcon,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Menu.Trigger>
|
||||||
|
<Menu.Outer>
|
||||||
|
<Menu.Group>
|
||||||
|
<Menu.Item
|
||||||
|
label={l({
|
||||||
|
message: 'Include',
|
||||||
|
comment: 'Advanced search filter',
|
||||||
|
})}
|
||||||
|
onPress={() => onChange({mode: 'include'})}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Include</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemRadio selected={filter.mode === 'include'} />
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
label={l({
|
||||||
|
message: 'Exclude',
|
||||||
|
comment: 'Advanced search filter',
|
||||||
|
})}
|
||||||
|
onPress={() => onChange({mode: 'exclude'})}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Exclude</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemRadio selected={filter.mode === 'exclude'} />
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Group>
|
||||||
|
</Menu.Outer>
|
||||||
|
</Menu.Root>
|
||||||
|
|
||||||
|
<Menu.Root>
|
||||||
|
<Menu.Trigger
|
||||||
|
label={l`Select filter type (currently: ${labels[filter.field].title})`}>
|
||||||
|
{({props}) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
label={props.accessibilityLabel}
|
||||||
|
size="small"
|
||||||
|
color="secondary">
|
||||||
|
<ButtonText>{labels[filter.field].title}</ButtonText>
|
||||||
|
<ButtonIcon
|
||||||
|
icon={platform({
|
||||||
|
native: ChevronUpDownIcon,
|
||||||
|
default: ChevronDownIcon,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Menu.Trigger>
|
||||||
|
<Menu.Outer>
|
||||||
|
<Menu.Group>
|
||||||
|
{FILTER_FIELDS.map(field => (
|
||||||
|
<Menu.Item
|
||||||
|
key={field}
|
||||||
|
label={labels[field].title}
|
||||||
|
/*
|
||||||
|
* Switching the field type clears any text entered for the
|
||||||
|
* previous type, since values rarely carry over meaningfully
|
||||||
|
* (e.g. a handle is not a domain). Changing include/exclude
|
||||||
|
* mode leaves the text intact - it's the same field.
|
||||||
|
*/
|
||||||
|
onPress={() => onChange({field, value: ''})}>
|
||||||
|
<Menu.ItemText>{labels[field].title}</Menu.ItemText>
|
||||||
|
<Menu.ItemRadio selected={filter.field === field} />
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Group>
|
||||||
|
</Menu.Outer>
|
||||||
|
</Menu.Root>
|
||||||
|
|
||||||
|
<View style={[a.flex_1]} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={l({
|
||||||
|
message: 'Remove filter',
|
||||||
|
comment: 'Advanced search filter',
|
||||||
|
})}
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
shape="round"
|
||||||
|
onPress={onRemove}>
|
||||||
|
<ButtonIcon icon={XIcon} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{HANDLE_FIELDS.has(filter.field) ? (
|
||||||
|
<AutocompleteInput
|
||||||
|
/*
|
||||||
|
* Remount on field-type change so the input resets to the cleared
|
||||||
|
* value; mode changes keep the same field and so preserve the text.
|
||||||
|
*/
|
||||||
|
key={filter.field}
|
||||||
|
label={labels[filter.field].label}
|
||||||
|
value={filter.value}
|
||||||
|
onChangeText={text => onChange({value: text})}
|
||||||
|
onSubmitEditing={onSubmitEditing}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ClearableInput
|
||||||
|
/*
|
||||||
|
* The input is uncontrolled (defaultValue), so remount on field-type
|
||||||
|
* change to reset it; mode changes keep the same field and text.
|
||||||
|
*/
|
||||||
|
key={filter.field}
|
||||||
|
label={labels[filter.field].label}
|
||||||
|
defaultValue={filter.value}
|
||||||
|
onChangeText={text => onChange({value: text})}
|
||||||
|
onSubmitEditing={onSubmitEditing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Menu.Root>
|
||||||
|
<Menu.Trigger label={l`Filter by author (currently: ${currentLabel})`}>
|
||||||
|
{({props}) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
label={props.accessibilityLabel}
|
||||||
|
size="small"
|
||||||
|
color="secondary">
|
||||||
|
<ButtonText>{currentLabel}</ButtonText>
|
||||||
|
<ButtonIcon
|
||||||
|
icon={platform({
|
||||||
|
native: ChevronUpDownIcon,
|
||||||
|
default: ChevronDownIcon,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Menu.Trigger>
|
||||||
|
<Menu.Outer>
|
||||||
|
<Menu.LabelText>
|
||||||
|
<Trans>Filter by author</Trans>
|
||||||
|
</Menu.LabelText>
|
||||||
|
<Menu.Group>
|
||||||
|
{options.map(option => (
|
||||||
|
<Menu.Item
|
||||||
|
key={option.value}
|
||||||
|
label={option.label}
|
||||||
|
onPress={() => onChange(option.value)}>
|
||||||
|
<Menu.ItemText>{option.label}</Menu.ItemText>
|
||||||
|
<Menu.ItemRadio selected={value === option.value} />
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Group>
|
||||||
|
</Menu.Outer>
|
||||||
|
</Menu.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Menu.Root>
|
||||||
|
<Menu.Trigger label={l`Filter by media (currently: ${currentLabel})`}>
|
||||||
|
{({props}) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
label={props.accessibilityLabel}
|
||||||
|
size="small"
|
||||||
|
color="secondary">
|
||||||
|
<ButtonText>{currentLabel}</ButtonText>
|
||||||
|
<ButtonIcon
|
||||||
|
icon={platform({
|
||||||
|
native: ChevronUpDownIcon,
|
||||||
|
default: ChevronDownIcon,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Menu.Trigger>
|
||||||
|
<Menu.Outer>
|
||||||
|
<Menu.LabelText>
|
||||||
|
<Trans>Filter by media</Trans>
|
||||||
|
</Menu.LabelText>
|
||||||
|
<Menu.Group>
|
||||||
|
{options.map(option => (
|
||||||
|
<Menu.Item
|
||||||
|
key={option.value}
|
||||||
|
label={option.label}
|
||||||
|
onPress={() => onChange(option.value)}>
|
||||||
|
<Menu.ItemText>{option.label}</Menu.ItemText>
|
||||||
|
<Menu.ItemRadio selected={value === option.value} />
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Group>
|
||||||
|
</Menu.Outer>
|
||||||
|
</Menu.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Menu.Root>
|
||||||
|
<Menu.Trigger
|
||||||
|
label={l`Include posts and/or replies (currently: ${currentLabel})`}>
|
||||||
|
{({props}) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
label={props.accessibilityLabel}
|
||||||
|
size="small"
|
||||||
|
color="secondary">
|
||||||
|
<ButtonText>{currentLabel}</ButtonText>
|
||||||
|
<ButtonIcon
|
||||||
|
icon={platform({
|
||||||
|
native: ChevronUpDownIcon,
|
||||||
|
default: ChevronDownIcon,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Menu.Trigger>
|
||||||
|
<Menu.Outer>
|
||||||
|
<Menu.LabelText>
|
||||||
|
<Trans>Include these results</Trans>
|
||||||
|
</Menu.LabelText>
|
||||||
|
<Menu.Group>
|
||||||
|
{options.map(option => (
|
||||||
|
<Menu.Item
|
||||||
|
key={option.value}
|
||||||
|
label={option.label}
|
||||||
|
onPress={() => onChange(option.value)}>
|
||||||
|
<Menu.ItemText>{option.label}</Menu.ItemText>
|
||||||
|
<Menu.ItemRadio selected={value === option.value} />
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Group>
|
||||||
|
</Menu.Outer>
|
||||||
|
</Menu.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<View style={[a.relative]}>
|
||||||
|
<Button
|
||||||
|
label={l`Open advanced search options`}
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
style={native([a.py_sm, a.px_sm])}
|
||||||
|
onPress={() => {
|
||||||
|
ax.metric('search:advanced:press', {
|
||||||
|
filterCount: countActiveFilters(filters),
|
||||||
|
})
|
||||||
|
control.open()
|
||||||
|
}}>
|
||||||
|
<ButtonIcon icon={SettingsSliderIcon} />
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Advanced search</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
{filtersActive && (
|
||||||
|
<View
|
||||||
|
accessible={false}
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.rounded_full,
|
||||||
|
{
|
||||||
|
top: -2,
|
||||||
|
right: -2,
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
backgroundColor: t.palette.primary_500,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<DialogInner
|
||||||
|
key={stateKey}
|
||||||
|
control={control}
|
||||||
|
q={q}
|
||||||
|
filters={filters}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
</Dialog.Outer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<MediaFilter>(parsed.media)
|
||||||
|
const [replies, setReplies] = useState<RepliesFilter>(parsed.replies)
|
||||||
|
const [following, setFollowing] = useState<FollowingFilter>(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<AdvancedFilter[]>(parsed.filters)
|
||||||
|
const scrollRef = useRef<ScrollView>(null)
|
||||||
|
const filtersSectionRef = useRef<View>(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<AdvancedFilter>) {
|
||||||
|
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 (
|
||||||
|
<Button
|
||||||
|
label={l`Cancel`}
|
||||||
|
onPress={() => control.close()}
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
variant="ghost"
|
||||||
|
style={[a.rounded_full]}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchButton() {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
label={l`Search`}
|
||||||
|
onPress={handlePressSearch}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
style={[a.rounded_full]}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Search</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
ref={scrollRef}
|
||||||
|
label={l`Dialog: Set advanced search options`}
|
||||||
|
contentContainerStyle={[a.px_0, a.pt_0]}
|
||||||
|
header={
|
||||||
|
<Dialog.Header renderLeft={cancelButton} renderRight={searchButton}>
|
||||||
|
<Dialog.HeaderText>
|
||||||
|
<Trans>Advanced search</Trans>
|
||||||
|
</Dialog.HeaderText>
|
||||||
|
</Dialog.Header>
|
||||||
|
}>
|
||||||
|
<View style={[a.mt_xl, a.px_xl, a.gap_xl]}>
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<TextField.LabelText>
|
||||||
|
<Trans>All of these words</Trans>
|
||||||
|
</TextField.LabelText>
|
||||||
|
<ClearableInput
|
||||||
|
label={l`Search query`}
|
||||||
|
defaultValue={query}
|
||||||
|
placeholder={l({
|
||||||
|
message: 'bluesky atproto',
|
||||||
|
comment:
|
||||||
|
'Advanced search: Example of an “all of these words” search',
|
||||||
|
})}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
onSubmitEditing={handlePressSearch}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[twoColumn ? a.flex_row : a.flex_col, a.gap_xl]}>
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<TextField.LabelText>
|
||||||
|
<Trans>This exact phrase</Trans>
|
||||||
|
</TextField.LabelText>
|
||||||
|
<ClearableInput
|
||||||
|
label={l`This exact phrase`}
|
||||||
|
defaultValue={exactPhrase}
|
||||||
|
placeholder={l({
|
||||||
|
message: 'what’s up',
|
||||||
|
comment: 'Advanced search: Example of an “exact phrase” search',
|
||||||
|
})}
|
||||||
|
onChangeText={setExactPhrase}
|
||||||
|
onSubmitEditing={handlePressSearch}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<TextField.LabelText>
|
||||||
|
<Trans>None of these words</Trans>
|
||||||
|
</TextField.LabelText>
|
||||||
|
<ClearableInput
|
||||||
|
label={l`None of these words`}
|
||||||
|
defaultValue={negatedWords}
|
||||||
|
placeholder={l({
|
||||||
|
message: 'cows pigs',
|
||||||
|
comment:
|
||||||
|
'Advanced search: Example of an “none of these words” search',
|
||||||
|
})}
|
||||||
|
onChangeText={setNegatedWords}
|
||||||
|
onSubmitEditing={handlePressSearch}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<View style={[a.flex_row, a.gap_lg]}>
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<TextField.LabelText>
|
||||||
|
<Trans>Since</Trans>
|
||||||
|
</TextField.LabelText>
|
||||||
|
<ClearableDateField
|
||||||
|
label={l`Since`}
|
||||||
|
value={dateSince}
|
||||||
|
active={dateSinceActive}
|
||||||
|
accessibilityHint={l({
|
||||||
|
message: 'Include posts made since this date',
|
||||||
|
comment: 'Advanced search filter',
|
||||||
|
})}
|
||||||
|
// Can't choose a Since later than an active Until.
|
||||||
|
maximumDate={dateUntilActive ? dateUntil : DEFAULT_DATE}
|
||||||
|
onConfirm={(value: string) => {
|
||||||
|
setDateSince(value)
|
||||||
|
setDateSinceActive(true)
|
||||||
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
setDateSinceActive(false)
|
||||||
|
setDateSince(DEFAULT_DATE)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<TextField.LabelText>
|
||||||
|
<Trans>Until</Trans>
|
||||||
|
</TextField.LabelText>
|
||||||
|
<ClearableDateField
|
||||||
|
label={l`Until`}
|
||||||
|
value={dateUntil}
|
||||||
|
active={dateUntilActive}
|
||||||
|
accessibilityHint={l({
|
||||||
|
message: 'Include posts made until this date',
|
||||||
|
comment: 'Advanced search filter',
|
||||||
|
})}
|
||||||
|
// Can't choose an Until earlier than an active Since.
|
||||||
|
minimumDate={dateSinceActive ? dateSince : undefined}
|
||||||
|
onConfirm={(value: string) => {
|
||||||
|
setDateUntil(value)
|
||||||
|
setDateUntilActive(true)
|
||||||
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
setDateUntilActive(false)
|
||||||
|
setDateUntil(DEFAULT_DATE)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[twoColumn ? a.flex_row : a.flex_col, a.gap_lg]}>
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.font_medium,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
a.mb_sm,
|
||||||
|
]}>
|
||||||
|
<Trans>Language</Trans>
|
||||||
|
</Text>
|
||||||
|
<View style={[a.flex_row]}>
|
||||||
|
<SearchLanguageDropdown
|
||||||
|
showIcon={false}
|
||||||
|
value={language}
|
||||||
|
onChange={setLanguage}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.font_medium,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
a.mb_sm,
|
||||||
|
]}>
|
||||||
|
<Trans>Media</Trans>
|
||||||
|
</Text>
|
||||||
|
<View style={[a.flex_row]}>
|
||||||
|
<MediaDropdown value={media} onChange={setMedia} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[twoColumn ? a.flex_row : a.flex_col, a.gap_lg]}>
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.font_medium,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
a.mb_sm,
|
||||||
|
]}>
|
||||||
|
<Trans>Include</Trans>
|
||||||
|
</Text>
|
||||||
|
<View style={[a.flex_row]}>
|
||||||
|
<RepliesDropdown value={replies} onChange={setReplies} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.font_medium,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
a.mb_sm,
|
||||||
|
]}>
|
||||||
|
<Trans>From</Trans>
|
||||||
|
</Text>
|
||||||
|
<View style={[a.flex_row]}>
|
||||||
|
<FollowingDropdown value={following} onChange={setFollowing} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View ref={filtersSectionRef} style={[a.gap_md]}>
|
||||||
|
{filters.map(filter => (
|
||||||
|
<FilterBlock
|
||||||
|
key={filter.id}
|
||||||
|
filter={filter}
|
||||||
|
onChange={patch => updateFilter(filter.id, patch)}
|
||||||
|
onSubmitEditing={handlePressSearch}
|
||||||
|
onRemove={() => removeFilter(filter.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{filters.length >= MAX_FILTERS && (
|
||||||
|
<Admonition type="info">
|
||||||
|
<Trans>
|
||||||
|
You’ve reached the maximum of{' '}
|
||||||
|
<Plural value={MAX_FILTERS} one="# filter" other="# filters" />.
|
||||||
|
Add more values to an existing filter instead of creating new
|
||||||
|
ones.
|
||||||
|
</Trans>
|
||||||
|
</Admonition>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
label={l`Add an additional search filter`}
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
disabled={filters.length >= MAX_FILTERS}
|
||||||
|
style={[a.mt_sm]}
|
||||||
|
onPress={addFilter}>
|
||||||
|
<ButtonIcon icon={PlusIcon} />
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Add filter</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<FilterField>(['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<Record<FilterField, string>> = {
|
||||||
|
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<FilterField, keyof SearchFilters>
|
||||||
|
> = {
|
||||||
|
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<string>()
|
||||||
|
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<FilterField, string> = {
|
||||||
|
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<keyof SearchFilters, Set<string>>()
|
||||||
|
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<string>()
|
||||||
|
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}
|
||||||
|
}
|
||||||
@@ -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<DetectedLanguage[]>(() => {
|
||||||
|
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 (
|
||||||
|
<Admonition type="tip">
|
||||||
|
<DetectedLanguagesPrompt
|
||||||
|
languages={languages}
|
||||||
|
onPressLanguage={onPressLanguage}
|
||||||
|
/>
|
||||||
|
</Admonition>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) => (
|
||||||
|
<InlineLinkText
|
||||||
|
label={l`Filter this search by ${lang.name}`}
|
||||||
|
{...createStaticClick(() => onPressLanguage(lang.code))}>
|
||||||
|
{lang.name}
|
||||||
|
</InlineLinkText>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (languages.length === 1) {
|
||||||
|
return <Trans>Are you searching for posts in {link(languages[0])}?</Trans>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (languages.length === 2) {
|
||||||
|
return (
|
||||||
|
<Trans>
|
||||||
|
Are you searching for posts in {link(languages[0])} or{' '}
|
||||||
|
{link(languages[1])}?
|
||||||
|
</Trans>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const head = languages.slice(0, -1)
|
||||||
|
const last = languages[languages.length - 1]
|
||||||
|
return (
|
||||||
|
<Trans comment="List formatting: {0}, {1}, or {2}">
|
||||||
|
Are you searching for posts in{' '}
|
||||||
|
{head.map(lang => (
|
||||||
|
<Trans key={lang.code} comment="List formatting: {0}, {1}, {2}">
|
||||||
|
{link(lang)},{' '}
|
||||||
|
</Trans>
|
||||||
|
))}
|
||||||
|
or {link(last)}?
|
||||||
|
</Trans>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Pressable, ScrollView, View} from 'react-native'
|
import {Pressable, ScrollView, View} from 'react-native'
|
||||||
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
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 {createHitslop, HITSLOP_10} from '#/lib/constants'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
@@ -9,7 +9,11 @@ import {sanitizeHandle} from '#/lib/strings/handles'
|
|||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
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 {Button, ButtonIcon} from '#/components/Button'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
@@ -35,7 +39,6 @@ export function SearchHistory({
|
|||||||
onRemoveProfileClick: (profile: bsky.profile.AnyProfileView) => void
|
onRemoveProfileClick: (profile: bsky.profile.AnyProfileView) => void
|
||||||
}) {
|
}) {
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {t: l} = useLingui()
|
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -87,31 +90,26 @@ export function SearchHistory({
|
|||||||
|
|
||||||
{searchHistory.length > 0 && (
|
{searchHistory.length > 0 && (
|
||||||
<View style={[a.px_lg, a.pt_sm]}>
|
<View style={[a.px_lg, a.pt_sm]}>
|
||||||
{searchHistory.slice(0, 5).map((historyItem, index) => (
|
{searchHistory.slice(0, 5).map((historyItem, index) => {
|
||||||
<View key={index} style={[a.flex_row, a.align_center]}>
|
const {q, filters} = parseHistoryEntry(historyItem)
|
||||||
<Pressable
|
const filterCount = countActiveFilters(filters)
|
||||||
accessibilityRole="button"
|
|
||||||
|
return (
|
||||||
|
<SearchHistoryItem
|
||||||
|
key={index}
|
||||||
|
q={q}
|
||||||
|
filterCount={filterCount}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
ax.metric('search:query', {
|
ax.metric('search:query', {
|
||||||
source: 'history',
|
source: 'history',
|
||||||
|
filterCount,
|
||||||
})
|
})
|
||||||
onItemClick(historyItem)
|
onItemClick(historyItem)
|
||||||
}}
|
}}
|
||||||
hitSlop={HITSLOP_10}
|
onRemove={() => onRemoveItemClick(historyItem)}
|
||||||
style={[a.flex_1, a.py_sm]}>
|
/>
|
||||||
<Text style={[a.text_md]}>{historyItem}</Text>
|
)
|
||||||
</Pressable>
|
})}
|
||||||
<Button
|
|
||||||
label={l`Remove ${historyItem}`}
|
|
||||||
onPress={() => onRemoveItemClick(historyItem)}
|
|
||||||
size="small"
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
shape="round">
|
|
||||||
<ButtonIcon icon={XIcon} />
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -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 (
|
||||||
|
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={onPress}
|
||||||
|
hitSlop={HITSLOP_10}
|
||||||
|
style={[a.flex_1, a.py_sm, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Text style={[a.text_md, a.flex_shrink]} numberOfLines={1}>
|
||||||
|
{q}
|
||||||
|
</Text>
|
||||||
|
{filterCount > 0 ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_shrink_0,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.px_sm,
|
||||||
|
a.py_2xs,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_xs, a.font_medium, t.atoms.text_contrast_medium]}>
|
||||||
|
<Plural value={filterCount} one="+# filter" other="+# filters" />
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
<Button
|
||||||
|
label={l`Remove ${q}`}
|
||||||
|
onPress={onRemove}
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
shape="round">
|
||||||
|
<ButtonIcon icon={XIcon} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function RecentProfileItem({
|
function RecentProfileItem({
|
||||||
profile,
|
profile,
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
|
|||||||
@@ -14,17 +14,25 @@ import {
|
|||||||
} from '#/components/icons/Chevron'
|
} from '#/components/icons/Chevron'
|
||||||
import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe'
|
import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
|
|
||||||
export function SearchLanguageDropdown({
|
export function SearchLanguageDropdown({
|
||||||
|
showIcon = true,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
|
showIcon?: boolean
|
||||||
value: string
|
value: string
|
||||||
onChange(value: string): void
|
onChange(value: string): void
|
||||||
}) {
|
}) {
|
||||||
|
const ax = useAnalytics()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {appLanguage, contentLanguages, primaryLanguage} = useLanguagePrefs()
|
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(() => {
|
const languages = useMemo(() => {
|
||||||
return LANGUAGES.filter(
|
return LANGUAGES.filter(
|
||||||
(lang, index, self) =>
|
(lang, index, self) =>
|
||||||
@@ -75,14 +83,28 @@ export function SearchLanguageDropdown({
|
|||||||
{...props}
|
{...props}
|
||||||
label={props.accessibilityLabel}
|
label={props.accessibilityLabel}
|
||||||
size="small"
|
size="small"
|
||||||
color={platform({native: 'primary', default: 'secondary'})}
|
color={
|
||||||
variant={platform({native: 'ghost', default: 'solid'})}
|
advancedSearchV2Enabled
|
||||||
style={native([
|
? 'secondary'
|
||||||
a.py_sm,
|
: platform({native: 'primary', default: 'secondary'})
|
||||||
a.px_sm,
|
}
|
||||||
{marginRight: tokens.space.sm * -1},
|
variant={
|
||||||
])}>
|
advancedSearchV2Enabled
|
||||||
<ButtonIcon icon={EarthIcon} />
|
? 'solid'
|
||||||
|
: platform({native: 'ghost', default: 'solid'})
|
||||||
|
}
|
||||||
|
style={
|
||||||
|
showIcon
|
||||||
|
? native([
|
||||||
|
a.py_sm,
|
||||||
|
a.px_sm,
|
||||||
|
advancedSearchV2Enabled
|
||||||
|
? null
|
||||||
|
: {marginRight: tokens.space.sm * -1},
|
||||||
|
])
|
||||||
|
: null
|
||||||
|
}>
|
||||||
|
{showIcon ? <ButtonIcon icon={EarthIcon} /> : null}
|
||||||
<ButtonText>{currentLanguageLabel}</ButtonText>
|
<ButtonText>{currentLanguageLabel}</ButtonText>
|
||||||
<ButtonIcon
|
<ButtonIcon
|
||||||
icon={platform({
|
icon={platform({
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* Structured advanced-search filters that live as sibling URL query params
|
||||||
|
* alongside `q` (e.g. /search?q=test&domain=bsky.app). `q` itself holds only
|
||||||
|
* free text; these params hold everything configured in the advanced search
|
||||||
|
* dialog. All values are strings so they map cleanly to route params; `tag` is
|
||||||
|
* space-joined here and split into the lexicon's string[] at the API boundary.
|
||||||
|
*/
|
||||||
|
export type SearchFilters = {
|
||||||
|
author?: string
|
||||||
|
mentions?: string
|
||||||
|
domain?: string
|
||||||
|
url?: string
|
||||||
|
tag?: string
|
||||||
|
/**
|
||||||
|
* Exclude variants of the list fields above. v2-only; v1 has no structured
|
||||||
|
* exclude operators so these are dropped on the legacy path.
|
||||||
|
*/
|
||||||
|
excludeAuthor?: string
|
||||||
|
excludeMentions?: string
|
||||||
|
excludeDomain?: string
|
||||||
|
excludeUrl?: string
|
||||||
|
excludeTag?: string
|
||||||
|
lang?: string
|
||||||
|
since?: string
|
||||||
|
until?: string
|
||||||
|
/** 'none' | 'only' */
|
||||||
|
replies?: string
|
||||||
|
/** 'true' */
|
||||||
|
media?: string
|
||||||
|
/** 'true' */
|
||||||
|
video?: string
|
||||||
|
/** 'true' */
|
||||||
|
following?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FILTER_PARAM_KEYS = [
|
||||||
|
'author',
|
||||||
|
'mentions',
|
||||||
|
'domain',
|
||||||
|
'url',
|
||||||
|
'tag',
|
||||||
|
'excludeAuthor',
|
||||||
|
'excludeMentions',
|
||||||
|
'excludeDomain',
|
||||||
|
'excludeUrl',
|
||||||
|
'excludeTag',
|
||||||
|
'lang',
|
||||||
|
'since',
|
||||||
|
'until',
|
||||||
|
'replies',
|
||||||
|
'media',
|
||||||
|
'video',
|
||||||
|
'following',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads filter params out of a route's params object, keeping only non-empty
|
||||||
|
* string values.
|
||||||
|
*/
|
||||||
|
export function readSearchFilters(
|
||||||
|
routeParams: Record<string, unknown> | 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<string, unknown>}
|
||||||
|
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<string, string | undefined> {
|
||||||
|
const params: Record<string, string | undefined> = {}
|
||||||
|
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<string, string> {
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
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<string, unknown> | undefined,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const base: Record<string, unknown> = {...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<string, string> {
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
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<typeof filtersToApiParams> = {}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<typeof extractSearchPostsParams>
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
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`],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<ExtractedSearchParams, 'q'>,
|
||||||
|
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'
|
||||||
|
}
|
||||||
@@ -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<AppBskyFeedSearchPostsV2.OutputSchema>
|
||||||
|
args: typeof selectArgs
|
||||||
|
result: InfiniteData<AppBskyFeedSearchPostsV2.OutputSchema>
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
return useInfiniteQuery<
|
||||||
|
AppBskyFeedSearchPostsV2.OutputSchema,
|
||||||
|
Error,
|
||||||
|
InfiniteData<AppBskyFeedSearchPostsV2.OutputSchema>,
|
||||||
|
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<AppBskyFeedSearchPostsV2.OutputSchema>) => {
|
||||||
|
const {moderationOpts, isSearchingSpecificUser} = selectArgs
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If a user applies the `from:<user>` 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<string, unknown>)[key] !==
|
||||||
|
(lastArgs as Record<string, unknown>)[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],
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -97,7 +97,10 @@ export function useSearchPostsQuery({
|
|||||||
let canReuse = true
|
let canReuse = true
|
||||||
for (let key in selectArgs) {
|
for (let key in selectArgs) {
|
||||||
if (selectArgs.hasOwnProperty(key)) {
|
if (selectArgs.hasOwnProperty(key)) {
|
||||||
if ((selectArgs as any)[key] !== (lastArgs as any)[key]) {
|
if (
|
||||||
|
(selectArgs as Record<string, unknown>)[key] !==
|
||||||
|
(lastArgs as Record<string, unknown>)[key]
|
||||||
|
) {
|
||||||
// Can't do reuse anything if any input has changed.
|
// Can't do reuse anything if any input has changed.
|
||||||
canReuse = false
|
canReuse = false
|
||||||
break
|
break
|
||||||
|
|||||||
Reference in New Issue
Block a user