From 22b76423a0a0e5cfb40bb00c22dec628f5a5a4c0 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 15 Nov 2023 17:55:28 -0600 Subject: Search page (#1912) * Desktop web work * Mobile search * Dedupe suggestions * Clean up and reorg * Cleanup * Cleanup * Use Pager * Delete unused code * Fix conflicts * Remove search ui model * Soft reset * Fix scrollable results, remove observer * Use correct ScrollView * Clean up layout --------- Co-authored-by: Paul Frazee --- src/state/models/ui/search.ts | 69 ---- src/state/queries/actor-autocomplete.ts | 4 +- src/state/queries/search-posts.ts | 32 ++ src/state/queries/suggested-follows.ts | 30 +- src/view/com/search/HeaderWithInput.tsx | 186 ---------- src/view/com/search/SearchResults.tsx | 150 -------- src/view/com/search/Suggestions.tsx | 265 ------------- src/view/screens/Search.tsx | 1 - src/view/screens/Search.web.tsx | 76 ---- src/view/screens/Search/Search.tsx | 639 ++++++++++++++++++++++++++++++++ src/view/screens/Search/index.tsx | 1 + src/view/screens/Search/index.web.tsx | 1 + src/view/screens/SearchMobile.tsx | 205 ---------- src/view/shell/desktop/Search.tsx | 74 ++-- 14 files changed, 742 insertions(+), 991 deletions(-) delete mode 100644 src/state/models/ui/search.ts create mode 100644 src/state/queries/search-posts.ts delete mode 100644 src/view/com/search/HeaderWithInput.tsx delete mode 100644 src/view/com/search/SearchResults.tsx delete mode 100644 src/view/com/search/Suggestions.tsx delete mode 100644 src/view/screens/Search.tsx delete mode 100644 src/view/screens/Search.web.tsx create mode 100644 src/view/screens/Search/Search.tsx create mode 100644 src/view/screens/Search/index.tsx create mode 100644 src/view/screens/Search/index.web.tsx delete mode 100644 src/view/screens/SearchMobile.tsx (limited to 'src') 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, + 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({ - 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(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 ( - - {showMenu && isMobile ? ( - - - - ) : null} - - - setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - onChangeText={onChangeQuery} - onSubmitEditing={onSubmitQuery} - autoFocus={false} - accessibilityRole="search" - accessibilityLabel={_(msg`Search`)} - accessibilityHint="" - autoCorrect={false} - autoCapitalize="none" - /> - {query ? ( - - - - ) : undefined} - - {query || isInputFocused ? ( - - - - Cancel - - - - ) : undefined} - - ) -} - -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 ( - - - - ) - }, - [pal], - ) - - return ( - - - - - - - - - ) -}) - -const PostResults = observer(function PostResultsImpl({ - model, -}: { - model: SearchUIModel -}) { - const pal = usePalette('default') - if (model.isPostsLoading) { - return ( - - - - ) - } - - if (model.posts.length === 0) { - return ( - - - No posts found for "{model.query}" - - - ) - } - - return ( - - {model.posts.map(post => ( - - ))} - - - - - ) -}) - -const Profiles = observer(function ProfilesImpl({ - model, -}: { - model: SearchUIModel -}) { - const pal = usePalette('default') - if (model.isProfilesLoading) { - return ( - - - - ) - } - - if (model.profiles.length === 0) { - return ( - - - No users found for "{model.query}" - - - ) - } - - return ( - - {model.profiles.map(item => ( - - ))} - - - - - ) -}) - -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, - ) { - 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 ( - - {item.title} - - ) - } - if (item.type === 'ref') { - return ( - - - - ) - } - if (item.type === 'profile-view') { - return ( - - - - ) - } - if (item.type === 'suggested') { - return ( - - - - ) - } - if (item.type === 'loading-placeholder') { - return ( - - - - - - - ) - } - return null - }, - [pal], - ) - - return ( - item._reactKey} - refreshControl={ - - } - 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 -export const SearchScreen = withAuthRequired( - observer(function SearchScreenImpl({navigation, route}: Props) { - const store = useStores() - const params = route.params || {} - const foafs = React.useMemo( - () => new FoafsModel(store), - [store], - ) - const suggestedActors = React.useMemo( - () => new SuggestedActorsModel(store), - [store], - ) - const searchUIModel = React.useMemo( - () => (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 ( - - - - ) - } - - if (!isDesktop) { - return ( - - - - ) - } - - return - }), -) - -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 ( + + + + ) +} + +// TODO refactor how to translate? +function EmptyState({message, error}: {message: string; error?: string}) { + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + + return ( + + + + {message} + + + {error && ( + <> + + + + Error: {error} + + + )} + + + ) +} + +function SearchScreenSuggestedFollows() { + const pal = usePalette('default') + const {currentAccount} = useSession() + const [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 ? ( + ( + + )} + keyExtractor={item => item.did} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 1200}} + /> + ) : ( + + + + ) +} + +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 ? ( + + ) : ( + <> + {isFetched ? ( + <> + {posts.length ? ( + { + if (item.type === 'post') { + return + } else { + return + } + }} + keyExtractor={item => item.key} + refreshControl={ + + } + onEndReached={onEndReached} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + + )} + + ) : ( + + )} + + ) +} + +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 ? ( + ( + + )} + keyExtractor={item => item.did} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + + )} + + ) : ( + + ) +} + +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 ? ( + ( + + + + )} + initialPage={0}> + + + + + + + + ) : ( + + + + Suggested Follows + + + + + ) +} + +export function SearchScreenDesktop( + props: NativeStackScreenProps, +) { + const {isDesktop} = useWebMediaQueries() + + return isDesktop ? ( + + ) : ( + + ) +} + +export function SearchScreenMobile( + _props: NativeStackScreenProps, +) { + const theme = useTheme() + const textInput = React.useRef(null) + const {_} = useLingui() + const pal = usePalette('default') + const {track} = useAnalytics() + const setDrawerOpen = useSetDrawerOpen() + const moderationOpts = useModerationOpts() + const search = useActorAutocompleteFn() + const setMinimalShellMode = useSetMinimalShellMode() + const store = useStores() + const {isTablet} = useWebMediaQueries() + + const searchDebounceTimeout = React.useRef( + undefined, + ) + const [isFetching, setIsFetching] = React.useState(false) + const [query, setQuery] = React.useState('') + 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 ( + + + + + + + + + setInputIsFocused(true)} + onBlur={() => setInputIsFocused(false)} + onChangeText={onChangeText} + onSubmitEditing={onSubmit} + autoFocus={false} + accessibilityRole="search" + accessibilityLabel={_(msg`Search`)} + accessibilityHint="" + autoCorrect={false} + autoCapitalize="none" + /> + {query ? ( + + + + ) : undefined} + + + {query || inputIsFocused ? ( + + + + Cancel + + + + ) : undefined} + + + {showAutocompleteResults && moderationOpts ? ( + <> + {isFetching ? ( + + ) : ( + + {searchResults.length ? ( + searchResults.map((item, i) => ( + + )) + ) : ( + + )} + + + + )} + + ) : ( + + )} + + ) +} + +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 -export const SearchScreen = withAuthRequired( - observer(function SearchScreenImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const setIsDrawerSwipeDisabled = useSetDrawerSwipeDisabled() - const scrollViewRef = React.useRef(null) - const flatListRef = React.useRef(null) - const [onMainScroll] = useOnMainScroll() - const [isInputFocused, setIsInputFocused] = React.useState(false) - const [query, setQuery] = React.useState('') - const autocompleteView = React.useMemo( - () => new UserAutocompleteModel(store), - [store], - ) - const foafs = React.useMemo( - () => new FoafsModel(store), - [store], - ) - const suggestedActors = React.useMemo( - () => 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 ( - - - - {searchUIModel ? ( - - ) : !isInputFocused && !query ? ( - - ) : ( - - {query && autocompleteView.suggestions.length ? ( - <> - {autocompleteView.suggestions.map((suggestion, index) => ( - - ))} - - ) : query && !autocompleteView.suggestions.length ? ( - - - No results found for {autocompleteView.prefix} - - - ) : isInputFocused ? ( - - - Search for users and posts on the network - - - ) : null} - - - )} - - - ) - }), -) - -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() const searchDebounceTimeout = React.useRef( undefined, ) - const [isInputFocused, setIsInputFocused] = React.useState(false) + const [isActive, setIsActive] = React.useState(false) + const [isFetching, setIsFetching] = React.useState(false) const [query, setQuery] = React.useState('') 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 ( @@ -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() { - {query !== '' && ( + {query !== '' && isActive && moderationOpts && ( - {searchResults.length && moderationOpts ? ( - searchResults.map((item, i) => ( - - )) - ) : ( - - - No results found for {query} - + {isFetching ? ( + + + ) : ( + <> + {searchResults.length ? ( + searchResults.map((item, i) => ( + + )) + ) : ( + + + No results found for {query} + + + )} + )} )} ) -}) +} const styles = StyleSheet.create({ container: { -- cgit 1.4.1