import React from 'react' import { ActivityIndicator, Image, ImageStyle, Platform, Pressable, StyleProp, StyleSheet, TextInput, View, } from 'react-native' import {ScrollView as RNGHScrollView} from 'react-native-gesture-handler' import RNPickerSelect from 'react-native-picker-select' 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 {APP_LANGUAGES, LANGUAGES} from '#/lib/../locale/languages' import {createHitslop} 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 {augmentSearchQuery} from '#/lib/strings/helpers' import {logger} from '#/logger' 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 {useSearchPostsQuery} from '#/state/queries/search-posts' import {useSession} from '#/state/session' import {useSetDrawerOpen} from '#/state/shell' 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, useBreakpoints, useTheme as useThemeNew, 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 ChevronDown} from '#/components/icons/Chevron' import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' import * as Layout from '#/components/Layout' 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} // @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, 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 SearchScreenFeedsResults = ({ query, active, }: { query: string active: boolean }): React.ReactNode => { const t = useThemeNew() const {_} = useLingui() const {data: results, isFetched} = usePopularFeedsSearch({ query, enabled: active, }) return isFetched && results ? ( <> {results.length ? ( ( )} keyExtractor={item => item.uri} // @ts-ignore web only -prf desktopFixedHeight contentContainerStyle={{paddingBottom: 100}} /> ) : ( )} ) : ( ) } SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults) function SearchLanguageDropdown({ value, onChange, }: { value: string onChange(value: string): void }) { const t = useThemeNew() const {_} = useLingui() const {contentLanguages} = useLanguagePrefs() const items = React.useMemo(() => { return [ { label: _(msg`Any language`), inputLabel: _(msg`Any language`), value: '', key: '*', }, ].concat( 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: l.name, inputLabel: l.name, 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) }), ) }, [_, contentLanguages]) const style = { backgroundColor: t.atoms.bg_contrast_25.backgroundColor, color: t.atoms.text.color, fontSize: a.text_xs.fontSize, fontFamily: 'inherit', fontWeight: a.font_bold.fontWeight, paddingHorizontal: 14, paddingRight: 32, paddingVertical: 8, borderRadius: a.rounded_full.borderRadius, borderWidth: a.border.borderWidth, borderColor: t.atoms.border_contrast_low.borderColor, } return ( ( )} useNativeAndroidPickerStyle={false} style={{ iconContainer: { pointerEvents: 'none', right: a.px_sm.paddingRight, top: 0, bottom: 0, display: 'flex', justifyContent: 'center', }, inputAndroid: { ...style, paddingVertical: 2, }, inputIOS: { ...style, }, inputWeb: web({ ...style, cursor: 'pointer', // @ts-ignore web only '-moz-appearance': 'none', '-webkit-appearance': 'none', appearance: 'none', outline: 0, borderWidth: 0, overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', }), }} /> ) } function useQueryManager({initialQuery}: {initialQuery: string}) { const {query, params: initialParams} = React.useMemo(() => { return parseSearchQuery(initialQuery || '') }, [initialQuery]) const prevInitialQuery = React.useRef(initialQuery) const [lang, setLang] = React.useState(initialParams.lang || '') if (initialQuery !== prevInitialQuery.current) { // handle new queryParam change (from manual search entry) prevInitialQuery.current = 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 = useThemeNew() const {gtMobile} = useBreakpoints() const navigation = useNavigation() const textInput = React.useRef(null) const {_} = useLingui() const setDrawerOpen = useSetDrawerOpen() const setMinimalShellMode = useSetMinimalShellMode() // 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([]) const [selectedProfiles, setSelectedProfiles] = React.useState< AppBskyActorDefs.ProfileViewBasic[] >([]) const {params, query, queryWithParams} = useQueryManager({ initialQuery: queryParam, }) const showFilters = Boolean(queryWithParams && !showAutocomplete) /* * Arbitrary sizing, so guess and check, used for sticky header alignment and * sizing. */ const headerHeight = 60 + (showFilters ? 40 : 0) 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)) } const profiles = await AsyncStorage.getItem('selectedProfiles') if (profiles !== null) { setSelectedProfiles(JSON.parse(profiles)) } } catch (e: any) { logger.error('Failed to load search history', {message: e}) } } loadSearchHistory() }, []) const onPressMenu = React.useCallback(() => { textInput.current?.blur() setDrawerOpen(true) }, [setDrawerOpen]) const onPressClearQuery = React.useCallback(() => { scrollToTopWeb() setSearchText('') textInput.current?.focus() }, []) 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 updateSelectedProfiles = React.useCallback( async (profile: AppBskyActorDefs.ProfileViewBasic) => { let newProfiles = [ profile, ...selectedProfiles.filter(p => p.did !== profile.did), ] if (newProfiles.length > 5) { newProfiles = newProfiles.slice(0, 5) } setSelectedProfiles(newProfiles) try { await AsyncStorage.setItem( 'selectedProfiles', JSON.stringify(newProfiles), ) } catch (e: any) { logger.error('Failed to save selected profiles', {message: e}) } }, [selectedProfiles, setSelectedProfiles], ) 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) setSearchText(queryParam) }, [setShowAutocomplete, setSearchText, queryParam]) 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(() => { updateSelectedProfiles(profile) }, 400) }, [updateSelectedProfiles], ) 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 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], ) const handleRemoveProfile = React.useCallback( (profileToRemove: AppBskyActorDefs.ProfileViewBasic) => { const updatedProfiles = selectedProfiles.filter( profile => profile.did !== profileToRemove.did, ) setSelectedProfiles(updatedProfiles) AsyncStorage.setItem( 'selectedProfiles', JSON.stringify(updatedProfiles), ).catch(e => { logger.error('Failed to update selected profiles', {message: e}) }) }, [selectedProfiles], ) 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 ( {!gtMobile && ( )} {showAutocomplete && ( )} {showFilters && ( )} {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 /> {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, }, profilesRow: { flexDirection: 'row', flexWrap: 'nowrap', }, profileItem: { alignItems: 'center', marginRight: 15, width: 78, }, profileItemMobile: { width: 70, }, profilePressable: { alignItems: 'center', }, 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, }, })