diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-03-21 17:58:50 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-03-21 17:58:50 -0500 |
commit | a7e3ce25854d6186b77e68c155a9a8bcdbd896ec (patch) | |
tree | 55c1a86575876c50824be7175a047c3e409ff7e6 /src | |
parent | 48e18662f69530d5c201d08014a039126c88e7dd (diff) | |
download | voidsky-a7e3ce25854d6186b77e68c155a9a8bcdbd896ec.tar.zst |
* Refactor mobile search screen * Remove 'staleness' fetch trigger on search * Implement a temporary fulltext search solution * Add missing key from profile search result * A few UI & UX improvements to the search suggestions * Update web search suggestions * Implement search in web build
Diffstat (limited to 'src')
-rw-r--r-- | src/lib/api/search.ts | 69 | ||||
-rw-r--r-- | src/lib/routes/router.ts | 29 | ||||
-rw-r--r-- | src/lib/routes/types.ts | 6 | ||||
-rw-r--r-- | src/state/models/ui/search.ts | 51 | ||||
-rw-r--r-- | src/view/com/discover/SuggestedFollows.tsx | 16 | ||||
-rw-r--r-- | src/view/com/discover/WhoToFollow.tsx | 27 | ||||
-rw-r--r-- | src/view/com/post/Post.tsx | 5 | ||||
-rw-r--r-- | src/view/com/profile/ProfileCard.tsx | 4 | ||||
-rw-r--r-- | src/view/com/search/HeaderWithInput.tsx | 146 | ||||
-rw-r--r-- | src/view/com/search/SearchResults.tsx | 110 | ||||
-rw-r--r-- | src/view/com/search/Suggestions.tsx | 50 | ||||
-rw-r--r-- | src/view/com/util/TabBar.tsx | 1 | ||||
-rw-r--r-- | src/view/com/util/UserBanner.tsx | 1 | ||||
-rw-r--r-- | src/view/screens/Search.tsx | 260 | ||||
-rw-r--r-- | src/view/screens/Search.web.tsx | 59 | ||||
-rw-r--r-- | src/view/shell/desktop/Search.tsx | 34 |
16 files changed, 586 insertions, 282 deletions
diff --git a/src/lib/api/search.ts b/src/lib/api/search.ts new file mode 100644 index 000000000..dfe9b688b --- /dev/null +++ b/src/lib/api/search.ts @@ -0,0 +1,69 @@ +/** + * This is a temporary off-spec search endpoint + * TODO removeme when we land this in proto! + */ +import {AppBskyFeedPost} from '@atproto/api' + +const PROFILES_ENDPOINT = 'https://search.bsky.social/search/profiles' +const POSTS_ENDPOINT = 'https://search.bsky.social/search/posts' + +export interface ProfileSearchItem { + $type: string + avatar: { + cid: string + mimeType: string + } + banner: { + cid: string + mimeType: string + } + description: string | undefined + displayName: string | undefined + did: string +} + +export interface PostSearchItem { + tid: string + cid: string + user: { + did: string + handle: string + } + post: AppBskyFeedPost.Record +} + +export async function searchProfiles( + query: string, +): Promise<ProfileSearchItem[]> { + return await doFetch<ProfileSearchItem[]>(PROFILES_ENDPOINT, query) +} + +export async function searchPosts(query: string): Promise<PostSearchItem[]> { + return await doFetch<PostSearchItem[]>(POSTS_ENDPOINT, query) +} + +async function doFetch<T>(endpoint: string, query: string): Promise<T> { + const controller = new AbortController() + const to = setTimeout(() => controller.abort(), 15e3) + + const uri = new URL(endpoint) + uri.searchParams.set('q', query) + + const res = await fetch(String(uri), { + method: 'get', + headers: { + accept: 'application/json', + }, + signal: controller.signal, + }) + + const resHeaders: Record<string, string> = {} + res.headers.forEach((value: string, key: string) => { + resHeaders[key] = value + }) + let resBody = await res.json() + + clearTimeout(to) + + return resBody as unknown as T +} diff --git a/src/lib/routes/router.ts b/src/lib/routes/router.ts index 05e0a63de..00defaeda 100644 --- a/src/lib/routes/router.ts +++ b/src/lib/routes/router.ts @@ -32,24 +32,39 @@ export class Router { } function createRoute(pattern: string): Route { - let matcherReInternal = pattern.replace( - /:([\w]+)/g, - (_m, name) => `(?<${name}>[^/]+)`, - ) + const pathParamNames: Set<string> = new Set() + let matcherReInternal = pattern.replace(/:([\w]+)/g, (_m, name) => { + pathParamNames.add(name) + return `(?<${name}>[^/]+)` + }) const matcherRe = new RegExp(`^${matcherReInternal}([?]|$)`, 'i') return { match(path: string) { - const res = matcherRe.exec(path) + const {pathname, searchParams} = new URL(path, 'http://throwaway.com') + const addedParams = Object.fromEntries(searchParams.entries()) + + const res = matcherRe.exec(pathname) if (res) { - return {params: res.groups || {}} + return {params: Object.assign(addedParams, res.groups || {})} } return undefined }, build(params: Record<string, string>) { - return pattern.replace( + const str = pattern.replace( /:([\w]+)/g, (_m, name) => params[name] || 'undefined', ) + + let hasQp = false + const qp = new URLSearchParams() + for (const paramName in params) { + if (!pathParamNames.has(paramName)) { + qp.set(paramName, params[paramName]) + hasQp = true + } + } + + return str + (hasQp ? `?${qp.toString()}` : '') }, } } diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 9ec623970..cc48e2dbe 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -23,7 +23,7 @@ export type HomeTabNavigatorParams = CommonNavigatorParams & { } export type SearchTabNavigatorParams = CommonNavigatorParams & { - Search: undefined + Search: {q?: string} } export type NotificationsTabNavigatorParams = CommonNavigatorParams & { @@ -32,7 +32,7 @@ export type NotificationsTabNavigatorParams = CommonNavigatorParams & { export type FlatNavigatorParams = CommonNavigatorParams & { Home: undefined - Search: undefined + Search: {q?: string} Notifications: undefined } @@ -40,7 +40,7 @@ export type AllNavigatorParams = CommonNavigatorParams & { HomeTab: undefined Home: undefined SearchTab: undefined - Search: undefined + Search: {q?: string} NotificationsTab: undefined Notifications: undefined } diff --git a/src/state/models/ui/search.ts b/src/state/models/ui/search.ts new file mode 100644 index 000000000..91e1b24bf --- /dev/null +++ b/src/state/models/ui/search.ts @@ -0,0 +1,51 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {searchProfiles, searchPosts} from 'lib/api/search' +import {AppBskyActorProfile as Profile} from '@atproto/api' +import {RootStoreModel} from '../root-store' + +export class SearchUIModel { + isPostsLoading = false + isProfilesLoading = false + query: string = '' + postUris: string[] = [] + profiles: Profile.View[] = [] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable(this) + } + + async fetch(q: string) { + this.postUris = [] + 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 => []), + ]) + runInAction(() => { + this.postUris = postsSearch?.map(p => `at://${p.user.did}/${p.tid}`) || [] + this.isPostsLoading = false + }) + + let profiles: Profile.View[] = [] + if (profilesSearch?.length) { + do { + const res = await this.rootStore.api.app.bsky.actor.getProfiles({ + actors: profilesSearch.splice(0, 25).map(p => p.did), + }) + profiles = profiles.concat(res.data.profiles) + } while (profilesSearch.length) + } + runInAction(() => { + this.profiles = profiles + this.isProfilesLoading = false + }) + } +} diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx index 7a64a15f6..bce224231 100644 --- a/src/view/com/discover/SuggestedFollows.tsx +++ b/src/view/com/discover/SuggestedFollows.tsx @@ -15,7 +15,7 @@ export const SuggestedFollows = ({ }) => { const pal = usePalette('default') return ( - <View style={[styles.container, pal.view]}> + <View style={[styles.container, pal.view, pal.border]}> <Text type="title" style={[styles.heading, pal.text]}> {title} </Text> @@ -45,24 +45,16 @@ export const SuggestedFollows = ({ const styles = StyleSheet.create({ container: { - paddingVertical: 10, - paddingHorizontal: 4, + borderBottomWidth: 1, }, heading: { fontWeight: 'bold', - paddingHorizontal: 4, + paddingHorizontal: 12, paddingBottom: 8, }, card: { - borderRadius: 12, - marginBottom: 2, - borderWidth: 1, - }, - - loadMore: { - paddingLeft: 16, - paddingVertical: 12, + borderTopWidth: 1, }, }) diff --git a/src/view/com/discover/WhoToFollow.tsx b/src/view/com/discover/WhoToFollow.tsx index 17c10ca7e..715fadae2 100644 --- a/src/view/com/discover/WhoToFollow.tsx +++ b/src/view/com/discover/WhoToFollow.tsx @@ -1,10 +1,5 @@ import React from 'react' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' import {useStores} from 'state/index' import {SuggestedActorsViewModel} from 'state/models/suggested-actors-view' @@ -17,7 +12,7 @@ export const WhoToFollow = observer(() => { const pal = usePalette('default') const store = useStores() const suggestedActorsView = React.useMemo<SuggestedActorsViewModel>( - () => new SuggestedActorsViewModel(store, {pageSize: 5}), + () => new SuggestedActorsViewModel(store, {pageSize: 15}), [store], ) @@ -25,9 +20,6 @@ export const WhoToFollow = observer(() => { suggestedActorsView.loadMore(true) }, [store, suggestedActorsView]) - const onPressLoadMoreSuggestedActors = () => { - suggestedActorsView.loadMore() - } return ( <> {(suggestedActorsView.hasContent || suggestedActorsView.isLoading) && ( @@ -50,15 +42,6 @@ export const WhoToFollow = observer(() => { /> ))} </View> - {!suggestedActorsView.isLoading && suggestedActorsView.hasMore && ( - <TouchableOpacity - onPress={onPressLoadMoreSuggestedActors} - style={styles.loadMore}> - <Text type="lg" style={pal.link}> - Show more - </Text> - </TouchableOpacity> - )} </> )} {suggestedActorsView.isLoading && ( @@ -74,16 +57,10 @@ const styles = StyleSheet.create({ heading: { fontWeight: 'bold', paddingHorizontal: 12, - paddingTop: 16, paddingBottom: 8, }, bottomBorder: { borderBottomWidth: 1, }, - - loadMore: { - paddingLeft: 16, - paddingVertical: 12, - }, }) diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index ac7d1cc55..a6c66d143 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -30,11 +30,13 @@ export const Post = observer(function Post({ uri, initView, showReplyLine, + hideError, style, }: { uri: string initView?: PostThreadViewModel showReplyLine?: boolean + hideError?: boolean style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') @@ -70,6 +72,9 @@ export const Post = observer(function Post({ // error // = if (view.hasError || !view.thread || !view.thread?.postRecord) { + if (hideError) { + return <View /> + } return ( <View style={pal.view}> <Text>{view.error || 'Thread not found'}</Text> diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 7b454cc8b..748648742 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -184,7 +184,7 @@ const styles = StyleSheet.create({ paddingRight: 10, }, details: { - paddingLeft: 60, + paddingLeft: 54, paddingRight: 10, paddingBottom: 10, }, @@ -202,7 +202,7 @@ const styles = StyleSheet.create({ followedBy: { flexDirection: 'row', - alignItems: 'flex-start', + alignItems: 'center', paddingLeft: 54, paddingRight: 20, marginBottom: 10, diff --git a/src/view/com/search/HeaderWithInput.tsx b/src/view/com/search/HeaderWithInput.tsx new file mode 100644 index 000000000..cc0b90af7 --- /dev/null +++ b/src/view/com/search/HeaderWithInput.tsx @@ -0,0 +1,146 @@ +import React from 'react' +import {StyleSheet, TextInput, TouchableOpacity, View} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {UserAvatar} from 'view/com/util/UserAvatar' +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 {useStores} from 'state/index' +import {useAnalytics} from 'lib/analytics' + +const MENU_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10} + +interface Props { + isInputFocused: boolean + query: string + setIsInputFocused: (v: boolean) => void + onChangeQuery: (v: string) => void + onPressClearQuery: () => void + onPressCancelSearch: () => void + onSubmitQuery: () => void +} +export function HeaderWithInput({ + isInputFocused, + query, + setIsInputFocused, + onChangeQuery, + onPressClearQuery, + onPressCancelSearch, + onSubmitQuery, +}: Props) { + const store = useStores() + const theme = useTheme() + const pal = usePalette('default') + const {track} = useAnalytics() + const textInput = React.useRef<TextInput>(null) + + const onPressMenu = React.useCallback(() => { + track('ViewHeader:MenuButtonClicked') + store.shell.openDrawer() + }, [track, store]) + + const onPressCancelSearchInner = React.useCallback(() => { + onPressCancelSearch() + textInput.current?.blur() + }, [onPressCancelSearch, textInput]) + + return ( + <View style={[pal.view, pal.border, styles.header]}> + <TouchableOpacity + testID="viewHeaderBackOrMenuBtn" + onPress={onPressMenu} + hitSlop={MENU_HITSLOP} + style={styles.headerMenuBtn}> + <UserAvatar size={30} avatar={store.me.avatar} /> + </TouchableOpacity> + <View + style={[ + {backgroundColor: pal.colors.backgroundLight}, + styles.headerSearchContainer, + ]}> + <MagnifyingGlassIcon + style={[pal.icon, styles.headerSearchIcon]} + size={21} + /> + <TextInput + testID="searchTextInput" + ref={textInput} + placeholder="Search" + placeholderTextColor={pal.colors.textLight} + selectTextOnFocus + returnKeyType="search" + value={query} + style={[pal.text, styles.headerSearchInput]} + keyboardAppearance={theme.colorScheme} + onFocus={() => setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + onChangeText={onChangeQuery} + onSubmitEditing={onSubmitQuery} + /> + {query ? ( + <TouchableOpacity onPress={onPressClearQuery}> + <FontAwesomeIcon + icon="xmark" + size={16} + style={pal.textLight as FontAwesomeIconStyle} + /> + </TouchableOpacity> + ) : undefined} + </View> + {query || isInputFocused ? ( + <View style={styles.headerCancelBtn}> + <TouchableOpacity onPress={onPressCancelSearchInner}> + <Text style={pal.text}>Cancel</Text> + </TouchableOpacity> + </View> + ) : undefined} + </View> + ) +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 4, + }, + headerMenuBtn: { + width: 40, + height: 30, + marginLeft: 6, + }, + headerSearchContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + borderRadius: 30, + paddingHorizontal: 12, + paddingVertical: 8, + }, + headerSearchIcon: { + marginRight: 6, + alignSelf: 'center', + }, + headerSearchInput: { + flex: 1, + fontSize: 17, + }, + headerCancelBtn: { + width: 60, + 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 new file mode 100644 index 000000000..062b703ee --- /dev/null +++ b/src/view/com/search/SearchResults.tsx @@ -0,0 +1,110 @@ +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/util/pager/Pager' +import {TabBar} from 'view/com/util/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 {s} from 'lib/styles' + +const SECTIONS = ['Posts', 'Users'] + +export const SearchResults = observer(({model}: {model: SearchUIModel}) => { + const pal = usePalette('default') + + const renderTabBar = React.useCallback( + (props: RenderTabBarFnProps) => { + return ( + <CenteredView style={[pal.border, styles.tabBar]}> + <TabBar {...props} items={SECTIONS} /> + </CenteredView> + ) + }, + [pal], + ) + + return ( + <Pager renderTabBar={renderTabBar} tabBarPosition="top" initialPage={0}> + <PostResults key="0" model={model} /> + <Profiles key="1" model={model} /> + </Pager> + ) +}) + +const PostResults = observer(({model}: {model: SearchUIModel}) => { + const pal = usePalette('default') + if (model.isPostsLoading) { + return <PostFeedLoadingPlaceholder /> + } + + if (model.postUris.length === 0) { + return ( + <Text type="xl" style={[styles.empty, pal.text]}> + No posts found for "{model.query}" + </Text> + ) + } + + return ( + <ScrollView style={pal.view}> + {model.postUris.map(uri => ( + <Post key={uri} uri={uri} hideError /> + ))} + <View style={s.footerSpacer} /> + <View style={s.footerSpacer} /> + <View style={s.footerSpacer} /> + </ScrollView> + ) +}) + +const Profiles = observer(({model}: {model: SearchUIModel}) => { + const pal = usePalette('default') + if (model.isProfilesLoading) { + return <ProfileCardFeedLoadingPlaceholder /> + } + + if (model.profiles.length === 0) { + return ( + <Text type="xl" style={[styles.empty, pal.text]}> + No users found for "{model.query}" + </Text> + ) + } + + return ( + <ScrollView style={pal.view}> + {model.profiles.map(item => ( + <ProfileCardWithFollowBtn + key={item.did} + did={item.did} + declarationCid={item.declaration.cid} + handle={item.handle} + displayName={item.displayName} + avatar={item.avatar} + description={item.description} + /> + ))} + <View style={s.footerSpacer} /> + <View style={s.footerSpacer} /> + <View style={s.footerSpacer} /> + </ScrollView> + ) +}) + +const styles = StyleSheet.create({ + tabBar: { + borderBottomWidth: 1, + }, + empty: { + paddingHorizontal: 14, + paddingVertical: 16, + }, +}) diff --git a/src/view/com/search/Suggestions.tsx b/src/view/com/search/Suggestions.tsx new file mode 100644 index 000000000..1747036ba --- /dev/null +++ b/src/view/com/search/Suggestions.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {FoafsModel} from 'state/models/discovery/foafs' +import {WhoToFollow} from 'view/com/discover/WhoToFollow' +import {SuggestedFollows} from 'view/com/discover/SuggestedFollows' +import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' + +export const Suggestions = observer(({foafs}: {foafs: FoafsModel}) => { + if (foafs.isLoading) { + return <ProfileCardFeedLoadingPlaceholder /> + } + if (foafs.hasContent) { + return ( + <> + {foafs.popular.length > 0 && ( + <View style={styles.suggestions}> + <SuggestedFollows + title="In your network" + suggestions={foafs.popular} + /> + </View> + )} + <WhoToFollow /> + {foafs.sources.map((source, i) => { + const item = foafs.foafs.get(source) + if (!item || item.follows.length === 0) { + return <View key={`sf-${item?.did || i}`} /> + } + return ( + <View key={`sf-${item.did}`} style={styles.suggestions}> + <SuggestedFollows + title={`Followed by ${item.displayName || item.handle}`} + suggestions={item.follows.slice(0, 10)} + /> + </View> + ) + })} + </> + ) + } + return <WhoToFollow /> +}) + +const styles = StyleSheet.create({ + suggestions: { + marginTop: 10, + marginBottom: 20, + }, +}) diff --git a/src/view/com/util/TabBar.tsx b/src/view/com/util/TabBar.tsx index 4b67b8a80..545a6b742 100644 --- a/src/view/com/util/TabBar.tsx +++ b/src/view/com/util/TabBar.tsx @@ -157,6 +157,5 @@ const styles = isDesktopWeb left: 0, width: 1, height: 3, - borderRadius: 4, }, }) diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 847ef6dba..1752c260e 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -1,6 +1,5 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import Svg, {Rect} from 'react-native-svg' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {IconProp} from '@fortawesome/fontawesome-svg-core' import Image from 'view/com/util/images/Image' diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx index a21ef9701..5850915b3 100644 --- a/src/view/screens/Search.tsx +++ b/src/view/screens/Search.tsx @@ -3,16 +3,10 @@ import { Keyboard, RefreshControl, StyleSheet, - TextInput, - TouchableOpacity, TouchableWithoutFeedback, View, } from 'react-native' import {useFocusEffect} from '@react-navigation/native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ScrollView} from 'view/com/util/Views' import { @@ -20,46 +14,39 @@ import { SearchTabNavigatorParams, } from 'lib/routes/types' import {observer} from 'mobx-react-lite' -import {UserAvatar} from 'view/com/util/UserAvatar' import {Text} from 'view/com/util/text/Text' import {useStores} from 'state/index' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {SearchUIModel} from 'state/models/ui/search' import {FoafsModel} from 'state/models/discovery/foafs' +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 {MagnifyingGlassIcon} from 'lib/icons' -import {WhoToFollow} from 'view/com/discover/WhoToFollow' -import {SuggestedFollows} from 'view/com/discover/SuggestedFollows' import {ProfileCard} from 'view/com/profile/ProfileCard' -import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {usePalette} from 'lib/hooks/usePalette' -import {useTheme} from 'lib/ThemeContext' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' -import {useAnalytics} from 'lib/analytics' - -const MENU_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10} -const FIVE_MIN = 5 * 60 * 1e3 type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> export const SearchScreen = withAuthRequired( observer<Props>(({}: Props) => { const pal = usePalette('default') - const theme = useTheme() const store = useStores() - const {track} = useAnalytics() const scrollElRef = React.useRef<ScrollView>(null) const onMainScroll = useOnMainScroll(store) - const textInput = React.useRef<TextInput>(null) - const [lastRenderTime, setRenderTime] = React.useState<number>(Date.now()) // used to trigger reloads const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) const [query, setQuery] = React.useState<string>('') const autocompleteView = React.useMemo<UserAutocompleteViewModel>( () => new UserAutocompleteViewModel(store), [store], ) - const foafsView = React.useMemo<FoafsModel>( + const foafs = React.useMemo<FoafsModel>( () => new FoafsModel(store), [store], ) + const [searchUIModel, setSearchUIModel] = React.useState< + SearchUIModel | undefined + >() const [refreshing, setRefreshing] = React.useState(false) const onSoftReset = () => { @@ -73,126 +60,70 @@ export const SearchScreen = withAuthRequired( softResetSub.remove() } - const now = Date.now() - if (now - lastRenderTime > FIVE_MIN) { - setRenderTime(Date.now()) // trigger reload of suggestions - } store.shell.setMinimalShellMode(false) autocompleteView.setup() - if (!foafsView.hasData) { - foafsView.fetch() + if (!foafs.hasData) { + foafs.fetch() } return cleanup - }, [store, autocompleteView, foafsView, lastRenderTime, setRenderTime]), + }, [store, autocompleteView, foafs]), ) - const onPressMenu = () => { - track('ViewHeader:MenuButtonClicked') - store.shell.openDrawer() - } + 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 onChangeQuery = (text: string) => { - setQuery(text) - if (text.length > 0) { - autocompleteView.setActive(true) - autocompleteView.setPrefix(text) - } else { - autocompleteView.setActive(false) - } - } - const onPressClearQuery = () => { + const onPressClearQuery = React.useCallback(() => { setQuery('') - } - const onPressCancelSearch = () => { + }, [setQuery]) + + const onPressCancelSearch = React.useCallback(() => { setQuery('') autocompleteView.setActive(false) - textInput.current?.blur() - } + setSearchUIModel(undefined) + store.shell.setIsDrawerSwipeDisabled(false) + }, [setQuery, autocompleteView, store]) + + const onSubmitQuery = React.useCallback(() => { + const model = new SearchUIModel(store) + model.fetch(query) + setSearchUIModel(model) + store.shell.setIsDrawerSwipeDisabled(true) + }, [query, setSearchUIModel, store]) + const onRefresh = React.useCallback(async () => { setRefreshing(true) try { - await foafsView.fetch() + await foafs.fetch() } finally { setRefreshing(false) } - }, [foafsView, setRefreshing]) + }, [foafs, setRefreshing]) return ( <TouchableWithoutFeedback onPress={Keyboard.dismiss}> <View style={[pal.view, styles.container]}> - <View style={[pal.view, pal.border, styles.header]}> - <TouchableOpacity - testID="viewHeaderBackOrMenuBtn" - onPress={onPressMenu} - hitSlop={MENU_HITSLOP} - style={styles.headerMenuBtn}> - <UserAvatar size={30} avatar={store.me.avatar} /> - </TouchableOpacity> - <View - style={[ - {backgroundColor: pal.colors.backgroundLight}, - styles.headerSearchContainer, - ]}> - <MagnifyingGlassIcon - style={[pal.icon, styles.headerSearchIcon]} - size={21} - /> - <TextInput - testID="searchTextInput" - ref={textInput} - placeholder="Search" - placeholderTextColor={pal.colors.textLight} - selectTextOnFocus - returnKeyType="search" - value={query} - style={[pal.text, styles.headerSearchInput]} - keyboardAppearance={theme.colorScheme} - onFocus={() => setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - onChangeText={onChangeQuery} - /> - {query ? ( - <TouchableOpacity onPress={onPressClearQuery}> - <FontAwesomeIcon - icon="xmark" - size={16} - style={pal.textLight as FontAwesomeIconStyle} - /> - </TouchableOpacity> - ) : undefined} - </View> - {query || isInputFocused ? ( - <View style={styles.headerCancelBtn}> - <TouchableOpacity onPress={onPressCancelSearch}> - <Text style={pal.text}>Cancel</Text> - </TouchableOpacity> - </View> - ) : undefined} - </View> - {query && autocompleteView.searchRes.length ? ( - <> - {autocompleteView.searchRes.map(item => ( - <ProfileCard - key={item.did} - handle={item.handle} - displayName={item.displayName} - avatar={item.avatar} - /> - ))} - </> - ) : query && !autocompleteView.searchRes.length ? ( - <View> - <Text style={[pal.textLight, styles.searchPrompt]}> - No results found for {autocompleteView.prefix} - </Text> - </View> - ) : isInputFocused ? ( - <View> - <Text style={[pal.textLight, styles.searchPrompt]}> - Search for users on the network - </Text> - </View> + <HeaderWithInput + isInputFocused={isInputFocused} + query={query} + setIsInputFocused={setIsInputFocused} + onChangeQuery={onChangeQuery} + onPressClearQuery={onPressClearQuery} + onPressCancelSearch={onPressCancelSearch} + onSubmitQuery={onSubmitQuery} + /> + {searchUIModel ? ( + <SearchResults model={searchUIModel} /> ) : ( <ScrollView ref={scrollElRef} @@ -208,39 +139,31 @@ export const SearchScreen = withAuthRequired( titleColor={pal.colors.text} /> }> - {foafsView.isLoading ? ( - <ProfileCardFeedLoadingPlaceholder /> - ) : foafsView.hasContent ? ( + {query && autocompleteView.searchRes.length ? ( <> - {foafsView.popular.length > 0 && ( - <View style={styles.suggestions}> - <SuggestedFollows - title="In your network" - suggestions={foafsView.popular} - /> - </View> - )} - {foafsView.sources.map((source, i) => { - const item = foafsView.foafs.get(source) - if (!item || item.follows.length === 0) { - return <View key={`sf-${item?.did || i}`} /> - } - return ( - <View key={`sf-${item.did}`} style={styles.suggestions}> - <SuggestedFollows - title={`Followed by ${ - item.displayName || item.handle - }`} - suggestions={item.follows.slice(0, 10)} - /> - </View> - ) - })} + {autocompleteView.searchRes.map(item => ( + <ProfileCard + key={item.did} + handle={item.handle} + displayName={item.displayName} + avatar={item.avatar} + /> + ))} </> - ) : ( - <View style={pal.view}> - <WhoToFollow /> + ) : query && !autocompleteView.searchRes.length ? ( + <View> + <Text style={[pal.textLight, styles.searchPrompt]}> + No results found for {autocompleteView.prefix} + </Text> + </View> + ) : isInputFocused ? ( + <View> + <Text style={[pal.textLight, styles.searchPrompt]}> + Search for users on the network + </Text> </View> + ) : ( + <Suggestions foafs={foafs} /> )} <View style={s.footerSpacer} /> </ScrollView> @@ -256,45 +179,8 @@ const styles = StyleSheet.create({ flex: 1, }, - header: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 12, - paddingTop: 4, - marginBottom: 14, - }, - headerMenuBtn: { - width: 40, - height: 30, - marginLeft: 6, - }, - headerSearchContainer: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - borderRadius: 30, - paddingHorizontal: 12, - paddingVertical: 8, - }, - headerSearchIcon: { - marginRight: 6, - alignSelf: 'center', - }, - headerSearchInput: { - flex: 1, - fontSize: 17, - }, - headerCancelBtn: { - width: 60, - paddingLeft: 10, - }, - searchPrompt: { textAlign: 'center', paddingTop: 10, }, - - suggestions: { - marginBottom: 8, - }, }) diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx index 29b884493..cb094d952 100644 --- a/src/view/screens/Search.web.tsx +++ b/src/view/screens/Search.web.tsx @@ -1,8 +1,11 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' +import {SearchUIModel} from 'state/models/ui/search' +import {FoafsModel} from 'state/models/discovery/foafs' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ScrollView} from '../com/util/Views' +import {ScrollView} from 'view/com/util/Views' +import {Suggestions} from 'view/com/search/Suggestions' +import {SearchResults} from 'view/com/search/SearchResults' import {observer} from 'mobx-react-lite' import { NativeStackScreenProps, @@ -10,51 +13,41 @@ import { } from 'lib/routes/types' import {useStores} from 'state/index' import {s} from 'lib/styles' -import {WhoToFollow} from '../com/discover/WhoToFollow' -import {SuggestedPosts} from '../com/discover/SuggestedPosts' import {usePalette} from 'lib/hooks/usePalette' -import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' - -const FIVE_MIN = 5 * 60 * 1e3 type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> export const SearchScreen = withAuthRequired( - observer(({}: Props) => { + observer(({route}: Props) => { const pal = usePalette('default') const store = useStores() - const scrollElRef = React.useRef<ScrollView>(null) - const onMainScroll = useOnMainScroll(store) - const [lastRenderTime, setRenderTime] = React.useState<number>(Date.now()) // used to trigger reloads - - const onSoftReset = () => { - scrollElRef.current?.scrollTo({x: 0, y: 0}) - } - - useFocusEffect( - React.useCallback(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) + const foafs = React.useMemo<FoafsModel>( + () => new FoafsModel(store), + [store], + ) + const searchUIModel = React.useMemo<SearchUIModel | undefined>( + () => (route.params.q ? new SearchUIModel(store) : undefined), + [route.params.q, store], + ) - const now = Date.now() - if (now - lastRenderTime > FIVE_MIN) { - setRenderTime(Date.now()) // trigger reload of suggestions - } - store.shell.setMinimalShellMode(false) + React.useEffect(() => { + if (route.params.q && searchUIModel) { + searchUIModel.fetch(route.params.q) + } + if (!foafs.hasData) { + foafs.fetch() + } + }, [foafs, searchUIModel, route.params.q]) - return () => { - softResetSub.remove() - } - }, [store, lastRenderTime, setRenderTime]), - ) + if (searchUIModel) { + return <SearchResults model={searchUIModel} /> + } return ( <ScrollView - ref={scrollElRef} testID="searchScrollView" style={[pal.view, styles.container]} - onScroll={onMainScroll} scrollEventThrottle={100}> - <WhoToFollow key={`wtf-${lastRenderTime}`} /> - <SuggestedPosts key={`sp-${lastRenderTime}`} /> + <Suggestions foafs={foafs} /> <View style={s.footerSpacer} /> </ScrollView> ) diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 0ae1c1ad9..101840b89 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -1,10 +1,12 @@ import React from 'react' import {TextInput, View, StyleSheet, TouchableOpacity} from 'react-native' +import {useNavigation, StackActions} from '@react-navigation/native' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' import {observer} from 'mobx-react-lite' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {MagnifyingGlassIcon2} from 'lib/icons' +import {NavigationProp} from 'lib/routes/types' import {ProfileCard} from 'view/com/profile/ProfileCard' import {Text} from 'view/com/util/text/Text' @@ -18,21 +20,30 @@ export const DesktopSearch = observer(function DesktopSearch() { () => new UserAutocompleteViewModel(store), [store], ) + const navigation = useNavigation<NavigationProp>() - const onChangeQuery = (text: string) => { - setQuery(text) - if (text.length > 0 && isInputFocused) { - autocompleteView.setActive(true) - autocompleteView.setPrefix(text) - } else { - autocompleteView.setActive(false) - } - } + const onChangeQuery = React.useCallback( + (text: string) => { + setQuery(text) + if (text.length > 0 && isInputFocused) { + autocompleteView.setActive(true) + autocompleteView.setPrefix(text) + } else { + autocompleteView.setActive(false) + } + }, + [setQuery, autocompleteView, isInputFocused], + ) - const onPressCancelSearch = () => { + const onPressCancelSearch = React.useCallback(() => { setQuery('') autocompleteView.setActive(false) - } + }, [setQuery, autocompleteView]) + + const onSubmit = React.useCallback(() => { + navigation.dispatch(StackActions.push('Search', {q: query})) + autocompleteView.setActive(false) + }, [query, navigation, autocompleteView]) return ( <View style={[styles.container, pal.view]}> @@ -55,6 +66,7 @@ export const DesktopSearch = observer(function DesktopSearch() { onFocus={() => setIsInputFocused(true)} onBlur={() => setIsInputFocused(false)} onChangeText={onChangeQuery} + onSubmitEditing={onSubmit} /> {query ? ( <View style={styles.cancelBtn}> |