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', }, })