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}>
|