import React from 'react'
import {
ActivityIndicator,
Platform,
Pressable,
StyleSheet,
TextInput,
View,
} from 'react-native'
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 AsyncStorage from '@react-native-async-storage/async-storage'
import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {useAnalytics} from '#/lib/analytics/analytics'
import {HITSLOP_10} from '#/lib/constants'
import {usePalette} from '#/lib/hooks/usePalette'
import {MagnifyingGlassIcon} from '#/lib/icons'
import {NavigationProp} from '#/lib/routes/types'
import {augmentSearchQuery} from '#/lib/strings/helpers'
import {s} from '#/lib/styles'
import {logger} from '#/logger'
import {isIOS, isNative, isWeb} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useActorSearch} from '#/state/queries/actor-search'
import {useSearchPostsQuery} from '#/state/queries/search-posts'
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session'
import {useSetDrawerOpen} from '#/state/shell'
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
import {useNonReactiveCallback} from 'lib/hooks/useNonReactiveCallback'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {
NativeStackScreenProps,
SearchTabNavigatorParams,
} from 'lib/routes/types'
import {useTheme} from 'lib/ThemeContext'
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 {List} from '#/view/com/util/List'
import {Text} from '#/view/com/util/text/Text'
import {CenteredView, ScrollView} from '#/view/com/util/Views'
import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {atoms as a} from '#/alf'
function Loader() {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
return (
)
}
function EmptyState({message, error}: {message: string; error?: string}) {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
return (
{message}
{error && (
<>
Error: {error}
>
)}
)
}
function useSuggestedFollows(): [
AppBskyActorDefs.ProfileViewBasic[],
() => void,
] {
const {
data: suggestions,
hasNextPage,
isFetchingNextPage,
isError,
fetchNextPage,
} = useSuggestedFollowsQuery()
const onEndReached = React.useCallback(async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more suggested follows', {message: err})
}
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
const items: AppBskyActorDefs.ProfileViewBasic[] = []
if (suggestions) {
// Currently the responses contain duplicate items.
// Needs to be fixed on backend, but let's dedupe to be safe.
let seen = new Set()
for (const page of suggestions.pages) {
for (const actor of page.actors) {
if (!seen.has(actor.did)) {
seen.add(actor.did)
items.push(actor)
}
}
}
}
return [items, onEndReached]
}
let SearchScreenSuggestedFollows = (_props: {}): React.ReactNode => {
const pal = usePalette('default')
const [suggestions, onEndReached] = useSuggestedFollows()
return suggestions.length ? (
}
keyExtractor={item => item.did}
// @ts-ignore web only -prf
desktopFixedHeight
contentContainerStyle={{paddingBottom: 200}}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
onEndReached={onEndReached}
onEndReachedThreshold={2}
/>
) : (
)
}
SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows)
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
}
}}
keyExtractor={item => item.key}
refreshing={isPTR}
onRefresh={onPullToRefresh}
onEndReached={onEndReached}
// @ts-ignore web only -prf
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: query,
enabled: active,
})
return isFetched && results ? (
<>
{results.length ? (
(
)}
keyExtractor={item => item.did}
// @ts-ignore web only -prf
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
/>
) : (
)}
>
) : (
)
}
SearchScreenUserResults = React.memo(SearchScreenUserResults)
let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
const {hasSession} = useSession()
const {isDesktop} = useWebMediaQueries()
const [activeTab, setActiveTab] = React.useState(0)
const {_} = useLingui()
const onPageSelected = React.useCallback(
(index: number) => {
setMinimalShellMode(false)
setDrawerSwipeDisabled(index > 0)
setActiveTab(index)
},
[setDrawerSwipeDisabled, setMinimalShellMode],
)
const sections = React.useMemo(() => {
if (!query) return []
return [
{
title: _(msg`Top`),
component: (
),
},
{
title: _(msg`Latest`),
component: (
),
},
{
title: _(msg`People`),
component: (
),
},
]
}, [_, query, activeTab])
return query ? (
(
section.title)} {...props} />
)}
initialPage={0}>
{sections.map((section, i) => (
{section.component}
))}
) : hasSession ? (
Suggested Follows
) : (
{isDesktop && (
Search
)}
Find posts and users on Bluesky
)
}
SearchScreenInner = React.memo(SearchScreenInner)
export function SearchScreen(
props: NativeStackScreenProps,
) {
const navigation = useNavigation()
const textInput = React.useRef(null)
const {_} = useLingui()
const pal = usePalette('default')
const {track} = useAnalytics()
const setDrawerOpen = useSetDrawerOpen()
const setMinimalShellMode = useSetMinimalShellMode()
const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries()
// 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 [searchHistory, setSearchHistory] = React.useState([])
useFocusEffect(
useNonReactiveCallback(() => {
if (isWeb) {
setSearchText(queryParam)
}
}),
)
React.useEffect(() => {
const loadSearchHistory = async () => {
try {
const history = await AsyncStorage.getItem('searchHistory')
if (history !== null) {
setSearchHistory(JSON.parse(history))
}
} catch (e: any) {
logger.error('Failed to load search history', {message: e})
}
}
loadSearchHistory()
}, [])
const onPressMenu = React.useCallback(() => {
track('ViewHeader:MenuButtonClicked')
setDrawerOpen(true)
}, [track, setDrawerOpen])
const onPressClearQuery = React.useCallback(() => {
scrollToTopWeb()
setSearchText('')
textInput.current?.focus()
}, [])
const onPressCancelSearch = React.useCallback(() => {
scrollToTopWeb()
textInput.current?.blur()
setShowAutocomplete(false)
setSearchText(queryParam)
}, [queryParam])
const onChangeText = React.useCallback(async (text: string) => {
scrollToTopWeb()
setSearchText(text)
}, [])
const updateSearchHistory = React.useCallback(
async (newQuery: string) => {
newQuery = newQuery.trim()
if (newQuery) {
let newHistory = [
newQuery,
...searchHistory.filter(q => q !== newQuery),
]
if (newHistory.length > 5) {
newHistory = newHistory.slice(0, 5)
}
setSearchHistory(newHistory)
try {
await AsyncStorage.setItem(
'searchHistory',
JSON.stringify(newHistory),
)
} catch (e: any) {
logger.error('Failed to save search history', {message: e})
}
}
},
[searchHistory, setSearchHistory],
)
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 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 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: ''})
}
}, [navigation])
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
return listenSoftReset(onSoftReset)
}, [onSoftReset, setMinimalShellMode]),
)
const handleRemoveHistoryItem = React.useCallback(
(itemToRemove: string) => {
const updatedHistory = searchHistory.filter(item => item !== itemToRemove)
setSearchHistory(updatedHistory)
AsyncStorage.setItem(
'searchHistory',
JSON.stringify(updatedHistory),
).catch(e => {
logger.error('Failed to update search history', {message: e})
})
},
[searchHistory],
)
return (
{isTabletOrMobile && (
)}
{showAutocomplete && (
Cancel
)}
{searchText.length > 0 ? (
) : (
)}
)
}
let SearchInputBox = ({
textInput,
searchText,
showAutocomplete,
setShowAutocomplete,
onChangeText,
onSubmit,
onPressClearQuery,
}: {
textInput: React.RefObject
searchText: string
showAutocomplete: boolean
setShowAutocomplete: (show: boolean) => void
onChangeText: (text: string) => void
onSubmit: () => void
onPressClearQuery: () => void
}): React.ReactNode => {
const pal = usePalette('default')
const {_} = useLingui()
const theme = useTheme()
return (
{
textInput.current?.focus()
}}>
{
if (isWeb) {
// Prevent a jump on iPad by ensuring that
// the initial focused render has no result list.
requestAnimationFrame(() => {
setShowAutocomplete(true)
})
} else {
setShowAutocomplete(true)
if (isIOS) {
// We rely on selectTextOnFocus, but it's broken on iOS:
// https://github.com/facebook/react-native/issues/41988
textInput.current?.setSelection(0, searchText.length)
// We still rely on selectTextOnFocus for it to be instant on Android.
}
}
}}
onChangeText={onChangeText}
onSubmitEditing={onSubmit}
autoFocus={false}
accessibilityRole="search"
accessibilityLabel={_(msg`Search`)}
accessibilityHint=""
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
/>
{showAutocomplete && searchText.length > 0 && (
)}
)
}
SearchInputBox = React.memo(SearchInputBox)
let AutocompleteResults = ({
isAutocompleteFetching,
autocompleteData,
searchText,
onSubmit,
onResultPress,
}: {
isAutocompleteFetching: boolean
autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined
searchText: string
onSubmit: () => void
onResultPress: () => void
}): React.ReactNode => {
const moderationOpts = useModerationOpts()
const {_} = useLingui()
return (
<>
{(isAutocompleteFetching && !autocompleteData?.length) ||
!moderationOpts ? (
) : (
{autocompleteData?.map(item => (
))}
)}
>
)
}
AutocompleteResults = React.memo(AutocompleteResults)
function SearchHistory({
searchHistory,
onItemClick,
onRemoveItemClick,
}: {
searchHistory: string[]
onItemClick: (item: string) => void
onRemoveItemClick: (item: string) => void
}) {
const {isTabletOrDesktop} = useWebMediaQueries()
const pal = usePalette('default')
return (
{searchHistory.length > 0 && (
Recent Searches
{searchHistory.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 HEADER_HEIGHT = 50
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 4,
height: HEADER_HEIGHT,
// @ts-ignore web only
position: isWeb ? 'sticky' : '',
top: 0,
zIndex: 1,
},
headerMenuBtn: {
width: 30,
height: 30,
borderRadius: 30,
marginRight: 6,
paddingBottom: 2,
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,
},
headerCancelBtn: {
paddingLeft: 10,
alignSelf: 'center',
zIndex: -1,
elevation: -1, // For Android
},
tabBarContainer: {
// @ts-ignore web only
position: isWeb ? 'sticky' : '',
top: isWeb ? HEADER_HEIGHT : 0,
zIndex: 1,
},
searchHistoryContainer: {
width: '100%',
paddingHorizontal: 12,
},
searchHistoryContent: {
padding: 10,
borderRadius: 8,
},
searchHistoryTitle: {
fontWeight: 'bold',
},
})