diff options
-rw-r--r-- | src/state/models/ui/search.ts | 69 | ||||
-rw-r--r-- | src/state/queries/actor-autocomplete.ts | 4 | ||||
-rw-r--r-- | src/state/queries/search-posts.ts | 32 | ||||
-rw-r--r-- | src/state/queries/suggested-follows.ts | 30 | ||||
-rw-r--r-- | src/view/com/search/HeaderWithInput.tsx | 186 | ||||
-rw-r--r-- | src/view/com/search/SearchResults.tsx | 150 | ||||
-rw-r--r-- | src/view/com/search/Suggestions.tsx | 265 | ||||
-rw-r--r-- | src/view/screens/Search.tsx | 1 | ||||
-rw-r--r-- | src/view/screens/Search.web.tsx | 76 | ||||
-rw-r--r-- | src/view/screens/Search/Search.tsx | 639 | ||||
-rw-r--r-- | src/view/screens/Search/index.tsx | 1 | ||||
-rw-r--r-- | src/view/screens/Search/index.web.tsx | 1 | ||||
-rw-r--r-- | src/view/screens/SearchMobile.tsx | 205 | ||||
-rw-r--r-- | src/view/shell/desktop/Search.tsx | 74 |
14 files changed, 742 insertions, 991 deletions
diff --git a/src/state/models/ui/search.ts b/src/state/models/ui/search.ts deleted file mode 100644 index 2b2036751..000000000 --- a/src/state/models/ui/search.ts +++ /dev/null @@ -1,69 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {searchProfiles, searchPosts} from 'lib/api/search' -import {PostThreadModel} from '../content/post-thread' -import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' -import {RootStoreModel} from '../root-store' - -export class SearchUIModel { - isPostsLoading = false - isProfilesLoading = false - query: string = '' - posts: PostThreadModel[] = [] - profiles: AppBskyActorDefs.ProfileView[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this) - } - - async fetch(q: string) { - this.posts = [] - this.profiles = [] - this.query = q - if (!q.trim()) { - return - } - - this.isPostsLoading = true - this.isProfilesLoading = true - - const [postsSearch, profilesSearch] = await Promise.all([ - searchPosts(q).catch(_e => []), - searchProfiles(q).catch(_e => []), - ]) - - let posts: AppBskyFeedDefs.PostView[] = [] - if (postsSearch?.length) { - do { - const res = await this.rootStore.agent.app.bsky.feed.getPosts({ - uris: postsSearch - .splice(0, 25) - .map(p => `at://${p.user.did}/${p.tid}`), - }) - posts = posts.concat(res.data.posts) - } while (postsSearch.length) - } - runInAction(() => { - this.posts = posts.map(post => - PostThreadModel.fromPostView(this.rootStore, post), - ) - this.isPostsLoading = false - }) - - let profiles: AppBskyActorDefs.ProfileView[] = [] - if (profilesSearch?.length) { - do { - const res = await this.rootStore.agent.getProfiles({ - actors: profilesSearch.splice(0, 25).map(p => p.did), - }) - profiles = profiles.concat(res.data.profiles) - } while (profilesSearch.length) - } - - this.rootStore.me.follows.hydrateMany(profiles) - - runInAction(() => { - this.profiles = profiles - this.isProfilesLoading = false - }) - } -} diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index 57f30f9c5..de7a2e1f6 100644 --- a/src/state/queries/actor-autocomplete.ts +++ b/src/state/queries/actor-autocomplete.ts @@ -36,7 +36,7 @@ export function useActorAutocompleteFn() { const {data: follows} = useMyFollowsQuery() return React.useCallback( - async ({query}: {query: string}) => { + async ({query, limit = 8}: {query: string; limit?: number}) => { let res if (query) { try { @@ -47,7 +47,7 @@ export function useActorAutocompleteFn() { queryFn: () => agent.searchActorsTypeahead({ term: query, - limit: 8, + limit, }), }) } catch (e) { diff --git a/src/state/queries/search-posts.ts b/src/state/queries/search-posts.ts new file mode 100644 index 000000000..ab6ed4d41 --- /dev/null +++ b/src/state/queries/search-posts.ts @@ -0,0 +1,32 @@ +import {AppBskyFeedSearchPosts} from '@atproto/api' +import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' + +import {useSession} from '#/state/session' + +const searchPostsQueryKey = ({query}: {query: string}) => [ + 'search-posts', + query, +] + +export function useSearchPostsQuery({query}: {query: string}) { + const {agent} = useSession() + + return useInfiniteQuery< + AppBskyFeedSearchPosts.OutputSchema, + Error, + InfiniteData<AppBskyFeedSearchPosts.OutputSchema>, + QueryKey, + string | undefined + >({ + queryKey: searchPostsQueryKey({query}), + queryFn: async () => { + const res = await agent.app.bsky.feed.searchPosts({ + q: query, + limit: 25, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts index 5b5e142ca..b31e69366 100644 --- a/src/state/queries/suggested-follows.ts +++ b/src/state/queries/suggested-follows.ts @@ -1,3 +1,4 @@ +import React from 'react' import { AppBskyActorGetSuggestions, AppBskyGraphGetSuggestedFollowsByActor, @@ -5,7 +6,7 @@ import { } from '@atproto/api' import { useInfiniteQuery, - useMutation, + useQueryClient, useQuery, InfiniteData, QueryKey, @@ -15,7 +16,7 @@ import {useSession} from '#/state/session' import {useModerationOpts} from '#/state/queries/preferences' const suggestedFollowsQueryKey = ['suggested-follows'] -const suggestedFollowsByActorQuery = (did: string) => [ +const suggestedFollowsByActorQueryKey = (did: string) => [ 'suggested-follows-by-actor', did, ] @@ -73,7 +74,7 @@ export function useSuggestedFollowsByActorQuery({did}: {did: string}) { const {agent} = useSession() return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({ - queryKey: suggestedFollowsByActorQuery(did), + queryKey: suggestedFollowsByActorQueryKey(did), queryFn: async () => { const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ actor: did, @@ -83,17 +84,26 @@ export function useSuggestedFollowsByActorQuery({did}: {did: string}) { }) } -// TODO: Delete and replace usages with the one above. +// TODO refactor onboarding to use above, but this is still used export function useGetSuggestedFollowersByActor() { const {agent} = useSession() + const queryClient = useQueryClient() - return useMutation({ - mutationFn: async (actor: string) => { - const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ - actor: actor, + return React.useCallback( + async (actor: string) => { + const res = await queryClient.fetchQuery({ + staleTime: 60 * 1000, + queryKey: suggestedFollowsByActorQueryKey(actor), + queryFn: async () => { + const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ + actor: actor, + }) + return res.data + }, }) - return res.data + return res }, - }) + [agent, queryClient], + ) } diff --git a/src/view/com/search/HeaderWithInput.tsx b/src/view/com/search/HeaderWithInput.tsx deleted file mode 100644 index 04cbeab6e..000000000 --- a/src/view/com/search/HeaderWithInput.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import React from 'react' -import {StyleSheet, TextInput, TouchableOpacity, View} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {Text} from 'view/com/util/text/Text' -import {MagnifyingGlassIcon} from 'lib/icons' -import {useTheme} from 'lib/ThemeContext' -import {usePalette} from 'lib/hooks/usePalette' -import {useAnalytics} from 'lib/analytics/analytics' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {HITSLOP_10} from 'lib/constants' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useSetDrawerOpen} from '#/state/shell' - -interface Props { - isInputFocused: boolean - query: string - setIsInputFocused: (v: boolean) => void - onChangeQuery: (v: string) => void - onPressClearQuery: () => void - onPressCancelSearch: () => void - onSubmitQuery: () => void - showMenu?: boolean -} -export function HeaderWithInput({ - isInputFocused, - query, - setIsInputFocused, - onChangeQuery, - onPressClearQuery, - onPressCancelSearch, - onSubmitQuery, - showMenu = true, -}: Props) { - const setDrawerOpen = useSetDrawerOpen() - const theme = useTheme() - const pal = usePalette('default') - const {_} = useLingui() - const {track} = useAnalytics() - const textInput = React.useRef<TextInput>(null) - const {isMobile} = useWebMediaQueries() - - const onPressMenu = React.useCallback(() => { - track('ViewHeader:MenuButtonClicked') - setDrawerOpen(true) - }, [track, setDrawerOpen]) - - const onPressCancelSearchInner = React.useCallback(() => { - onPressCancelSearch() - textInput.current?.blur() - }, [onPressCancelSearch, textInput]) - - return ( - <View - style={[ - pal.view, - pal.border, - styles.header, - !isMobile && styles.headerDesktop, - ]}> - {showMenu && isMobile ? ( - <TouchableOpacity - testID="viewHeaderBackOrMenuBtn" - onPress={onPressMenu} - hitSlop={HITSLOP_10} - style={styles.headerMenuBtn} - accessibilityRole="button" - accessibilityLabel={_(msg`Menu`)} - accessibilityHint="Access navigation links and settings"> - <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} /> - </TouchableOpacity> - ) : null} - <View - style={[ - {backgroundColor: pal.colors.backgroundLight}, - styles.headerSearchContainer, - ]}> - <MagnifyingGlassIcon - style={[pal.icon, styles.headerSearchIcon]} - size={21} - /> - <TextInput - testID="searchTextInput" - ref={textInput} - placeholder="Search" - placeholderTextColor={pal.colors.textLight} - selectTextOnFocus - returnKeyType="search" - value={query} - style={[pal.text, styles.headerSearchInput]} - keyboardAppearance={theme.colorScheme} - onFocus={() => setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - onChangeText={onChangeQuery} - onSubmitEditing={onSubmitQuery} - autoFocus={false} - accessibilityRole="search" - accessibilityLabel={_(msg`Search`)} - accessibilityHint="" - autoCorrect={false} - autoCapitalize="none" - /> - {query ? ( - <TouchableOpacity - testID="searchTextInputClearBtn" - onPress={onPressClearQuery} - accessibilityRole="button" - accessibilityLabel={_(msg`Clear search query`)} - accessibilityHint=""> - <FontAwesomeIcon - icon="xmark" - size={16} - style={pal.textLight as FontAwesomeIconStyle} - /> - </TouchableOpacity> - ) : undefined} - </View> - {query || isInputFocused ? ( - <View style={styles.headerCancelBtn}> - <TouchableOpacity - onPress={onPressCancelSearchInner} - accessibilityRole="button"> - <Text style={pal.text}> - <Trans>Cancel</Trans> - </Text> - </TouchableOpacity> - </View> - ) : undefined} - </View> - ) -} - -const styles = StyleSheet.create({ - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 12, - paddingVertical: 4, - }, - headerDesktop: { - borderWidth: 1, - borderTopWidth: 0, - paddingVertical: 10, - }, - 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, - }, - - searchPrompt: { - textAlign: 'center', - paddingTop: 10, - }, - - suggestions: { - marginBottom: 8, - }, -}) diff --git a/src/view/com/search/SearchResults.tsx b/src/view/com/search/SearchResults.tsx deleted file mode 100644 index 87378bba7..000000000 --- a/src/view/com/search/SearchResults.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {SearchUIModel} from 'state/models/ui/search' -import {CenteredView, ScrollView} from '../util/Views' -import {Pager, RenderTabBarFnProps} 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 { - PostFeedLoadingPlaceholder, - ProfileCardFeedLoadingPlaceholder, -} from 'view/com/util/LoadingPlaceholder' -import {Text} from 'view/com/util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {s} from 'lib/styles' - -const SECTIONS = ['Posts', 'Users'] - -export const SearchResults = observer(function SearchResultsImpl({ - model, -}: { - model: SearchUIModel -}) { - const pal = usePalette('default') - const {isMobile} = useWebMediaQueries() - - const renderTabBar = React.useCallback( - (props: RenderTabBarFnProps) => { - return ( - <CenteredView style={[pal.border, pal.view, styles.tabBar]}> - <TabBar - items={SECTIONS} - {...props} - key={SECTIONS.join()} - indicatorColor={pal.colors.link} - /> - </CenteredView> - ) - }, - [pal], - ) - - return ( - <Pager renderTabBar={renderTabBar} tabBarPosition="top" initialPage={0}> - <View - style={{ - paddingTop: isMobile ? 42 : 50, - }}> - <PostResults key="0" model={model} /> - </View> - <View - style={{ - paddingTop: isMobile ? 42 : 50, - }}> - <Profiles key="1" model={model} /> - </View> - </Pager> - ) -}) - -const PostResults = observer(function PostResultsImpl({ - model, -}: { - model: SearchUIModel -}) { - const pal = usePalette('default') - if (model.isPostsLoading) { - return ( - <CenteredView> - <PostFeedLoadingPlaceholder /> - </CenteredView> - ) - } - - if (model.posts.length === 0) { - return ( - <CenteredView> - <Text type="xl" style={[styles.empty, pal.text]}> - No posts found for "{model.query}" - </Text> - </CenteredView> - ) - } - - return ( - <ScrollView style={[pal.view]}> - {model.posts.map(post => ( - <Post key={post.resolvedUri} view={post} hideError /> - ))} - <View style={s.footerSpacer} /> - <View style={s.footerSpacer} /> - <View style={s.footerSpacer} /> - </ScrollView> - ) -}) - -const Profiles = observer(function ProfilesImpl({ - model, -}: { - model: SearchUIModel -}) { - const pal = usePalette('default') - if (model.isProfilesLoading) { - return ( - <CenteredView> - <ProfileCardFeedLoadingPlaceholder /> - </CenteredView> - ) - } - - if (model.profiles.length === 0) { - return ( - <CenteredView> - <Text type="xl" style={[styles.empty, pal.text]}> - No users found for "{model.query}" - </Text> - </CenteredView> - ) - } - - return ( - <ScrollView style={pal.view}> - {model.profiles.map(item => ( - <ProfileCardWithFollowBtn key={item.did} profile={item} /> - ))} - <View style={s.footerSpacer} /> - <View style={s.footerSpacer} /> - <View style={s.footerSpacer} /> - </ScrollView> - ) -}) - -const styles = StyleSheet.create({ - tabBar: { - borderBottomWidth: 1, - position: 'absolute', - zIndex: 1, - left: 0, - right: 0, - top: 0, - flexDirection: 'column', - alignItems: 'center', - }, - empty: { - paddingHorizontal: 14, - paddingVertical: 16, - }, -}) diff --git a/src/view/com/search/Suggestions.tsx b/src/view/com/search/Suggestions.tsx deleted file mode 100644 index 2a80d10ae..000000000 --- a/src/view/com/search/Suggestions.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import React, {forwardRef, ForwardedRef} from 'react' -import {RefreshControl, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {AppBskyActorDefs} from '@atproto/api' -import {FlatList} from '../util/Views' -import {FoafsModel} from 'state/models/discovery/foafs' -import { - SuggestedActorsModel, - SuggestedActor, -} from 'state/models/discovery/suggested-actors' -import {Text} from '../util/text/Text' -import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' -import {ProfileCardLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' -import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs' -import {usePalette} from 'lib/hooks/usePalette' -import {s} from 'lib/styles' - -interface Heading { - _reactKey: string - type: 'heading' - title: string -} -interface RefWrapper { - _reactKey: string - type: 'ref' - ref: RefWithInfoAndFollowers -} -interface SuggestWrapper { - _reactKey: string - type: 'suggested' - suggested: SuggestedActor -} -interface ProfileView { - _reactKey: string - type: 'profile-view' - view: AppBskyActorDefs.ProfileViewBasic -} -interface LoadingPlaceholder { - _reactKey: string - type: 'loading-placeholder' -} -type Item = - | Heading - | RefWrapper - | SuggestWrapper - | ProfileView - | LoadingPlaceholder - -// FIXME(dan): Figure out why the false positives -/* eslint-disable react/prop-types */ - -export const Suggestions = observer( - forwardRef(function SuggestionsImpl( - { - foafs, - suggestedActors, - }: { - foafs: FoafsModel - suggestedActors: SuggestedActorsModel - }, - flatListRef: ForwardedRef<FlatList>, - ) { - const pal = usePalette('default') - const [refreshing, setRefreshing] = React.useState(false) - const data = React.useMemo(() => { - let items: Item[] = [] - - if (suggestedActors.hasContent) { - items = items - .concat([ - { - _reactKey: '__suggested_heading__', - type: 'heading', - title: 'Suggested Follows', - }, - ]) - .concat( - suggestedActors.suggestions.map(suggested => ({ - _reactKey: `suggested-${suggested.did}`, - type: 'suggested', - suggested, - })), - ) - } else if (suggestedActors.isLoading) { - items = items.concat([ - { - _reactKey: '__suggested_heading__', - type: 'heading', - title: 'Suggested Follows', - }, - {_reactKey: '__suggested_loading__', type: 'loading-placeholder'}, - ]) - } - if (foafs.isLoading) { - items = items.concat([ - { - _reactKey: '__popular_heading__', - type: 'heading', - title: 'In Your Network', - }, - {_reactKey: '__foafs_loading__', type: 'loading-placeholder'}, - ]) - } else { - if (foafs.popular.length > 0) { - items = items - .concat([ - { - _reactKey: '__popular_heading__', - type: 'heading', - title: 'In Your Network', - }, - ]) - .concat( - foafs.popular.map(ref => ({ - _reactKey: `popular-${ref.did}`, - type: 'ref', - ref, - })), - ) - } - for (const source of foafs.sources) { - const item = foafs.foafs.get(source) - if (!item || item.follows.length === 0) { - continue - } - items = items - .concat([ - { - _reactKey: `__${item.did}_heading__`, - type: 'heading', - title: `Followed by ${sanitizeDisplayName( - item.displayName || sanitizeHandle(item.handle), - )}`, - }, - ]) - .concat( - item.follows.slice(0, 10).map(view => ({ - _reactKey: `${item.did}-${view.did}`, - type: 'profile-view', - view, - })), - ) - } - } - - return items - }, [ - foafs.isLoading, - foafs.popular, - suggestedActors.isLoading, - suggestedActors.hasContent, - suggestedActors.suggestions, - foafs.sources, - foafs.foafs, - ]) - - const onRefresh = React.useCallback(async () => { - setRefreshing(true) - try { - await foafs.fetch() - } finally { - setRefreshing(false) - } - }, [foafs, setRefreshing]) - - const renderItem = React.useCallback( - ({item}: {item: Item}) => { - if (item.type === 'heading') { - return ( - <Text type="title" style={[styles.heading, pal.text]}> - {item.title} - </Text> - ) - } - if (item.type === 'ref') { - return ( - <View style={[styles.card, pal.view, pal.border]}> - <ProfileCardWithFollowBtn - key={item.ref.did} - profile={item.ref} - noBg - noBorder - followers={ - item.ref.followers - ? (item.ref.followers as AppBskyActorDefs.ProfileView[]) - : undefined - } - /> - </View> - ) - } - if (item.type === 'profile-view') { - return ( - <View style={[styles.card, pal.view, pal.border]}> - <ProfileCardWithFollowBtn - key={item.view.did} - profile={item.view} - noBg - noBorder - /> - </View> - ) - } - if (item.type === 'suggested') { - return ( - <View style={[styles.card, pal.view, pal.border]}> - <ProfileCardWithFollowBtn - key={item.suggested.did} - profile={item.suggested} - noBg - noBorder - /> - </View> - ) - } - if (item.type === 'loading-placeholder') { - return ( - <View> - <ProfileCardLoadingPlaceholder /> - <ProfileCardLoadingPlaceholder /> - <ProfileCardLoadingPlaceholder /> - <ProfileCardLoadingPlaceholder /> - </View> - ) - } - return null - }, - [pal], - ) - - return ( - <FlatList - ref={flatListRef} - data={data} - keyExtractor={item => item._reactKey} - refreshControl={ - <RefreshControl - refreshing={refreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - renderItem={renderItem} - initialNumToRender={15} - contentContainerStyle={s.contentContainer} - /> - ) - }), -) - -const styles = StyleSheet.create({ - heading: { - fontWeight: 'bold', - paddingHorizontal: 12, - paddingBottom: 8, - paddingTop: 16, - }, - - card: { - borderTopWidth: 1, - }, -}) diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx deleted file mode 100644 index bf9857df4..000000000 --- a/src/view/screens/Search.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './SearchMobile' diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx deleted file mode 100644 index 2d0c0288a..000000000 --- a/src/view/screens/Search.web.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react' -import {View, StyleSheet} from 'react-native' -import {SearchUIModel} from 'state/models/ui/search' -import {FoafsModel} from 'state/models/discovery/foafs' -import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {Suggestions} from 'view/com/search/Suggestions' -import {SearchResults} from 'view/com/search/SearchResults' -import {observer} from 'mobx-react-lite' -import { - NativeStackScreenProps, - SearchTabNavigatorParams, -} from 'lib/routes/types' -import {useStores} from 'state/index' -import {CenteredView} from 'view/com/util/Views' -import * as Mobile from './SearchMobile' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' - -type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> -export const SearchScreen = withAuthRequired( - observer(function SearchScreenImpl({navigation, route}: Props) { - const store = useStores() - const params = route.params || {} - const foafs = React.useMemo<FoafsModel>( - () => new FoafsModel(store), - [store], - ) - const suggestedActors = React.useMemo<SuggestedActorsModel>( - () => new SuggestedActorsModel(store), - [store], - ) - const searchUIModel = React.useMemo<SearchUIModel | undefined>( - () => (params.q ? new SearchUIModel(store) : undefined), - [params.q, store], - ) - - React.useEffect(() => { - if (params.q && searchUIModel) { - searchUIModel.fetch(params.q) - } - if (!foafs.hasData) { - foafs.fetch() - } - if (!suggestedActors.hasLoaded) { - suggestedActors.loadMore(true) - } - }, [foafs, suggestedActors, searchUIModel, params.q]) - - const {isDesktop} = useWebMediaQueries() - - if (searchUIModel) { - return ( - <View style={styles.scrollContainer}> - <SearchResults model={searchUIModel} /> - </View> - ) - } - - if (!isDesktop) { - return ( - <CenteredView style={styles.scrollContainer}> - <Mobile.SearchScreen navigation={navigation} route={route} /> - </CenteredView> - ) - } - - return <Suggestions foafs={foafs} suggestedActors={suggestedActors} /> - }), -) - -const styles = StyleSheet.create({ - scrollContainer: { - height: '100%', - overflowY: 'auto', - }, -}) diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx new file mode 100644 index 000000000..08f4fbd70 --- /dev/null +++ b/src/view/screens/Search/Search.tsx @@ -0,0 +1,639 @@ +import React from 'react' +import { + View, + StyleSheet, + ActivityIndicator, + RefreshControl, + TextInput, + Pressable, +} from 'react-native' +import {FlatList, ScrollView, CenteredView} from '#/view/com/util/Views' +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 {NotificationFeedLoadingPlaceholder} 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 {useMyFollowsQuery} from '#/state/queries/my-follows' +import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows' +import {useSearchPostsQuery} from '#/state/queries/search-posts' +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 {SearchResultCard} from '#/view/shell/desktop/Search' +import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' +import {useStores} from '#/state' +import {isWeb} from '#/platform/detection' + +function Loader() { + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + return ( + <CenteredView + style={[ + // @ts-ignore web only -prf + { + padding: 18, + height: isWeb ? '100vh' : undefined, + }, + pal.border, + ]} + sideBorders={!isMobile}> + <ActivityIndicator /> + </CenteredView> + ) +} + +// TODO refactor how to translate? +function EmptyState({message, error}: {message: string; error?: string}) { + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + + return ( + <CenteredView + sideBorders={!isMobile} + style={[ + pal.border, + // @ts-ignore web only -prf + { + padding: 18, + height: isWeb ? '100vh' : undefined, + }, + ]}> + <View style={[pal.viewLight, {padding: 18, borderRadius: 8}]}> + <Text style={[pal.text]}> + <Trans>{message}</Trans> + </Text> + + {error && ( + <> + <View + style={[ + { + marginVertical: 12, + height: 1, + width: '100%', + backgroundColor: pal.text.color, + opacity: 0.2, + }, + ]} + /> + + <Text style={[pal.textLight]}> + <Trans>Error:</Trans> {error} + </Text> + </> + )} + </View> + </CenteredView> + ) +} + +function SearchScreenSuggestedFollows() { + const pal = usePalette('default') + const {currentAccount} = useSession() + const [dataUpdatedAt, setDataUpdatedAt] = React.useState(0) + const [suggestions, setSuggestions] = React.useState< + AppBskyActorDefs.ProfileViewBasic[] + >([]) + const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor() + + React.useEffect(() => { + async function getSuggestions() { + // TODO not quite right, doesn't fetch your follows + const friends = await getSuggestedFollowsByActor( + currentAccount!.did, + ).then(friendsRes => friendsRes.suggestions) + + if (!friends) return // :( + + const friendsOfFriends = ( + await Promise.all( + friends + .slice(0, 4) + .map(friend => + getSuggestedFollowsByActor(friend.did).then( + foafsRes => foafsRes.suggestions, + ), + ), + ) + ).flat() + + setSuggestions( + // dedupe + friendsOfFriends.filter(f => !friends.find(f2 => f.did === f2.did)), + ) + setDataUpdatedAt(Date.now()) + } + + try { + getSuggestions() + } catch (e) { + logger.error(`SearchScreenSuggestedFollows: failed to get suggestions`, { + error: e, + }) + } + }, [ + currentAccount, + setSuggestions, + setDataUpdatedAt, + getSuggestedFollowsByActor, + ]) + + return suggestions.length ? ( + <FlatList + data={suggestions} + renderItem={({item}) => ( + <ProfileCardWithFollowBtn + profile={item} + noBg + dataUpdatedAt={dataUpdatedAt} + /> + )} + keyExtractor={item => item.did} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 1200}} + /> + ) : ( + <CenteredView + style={[pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]}> + <NotificationFeedLoadingPlaceholder /> + </CenteredView> + ) +} + +type SearchResultSlice = + | { + type: 'post' + key: string + post: AppBskyFeedDefs.PostView + } + | { + type: 'loadingMore' + key: string + } + +function SearchScreenPostResults({query}: {query: string}) { + const pal = usePalette('default') + const [isPTR, setIsPTR] = React.useState(false) + const { + isFetched, + data: results, + isFetching, + error, + refetch, + fetchNextPage, + isFetchingNextPage, + hasNextPage, + dataUpdatedAt, + } = 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 items: SearchResultSlice[] = [] + + for (const post of posts) { + items.push({ + type: 'post', + key: post.uri, + post, + }) + } + + if (isFetchingNextPage) { + items.push({ + type: 'loadingMore', + key: 'loadingMore', + }) + } + + return items + }, [posts, isFetchingNextPage]) + + return error ? ( + <EmptyState + message="We're sorry, but your search could not be completed. Please try again in a few minutes." + error={error.toString()} + /> + ) : ( + <> + {isFetched ? ( + <> + {posts.length ? ( + <FlatList + data={items} + renderItem={({item}) => { + if (item.type === 'post') { + return <Post post={item.post} dataUpdatedAt={dataUpdatedAt} /> + } else { + return <Loader /> + } + }} + keyExtractor={item => item.key} + refreshControl={ + <RefreshControl + refreshing={isPTR} + onRefresh={onPullToRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onEndReached={onEndReached} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + <EmptyState message={`No results found for ${query}`} /> + )} + </> + ) : ( + <Loader /> + )} + </> + ) +} + +function SearchScreenUserResults({query}: {query: string}) { + const [isFetched, setIsFetched] = React.useState(false) + const [dataUpdatedAt, setDataUpdatedAt] = React.useState(0) + const [results, setResults] = React.useState< + AppBskyActorDefs.ProfileViewBasic[] + >([]) + const search = useActorAutocompleteFn() + // fuzzy search relies on followers + const {isFetched: isFollowsFetched} = useMyFollowsQuery() + + React.useEffect(() => { + async function getResults() { + const results = await search({query, limit: 30}) + + if (results) { + setDataUpdatedAt(Date.now()) + setResults(results) + setIsFetched(true) + } + } + + if (query && isFollowsFetched) { + getResults() + } else { + setResults([]) + setIsFetched(false) + } + }, [query, isFollowsFetched, setDataUpdatedAt, search]) + + return isFetched ? ( + <> + {results.length ? ( + <FlatList + data={results} + renderItem={({item}) => ( + <ProfileCardWithFollowBtn + profile={item} + noBg + dataUpdatedAt={dataUpdatedAt} + /> + )} + keyExtractor={item => item.did} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + <EmptyState message={`No results found for ${query}`} /> + )} + </> + ) : ( + <Loader /> + ) +} + +const SECTIONS = ['Posts', 'Users'] +export function SearchScreenInner({query}: {query?: string}) { + const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() + const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + + const onPageSelected = React.useCallback( + (index: number) => { + setMinimalShellMode(false) + setDrawerSwipeDisabled(index > 0) + }, + [setDrawerSwipeDisabled, setMinimalShellMode], + ) + + return query ? ( + <Pager + tabBarPosition="top" + onPageSelected={onPageSelected} + renderTabBar={props => ( + <CenteredView sideBorders style={pal.border}> + <TabBar items={SECTIONS} {...props} /> + </CenteredView> + )} + initialPage={0}> + <View> + <SearchScreenPostResults query={query} /> + </View> + <View> + <SearchScreenUserResults query={query} /> + </View> + </Pager> + ) : ( + <View> + <CenteredView sideBorders style={pal.border}> + <Text + type="title" + style={[ + pal.text, + pal.border, + { + display: 'flex', + paddingVertical: 12, + paddingHorizontal: 18, + fontWeight: 'bold', + }, + ]}> + <Trans>Suggested Follows</Trans> + </Text> + </CenteredView> + <SearchScreenSuggestedFollows /> + </View> + ) +} + +export function SearchScreenDesktop( + props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, +) { + const {isDesktop} = useWebMediaQueries() + + return isDesktop ? ( + <SearchScreenInner query={props.route.params?.q} /> + ) : ( + <SearchScreenMobile {...props} /> + ) +} + +export function SearchScreenMobile( + _props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, +) { + const theme = useTheme() + const textInput = React.useRef<TextInput>(null) + const {_} = useLingui() + const pal = usePalette('default') + const {track} = useAnalytics() + const setDrawerOpen = useSetDrawerOpen() + const moderationOpts = useModerationOpts() + const search = useActorAutocompleteFn() + const setMinimalShellMode = useSetMinimalShellMode() + const store = useStores() + const {isTablet} = useWebMediaQueries() + + const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>( + undefined, + ) + const [isFetching, setIsFetching] = React.useState<boolean>(false) + const [query, setQuery] = React.useState<string>('') + const [searchResults, setSearchResults] = React.useState< + AppBskyActorDefs.ProfileViewBasic[] + >([]) + const [inputIsFocused, setInputIsFocused] = React.useState(false) + const [showAutocompleteResults, setShowAutocompleteResults] = + React.useState(false) + + const onPressMenu = React.useCallback(() => { + track('ViewHeader:MenuButtonClicked') + setDrawerOpen(true) + }, [track, setDrawerOpen]) + const onPressCancelSearch = React.useCallback(() => { + textInput.current?.blur() + setQuery('') + setShowAutocompleteResults(false) + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + }, [textInput]) + const onPressClearQuery = React.useCallback(() => { + setQuery('') + setShowAutocompleteResults(false) + }, [setQuery]) + const onChangeText = React.useCallback( + async (text: string) => { + 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 onSubmit = React.useCallback(() => { + setShowAutocompleteResults(false) + }, [setShowAutocompleteResults]) + + const onSoftReset = React.useCallback(() => { + onPressCancelSearch() + }, [onPressCancelSearch]) + + useFocusEffect( + React.useCallback(() => { + const softResetSub = store.onScreenSoftReset(onSoftReset) + + setMinimalShellMode(false) + + return () => { + softResetSub.remove() + } + }, [store, onSoftReset, setMinimalShellMode]), + ) + + return ( + <View style={{flex: 1}}> + <CenteredView style={[styles.header, pal.border]} sideBorders={isTablet}> + <Pressable + testID="viewHeaderBackOrMenuBtn" + onPress={onPressMenu} + hitSlop={HITSLOP_10} + style={styles.headerMenuBtn} + accessibilityRole="button" + accessibilityLabel={_(msg`Menu`)} + accessibilityHint="Access navigation links and settings"> + <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} /> + </Pressable> + + <View + style={[ + {backgroundColor: pal.colors.backgroundLight}, + styles.headerSearchContainer, + ]}> + <MagnifyingGlassIcon + style={[pal.icon, styles.headerSearchIcon]} + size={21} + /> + <TextInput + testID="searchTextInput" + ref={textInput} + placeholder="Search" + placeholderTextColor={pal.colors.textLight} + selectTextOnFocus + returnKeyType="search" + value={query} + style={[pal.text, styles.headerSearchInput]} + keyboardAppearance={theme.colorScheme} + onFocus={() => setInputIsFocused(true)} + onBlur={() => setInputIsFocused(false)} + onChangeText={onChangeText} + onSubmitEditing={onSubmit} + autoFocus={false} + accessibilityRole="search" + accessibilityLabel={_(msg`Search`)} + accessibilityHint="" + autoCorrect={false} + autoCapitalize="none" + /> + {query ? ( + <Pressable + testID="searchTextInputClearBtn" + onPress={onPressClearQuery} + accessibilityRole="button" + accessibilityLabel={_(msg`Clear search query`)} + accessibilityHint=""> + <FontAwesomeIcon + icon="xmark" + size={16} + style={pal.textLight as FontAwesomeIconStyle} + /> + </Pressable> + ) : undefined} + </View> + + {query || inputIsFocused ? ( + <View style={styles.headerCancelBtn}> + <Pressable onPress={onPressCancelSearch} accessibilityRole="button"> + <Text style={[pal.text]}> + <Trans>Cancel</Trans> + </Text> + </Pressable> + </View> + ) : undefined} + </CenteredView> + + {showAutocompleteResults && moderationOpts ? ( + <> + {isFetching ? ( + <Loader /> + ) : ( + <ScrollView style={{height: '100%'}}> + {searchResults.length ? ( + searchResults.map((item, i) => ( + <SearchResultCard + key={item.did} + profile={item} + moderation={moderateProfile(item, moderationOpts)} + style={i === 0 ? {borderTopWidth: 0} : {}} + /> + )) + ) : ( + <EmptyState message={`No results found for ${query}`} /> + )} + + <View style={{height: 200}} /> + </ScrollView> + )} + </> + ) : ( + <SearchScreenInner query={query} /> + )} + </View> + ) +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 4, + }, + 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, + }, +}) diff --git a/src/view/screens/Search/index.tsx b/src/view/screens/Search/index.tsx new file mode 100644 index 000000000..d1b9684ef --- /dev/null +++ b/src/view/screens/Search/index.tsx @@ -0,0 +1 @@ +export {SearchScreenMobile as SearchScreen} from '#/view/screens/Search/Search' diff --git a/src/view/screens/Search/index.web.tsx b/src/view/screens/Search/index.web.tsx new file mode 100644 index 000000000..8d09f342a --- /dev/null +++ b/src/view/screens/Search/index.web.tsx @@ -0,0 +1 @@ +export {SearchScreenDesktop as SearchScreen} from '#/view/screens/Search/Search' diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx deleted file mode 100644 index 92c255d5b..000000000 --- a/src/view/screens/SearchMobile.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, {useCallback} from 'react' -import { - StyleSheet, - TouchableWithoutFeedback, - Keyboard, - View, -} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {FlatList, ScrollView} from 'view/com/util/Views' -import { - NativeStackScreenProps, - SearchTabNavigatorParams, -} from 'lib/routes/types' -import {observer} from 'mobx-react-lite' -import {Text} from 'view/com/util/text/Text' -import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' -import {useStores} from 'state/index' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' -import {SearchUIModel} from 'state/models/ui/search' -import {FoafsModel} from 'state/models/discovery/foafs' -import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' -import {HeaderWithInput} from 'view/com/search/HeaderWithInput' -import {Suggestions} from 'view/com/search/Suggestions' -import {SearchResults} from 'view/com/search/SearchResults' -import {s} from 'lib/styles' -import {ProfileCard} from 'view/com/profile/ProfileCard' -import {usePalette} from 'lib/hooks/usePalette' -import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' -import {isAndroid, isIOS} from 'platform/detection' -import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' - -type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> -export const SearchScreen = withAuthRequired( - observer<Props>(function SearchScreenImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const setIsDrawerSwipeDisabled = useSetDrawerSwipeDisabled() - const scrollViewRef = React.useRef<ScrollView>(null) - const flatListRef = React.useRef<FlatList>(null) - const [onMainScroll] = useOnMainScroll() - const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) - const [query, setQuery] = React.useState<string>('') - const autocompleteView = React.useMemo<UserAutocompleteModel>( - () => new UserAutocompleteModel(store), - [store], - ) - const foafs = React.useMemo<FoafsModel>( - () => new FoafsModel(store), - [store], - ) - const suggestedActors = React.useMemo<SuggestedActorsModel>( - () => new SuggestedActorsModel(store), - [store], - ) - const [searchUIModel, setSearchUIModel] = React.useState< - SearchUIModel | undefined - >() - - const onChangeQuery = React.useCallback( - (text: string) => { - setQuery(text) - if (text.length > 0) { - autocompleteView.setActive(true) - autocompleteView.setPrefix(text) - } else { - autocompleteView.setActive(false) - } - }, - [setQuery, autocompleteView], - ) - - const onPressClearQuery = React.useCallback(() => { - setQuery('') - }, [setQuery]) - - const onPressCancelSearch = React.useCallback(() => { - setQuery('') - autocompleteView.setActive(false) - setSearchUIModel(undefined) - setIsDrawerSwipeDisabled(false) - }, [setQuery, autocompleteView, setIsDrawerSwipeDisabled]) - - const onSubmitQuery = React.useCallback(() => { - if (query.length === 0) { - return - } - - const model = new SearchUIModel(store) - model.fetch(query) - setSearchUIModel(model) - setIsDrawerSwipeDisabled(true) - }, [query, setSearchUIModel, store, setIsDrawerSwipeDisabled]) - - const onSoftReset = React.useCallback(() => { - scrollViewRef.current?.scrollTo({x: 0, y: 0}) - flatListRef.current?.scrollToOffset({offset: 0}) - onPressCancelSearch() - }, [scrollViewRef, flatListRef, onPressCancelSearch]) - - useFocusEffect( - React.useCallback(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) - const cleanup = () => { - softResetSub.remove() - } - - setMinimalShellMode(false) - autocompleteView.setup() - if (!foafs.hasData) { - foafs.fetch() - } - if (!suggestedActors.hasLoaded) { - suggestedActors.loadMore(true) - } - - return cleanup - }, [ - store, - autocompleteView, - foafs, - suggestedActors, - onSoftReset, - setMinimalShellMode, - ]), - ) - - const onPress = useCallback(() => { - if (isIOS || isAndroid) { - Keyboard.dismiss() - } - }, []) - - const scrollHandler = useAnimatedScrollHandler(onMainScroll) - return ( - <TouchableWithoutFeedback onPress={onPress} accessible={false}> - <View style={[pal.view, styles.container]}> - <HeaderWithInput - isInputFocused={isInputFocused} - query={query} - setIsInputFocused={setIsInputFocused} - onChangeQuery={onChangeQuery} - onPressClearQuery={onPressClearQuery} - onPressCancelSearch={onPressCancelSearch} - onSubmitQuery={onSubmitQuery} - /> - {searchUIModel ? ( - <SearchResults model={searchUIModel} /> - ) : !isInputFocused && !query ? ( - <Suggestions - ref={flatListRef} - foafs={foafs} - suggestedActors={suggestedActors} - /> - ) : ( - <ScrollView - ref={scrollViewRef} - testID="searchScrollView" - style={pal.view} - onScroll={scrollHandler} - scrollEventThrottle={1}> - {query && autocompleteView.suggestions.length ? ( - <> - {autocompleteView.suggestions.map((suggestion, index) => ( - <ProfileCard - key={suggestion.did} - testID={`searchAutoCompleteResult-${suggestion.handle}`} - profile={suggestion} - noBorder={index === 0} - /> - ))} - </> - ) : query && !autocompleteView.suggestions.length ? ( - <View> - <Text style={[pal.textLight, styles.searchPrompt]}> - No results found for {autocompleteView.prefix} - </Text> - </View> - ) : isInputFocused ? ( - <View> - <Text style={[pal.textLight, styles.searchPrompt]}> - Search for users and posts on the network - </Text> - </View> - ) : null} - <View style={s.footerSpacer} /> - </ScrollView> - )} - </View> - </TouchableWithoutFeedback> - ) - }), -) - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - - searchPrompt: { - textAlign: 'center', - paddingTop: 10, - }, -}) diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index d1598c3d3..831eda7ca 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -5,6 +5,7 @@ import { View, StyleSheet, TouchableOpacity, + ActivityIndicator, } from 'react-native' import {useNavigation, StackActions} from '@react-navigation/native' import { @@ -12,7 +13,6 @@ import { moderateProfile, ProfileModeration, } from '@atproto/api' -import {observer} from 'mobx-react-lite' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -84,14 +84,15 @@ export function SearchResultCard({ ) } -export const DesktopSearch = observer(function DesktopSearch() { +export function DesktopSearch() { const {_} = useLingui() const pal = usePalette('default') const navigation = useNavigation<NavigationProp>() const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>( undefined, ) - const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) + const [isActive, setIsActive] = React.useState<boolean>(false) + const [isFetching, setIsFetching] = React.useState<boolean>(false) const [query, setQuery] = React.useState<string>('') const [searchResults, setSearchResults] = React.useState< AppBskyActorDefs.ProfileViewBasic[] @@ -104,7 +105,10 @@ export const DesktopSearch = observer(function DesktopSearch() { async (text: string) => { setQuery(text) - if (text.length > 0 && isInputFocused) { + if (text.length > 0) { + setIsFetching(true) + setIsActive(true) + if (searchDebounceTimeout.current) clearTimeout(searchDebounceTimeout.current) @@ -113,24 +117,34 @@ export const DesktopSearch = observer(function DesktopSearch() { if (results) { setSearchResults(results) + setIsFetching(false) } }, 300) } else { if (searchDebounceTimeout.current) clearTimeout(searchDebounceTimeout.current) setSearchResults([]) + setIsFetching(false) + setIsActive(false) } }, - [setQuery, isInputFocused, search, setSearchResults], + [setQuery, search, setSearchResults], ) const onPressCancelSearch = React.useCallback(() => { - onChangeText('') - }, [onChangeText]) - + setQuery('') + setIsActive(false) + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + }, [setQuery]) const onSubmit = React.useCallback(() => { + setIsActive(false) + if (!query.length) return + setSearchResults([]) + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) navigation.dispatch(StackActions.push('Search', {q: query})) - }, [query, navigation]) + }, [query, navigation, setSearchResults]) return ( <View style={[styles.container, pal.view]}> @@ -149,8 +163,6 @@ export const DesktopSearch = observer(function DesktopSearch() { returnKeyType="search" value={query} style={[pal.textLight, styles.input]} - onFocus={() => setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} onChangeText={onChangeText} onSubmitEditing={onSubmit} accessibilityRole="search" @@ -174,29 +186,37 @@ export const DesktopSearch = observer(function DesktopSearch() { </View> </View> - {query !== '' && ( + {query !== '' && isActive && moderationOpts && ( <View style={[pal.view, pal.borderDark, styles.resultsContainer]}> - {searchResults.length && moderationOpts ? ( - searchResults.map((item, i) => ( - <SearchResultCard - key={item.did} - profile={item} - moderation={moderateProfile(item, moderationOpts)} - style={i === 0 ? {borderTopWidth: 0} : {}} - /> - )) - ) : ( - <View> - <Text style={[pal.textLight, styles.noResults]}> - <Trans>No results found for {query}</Trans> - </Text> + {isFetching ? ( + <View style={{padding: 8}}> + <ActivityIndicator /> </View> + ) : ( + <> + {searchResults.length ? ( + searchResults.map((item, i) => ( + <SearchResultCard + key={item.did} + profile={item} + moderation={moderateProfile(item, moderationOpts)} + style={i === 0 ? {borderTopWidth: 0} : {}} + /> + )) + ) : ( + <View> + <Text style={[pal.textLight, styles.noResults]}> + <Trans>No results found for {query}</Trans> + </Text> + </View> + )} + </> )} </View> )} </View> ) -}) +} const styles = StyleSheet.create({ container: { |