import React, {useCallback, useLayoutEffect, useMemo} from 'react'
import {
ActivityIndicator,
Image,
ImageStyle,
Pressable,
StyleProp,
StyleSheet,
TextInput,
View,
} from 'react-native'
import {ScrollView as RNGHScrollView} from 'react-native-gesture-handler'
import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {APP_LANGUAGES, LANGUAGES} from '#/lib/../locale/languages'
import {createHitslop, HITSLOP_20} from '#/lib/constants'
import {HITSLOP_10} from '#/lib/constants'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {MagnifyingGlassIcon} from '#/lib/icons'
import {makeProfileLink} from '#/lib/routes/links'
import {NavigationProp} from '#/lib/routes/types'
import {
NativeStackScreenProps,
SearchTabNavigatorParams,
} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {augmentSearchQuery} from '#/lib/strings/helpers'
import {languageName} from '#/locale/helpers'
import {isNative, isWeb} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
import {useLanguagePrefs} from '#/state/preferences/languages'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useActorSearch} from '#/state/queries/actor-search'
import {usePopularFeedsSearch} from '#/state/queries/feed'
import {useProfilesQuery} from '#/state/queries/profile'
import {useSearchPostsQuery} from '#/state/queries/search-posts'
import {useSession} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell'
import {Pager} from '#/view/com/pager/Pager'
import {TabBar} from '#/view/com/pager/TabBar'
import {Post} from '#/view/com/post/Post'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {Link} from '#/view/com/util/Link'
import {List} from '#/view/com/util/List'
import {Text} from '#/view/com/util/text/Text'
import {Explore} from '#/view/screens/Search/Explore'
import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
import {makeSearchQuery, parseSearchQuery} from '#/screens/Search/utils'
import {
atoms as a,
native,
platform,
tokens,
useBreakpoints,
useTheme,
web,
} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
import {SearchInput} from '#/components/forms/SearchInput'
import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon,
} from '#/components/icons/Chevron'
import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe'
import * as Layout from '#/components/Layout'
import * as Menu from '#/components/Menu'
import {account, useStorage} from '#/storage'
function Loader() {
return (
)
}
function EmptyState({message, error}: {message: string; error?: string}) {
const pal = usePalette('default')
return (
{message}
{error && (
<>
Error: {error}
>
)}
)
}
type SearchResultSlice =
| {
type: 'post'
key: string
post: AppBskyFeedDefs.PostView
}
| {
type: 'loadingMore'
key: string
}
let SearchScreenPostResults = ({
query,
sort,
active,
}: {
query: string
sort?: 'top' | 'latest'
active: boolean
}): React.ReactNode => {
const {_} = useLingui()
const {currentAccount} = useSession()
const [isPTR, setIsPTR] = React.useState(false)
const augmentedQuery = React.useMemo(() => {
return augmentSearchQuery(query || '', {did: currentAccount?.did})
}, [query, currentAccount])
const {
isFetched,
data: results,
isFetching,
error,
refetch,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
} = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active})
const onPullToRefresh = React.useCallback(async () => {
setIsPTR(true)
await refetch()
setIsPTR(false)
}, [setIsPTR, refetch])
const onEndReached = React.useCallback(() => {
if (isFetching || !hasNextPage || error) return
fetchNextPage()
}, [isFetching, error, hasNextPage, fetchNextPage])
const posts = React.useMemo(() => {
return results?.pages.flatMap(page => page.posts) || []
}, [results])
const items = React.useMemo(() => {
let temp: SearchResultSlice[] = []
const seenUris = new Set()
for (const post of posts) {
if (seenUris.has(post.uri)) {
continue
}
temp.push({
type: 'post',
key: post.uri,
post,
})
seenUris.add(post.uri)
}
if (isFetchingNextPage) {
temp.push({
type: 'loadingMore',
key: 'loadingMore',
})
}
return temp
}, [posts, isFetchingNextPage])
return error ? (
) : (
<>
{isFetched ? (
<>
{posts.length ? (
{
if (item.type === 'post') {
return
} else {
return null
}
}}
keyExtractor={item => item.key}
refreshing={isPTR}
onRefresh={onPullToRefresh}
onEndReached={onEndReached}
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
/>
) : (
)}
>
) : (
)}
>
)
}
SearchScreenPostResults = React.memo(SearchScreenPostResults)
let SearchScreenUserResults = ({
query,
active,
}: {
query: string
active: boolean
}): React.ReactNode => {
const {_} = useLingui()
const {data: results, isFetched} = useActorSearch({
query,
enabled: active,
})
return isFetched && results ? (
<>
{results.length ? (
(
)}
keyExtractor={item => item.did}
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
/>
) : (
)}
>
) : (
)
}
SearchScreenUserResults = React.memo(SearchScreenUserResults)
let SearchScreenFeedsResults = ({
query,
active,
}: {
query: string
active: boolean
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {data: results, isFetched} = usePopularFeedsSearch({
query,
enabled: active,
})
return isFetched && results ? (
<>
{results.length ? (
(
)}
keyExtractor={item => item.uri}
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
/>
) : (
)}
>
) : (
)
}
SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults)
function SearchLanguageDropdown({
value,
onChange,
}: {
value: string
onChange(value: string): void
}) {
const {_} = useLingui()
const {appLanguage, contentLanguages} = useLanguagePrefs()
const languages = useMemo(() => {
return LANGUAGES.filter(
(lang, index, self) =>
Boolean(lang.code2) && // reduce to the code2 varieties
index === self.findIndex(t => t.code2 === lang.code2), // remove dupes (which will happen)
)
.map(l => ({
label: languageName(l, appLanguage),
value: l.code2,
key: l.code2 + l.code3,
}))
.sort((a, b) => {
// prioritize user's languages
const aIsUser = contentLanguages.includes(a.value)
const bIsUser = contentLanguages.includes(b.value)
if (aIsUser && !bIsUser) return -1
if (bIsUser && !aIsUser) return 1
// prioritize "common" langs in the network
const aIsCommon = !!APP_LANGUAGES.find(al => al.code2 === a.value)
const bIsCommon = !!APP_LANGUAGES.find(al => al.code2 === b.value)
if (aIsCommon && !bIsCommon) return -1
if (bIsCommon && !aIsCommon) return 1
// fall back to alphabetical
return a.label.localeCompare(b.label)
})
}, [appLanguage, contentLanguages])
const currentLanguageLabel =
languages.find(lang => lang.value === value)?.label ?? _(msg`All languages`)
return (
{({props}) => (
)}
Filter search by language
onChange('')}>
All languages
{languages.map(lang => (
onChange(lang.value)}>
{lang.label}
))}
)
}
function useQueryManager({initialQuery}: {initialQuery: string}) {
const {query, params: initialParams} = React.useMemo(() => {
return parseSearchQuery(initialQuery || '')
}, [initialQuery])
const [prevInitialQuery, setPrevInitialQuery] = React.useState(initialQuery)
const [lang, setLang] = React.useState(initialParams.lang || '')
if (initialQuery !== prevInitialQuery) {
// handle new queryParam change (from manual search entry)
setPrevInitialQuery(initialQuery)
setLang(initialParams.lang || '')
}
const params = React.useMemo(
() => ({
// default stuff
...initialParams,
// managed stuff
lang,
}),
[lang, initialParams],
)
const handlers = React.useMemo(
() => ({
setLang,
}),
[setLang],
)
return React.useMemo(() => {
return {
query,
queryWithParams: makeSearchQuery(query, params),
params: {
...params,
...handlers,
},
}
}, [query, params, handlers])
}
let SearchScreenInner = ({
query,
queryWithParams,
headerHeight,
}: {
query: string
queryWithParams: string
headerHeight: number
}): React.ReactNode => {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const {hasSession} = useSession()
const {isDesktop} = useWebMediaQueries()
const [activeTab, setActiveTab] = React.useState(0)
const {_} = useLingui()
const onPageSelected = React.useCallback(
(index: number) => {
setMinimalShellMode(false)
setActiveTab(index)
},
[setMinimalShellMode],
)
const sections = React.useMemo(() => {
if (!queryWithParams) return []
const noParams = queryWithParams === query
return [
{
title: _(msg`Top`),
component: (
),
},
{
title: _(msg`Latest`),
component: (
),
},
noParams && {
title: _(msg`People`),
component: (
),
},
noParams && {
title: _(msg`Feeds`),
component: (
),
},
].filter(Boolean) as {
title: string
component: React.ReactNode
}[]
}, [_, query, queryWithParams, activeTab])
return queryWithParams ? (
(
section.title)} {...props} />
)}
initialPage={0}>
{sections.map((section, i) => (
{section.component}
))}
) : hasSession ? (
) : (
{isDesktop && (
Search
)}
Find posts and users on Bluesky
)
}
SearchScreenInner = React.memo(SearchScreenInner)
export function SearchScreen(
props: NativeStackScreenProps,
) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
const navigation = useNavigation()
const textInput = React.useRef(null)
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const {currentAccount} = useSession()
// Query terms
const queryParam = props.route?.params?.q ?? ''
const [searchText, setSearchText] = React.useState(queryParam)
const {data: autocompleteData, isFetching: isAutocompleteFetching} =
useActorAutocompleteQuery(searchText, true)
const [showAutocomplete, setShowAutocomplete] = React.useState(false)
const [termHistory = [], setTermHistory] = useStorage(account, [
currentAccount?.did ?? 'pwi',
'searchTermHistory',
] as const)
const [accountHistory = [], setAccountHistory] = useStorage(account, [
currentAccount?.did ?? 'pwi',
'searchAccountHistory',
])
const {data: accountHistoryProfiles} = useProfilesQuery({
handles: accountHistory,
maintainData: true,
})
const updateSearchHistory = useCallback(
async (item: string) => {
if (!item) return
const newSearchHistory = [
item,
...termHistory.filter(search => search !== item),
].slice(0, 6)
setTermHistory(newSearchHistory)
},
[termHistory, setTermHistory],
)
const updateProfileHistory = useCallback(
async (item: AppBskyActorDefs.ProfileViewBasic) => {
const newAccountHistory = [
item.did,
...accountHistory.filter(p => p !== item.did),
].slice(0, 5)
setAccountHistory(newAccountHistory)
},
[accountHistory, setAccountHistory],
)
const deleteSearchHistoryItem = useCallback(
async (item: string) => {
setTermHistory(termHistory.filter(search => search !== item))
},
[termHistory, setTermHistory],
)
const deleteProfileHistoryItem = useCallback(
async (item: AppBskyActorDefs.ProfileViewBasic) => {
setAccountHistory(accountHistory.filter(p => p !== item.did))
},
[accountHistory, setAccountHistory],
)
const {params, query, queryWithParams} = useQueryManager({
initialQuery: queryParam,
})
const showFilters = Boolean(queryWithParams && !showAutocomplete)
// web only - measure header height for sticky positioning
const [headerHeight, setHeaderHeight] = React.useState(0)
const headerRef = React.useRef(null)
useLayoutEffect(() => {
if (isWeb) {
if (!headerRef.current) return
const measurement = (headerRef.current as Element).getBoundingClientRect()
setHeaderHeight(measurement.height)
}
}, [])
useFocusEffect(
useNonReactiveCallback(() => {
if (isWeb) {
setSearchText(queryParam)
}
}),
)
const onPressClearQuery = React.useCallback(() => {
scrollToTopWeb()
setSearchText('')
textInput.current?.focus()
}, [])
const onChangeText = React.useCallback(async (text: string) => {
scrollToTopWeb()
setSearchText(text)
}, [])
const navigateToItem = React.useCallback(
(item: string) => {
scrollToTopWeb()
setShowAutocomplete(false)
updateSearchHistory(item)
if (isWeb) {
navigation.push('Search', {q: item})
} else {
textInput.current?.blur()
navigation.setParams({q: item})
}
},
[updateSearchHistory, navigation],
)
const onPressCancelSearch = React.useCallback(() => {
scrollToTopWeb()
textInput.current?.blur()
setShowAutocomplete(false)
if (isWeb) {
// Empty params resets the URL to be /search rather than /search?q=
navigation.replace('Search', {})
} else {
setSearchText('')
navigation.setParams({q: ''})
}
}, [setShowAutocomplete, setSearchText, navigation])
const onSubmit = React.useCallback(() => {
navigateToItem(searchText)
}, [navigateToItem, searchText])
const onAutocompleteResultPress = React.useCallback(() => {
if (isWeb) {
setShowAutocomplete(false)
} else {
textInput.current?.blur()
}
}, [])
const handleHistoryItemClick = React.useCallback(
(item: string) => {
setSearchText(item)
navigateToItem(item)
},
[navigateToItem],
)
const handleProfileClick = React.useCallback(
(profile: AppBskyActorDefs.ProfileViewBasic) => {
// Slight delay to avoid updating during push nav animation.
setTimeout(() => {
updateProfileHistory(profile)
}, 400)
},
[updateProfileHistory],
)
const onSoftReset = React.useCallback(() => {
if (isWeb) {
// Empty params resets the URL to be /search rather than /search?q=
navigation.replace('Search', {})
} else {
setSearchText('')
navigation.setParams({q: ''})
textInput.current?.focus()
}
}, [navigation])
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
return listenSoftReset(onSoftReset)
}, [onSoftReset, setMinimalShellMode]),
)
const onSearchInputFocus = React.useCallback(() => {
if (isWeb) {
// Prevent a jump on iPad by ensuring that
// the initial focused render has no result list.
requestAnimationFrame(() => {
setShowAutocomplete(true)
})
} else {
setShowAutocomplete(true)
}
}, [setShowAutocomplete])
return (
{
if (isWeb) setHeaderHeight(evt.nativeEvent.layout.height)
}}
style={[
a.relative,
a.z_10,
web({
position: 'sticky',
top: 0,
}),
]}>
{!gtMobile && (
Search
{showFilters ? (
) : (
)}
)}
{showAutocomplete && (
)}
{showFilters && gtMobile && (
)}
{searchText.length > 0 ? (
) : (
)}
)
}
let AutocompleteResults = ({
isAutocompleteFetching,
autocompleteData,
searchText,
onSubmit,
onResultPress,
onProfileClick,
}: {
isAutocompleteFetching: boolean
autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined
searchText: string
onSubmit: () => void
onResultPress: () => void
onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void
}): React.ReactNode => {
const moderationOpts = useModerationOpts()
const {_} = useLingui()
return (
<>
{(isAutocompleteFetching && !autocompleteData?.length) ||
!moderationOpts ? (
) : (
{autocompleteData?.map(item => (
{
onProfileClick(item)
onResultPress()
}}
/>
))}
)}
>
)
}
AutocompleteResults = React.memo(AutocompleteResults)
function SearchHistory({
searchHistory,
selectedProfiles,
onItemClick,
onProfileClick,
onRemoveItemClick,
onRemoveProfileClick,
}: {
searchHistory: string[]
selectedProfiles: AppBskyActorDefs.ProfileViewBasic[]
onItemClick: (item: string) => void
onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void
onRemoveItemClick: (item: string) => void
onRemoveProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void
}) {
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default')
const {_} = useLingui()
return (
{(searchHistory.length > 0 || selectedProfiles.length > 0) && (
Recent Searches
)}
{selectedProfiles.length > 0 && (
{selectedProfiles.slice(0, 5).map((profile, index) => (
onProfileClick(profile)}
style={styles.profilePressable}>
}
accessibilityIgnoresInvertColors
/>
{sanitizeDisplayName(
profile.displayName || profile.handle,
)}
onRemoveProfileClick(profile)}
hitSlop={createHitslop(6)}
style={styles.profileRemoveBtn}>
))}
)}
{searchHistory.length > 0 && (
{searchHistory.slice(0, 5).map((historyItem, index) => (
onItemClick(historyItem)}
hitSlop={HITSLOP_10}
style={[a.flex_1, a.py_sm]}>
{historyItem}
onRemoveItemClick(historyItem)}
hitSlop={HITSLOP_10}
style={[a.px_md, a.py_xs, a.justify_center]}>
))}
)}
)
}
function scrollToTopWeb() {
if (isWeb) {
window.scrollTo(0, 0)
}
}
const styles = StyleSheet.create({
headerMenuBtn: {
width: 30,
height: 30,
borderRadius: 30,
marginRight: 6,
alignItems: 'center',
justifyContent: 'center',
},
headerSearchContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
borderRadius: 30,
paddingHorizontal: 12,
paddingVertical: 8,
},
headerSearchIcon: {
marginRight: 6,
alignSelf: 'center',
},
headerSearchInput: {
flex: 1,
fontSize: 17,
minWidth: 0,
},
headerCancelBtn: {
paddingLeft: 10,
alignSelf: 'center',
zIndex: -1,
elevation: -1, // For Android
},
searchHistoryContainer: {
width: '100%',
paddingHorizontal: 12,
},
selectedProfilesContainer: {
marginTop: 10,
paddingHorizontal: 12,
height: 80,
},
selectedProfilesContainerMobile: {
height: 100,
},
profileItem: {
alignItems: 'center',
marginRight: 15,
width: 78,
},
profileItemMobile: {
width: 70,
},
profilePressable: {
alignItems: 'center',
width: '100%',
},
profileAvatar: {
width: 60,
height: 60,
borderRadius: 45,
},
profileName: {
width: 78,
fontSize: 12,
textAlign: 'center',
marginTop: 5,
},
profileRemoveBtn: {
position: 'absolute',
top: 0,
right: 5,
backgroundColor: 'white',
borderRadius: 10,
width: 18,
height: 18,
alignItems: 'center',
justifyContent: 'center',
},
searchHistoryContent: {
paddingHorizontal: 10,
borderRadius: 8,
},
searchHistoryTitle: {
fontWeight: '600',
paddingVertical: 12,
paddingHorizontal: 10,
},
})