about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/lib/api/search.ts69
-rw-r--r--src/lib/routes/router.ts29
-rw-r--r--src/lib/routes/types.ts6
-rw-r--r--src/state/models/ui/search.ts51
-rw-r--r--src/view/com/discover/SuggestedFollows.tsx16
-rw-r--r--src/view/com/discover/WhoToFollow.tsx27
-rw-r--r--src/view/com/post/Post.tsx5
-rw-r--r--src/view/com/profile/ProfileCard.tsx4
-rw-r--r--src/view/com/search/HeaderWithInput.tsx146
-rw-r--r--src/view/com/search/SearchResults.tsx110
-rw-r--r--src/view/com/search/Suggestions.tsx50
-rw-r--r--src/view/com/util/TabBar.tsx1
-rw-r--r--src/view/com/util/UserBanner.tsx1
-rw-r--r--src/view/screens/Search.tsx260
-rw-r--r--src/view/screens/Search.web.tsx59
-rw-r--r--src/view/shell/desktop/Search.tsx34
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}>