Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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 <pfrazee@gmail.com>

authored by

Eric Bailey
Paul Frazee
and committed by
GitHub
22b76423 d5ea3192

+742 -991
-69
src/state/models/ui/search.ts
··· 1 - import {makeAutoObservable, runInAction} from 'mobx' 2 - import {searchProfiles, searchPosts} from 'lib/api/search' 3 - import {PostThreadModel} from '../content/post-thread' 4 - import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' 5 - import {RootStoreModel} from '../root-store' 6 - 7 - export class SearchUIModel { 8 - isPostsLoading = false 9 - isProfilesLoading = false 10 - query: string = '' 11 - posts: PostThreadModel[] = [] 12 - profiles: AppBskyActorDefs.ProfileView[] = [] 13 - 14 - constructor(public rootStore: RootStoreModel) { 15 - makeAutoObservable(this) 16 - } 17 - 18 - async fetch(q: string) { 19 - this.posts = [] 20 - this.profiles = [] 21 - this.query = q 22 - if (!q.trim()) { 23 - return 24 - } 25 - 26 - this.isPostsLoading = true 27 - this.isProfilesLoading = true 28 - 29 - const [postsSearch, profilesSearch] = await Promise.all([ 30 - searchPosts(q).catch(_e => []), 31 - searchProfiles(q).catch(_e => []), 32 - ]) 33 - 34 - let posts: AppBskyFeedDefs.PostView[] = [] 35 - if (postsSearch?.length) { 36 - do { 37 - const res = await this.rootStore.agent.app.bsky.feed.getPosts({ 38 - uris: postsSearch 39 - .splice(0, 25) 40 - .map(p => `at://${p.user.did}/${p.tid}`), 41 - }) 42 - posts = posts.concat(res.data.posts) 43 - } while (postsSearch.length) 44 - } 45 - runInAction(() => { 46 - this.posts = posts.map(post => 47 - PostThreadModel.fromPostView(this.rootStore, post), 48 - ) 49 - this.isPostsLoading = false 50 - }) 51 - 52 - let profiles: AppBskyActorDefs.ProfileView[] = [] 53 - if (profilesSearch?.length) { 54 - do { 55 - const res = await this.rootStore.agent.getProfiles({ 56 - actors: profilesSearch.splice(0, 25).map(p => p.did), 57 - }) 58 - profiles = profiles.concat(res.data.profiles) 59 - } while (profilesSearch.length) 60 - } 61 - 62 - this.rootStore.me.follows.hydrateMany(profiles) 63 - 64 - runInAction(() => { 65 - this.profiles = profiles 66 - this.isProfilesLoading = false 67 - }) 68 - } 69 - }
+2 -2
src/state/queries/actor-autocomplete.ts
··· 36 36 const {data: follows} = useMyFollowsQuery() 37 37 38 38 return React.useCallback( 39 - async ({query}: {query: string}) => { 39 + async ({query, limit = 8}: {query: string; limit?: number}) => { 40 40 let res 41 41 if (query) { 42 42 try { ··· 47 47 queryFn: () => 48 48 agent.searchActorsTypeahead({ 49 49 term: query, 50 - limit: 8, 50 + limit, 51 51 }), 52 52 }) 53 53 } catch (e) {
+32
src/state/queries/search-posts.ts
··· 1 + import {AppBskyFeedSearchPosts} from '@atproto/api' 2 + import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' 3 + 4 + import {useSession} from '#/state/session' 5 + 6 + const searchPostsQueryKey = ({query}: {query: string}) => [ 7 + 'search-posts', 8 + query, 9 + ] 10 + 11 + export function useSearchPostsQuery({query}: {query: string}) { 12 + const {agent} = useSession() 13 + 14 + return useInfiniteQuery< 15 + AppBskyFeedSearchPosts.OutputSchema, 16 + Error, 17 + InfiniteData<AppBskyFeedSearchPosts.OutputSchema>, 18 + QueryKey, 19 + string | undefined 20 + >({ 21 + queryKey: searchPostsQueryKey({query}), 22 + queryFn: async () => { 23 + const res = await agent.app.bsky.feed.searchPosts({ 24 + q: query, 25 + limit: 25, 26 + }) 27 + return res.data 28 + }, 29 + initialPageParam: undefined, 30 + getNextPageParam: lastPage => lastPage.cursor, 31 + }) 32 + }
+20 -10
src/state/queries/suggested-follows.ts
··· 1 + import React from 'react' 1 2 import { 2 3 AppBskyActorGetSuggestions, 3 4 AppBskyGraphGetSuggestedFollowsByActor, ··· 5 6 } from '@atproto/api' 6 7 import { 7 8 useInfiniteQuery, 8 - useMutation, 9 + useQueryClient, 9 10 useQuery, 10 11 InfiniteData, 11 12 QueryKey, ··· 15 16 import {useModerationOpts} from '#/state/queries/preferences' 16 17 17 18 const suggestedFollowsQueryKey = ['suggested-follows'] 18 - const suggestedFollowsByActorQuery = (did: string) => [ 19 + const suggestedFollowsByActorQueryKey = (did: string) => [ 19 20 'suggested-follows-by-actor', 20 21 did, 21 22 ] ··· 73 74 const {agent} = useSession() 74 75 75 76 return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({ 76 - queryKey: suggestedFollowsByActorQuery(did), 77 + queryKey: suggestedFollowsByActorQueryKey(did), 77 78 queryFn: async () => { 78 79 const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ 79 80 actor: did, ··· 83 84 }) 84 85 } 85 86 86 - // TODO: Delete and replace usages with the one above. 87 + // TODO refactor onboarding to use above, but this is still used 87 88 export function useGetSuggestedFollowersByActor() { 88 89 const {agent} = useSession() 90 + const queryClient = useQueryClient() 89 91 90 - return useMutation({ 91 - mutationFn: async (actor: string) => { 92 - const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ 93 - actor: actor, 92 + return React.useCallback( 93 + async (actor: string) => { 94 + const res = await queryClient.fetchQuery({ 95 + staleTime: 60 * 1000, 96 + queryKey: suggestedFollowsByActorQueryKey(actor), 97 + queryFn: async () => { 98 + const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ 99 + actor: actor, 100 + }) 101 + return res.data 102 + }, 94 103 }) 95 104 96 - return res.data 105 + return res 97 106 }, 98 - }) 107 + [agent, queryClient], 108 + ) 99 109 }
-186
src/view/com/search/HeaderWithInput.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet, TextInput, TouchableOpacity, View} from 'react-native' 3 - import { 4 - FontAwesomeIcon, 5 - FontAwesomeIconStyle, 6 - } from '@fortawesome/react-native-fontawesome' 7 - import {Text} from 'view/com/util/text/Text' 8 - import {MagnifyingGlassIcon} from 'lib/icons' 9 - import {useTheme} from 'lib/ThemeContext' 10 - import {usePalette} from 'lib/hooks/usePalette' 11 - import {useAnalytics} from 'lib/analytics/analytics' 12 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 13 - import {HITSLOP_10} from 'lib/constants' 14 - import {Trans, msg} from '@lingui/macro' 15 - import {useLingui} from '@lingui/react' 16 - import {useSetDrawerOpen} from '#/state/shell' 17 - 18 - interface Props { 19 - isInputFocused: boolean 20 - query: string 21 - setIsInputFocused: (v: boolean) => void 22 - onChangeQuery: (v: string) => void 23 - onPressClearQuery: () => void 24 - onPressCancelSearch: () => void 25 - onSubmitQuery: () => void 26 - showMenu?: boolean 27 - } 28 - export function HeaderWithInput({ 29 - isInputFocused, 30 - query, 31 - setIsInputFocused, 32 - onChangeQuery, 33 - onPressClearQuery, 34 - onPressCancelSearch, 35 - onSubmitQuery, 36 - showMenu = true, 37 - }: Props) { 38 - const setDrawerOpen = useSetDrawerOpen() 39 - const theme = useTheme() 40 - const pal = usePalette('default') 41 - const {_} = useLingui() 42 - const {track} = useAnalytics() 43 - const textInput = React.useRef<TextInput>(null) 44 - const {isMobile} = useWebMediaQueries() 45 - 46 - const onPressMenu = React.useCallback(() => { 47 - track('ViewHeader:MenuButtonClicked') 48 - setDrawerOpen(true) 49 - }, [track, setDrawerOpen]) 50 - 51 - const onPressCancelSearchInner = React.useCallback(() => { 52 - onPressCancelSearch() 53 - textInput.current?.blur() 54 - }, [onPressCancelSearch, textInput]) 55 - 56 - return ( 57 - <View 58 - style={[ 59 - pal.view, 60 - pal.border, 61 - styles.header, 62 - !isMobile && styles.headerDesktop, 63 - ]}> 64 - {showMenu && isMobile ? ( 65 - <TouchableOpacity 66 - testID="viewHeaderBackOrMenuBtn" 67 - onPress={onPressMenu} 68 - hitSlop={HITSLOP_10} 69 - style={styles.headerMenuBtn} 70 - accessibilityRole="button" 71 - accessibilityLabel={_(msg`Menu`)} 72 - accessibilityHint="Access navigation links and settings"> 73 - <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} /> 74 - </TouchableOpacity> 75 - ) : null} 76 - <View 77 - style={[ 78 - {backgroundColor: pal.colors.backgroundLight}, 79 - styles.headerSearchContainer, 80 - ]}> 81 - <MagnifyingGlassIcon 82 - style={[pal.icon, styles.headerSearchIcon]} 83 - size={21} 84 - /> 85 - <TextInput 86 - testID="searchTextInput" 87 - ref={textInput} 88 - placeholder="Search" 89 - placeholderTextColor={pal.colors.textLight} 90 - selectTextOnFocus 91 - returnKeyType="search" 92 - value={query} 93 - style={[pal.text, styles.headerSearchInput]} 94 - keyboardAppearance={theme.colorScheme} 95 - onFocus={() => setIsInputFocused(true)} 96 - onBlur={() => setIsInputFocused(false)} 97 - onChangeText={onChangeQuery} 98 - onSubmitEditing={onSubmitQuery} 99 - autoFocus={false} 100 - accessibilityRole="search" 101 - accessibilityLabel={_(msg`Search`)} 102 - accessibilityHint="" 103 - autoCorrect={false} 104 - autoCapitalize="none" 105 - /> 106 - {query ? ( 107 - <TouchableOpacity 108 - testID="searchTextInputClearBtn" 109 - onPress={onPressClearQuery} 110 - accessibilityRole="button" 111 - accessibilityLabel={_(msg`Clear search query`)} 112 - accessibilityHint=""> 113 - <FontAwesomeIcon 114 - icon="xmark" 115 - size={16} 116 - style={pal.textLight as FontAwesomeIconStyle} 117 - /> 118 - </TouchableOpacity> 119 - ) : undefined} 120 - </View> 121 - {query || isInputFocused ? ( 122 - <View style={styles.headerCancelBtn}> 123 - <TouchableOpacity 124 - onPress={onPressCancelSearchInner} 125 - accessibilityRole="button"> 126 - <Text style={pal.text}> 127 - <Trans>Cancel</Trans> 128 - </Text> 129 - </TouchableOpacity> 130 - </View> 131 - ) : undefined} 132 - </View> 133 - ) 134 - } 135 - 136 - const styles = StyleSheet.create({ 137 - header: { 138 - flexDirection: 'row', 139 - alignItems: 'center', 140 - justifyContent: 'center', 141 - paddingHorizontal: 12, 142 - paddingVertical: 4, 143 - }, 144 - headerDesktop: { 145 - borderWidth: 1, 146 - borderTopWidth: 0, 147 - paddingVertical: 10, 148 - }, 149 - headerMenuBtn: { 150 - width: 30, 151 - height: 30, 152 - borderRadius: 30, 153 - marginRight: 6, 154 - paddingBottom: 2, 155 - alignItems: 'center', 156 - justifyContent: 'center', 157 - }, 158 - headerSearchContainer: { 159 - flex: 1, 160 - flexDirection: 'row', 161 - alignItems: 'center', 162 - borderRadius: 30, 163 - paddingHorizontal: 12, 164 - paddingVertical: 8, 165 - }, 166 - headerSearchIcon: { 167 - marginRight: 6, 168 - alignSelf: 'center', 169 - }, 170 - headerSearchInput: { 171 - flex: 1, 172 - fontSize: 17, 173 - }, 174 - headerCancelBtn: { 175 - paddingLeft: 10, 176 - }, 177 - 178 - searchPrompt: { 179 - textAlign: 'center', 180 - paddingTop: 10, 181 - }, 182 - 183 - suggestions: { 184 - marginBottom: 8, 185 - }, 186 - })
-150
src/view/com/search/SearchResults.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet, View} from 'react-native' 3 - import {observer} from 'mobx-react-lite' 4 - import {SearchUIModel} from 'state/models/ui/search' 5 - import {CenteredView, ScrollView} from '../util/Views' 6 - import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager' 7 - import {TabBar} from 'view/com/pager/TabBar' 8 - import {Post} from 'view/com/post/Post' 9 - import {ProfileCardWithFollowBtn} from 'view/com/profile/ProfileCard' 10 - import { 11 - PostFeedLoadingPlaceholder, 12 - ProfileCardFeedLoadingPlaceholder, 13 - } from 'view/com/util/LoadingPlaceholder' 14 - import {Text} from 'view/com/util/text/Text' 15 - import {usePalette} from 'lib/hooks/usePalette' 16 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 17 - import {s} from 'lib/styles' 18 - 19 - const SECTIONS = ['Posts', 'Users'] 20 - 21 - export const SearchResults = observer(function SearchResultsImpl({ 22 - model, 23 - }: { 24 - model: SearchUIModel 25 - }) { 26 - const pal = usePalette('default') 27 - const {isMobile} = useWebMediaQueries() 28 - 29 - const renderTabBar = React.useCallback( 30 - (props: RenderTabBarFnProps) => { 31 - return ( 32 - <CenteredView style={[pal.border, pal.view, styles.tabBar]}> 33 - <TabBar 34 - items={SECTIONS} 35 - {...props} 36 - key={SECTIONS.join()} 37 - indicatorColor={pal.colors.link} 38 - /> 39 - </CenteredView> 40 - ) 41 - }, 42 - [pal], 43 - ) 44 - 45 - return ( 46 - <Pager renderTabBar={renderTabBar} tabBarPosition="top" initialPage={0}> 47 - <View 48 - style={{ 49 - paddingTop: isMobile ? 42 : 50, 50 - }}> 51 - <PostResults key="0" model={model} /> 52 - </View> 53 - <View 54 - style={{ 55 - paddingTop: isMobile ? 42 : 50, 56 - }}> 57 - <Profiles key="1" model={model} /> 58 - </View> 59 - </Pager> 60 - ) 61 - }) 62 - 63 - const PostResults = observer(function PostResultsImpl({ 64 - model, 65 - }: { 66 - model: SearchUIModel 67 - }) { 68 - const pal = usePalette('default') 69 - if (model.isPostsLoading) { 70 - return ( 71 - <CenteredView> 72 - <PostFeedLoadingPlaceholder /> 73 - </CenteredView> 74 - ) 75 - } 76 - 77 - if (model.posts.length === 0) { 78 - return ( 79 - <CenteredView> 80 - <Text type="xl" style={[styles.empty, pal.text]}> 81 - No posts found for "{model.query}" 82 - </Text> 83 - </CenteredView> 84 - ) 85 - } 86 - 87 - return ( 88 - <ScrollView style={[pal.view]}> 89 - {model.posts.map(post => ( 90 - <Post key={post.resolvedUri} view={post} hideError /> 91 - ))} 92 - <View style={s.footerSpacer} /> 93 - <View style={s.footerSpacer} /> 94 - <View style={s.footerSpacer} /> 95 - </ScrollView> 96 - ) 97 - }) 98 - 99 - const Profiles = observer(function ProfilesImpl({ 100 - model, 101 - }: { 102 - model: SearchUIModel 103 - }) { 104 - const pal = usePalette('default') 105 - if (model.isProfilesLoading) { 106 - return ( 107 - <CenteredView> 108 - <ProfileCardFeedLoadingPlaceholder /> 109 - </CenteredView> 110 - ) 111 - } 112 - 113 - if (model.profiles.length === 0) { 114 - return ( 115 - <CenteredView> 116 - <Text type="xl" style={[styles.empty, pal.text]}> 117 - No users found for "{model.query}" 118 - </Text> 119 - </CenteredView> 120 - ) 121 - } 122 - 123 - return ( 124 - <ScrollView style={pal.view}> 125 - {model.profiles.map(item => ( 126 - <ProfileCardWithFollowBtn key={item.did} profile={item} /> 127 - ))} 128 - <View style={s.footerSpacer} /> 129 - <View style={s.footerSpacer} /> 130 - <View style={s.footerSpacer} /> 131 - </ScrollView> 132 - ) 133 - }) 134 - 135 - const styles = StyleSheet.create({ 136 - tabBar: { 137 - borderBottomWidth: 1, 138 - position: 'absolute', 139 - zIndex: 1, 140 - left: 0, 141 - right: 0, 142 - top: 0, 143 - flexDirection: 'column', 144 - alignItems: 'center', 145 - }, 146 - empty: { 147 - paddingHorizontal: 14, 148 - paddingVertical: 16, 149 - }, 150 - })
-265
src/view/com/search/Suggestions.tsx
··· 1 - import React, {forwardRef, ForwardedRef} from 'react' 2 - import {RefreshControl, StyleSheet, View} from 'react-native' 3 - import {observer} from 'mobx-react-lite' 4 - import {AppBskyActorDefs} from '@atproto/api' 5 - import {FlatList} from '../util/Views' 6 - import {FoafsModel} from 'state/models/discovery/foafs' 7 - import { 8 - SuggestedActorsModel, 9 - SuggestedActor, 10 - } from 'state/models/discovery/suggested-actors' 11 - import {Text} from '../util/text/Text' 12 - import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' 13 - import {ProfileCardLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' 14 - import {sanitizeDisplayName} from 'lib/strings/display-names' 15 - import {sanitizeHandle} from 'lib/strings/handles' 16 - import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs' 17 - import {usePalette} from 'lib/hooks/usePalette' 18 - import {s} from 'lib/styles' 19 - 20 - interface Heading { 21 - _reactKey: string 22 - type: 'heading' 23 - title: string 24 - } 25 - interface RefWrapper { 26 - _reactKey: string 27 - type: 'ref' 28 - ref: RefWithInfoAndFollowers 29 - } 30 - interface SuggestWrapper { 31 - _reactKey: string 32 - type: 'suggested' 33 - suggested: SuggestedActor 34 - } 35 - interface ProfileView { 36 - _reactKey: string 37 - type: 'profile-view' 38 - view: AppBskyActorDefs.ProfileViewBasic 39 - } 40 - interface LoadingPlaceholder { 41 - _reactKey: string 42 - type: 'loading-placeholder' 43 - } 44 - type Item = 45 - | Heading 46 - | RefWrapper 47 - | SuggestWrapper 48 - | ProfileView 49 - | LoadingPlaceholder 50 - 51 - // FIXME(dan): Figure out why the false positives 52 - /* eslint-disable react/prop-types */ 53 - 54 - export const Suggestions = observer( 55 - forwardRef(function SuggestionsImpl( 56 - { 57 - foafs, 58 - suggestedActors, 59 - }: { 60 - foafs: FoafsModel 61 - suggestedActors: SuggestedActorsModel 62 - }, 63 - flatListRef: ForwardedRef<FlatList>, 64 - ) { 65 - const pal = usePalette('default') 66 - const [refreshing, setRefreshing] = React.useState(false) 67 - const data = React.useMemo(() => { 68 - let items: Item[] = [] 69 - 70 - if (suggestedActors.hasContent) { 71 - items = items 72 - .concat([ 73 - { 74 - _reactKey: '__suggested_heading__', 75 - type: 'heading', 76 - title: 'Suggested Follows', 77 - }, 78 - ]) 79 - .concat( 80 - suggestedActors.suggestions.map(suggested => ({ 81 - _reactKey: `suggested-${suggested.did}`, 82 - type: 'suggested', 83 - suggested, 84 - })), 85 - ) 86 - } else if (suggestedActors.isLoading) { 87 - items = items.concat([ 88 - { 89 - _reactKey: '__suggested_heading__', 90 - type: 'heading', 91 - title: 'Suggested Follows', 92 - }, 93 - {_reactKey: '__suggested_loading__', type: 'loading-placeholder'}, 94 - ]) 95 - } 96 - if (foafs.isLoading) { 97 - items = items.concat([ 98 - { 99 - _reactKey: '__popular_heading__', 100 - type: 'heading', 101 - title: 'In Your Network', 102 - }, 103 - {_reactKey: '__foafs_loading__', type: 'loading-placeholder'}, 104 - ]) 105 - } else { 106 - if (foafs.popular.length > 0) { 107 - items = items 108 - .concat([ 109 - { 110 - _reactKey: '__popular_heading__', 111 - type: 'heading', 112 - title: 'In Your Network', 113 - }, 114 - ]) 115 - .concat( 116 - foafs.popular.map(ref => ({ 117 - _reactKey: `popular-${ref.did}`, 118 - type: 'ref', 119 - ref, 120 - })), 121 - ) 122 - } 123 - for (const source of foafs.sources) { 124 - const item = foafs.foafs.get(source) 125 - if (!item || item.follows.length === 0) { 126 - continue 127 - } 128 - items = items 129 - .concat([ 130 - { 131 - _reactKey: `__${item.did}_heading__`, 132 - type: 'heading', 133 - title: `Followed by ${sanitizeDisplayName( 134 - item.displayName || sanitizeHandle(item.handle), 135 - )}`, 136 - }, 137 - ]) 138 - .concat( 139 - item.follows.slice(0, 10).map(view => ({ 140 - _reactKey: `${item.did}-${view.did}`, 141 - type: 'profile-view', 142 - view, 143 - })), 144 - ) 145 - } 146 - } 147 - 148 - return items 149 - }, [ 150 - foafs.isLoading, 151 - foafs.popular, 152 - suggestedActors.isLoading, 153 - suggestedActors.hasContent, 154 - suggestedActors.suggestions, 155 - foafs.sources, 156 - foafs.foafs, 157 - ]) 158 - 159 - const onRefresh = React.useCallback(async () => { 160 - setRefreshing(true) 161 - try { 162 - await foafs.fetch() 163 - } finally { 164 - setRefreshing(false) 165 - } 166 - }, [foafs, setRefreshing]) 167 - 168 - const renderItem = React.useCallback( 169 - ({item}: {item: Item}) => { 170 - if (item.type === 'heading') { 171 - return ( 172 - <Text type="title" style={[styles.heading, pal.text]}> 173 - {item.title} 174 - </Text> 175 - ) 176 - } 177 - if (item.type === 'ref') { 178 - return ( 179 - <View style={[styles.card, pal.view, pal.border]}> 180 - <ProfileCardWithFollowBtn 181 - key={item.ref.did} 182 - profile={item.ref} 183 - noBg 184 - noBorder 185 - followers={ 186 - item.ref.followers 187 - ? (item.ref.followers as AppBskyActorDefs.ProfileView[]) 188 - : undefined 189 - } 190 - /> 191 - </View> 192 - ) 193 - } 194 - if (item.type === 'profile-view') { 195 - return ( 196 - <View style={[styles.card, pal.view, pal.border]}> 197 - <ProfileCardWithFollowBtn 198 - key={item.view.did} 199 - profile={item.view} 200 - noBg 201 - noBorder 202 - /> 203 - </View> 204 - ) 205 - } 206 - if (item.type === 'suggested') { 207 - return ( 208 - <View style={[styles.card, pal.view, pal.border]}> 209 - <ProfileCardWithFollowBtn 210 - key={item.suggested.did} 211 - profile={item.suggested} 212 - noBg 213 - noBorder 214 - /> 215 - </View> 216 - ) 217 - } 218 - if (item.type === 'loading-placeholder') { 219 - return ( 220 - <View> 221 - <ProfileCardLoadingPlaceholder /> 222 - <ProfileCardLoadingPlaceholder /> 223 - <ProfileCardLoadingPlaceholder /> 224 - <ProfileCardLoadingPlaceholder /> 225 - </View> 226 - ) 227 - } 228 - return null 229 - }, 230 - [pal], 231 - ) 232 - 233 - return ( 234 - <FlatList 235 - ref={flatListRef} 236 - data={data} 237 - keyExtractor={item => item._reactKey} 238 - refreshControl={ 239 - <RefreshControl 240 - refreshing={refreshing} 241 - onRefresh={onRefresh} 242 - tintColor={pal.colors.text} 243 - titleColor={pal.colors.text} 244 - /> 245 - } 246 - renderItem={renderItem} 247 - initialNumToRender={15} 248 - contentContainerStyle={s.contentContainer} 249 - /> 250 - ) 251 - }), 252 - ) 253 - 254 - const styles = StyleSheet.create({ 255 - heading: { 256 - fontWeight: 'bold', 257 - paddingHorizontal: 12, 258 - paddingBottom: 8, 259 - paddingTop: 16, 260 - }, 261 - 262 - card: { 263 - borderTopWidth: 1, 264 - }, 265 - })
-1
src/view/screens/Search.tsx
··· 1 - export * from './SearchMobile'
-76
src/view/screens/Search.web.tsx
··· 1 - import React from 'react' 2 - import {View, StyleSheet} from 'react-native' 3 - import {SearchUIModel} from 'state/models/ui/search' 4 - import {FoafsModel} from 'state/models/discovery/foafs' 5 - import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' 6 - import {withAuthRequired} from 'view/com/auth/withAuthRequired' 7 - import {Suggestions} from 'view/com/search/Suggestions' 8 - import {SearchResults} from 'view/com/search/SearchResults' 9 - import {observer} from 'mobx-react-lite' 10 - import { 11 - NativeStackScreenProps, 12 - SearchTabNavigatorParams, 13 - } from 'lib/routes/types' 14 - import {useStores} from 'state/index' 15 - import {CenteredView} from 'view/com/util/Views' 16 - import * as Mobile from './SearchMobile' 17 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 18 - 19 - type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> 20 - export const SearchScreen = withAuthRequired( 21 - observer(function SearchScreenImpl({navigation, route}: Props) { 22 - const store = useStores() 23 - const params = route.params || {} 24 - const foafs = React.useMemo<FoafsModel>( 25 - () => new FoafsModel(store), 26 - [store], 27 - ) 28 - const suggestedActors = React.useMemo<SuggestedActorsModel>( 29 - () => new SuggestedActorsModel(store), 30 - [store], 31 - ) 32 - const searchUIModel = React.useMemo<SearchUIModel | undefined>( 33 - () => (params.q ? new SearchUIModel(store) : undefined), 34 - [params.q, store], 35 - ) 36 - 37 - React.useEffect(() => { 38 - if (params.q && searchUIModel) { 39 - searchUIModel.fetch(params.q) 40 - } 41 - if (!foafs.hasData) { 42 - foafs.fetch() 43 - } 44 - if (!suggestedActors.hasLoaded) { 45 - suggestedActors.loadMore(true) 46 - } 47 - }, [foafs, suggestedActors, searchUIModel, params.q]) 48 - 49 - const {isDesktop} = useWebMediaQueries() 50 - 51 - if (searchUIModel) { 52 - return ( 53 - <View style={styles.scrollContainer}> 54 - <SearchResults model={searchUIModel} /> 55 - </View> 56 - ) 57 - } 58 - 59 - if (!isDesktop) { 60 - return ( 61 - <CenteredView style={styles.scrollContainer}> 62 - <Mobile.SearchScreen navigation={navigation} route={route} /> 63 - </CenteredView> 64 - ) 65 - } 66 - 67 - return <Suggestions foafs={foafs} suggestedActors={suggestedActors} /> 68 - }), 69 - ) 70 - 71 - const styles = StyleSheet.create({ 72 - scrollContainer: { 73 - height: '100%', 74 - overflowY: 'auto', 75 - }, 76 - })
+639
src/view/screens/Search/Search.tsx
··· 1 + import React from 'react' 2 + import { 3 + View, 4 + StyleSheet, 5 + ActivityIndicator, 6 + RefreshControl, 7 + TextInput, 8 + Pressable, 9 + } from 'react-native' 10 + import {FlatList, ScrollView, CenteredView} from '#/view/com/util/Views' 11 + import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api' 12 + import {msg, Trans} from '@lingui/macro' 13 + import {useLingui} from '@lingui/react' 14 + import { 15 + FontAwesomeIcon, 16 + FontAwesomeIconStyle, 17 + } from '@fortawesome/react-native-fontawesome' 18 + import {useFocusEffect} from '@react-navigation/native' 19 + 20 + import {logger} from '#/logger' 21 + import { 22 + NativeStackScreenProps, 23 + SearchTabNavigatorParams, 24 + } from 'lib/routes/types' 25 + import {Text} from '#/view/com/util/text/Text' 26 + import {NotificationFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' 27 + import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' 28 + import {Post} from '#/view/com/post/Post' 29 + import {Pager} from '#/view/com/pager/Pager' 30 + import {TabBar} from '#/view/com/pager/TabBar' 31 + import {HITSLOP_10} from '#/lib/constants' 32 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 33 + import {usePalette} from '#/lib/hooks/usePalette' 34 + import {useTheme} from 'lib/ThemeContext' 35 + import {useSession} from '#/state/session' 36 + import {useMyFollowsQuery} from '#/state/queries/my-follows' 37 + import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows' 38 + import {useSearchPostsQuery} from '#/state/queries/search-posts' 39 + import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' 40 + import {useSetDrawerOpen} from '#/state/shell' 41 + import {useAnalytics} from '#/lib/analytics/analytics' 42 + import {MagnifyingGlassIcon} from '#/lib/icons' 43 + import {useModerationOpts} from '#/state/queries/preferences' 44 + import {SearchResultCard} from '#/view/shell/desktop/Search' 45 + import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' 46 + import {useStores} from '#/state' 47 + import {isWeb} from '#/platform/detection' 48 + 49 + function Loader() { 50 + const pal = usePalette('default') 51 + const {isMobile} = useWebMediaQueries() 52 + return ( 53 + <CenteredView 54 + style={[ 55 + // @ts-ignore web only -prf 56 + { 57 + padding: 18, 58 + height: isWeb ? '100vh' : undefined, 59 + }, 60 + pal.border, 61 + ]} 62 + sideBorders={!isMobile}> 63 + <ActivityIndicator /> 64 + </CenteredView> 65 + ) 66 + } 67 + 68 + // TODO refactor how to translate? 69 + function EmptyState({message, error}: {message: string; error?: string}) { 70 + const pal = usePalette('default') 71 + const {isMobile} = useWebMediaQueries() 72 + 73 + return ( 74 + <CenteredView 75 + sideBorders={!isMobile} 76 + style={[ 77 + pal.border, 78 + // @ts-ignore web only -prf 79 + { 80 + padding: 18, 81 + height: isWeb ? '100vh' : undefined, 82 + }, 83 + ]}> 84 + <View style={[pal.viewLight, {padding: 18, borderRadius: 8}]}> 85 + <Text style={[pal.text]}> 86 + <Trans>{message}</Trans> 87 + </Text> 88 + 89 + {error && ( 90 + <> 91 + <View 92 + style={[ 93 + { 94 + marginVertical: 12, 95 + height: 1, 96 + width: '100%', 97 + backgroundColor: pal.text.color, 98 + opacity: 0.2, 99 + }, 100 + ]} 101 + /> 102 + 103 + <Text style={[pal.textLight]}> 104 + <Trans>Error:</Trans> {error} 105 + </Text> 106 + </> 107 + )} 108 + </View> 109 + </CenteredView> 110 + ) 111 + } 112 + 113 + function SearchScreenSuggestedFollows() { 114 + const pal = usePalette('default') 115 + const {currentAccount} = useSession() 116 + const [dataUpdatedAt, setDataUpdatedAt] = React.useState(0) 117 + const [suggestions, setSuggestions] = React.useState< 118 + AppBskyActorDefs.ProfileViewBasic[] 119 + >([]) 120 + const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor() 121 + 122 + React.useEffect(() => { 123 + async function getSuggestions() { 124 + // TODO not quite right, doesn't fetch your follows 125 + const friends = await getSuggestedFollowsByActor( 126 + currentAccount!.did, 127 + ).then(friendsRes => friendsRes.suggestions) 128 + 129 + if (!friends) return // :( 130 + 131 + const friendsOfFriends = ( 132 + await Promise.all( 133 + friends 134 + .slice(0, 4) 135 + .map(friend => 136 + getSuggestedFollowsByActor(friend.did).then( 137 + foafsRes => foafsRes.suggestions, 138 + ), 139 + ), 140 + ) 141 + ).flat() 142 + 143 + setSuggestions( 144 + // dedupe 145 + friendsOfFriends.filter(f => !friends.find(f2 => f.did === f2.did)), 146 + ) 147 + setDataUpdatedAt(Date.now()) 148 + } 149 + 150 + try { 151 + getSuggestions() 152 + } catch (e) { 153 + logger.error(`SearchScreenSuggestedFollows: failed to get suggestions`, { 154 + error: e, 155 + }) 156 + } 157 + }, [ 158 + currentAccount, 159 + setSuggestions, 160 + setDataUpdatedAt, 161 + getSuggestedFollowsByActor, 162 + ]) 163 + 164 + return suggestions.length ? ( 165 + <FlatList 166 + data={suggestions} 167 + renderItem={({item}) => ( 168 + <ProfileCardWithFollowBtn 169 + profile={item} 170 + noBg 171 + dataUpdatedAt={dataUpdatedAt} 172 + /> 173 + )} 174 + keyExtractor={item => item.did} 175 + // @ts-ignore web only -prf 176 + desktopFixedHeight 177 + contentContainerStyle={{paddingBottom: 1200}} 178 + /> 179 + ) : ( 180 + <CenteredView 181 + style={[pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]}> 182 + <NotificationFeedLoadingPlaceholder /> 183 + </CenteredView> 184 + ) 185 + } 186 + 187 + type SearchResultSlice = 188 + | { 189 + type: 'post' 190 + key: string 191 + post: AppBskyFeedDefs.PostView 192 + } 193 + | { 194 + type: 'loadingMore' 195 + key: string 196 + } 197 + 198 + function SearchScreenPostResults({query}: {query: string}) { 199 + const pal = usePalette('default') 200 + const [isPTR, setIsPTR] = React.useState(false) 201 + const { 202 + isFetched, 203 + data: results, 204 + isFetching, 205 + error, 206 + refetch, 207 + fetchNextPage, 208 + isFetchingNextPage, 209 + hasNextPage, 210 + dataUpdatedAt, 211 + } = useSearchPostsQuery({query}) 212 + 213 + const onPullToRefresh = React.useCallback(async () => { 214 + setIsPTR(true) 215 + await refetch() 216 + setIsPTR(false) 217 + }, [setIsPTR, refetch]) 218 + const onEndReached = React.useCallback(() => { 219 + if (isFetching || !hasNextPage || error) return 220 + fetchNextPage() 221 + }, [isFetching, error, hasNextPage, fetchNextPage]) 222 + 223 + const posts = React.useMemo(() => { 224 + return results?.pages.flatMap(page => page.posts) || [] 225 + }, [results]) 226 + const items = React.useMemo(() => { 227 + let items: SearchResultSlice[] = [] 228 + 229 + for (const post of posts) { 230 + items.push({ 231 + type: 'post', 232 + key: post.uri, 233 + post, 234 + }) 235 + } 236 + 237 + if (isFetchingNextPage) { 238 + items.push({ 239 + type: 'loadingMore', 240 + key: 'loadingMore', 241 + }) 242 + } 243 + 244 + return items 245 + }, [posts, isFetchingNextPage]) 246 + 247 + return error ? ( 248 + <EmptyState 249 + message="We're sorry, but your search could not be completed. Please try again in a few minutes." 250 + error={error.toString()} 251 + /> 252 + ) : ( 253 + <> 254 + {isFetched ? ( 255 + <> 256 + {posts.length ? ( 257 + <FlatList 258 + data={items} 259 + renderItem={({item}) => { 260 + if (item.type === 'post') { 261 + return <Post post={item.post} dataUpdatedAt={dataUpdatedAt} /> 262 + } else { 263 + return <Loader /> 264 + } 265 + }} 266 + keyExtractor={item => item.key} 267 + refreshControl={ 268 + <RefreshControl 269 + refreshing={isPTR} 270 + onRefresh={onPullToRefresh} 271 + tintColor={pal.colors.text} 272 + titleColor={pal.colors.text} 273 + /> 274 + } 275 + onEndReached={onEndReached} 276 + // @ts-ignore web only -prf 277 + desktopFixedHeight 278 + contentContainerStyle={{paddingBottom: 100}} 279 + /> 280 + ) : ( 281 + <EmptyState message={`No results found for ${query}`} /> 282 + )} 283 + </> 284 + ) : ( 285 + <Loader /> 286 + )} 287 + </> 288 + ) 289 + } 290 + 291 + function SearchScreenUserResults({query}: {query: string}) { 292 + const [isFetched, setIsFetched] = React.useState(false) 293 + const [dataUpdatedAt, setDataUpdatedAt] = React.useState(0) 294 + const [results, setResults] = React.useState< 295 + AppBskyActorDefs.ProfileViewBasic[] 296 + >([]) 297 + const search = useActorAutocompleteFn() 298 + // fuzzy search relies on followers 299 + const {isFetched: isFollowsFetched} = useMyFollowsQuery() 300 + 301 + React.useEffect(() => { 302 + async function getResults() { 303 + const results = await search({query, limit: 30}) 304 + 305 + if (results) { 306 + setDataUpdatedAt(Date.now()) 307 + setResults(results) 308 + setIsFetched(true) 309 + } 310 + } 311 + 312 + if (query && isFollowsFetched) { 313 + getResults() 314 + } else { 315 + setResults([]) 316 + setIsFetched(false) 317 + } 318 + }, [query, isFollowsFetched, setDataUpdatedAt, search]) 319 + 320 + return isFetched ? ( 321 + <> 322 + {results.length ? ( 323 + <FlatList 324 + data={results} 325 + renderItem={({item}) => ( 326 + <ProfileCardWithFollowBtn 327 + profile={item} 328 + noBg 329 + dataUpdatedAt={dataUpdatedAt} 330 + /> 331 + )} 332 + keyExtractor={item => item.did} 333 + // @ts-ignore web only -prf 334 + desktopFixedHeight 335 + contentContainerStyle={{paddingBottom: 100}} 336 + /> 337 + ) : ( 338 + <EmptyState message={`No results found for ${query}`} /> 339 + )} 340 + </> 341 + ) : ( 342 + <Loader /> 343 + ) 344 + } 345 + 346 + const SECTIONS = ['Posts', 'Users'] 347 + export function SearchScreenInner({query}: {query?: string}) { 348 + const pal = usePalette('default') 349 + const setMinimalShellMode = useSetMinimalShellMode() 350 + const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() 351 + 352 + const onPageSelected = React.useCallback( 353 + (index: number) => { 354 + setMinimalShellMode(false) 355 + setDrawerSwipeDisabled(index > 0) 356 + }, 357 + [setDrawerSwipeDisabled, setMinimalShellMode], 358 + ) 359 + 360 + return query ? ( 361 + <Pager 362 + tabBarPosition="top" 363 + onPageSelected={onPageSelected} 364 + renderTabBar={props => ( 365 + <CenteredView sideBorders style={pal.border}> 366 + <TabBar items={SECTIONS} {...props} /> 367 + </CenteredView> 368 + )} 369 + initialPage={0}> 370 + <View> 371 + <SearchScreenPostResults query={query} /> 372 + </View> 373 + <View> 374 + <SearchScreenUserResults query={query} /> 375 + </View> 376 + </Pager> 377 + ) : ( 378 + <View> 379 + <CenteredView sideBorders style={pal.border}> 380 + <Text 381 + type="title" 382 + style={[ 383 + pal.text, 384 + pal.border, 385 + { 386 + display: 'flex', 387 + paddingVertical: 12, 388 + paddingHorizontal: 18, 389 + fontWeight: 'bold', 390 + }, 391 + ]}> 392 + <Trans>Suggested Follows</Trans> 393 + </Text> 394 + </CenteredView> 395 + <SearchScreenSuggestedFollows /> 396 + </View> 397 + ) 398 + } 399 + 400 + export function SearchScreenDesktop( 401 + props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, 402 + ) { 403 + const {isDesktop} = useWebMediaQueries() 404 + 405 + return isDesktop ? ( 406 + <SearchScreenInner query={props.route.params?.q} /> 407 + ) : ( 408 + <SearchScreenMobile {...props} /> 409 + ) 410 + } 411 + 412 + export function SearchScreenMobile( 413 + _props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, 414 + ) { 415 + const theme = useTheme() 416 + const textInput = React.useRef<TextInput>(null) 417 + const {_} = useLingui() 418 + const pal = usePalette('default') 419 + const {track} = useAnalytics() 420 + const setDrawerOpen = useSetDrawerOpen() 421 + const moderationOpts = useModerationOpts() 422 + const search = useActorAutocompleteFn() 423 + const setMinimalShellMode = useSetMinimalShellMode() 424 + const store = useStores() 425 + const {isTablet} = useWebMediaQueries() 426 + 427 + const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>( 428 + undefined, 429 + ) 430 + const [isFetching, setIsFetching] = React.useState<boolean>(false) 431 + const [query, setQuery] = React.useState<string>('') 432 + const [searchResults, setSearchResults] = React.useState< 433 + AppBskyActorDefs.ProfileViewBasic[] 434 + >([]) 435 + const [inputIsFocused, setInputIsFocused] = React.useState(false) 436 + const [showAutocompleteResults, setShowAutocompleteResults] = 437 + React.useState(false) 438 + 439 + const onPressMenu = React.useCallback(() => { 440 + track('ViewHeader:MenuButtonClicked') 441 + setDrawerOpen(true) 442 + }, [track, setDrawerOpen]) 443 + const onPressCancelSearch = React.useCallback(() => { 444 + textInput.current?.blur() 445 + setQuery('') 446 + setShowAutocompleteResults(false) 447 + if (searchDebounceTimeout.current) 448 + clearTimeout(searchDebounceTimeout.current) 449 + }, [textInput]) 450 + const onPressClearQuery = React.useCallback(() => { 451 + setQuery('') 452 + setShowAutocompleteResults(false) 453 + }, [setQuery]) 454 + const onChangeText = React.useCallback( 455 + async (text: string) => { 456 + setQuery(text) 457 + 458 + if (text.length > 0) { 459 + setIsFetching(true) 460 + setShowAutocompleteResults(true) 461 + 462 + if (searchDebounceTimeout.current) 463 + clearTimeout(searchDebounceTimeout.current) 464 + 465 + searchDebounceTimeout.current = setTimeout(async () => { 466 + const results = await search({query: text, limit: 30}) 467 + 468 + if (results) { 469 + setSearchResults(results) 470 + setIsFetching(false) 471 + } 472 + }, 300) 473 + } else { 474 + if (searchDebounceTimeout.current) 475 + clearTimeout(searchDebounceTimeout.current) 476 + setSearchResults([]) 477 + setIsFetching(false) 478 + setShowAutocompleteResults(false) 479 + } 480 + }, 481 + [setQuery, search, setSearchResults], 482 + ) 483 + const onSubmit = React.useCallback(() => { 484 + setShowAutocompleteResults(false) 485 + }, [setShowAutocompleteResults]) 486 + 487 + const onSoftReset = React.useCallback(() => { 488 + onPressCancelSearch() 489 + }, [onPressCancelSearch]) 490 + 491 + useFocusEffect( 492 + React.useCallback(() => { 493 + const softResetSub = store.onScreenSoftReset(onSoftReset) 494 + 495 + setMinimalShellMode(false) 496 + 497 + return () => { 498 + softResetSub.remove() 499 + } 500 + }, [store, onSoftReset, setMinimalShellMode]), 501 + ) 502 + 503 + return ( 504 + <View style={{flex: 1}}> 505 + <CenteredView style={[styles.header, pal.border]} sideBorders={isTablet}> 506 + <Pressable 507 + testID="viewHeaderBackOrMenuBtn" 508 + onPress={onPressMenu} 509 + hitSlop={HITSLOP_10} 510 + style={styles.headerMenuBtn} 511 + accessibilityRole="button" 512 + accessibilityLabel={_(msg`Menu`)} 513 + accessibilityHint="Access navigation links and settings"> 514 + <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} /> 515 + </Pressable> 516 + 517 + <View 518 + style={[ 519 + {backgroundColor: pal.colors.backgroundLight}, 520 + styles.headerSearchContainer, 521 + ]}> 522 + <MagnifyingGlassIcon 523 + style={[pal.icon, styles.headerSearchIcon]} 524 + size={21} 525 + /> 526 + <TextInput 527 + testID="searchTextInput" 528 + ref={textInput} 529 + placeholder="Search" 530 + placeholderTextColor={pal.colors.textLight} 531 + selectTextOnFocus 532 + returnKeyType="search" 533 + value={query} 534 + style={[pal.text, styles.headerSearchInput]} 535 + keyboardAppearance={theme.colorScheme} 536 + onFocus={() => setInputIsFocused(true)} 537 + onBlur={() => setInputIsFocused(false)} 538 + onChangeText={onChangeText} 539 + onSubmitEditing={onSubmit} 540 + autoFocus={false} 541 + accessibilityRole="search" 542 + accessibilityLabel={_(msg`Search`)} 543 + accessibilityHint="" 544 + autoCorrect={false} 545 + autoCapitalize="none" 546 + /> 547 + {query ? ( 548 + <Pressable 549 + testID="searchTextInputClearBtn" 550 + onPress={onPressClearQuery} 551 + accessibilityRole="button" 552 + accessibilityLabel={_(msg`Clear search query`)} 553 + accessibilityHint=""> 554 + <FontAwesomeIcon 555 + icon="xmark" 556 + size={16} 557 + style={pal.textLight as FontAwesomeIconStyle} 558 + /> 559 + </Pressable> 560 + ) : undefined} 561 + </View> 562 + 563 + {query || inputIsFocused ? ( 564 + <View style={styles.headerCancelBtn}> 565 + <Pressable onPress={onPressCancelSearch} accessibilityRole="button"> 566 + <Text style={[pal.text]}> 567 + <Trans>Cancel</Trans> 568 + </Text> 569 + </Pressable> 570 + </View> 571 + ) : undefined} 572 + </CenteredView> 573 + 574 + {showAutocompleteResults && moderationOpts ? ( 575 + <> 576 + {isFetching ? ( 577 + <Loader /> 578 + ) : ( 579 + <ScrollView style={{height: '100%'}}> 580 + {searchResults.length ? ( 581 + searchResults.map((item, i) => ( 582 + <SearchResultCard 583 + key={item.did} 584 + profile={item} 585 + moderation={moderateProfile(item, moderationOpts)} 586 + style={i === 0 ? {borderTopWidth: 0} : {}} 587 + /> 588 + )) 589 + ) : ( 590 + <EmptyState message={`No results found for ${query}`} /> 591 + )} 592 + 593 + <View style={{height: 200}} /> 594 + </ScrollView> 595 + )} 596 + </> 597 + ) : ( 598 + <SearchScreenInner query={query} /> 599 + )} 600 + </View> 601 + ) 602 + } 603 + 604 + const styles = StyleSheet.create({ 605 + header: { 606 + flexDirection: 'row', 607 + alignItems: 'center', 608 + paddingHorizontal: 12, 609 + paddingVertical: 4, 610 + }, 611 + headerMenuBtn: { 612 + width: 30, 613 + height: 30, 614 + borderRadius: 30, 615 + marginRight: 6, 616 + paddingBottom: 2, 617 + alignItems: 'center', 618 + justifyContent: 'center', 619 + }, 620 + headerSearchContainer: { 621 + flex: 1, 622 + flexDirection: 'row', 623 + alignItems: 'center', 624 + borderRadius: 30, 625 + paddingHorizontal: 12, 626 + paddingVertical: 8, 627 + }, 628 + headerSearchIcon: { 629 + marginRight: 6, 630 + alignSelf: 'center', 631 + }, 632 + headerSearchInput: { 633 + flex: 1, 634 + fontSize: 17, 635 + }, 636 + headerCancelBtn: { 637 + paddingLeft: 10, 638 + }, 639 + })
+1
src/view/screens/Search/index.tsx
··· 1 + export {SearchScreenMobile as SearchScreen} from '#/view/screens/Search/Search'
+1
src/view/screens/Search/index.web.tsx
··· 1 + export {SearchScreenDesktop as SearchScreen} from '#/view/screens/Search/Search'
-205
src/view/screens/SearchMobile.tsx
··· 1 - import React, {useCallback} from 'react' 2 - import { 3 - StyleSheet, 4 - TouchableWithoutFeedback, 5 - Keyboard, 6 - View, 7 - } from 'react-native' 8 - import {useFocusEffect} from '@react-navigation/native' 9 - import {withAuthRequired} from 'view/com/auth/withAuthRequired' 10 - import {FlatList, ScrollView} from 'view/com/util/Views' 11 - import { 12 - NativeStackScreenProps, 13 - SearchTabNavigatorParams, 14 - } from 'lib/routes/types' 15 - import {observer} from 'mobx-react-lite' 16 - import {Text} from 'view/com/util/text/Text' 17 - import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' 18 - import {useStores} from 'state/index' 19 - import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' 20 - import {SearchUIModel} from 'state/models/ui/search' 21 - import {FoafsModel} from 'state/models/discovery/foafs' 22 - import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' 23 - import {HeaderWithInput} from 'view/com/search/HeaderWithInput' 24 - import {Suggestions} from 'view/com/search/Suggestions' 25 - import {SearchResults} from 'view/com/search/SearchResults' 26 - import {s} from 'lib/styles' 27 - import {ProfileCard} from 'view/com/profile/ProfileCard' 28 - import {usePalette} from 'lib/hooks/usePalette' 29 - import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' 30 - import {isAndroid, isIOS} from 'platform/detection' 31 - import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' 32 - 33 - type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> 34 - export const SearchScreen = withAuthRequired( 35 - observer<Props>(function SearchScreenImpl({}: Props) { 36 - const pal = usePalette('default') 37 - const store = useStores() 38 - const setMinimalShellMode = useSetMinimalShellMode() 39 - const setIsDrawerSwipeDisabled = useSetDrawerSwipeDisabled() 40 - const scrollViewRef = React.useRef<ScrollView>(null) 41 - const flatListRef = React.useRef<FlatList>(null) 42 - const [onMainScroll] = useOnMainScroll() 43 - const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) 44 - const [query, setQuery] = React.useState<string>('') 45 - const autocompleteView = React.useMemo<UserAutocompleteModel>( 46 - () => new UserAutocompleteModel(store), 47 - [store], 48 - ) 49 - const foafs = React.useMemo<FoafsModel>( 50 - () => new FoafsModel(store), 51 - [store], 52 - ) 53 - const suggestedActors = React.useMemo<SuggestedActorsModel>( 54 - () => new SuggestedActorsModel(store), 55 - [store], 56 - ) 57 - const [searchUIModel, setSearchUIModel] = React.useState< 58 - SearchUIModel | undefined 59 - >() 60 - 61 - const onChangeQuery = React.useCallback( 62 - (text: string) => { 63 - setQuery(text) 64 - if (text.length > 0) { 65 - autocompleteView.setActive(true) 66 - autocompleteView.setPrefix(text) 67 - } else { 68 - autocompleteView.setActive(false) 69 - } 70 - }, 71 - [setQuery, autocompleteView], 72 - ) 73 - 74 - const onPressClearQuery = React.useCallback(() => { 75 - setQuery('') 76 - }, [setQuery]) 77 - 78 - const onPressCancelSearch = React.useCallback(() => { 79 - setQuery('') 80 - autocompleteView.setActive(false) 81 - setSearchUIModel(undefined) 82 - setIsDrawerSwipeDisabled(false) 83 - }, [setQuery, autocompleteView, setIsDrawerSwipeDisabled]) 84 - 85 - const onSubmitQuery = React.useCallback(() => { 86 - if (query.length === 0) { 87 - return 88 - } 89 - 90 - const model = new SearchUIModel(store) 91 - model.fetch(query) 92 - setSearchUIModel(model) 93 - setIsDrawerSwipeDisabled(true) 94 - }, [query, setSearchUIModel, store, setIsDrawerSwipeDisabled]) 95 - 96 - const onSoftReset = React.useCallback(() => { 97 - scrollViewRef.current?.scrollTo({x: 0, y: 0}) 98 - flatListRef.current?.scrollToOffset({offset: 0}) 99 - onPressCancelSearch() 100 - }, [scrollViewRef, flatListRef, onPressCancelSearch]) 101 - 102 - useFocusEffect( 103 - React.useCallback(() => { 104 - const softResetSub = store.onScreenSoftReset(onSoftReset) 105 - const cleanup = () => { 106 - softResetSub.remove() 107 - } 108 - 109 - setMinimalShellMode(false) 110 - autocompleteView.setup() 111 - if (!foafs.hasData) { 112 - foafs.fetch() 113 - } 114 - if (!suggestedActors.hasLoaded) { 115 - suggestedActors.loadMore(true) 116 - } 117 - 118 - return cleanup 119 - }, [ 120 - store, 121 - autocompleteView, 122 - foafs, 123 - suggestedActors, 124 - onSoftReset, 125 - setMinimalShellMode, 126 - ]), 127 - ) 128 - 129 - const onPress = useCallback(() => { 130 - if (isIOS || isAndroid) { 131 - Keyboard.dismiss() 132 - } 133 - }, []) 134 - 135 - const scrollHandler = useAnimatedScrollHandler(onMainScroll) 136 - return ( 137 - <TouchableWithoutFeedback onPress={onPress} accessible={false}> 138 - <View style={[pal.view, styles.container]}> 139 - <HeaderWithInput 140 - isInputFocused={isInputFocused} 141 - query={query} 142 - setIsInputFocused={setIsInputFocused} 143 - onChangeQuery={onChangeQuery} 144 - onPressClearQuery={onPressClearQuery} 145 - onPressCancelSearch={onPressCancelSearch} 146 - onSubmitQuery={onSubmitQuery} 147 - /> 148 - {searchUIModel ? ( 149 - <SearchResults model={searchUIModel} /> 150 - ) : !isInputFocused && !query ? ( 151 - <Suggestions 152 - ref={flatListRef} 153 - foafs={foafs} 154 - suggestedActors={suggestedActors} 155 - /> 156 - ) : ( 157 - <ScrollView 158 - ref={scrollViewRef} 159 - testID="searchScrollView" 160 - style={pal.view} 161 - onScroll={scrollHandler} 162 - scrollEventThrottle={1}> 163 - {query && autocompleteView.suggestions.length ? ( 164 - <> 165 - {autocompleteView.suggestions.map((suggestion, index) => ( 166 - <ProfileCard 167 - key={suggestion.did} 168 - testID={`searchAutoCompleteResult-${suggestion.handle}`} 169 - profile={suggestion} 170 - noBorder={index === 0} 171 - /> 172 - ))} 173 - </> 174 - ) : query && !autocompleteView.suggestions.length ? ( 175 - <View> 176 - <Text style={[pal.textLight, styles.searchPrompt]}> 177 - No results found for {autocompleteView.prefix} 178 - </Text> 179 - </View> 180 - ) : isInputFocused ? ( 181 - <View> 182 - <Text style={[pal.textLight, styles.searchPrompt]}> 183 - Search for users and posts on the network 184 - </Text> 185 - </View> 186 - ) : null} 187 - <View style={s.footerSpacer} /> 188 - </ScrollView> 189 - )} 190 - </View> 191 - </TouchableWithoutFeedback> 192 - ) 193 - }), 194 - ) 195 - 196 - const styles = StyleSheet.create({ 197 - container: { 198 - flex: 1, 199 - }, 200 - 201 - searchPrompt: { 202 - textAlign: 'center', 203 - paddingTop: 10, 204 - }, 205 - })
+47 -27
src/view/shell/desktop/Search.tsx
··· 5 5 View, 6 6 StyleSheet, 7 7 TouchableOpacity, 8 + ActivityIndicator, 8 9 } from 'react-native' 9 10 import {useNavigation, StackActions} from '@react-navigation/native' 10 11 import { ··· 12 13 moderateProfile, 13 14 ProfileModeration, 14 15 } from '@atproto/api' 15 - import {observer} from 'mobx-react-lite' 16 16 import {Trans, msg} from '@lingui/macro' 17 17 import {useLingui} from '@lingui/react' 18 18 ··· 84 84 ) 85 85 } 86 86 87 - export const DesktopSearch = observer(function DesktopSearch() { 87 + export function DesktopSearch() { 88 88 const {_} = useLingui() 89 89 const pal = usePalette('default') 90 90 const navigation = useNavigation<NavigationProp>() 91 91 const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>( 92 92 undefined, 93 93 ) 94 - const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) 94 + const [isActive, setIsActive] = React.useState<boolean>(false) 95 + const [isFetching, setIsFetching] = React.useState<boolean>(false) 95 96 const [query, setQuery] = React.useState<string>('') 96 97 const [searchResults, setSearchResults] = React.useState< 97 98 AppBskyActorDefs.ProfileViewBasic[] ··· 104 105 async (text: string) => { 105 106 setQuery(text) 106 107 107 - if (text.length > 0 && isInputFocused) { 108 + if (text.length > 0) { 109 + setIsFetching(true) 110 + setIsActive(true) 111 + 108 112 if (searchDebounceTimeout.current) 109 113 clearTimeout(searchDebounceTimeout.current) 110 114 ··· 113 117 114 118 if (results) { 115 119 setSearchResults(results) 120 + setIsFetching(false) 116 121 } 117 122 }, 300) 118 123 } else { 119 124 if (searchDebounceTimeout.current) 120 125 clearTimeout(searchDebounceTimeout.current) 121 126 setSearchResults([]) 127 + setIsFetching(false) 128 + setIsActive(false) 122 129 } 123 130 }, 124 - [setQuery, isInputFocused, search, setSearchResults], 131 + [setQuery, search, setSearchResults], 125 132 ) 126 133 127 134 const onPressCancelSearch = React.useCallback(() => { 128 - onChangeText('') 129 - }, [onChangeText]) 130 - 135 + setQuery('') 136 + setIsActive(false) 137 + if (searchDebounceTimeout.current) 138 + clearTimeout(searchDebounceTimeout.current) 139 + }, [setQuery]) 131 140 const onSubmit = React.useCallback(() => { 141 + setIsActive(false) 142 + if (!query.length) return 143 + setSearchResults([]) 144 + if (searchDebounceTimeout.current) 145 + clearTimeout(searchDebounceTimeout.current) 132 146 navigation.dispatch(StackActions.push('Search', {q: query})) 133 - }, [query, navigation]) 147 + }, [query, navigation, setSearchResults]) 134 148 135 149 return ( 136 150 <View style={[styles.container, pal.view]}> ··· 149 163 returnKeyType="search" 150 164 value={query} 151 165 style={[pal.textLight, styles.input]} 152 - onFocus={() => setIsInputFocused(true)} 153 - onBlur={() => setIsInputFocused(false)} 154 166 onChangeText={onChangeText} 155 167 onSubmitEditing={onSubmit} 156 168 accessibilityRole="search" ··· 174 186 </View> 175 187 </View> 176 188 177 - {query !== '' && ( 189 + {query !== '' && isActive && moderationOpts && ( 178 190 <View style={[pal.view, pal.borderDark, styles.resultsContainer]}> 179 - {searchResults.length && moderationOpts ? ( 180 - searchResults.map((item, i) => ( 181 - <SearchResultCard 182 - key={item.did} 183 - profile={item} 184 - moderation={moderateProfile(item, moderationOpts)} 185 - style={i === 0 ? {borderTopWidth: 0} : {}} 186 - /> 187 - )) 191 + {isFetching ? ( 192 + <View style={{padding: 8}}> 193 + <ActivityIndicator /> 194 + </View> 188 195 ) : ( 189 - <View> 190 - <Text style={[pal.textLight, styles.noResults]}> 191 - <Trans>No results found for {query}</Trans> 192 - </Text> 193 - </View> 196 + <> 197 + {searchResults.length ? ( 198 + searchResults.map((item, i) => ( 199 + <SearchResultCard 200 + key={item.did} 201 + profile={item} 202 + moderation={moderateProfile(item, moderationOpts)} 203 + style={i === 0 ? {borderTopWidth: 0} : {}} 204 + /> 205 + )) 206 + ) : ( 207 + <View> 208 + <Text style={[pal.textLight, styles.noResults]}> 209 + <Trans>No results found for {query}</Trans> 210 + </Text> 211 + </View> 212 + )} 213 + </> 194 214 )} 195 215 </View> 196 216 )} 197 217 </View> 198 218 ) 199 - }) 219 + } 200 220 201 221 const styles = StyleSheet.create({ 202 222 container: {