import React from 'react'
import {
View,
StyleSheet,
ActivityIndicator,
TextInput,
Pressable,
Platform,
} from 'react-native'
import {ScrollView, CenteredView} from '#/view/com/util/Views'
import {List} from '#/view/com/util/List'
import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useFocusEffect} from '@react-navigation/native'
import {logger} from '#/logger'
import {
NativeStackScreenProps,
SearchTabNavigatorParams,
} from 'lib/routes/types'
import {Text} from '#/view/com/util/text/Text'
import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {Post} from '#/view/com/post/Post'
import {Pager} from '#/view/com/pager/Pager'
import {TabBar} from '#/view/com/pager/TabBar'
import {HITSLOP_10} from '#/lib/constants'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {usePalette} from '#/lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {useSession} from '#/state/session'
import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows'
import {useSearchPostsQuery} from '#/state/queries/search-posts'
import {useActorSearch} from '#/state/queries/actor-search'
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {useSetDrawerOpen} from '#/state/shell'
import {useAnalytics} from '#/lib/analytics/analytics'
import {MagnifyingGlassIcon} from '#/lib/icons'
import {useModerationOpts} from '#/state/queries/preferences'
import {
MATCH_HANDLE,
SearchLinkCard,
SearchProfileCard,
} from '#/view/shell/desktop/Search'
import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
import {isNative, isWeb} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
import {s} from '#/lib/styles'
import AsyncStorage from '@react-native-async-storage/async-storage'
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 SearchScreenSuggestedFollows() {
const pal = usePalette('default')
const {currentAccount} = useSession()
const [suggestions, setSuggestions] = React.useState<
AppBskyActorDefs.ProfileViewBasic[]
>([])
const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor()
React.useEffect(() => {
async function getSuggestions() {
const friends = await getSuggestedFollowsByActor(
currentAccount!.did,
).then(friendsRes => friendsRes.suggestions)
if (!friends) return // :(
const friendsOfFriends = new Map<
string,
AppBskyActorDefs.ProfileViewBasic
>()
await Promise.all(
friends.slice(0, 4).map(friend =>
getSuggestedFollowsByActor(friend.did).then(foafsRes => {
for (const user of foafsRes.suggestions) {
friendsOfFriends.set(user.did, user)
}
}),
),
)
setSuggestions(Array.from(friendsOfFriends.values()))
}
try {
getSuggestions()
} catch (e) {
logger.error(`SearchScreenSuggestedFollows: failed to get suggestions`, {
error: e,
})
}
}, [currentAccount, setSuggestions, getSuggestedFollowsByActor])
return suggestions.length ? (
}
keyExtractor={item => item.did}
// @ts-ignore web only -prf
desktopFixedHeight
contentContainerStyle={{paddingBottom: 1200}}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
/>
) : (
)
}
type SearchResultSlice =
| {
type: 'post'
key: string
post: AppBskyFeedDefs.PostView
}
| {
type: 'loadingMore'
key: string
}
function SearchScreenPostResults({query}: {query: string}) {
const {_} = useLingui()
const [isPTR, setIsPTR] = React.useState(false)
const {
isFetched,
data: results,
isFetching,
error,
refetch,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
} = useSearchPostsQuery({query})
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}}
/>
) : (
)}
>
) : (
)}
>
)
}
function SearchScreenUserResults({query}: {query: string}) {
const {_} = useLingui()
const {data: results, isFetched} = useActorSearch(query)
return isFetched && results ? (
<>
{results.length ? (
(
)}
keyExtractor={item => item.did}
// @ts-ignore web only -prf
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
/>
) : (
)}
>
) : (
)
}
const SECTIONS_LOGGEDOUT = ['Users']
const SECTIONS_LOGGEDIN = ['Posts', 'Users']
export function SearchScreenInner({
query,
primarySearch,
}: {
query?: string
primarySearch?: boolean
}) {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
const {hasSession} = useSession()
const {isDesktop} = useWebMediaQueries()
const onPageSelected = React.useCallback(
(index: number) => {
setMinimalShellMode(false)
setDrawerSwipeDisabled(index > 0)
},
[setDrawerSwipeDisabled, setMinimalShellMode],
)
if (hasSession) {
return query ? (
(
)}
initialPage={0}>
) : (
Suggested Follows
)
}
return query ? (
(
)}
initialPage={0}>
) : (
{isDesktop && (
Search
)}
{isDesktop && !primarySearch ? (
Find users with the search tool on the right
) : (
Find users on Bluesky
)}
)
}
export function SearchScreen(
props: NativeStackScreenProps,
) {
const theme = useTheme()
const textInput = React.useRef(null)
const {_} = useLingui()
const pal = usePalette('default')
const {track} = useAnalytics()
const setDrawerOpen = useSetDrawerOpen()
const moderationOpts = useModerationOpts()
const search = useActorAutocompleteFn()
const setMinimalShellMode = useSetMinimalShellMode()
const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries()
const searchDebounceTimeout = React.useRef(
undefined,
)
const [isFetching, setIsFetching] = React.useState(false)
const [query, setQuery] = React.useState(props.route?.params?.q || '')
const [searchResults, setSearchResults] = React.useState<
AppBskyActorDefs.ProfileViewBasic[]
>([])
const [inputIsFocused, setInputIsFocused] = React.useState(false)
const [showAutocompleteResults, setShowAutocompleteResults] =
React.useState(false)
const [searchHistory, setSearchHistory] = React.useState([])
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', e)
}
}
loadSearchHistory()
}, [])
const onPressMenu = React.useCallback(() => {
track('ViewHeader:MenuButtonClicked')
setDrawerOpen(true)
}, [track, setDrawerOpen])
const onPressCancelSearch = React.useCallback(() => {
scrollToTopWeb()
textInput.current?.blur()
setQuery('')
setShowAutocompleteResults(false)
if (searchDebounceTimeout.current)
clearTimeout(searchDebounceTimeout.current)
}, [textInput])
const onPressClearQuery = React.useCallback(() => {
scrollToTopWeb()
setQuery('')
setShowAutocompleteResults(false)
}, [setQuery])
const onChangeText = React.useCallback(
async (text: string) => {
scrollToTopWeb()
setQuery(text)
if (text.length > 0) {
setIsFetching(true)
setShowAutocompleteResults(true)
if (searchDebounceTimeout.current) {
clearTimeout(searchDebounceTimeout.current)
}
searchDebounceTimeout.current = setTimeout(async () => {
const results = await search({query: text, limit: 30})
if (results) {
setSearchResults(results)
setIsFetching(false)
}
}, 300)
} else {
if (searchDebounceTimeout.current) {
clearTimeout(searchDebounceTimeout.current)
}
setSearchResults([])
setIsFetching(false)
setShowAutocompleteResults(false)
}
},
[setQuery, search, setSearchResults],
)
const updateSearchHistory = React.useCallback(
async (newQuery: string) => {
newQuery = newQuery.trim()
if (newQuery && !searchHistory.includes(newQuery)) {
let newHistory = [newQuery, ...searchHistory]
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', e)
}
}
},
[searchHistory, setSearchHistory],
)
const onSubmit = React.useCallback(() => {
scrollToTopWeb()
setShowAutocompleteResults(false)
updateSearchHistory(query)
}, [query, setShowAutocompleteResults, updateSearchHistory])
const onSoftReset = React.useCallback(() => {
scrollToTopWeb()
onPressCancelSearch()
}, [onPressCancelSearch])
const queryMaybeHandle = React.useMemo(() => {
const match = MATCH_HANDLE.exec(query)
return match && match[1]
}, [query])
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
return listenSoftReset(onSoftReset)
}, [onSoftReset, setMinimalShellMode]),
)
const handleHistoryItemClick = (item: React.SetStateAction) => {
setQuery(item)
onSubmit()
}
const handleRemoveHistoryItem = (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', e)
},
)
}
return (
{isTabletOrMobile && (
)}
setInputIsFocused(true)}
onBlur={() => {
// HACK
// give 100ms to not stop click handlers in the search history
// -prf
setTimeout(() => setInputIsFocused(false), 100)
}}
onChangeText={onChangeText}
onSubmitEditing={onSubmit}
autoFocus={false}
accessibilityRole="search"
accessibilityLabel={_(msg`Search`)}
accessibilityHint=""
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
/>
{query ? (
) : undefined}
{query || inputIsFocused ? (
Cancel
) : undefined}
{showAutocompleteResults ? (
<>
{isFetching || !moderationOpts ? (
) : (
{queryMaybeHandle ? (
) : null}
{searchResults.map(item => (
))}
)}
>
) : !query && inputIsFocused ? (
{searchHistory.length > 0 && (
Recent Searches
{searchHistory.map((historyItem, index) => (
handleHistoryItemClick(historyItem)}
style={styles.historyItem}>
{historyItem}
handleRemoveHistoryItem(historyItem)}>
))}
)}
) : (
)}
)
}
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,
},
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',
},
historyItem: {
paddingVertical: 8,
},
historyItemContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 8,
},
})