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 {useGate} from '#/lib/statsig/statsig' import {augmentSearchQuery} from '#/lib/strings/helpers' import {s} from '#/lib/styles' import {logger} from '#/logger' import {isNative, isWeb} from '#/platform/detection' import {listenSoftReset} from '#/state/events' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useActorSearch} from '#/state/queries/actor-search' import {useModerationOpts} from '#/state/queries/preferences' import {useSearchPostsQuery} from '#/state/queries/search-posts' import { useGetSuggestedFollowersByActor, useSuggestedFollowsQuery, } from '#/state/queries/suggested-follows' import {useSession} from '#/state/session' import {useSetDrawerOpen} from '#/state/shell' import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' 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 { MATCH_HANDLE, SearchLinkCard, SearchProfileCard, } from '#/view/shell/desktop/Search' import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {atoms as a} from '#/alf' import {useThrottledValue} from '#/components/hooks/useThrottledValue' 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 useSuggestedFollowsV1(): [ AppBskyActorDefs.ProfileViewBasic[], () => void, ] { 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) { if (user.associated?.labeler) continue friendsOfFriends.set(user.did, user) } }), ), ) setSuggestions(Array.from(friendsOfFriends.values())) } try { getSuggestions() } catch (e) { logger.error(`SearchScreenSuggestedFollows: failed to get suggestions`, { message: e, }) } }, [currentAccount, setSuggestions, getSuggestedFollowsByActor]) return [suggestions, () => {}] } function useSuggestedFollowsV2(): [ 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] } function SearchScreenSuggestedFollows() { const pal = usePalette('default') const gate = useGate() const useSuggestedFollows = gate('use_new_suggestions_endpoint') ? // Conditional hook call here is *only* OK because useGate() // result won't change until a remount. useSuggestedFollowsV2 : useSuggestedFollowsV1 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} /> ) : ( ) } type SearchResultSlice = | { type: 'post' key: string post: AppBskyFeedDefs.PostView } | { type: 'loadingMore' key: string } function SearchScreenPostResults({ query, sort, active, }: { query: string sort?: 'top' | 'latest' active: boolean }) { 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}} /> ) : ( )} ) : ( )} ) } function SearchScreenUserResults({ query, active, }: { query: string active: boolean }) { const {_} = useLingui() const {data: results, isFetched} = useActorSearch({ query, enabled: active, }) return isFetched && results ? ( <> {results.length ? ( ( )} keyExtractor={item => item.did} // @ts-ignore web only -prf desktopFixedHeight contentContainerStyle={{paddingBottom: 100}} /> ) : ( )} ) : ( ) } export function SearchScreenInner({query}: {query?: string}) { 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 ) } export function SearchScreen( props: NativeStackScreenProps, ) { const navigation = useNavigation() const theme = useTheme() const textInput = React.useRef(null) const {_} = useLingui() const pal = usePalette('default') const {track} = useAnalytics() const setDrawerOpen = useSetDrawerOpen() const moderationOpts = useModerationOpts() const setMinimalShellMode = useSetMinimalShellMode() const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries() const searchDebounceTimeout = React.useRef( undefined, ) // Query terms const q = props.route?.params?.q ?? '' const [query, setQuery] = React.useState(q) const [searchInput, setSearchInput] = React.useState(q) const throttledInput = useThrottledValue(searchInput, 300) const {data: autocompleteData, isFetching: isAutocompleteFetching} = useActorAutocompleteQuery(throttledInput) 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', {message: 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() setSearchInput(text) const showAutoComplete = text.length > 0 setShowAutocompleteResults(showAutoComplete) }, []) 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', {message: e}) } } }, [searchHistory, setSearchHistory], ) const onSubmit = React.useCallback(() => { scrollToTopWeb() setShowAutocompleteResults(false) updateSearchHistory(query) navigation.push('Search', {q: query}) }, [navigation, query, updateSearchHistory]) const onSoftReset = React.useCallback(() => { scrollToTopWeb() onPressCancelSearch() }, [onPressCancelSearch]) const queryMaybeHandle = React.useMemo(() => { const match = MATCH_HANDLE.exec(query) return match && match[1] }, [query]) // TODO ??? 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', {message: 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 ? ( <> {/* TODO avoid showing spinner if we have any previous results -hailey */} {(isAutocompleteFetching && !autocompleteData) || !moderationOpts ? ( ) : ( {queryMaybeHandle ? ( ) : null} {autocompleteData?.map(item => ( ))} )} ) : !query && inputIsFocused ? ( {searchHistory.length > 0 && ( Recent Searches {searchHistory.map((historyItem, index) => ( handleHistoryItemClick(historyItem)} style={[a.flex_1, a.py_sm]}> {historyItem} handleRemoveHistoryItem(historyItem)} 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, }, 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', }, })