about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/state/models/ui/search.ts69
-rw-r--r--src/state/queries/actor-autocomplete.ts4
-rw-r--r--src/state/queries/search-posts.ts32
-rw-r--r--src/state/queries/suggested-follows.ts30
-rw-r--r--src/view/com/search/HeaderWithInput.tsx186
-rw-r--r--src/view/com/search/SearchResults.tsx150
-rw-r--r--src/view/com/search/Suggestions.tsx265
-rw-r--r--src/view/screens/Search.tsx1
-rw-r--r--src/view/screens/Search.web.tsx76
-rw-r--r--src/view/screens/Search/Search.tsx639
-rw-r--r--src/view/screens/Search/index.tsx1
-rw-r--r--src/view/screens/Search/index.web.tsx1
-rw-r--r--src/view/screens/SearchMobile.tsx205
-rw-r--r--src/view/shell/desktop/Search.tsx74
14 files changed, 742 insertions, 991 deletions
diff --git a/src/state/models/ui/search.ts b/src/state/models/ui/search.ts
deleted file mode 100644
index 2b2036751..000000000
--- a/src/state/models/ui/search.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import {makeAutoObservable, runInAction} from 'mobx'
-import {searchProfiles, searchPosts} from 'lib/api/search'
-import {PostThreadModel} from '../content/post-thread'
-import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
-import {RootStoreModel} from '../root-store'
-
-export class SearchUIModel {
-  isPostsLoading = false
-  isProfilesLoading = false
-  query: string = ''
-  posts: PostThreadModel[] = []
-  profiles: AppBskyActorDefs.ProfileView[] = []
-
-  constructor(public rootStore: RootStoreModel) {
-    makeAutoObservable(this)
-  }
-
-  async fetch(q: string) {
-    this.posts = []
-    this.profiles = []
-    this.query = q
-    if (!q.trim()) {
-      return
-    }
-
-    this.isPostsLoading = true
-    this.isProfilesLoading = true
-
-    const [postsSearch, profilesSearch] = await Promise.all([
-      searchPosts(q).catch(_e => []),
-      searchProfiles(q).catch(_e => []),
-    ])
-
-    let posts: AppBskyFeedDefs.PostView[] = []
-    if (postsSearch?.length) {
-      do {
-        const res = await this.rootStore.agent.app.bsky.feed.getPosts({
-          uris: postsSearch
-            .splice(0, 25)
-            .map(p => `at://${p.user.did}/${p.tid}`),
-        })
-        posts = posts.concat(res.data.posts)
-      } while (postsSearch.length)
-    }
-    runInAction(() => {
-      this.posts = posts.map(post =>
-        PostThreadModel.fromPostView(this.rootStore, post),
-      )
-      this.isPostsLoading = false
-    })
-
-    let profiles: AppBskyActorDefs.ProfileView[] = []
-    if (profilesSearch?.length) {
-      do {
-        const res = await this.rootStore.agent.getProfiles({
-          actors: profilesSearch.splice(0, 25).map(p => p.did),
-        })
-        profiles = profiles.concat(res.data.profiles)
-      } while (profilesSearch.length)
-    }
-
-    this.rootStore.me.follows.hydrateMany(profiles)
-
-    runInAction(() => {
-      this.profiles = profiles
-      this.isProfilesLoading = false
-    })
-  }
-}
diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts
index 57f30f9c5..de7a2e1f6 100644
--- a/src/state/queries/actor-autocomplete.ts
+++ b/src/state/queries/actor-autocomplete.ts
@@ -36,7 +36,7 @@ export function useActorAutocompleteFn() {
   const {data: follows} = useMyFollowsQuery()
 
   return React.useCallback(
-    async ({query}: {query: string}) => {
+    async ({query, limit = 8}: {query: string; limit?: number}) => {
       let res
       if (query) {
         try {
@@ -47,7 +47,7 @@ export function useActorAutocompleteFn() {
             queryFn: () =>
               agent.searchActorsTypeahead({
                 term: query,
-                limit: 8,
+                limit,
               }),
           })
         } catch (e) {
diff --git a/src/state/queries/search-posts.ts b/src/state/queries/search-posts.ts
new file mode 100644
index 000000000..ab6ed4d41
--- /dev/null
+++ b/src/state/queries/search-posts.ts
@@ -0,0 +1,32 @@
+import {AppBskyFeedSearchPosts} from '@atproto/api'
+import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+
+import {useSession} from '#/state/session'
+
+const searchPostsQueryKey = ({query}: {query: string}) => [
+  'search-posts',
+  query,
+]
+
+export function useSearchPostsQuery({query}: {query: string}) {
+  const {agent} = useSession()
+
+  return useInfiniteQuery<
+    AppBskyFeedSearchPosts.OutputSchema,
+    Error,
+    InfiniteData<AppBskyFeedSearchPosts.OutputSchema>,
+    QueryKey,
+    string | undefined
+  >({
+    queryKey: searchPostsQueryKey({query}),
+    queryFn: async () => {
+      const res = await agent.app.bsky.feed.searchPosts({
+        q: query,
+        limit: 25,
+      })
+      return res.data
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+  })
+}
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
index 5b5e142ca..b31e69366 100644
--- a/src/state/queries/suggested-follows.ts
+++ b/src/state/queries/suggested-follows.ts
@@ -1,3 +1,4 @@
+import React from 'react'
 import {
   AppBskyActorGetSuggestions,
   AppBskyGraphGetSuggestedFollowsByActor,
@@ -5,7 +6,7 @@ import {
 } from '@atproto/api'
 import {
   useInfiniteQuery,
-  useMutation,
+  useQueryClient,
   useQuery,
   InfiniteData,
   QueryKey,
@@ -15,7 +16,7 @@ import {useSession} from '#/state/session'
 import {useModerationOpts} from '#/state/queries/preferences'
 
 const suggestedFollowsQueryKey = ['suggested-follows']
-const suggestedFollowsByActorQuery = (did: string) => [
+const suggestedFollowsByActorQueryKey = (did: string) => [
   'suggested-follows-by-actor',
   did,
 ]
@@ -73,7 +74,7 @@ export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
   const {agent} = useSession()
 
   return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
-    queryKey: suggestedFollowsByActorQuery(did),
+    queryKey: suggestedFollowsByActorQueryKey(did),
     queryFn: async () => {
       const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
         actor: did,
@@ -83,17 +84,26 @@ export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
   })
 }
 
-// TODO: Delete and replace usages with the one above.
+// TODO refactor onboarding to use above, but this is still used
 export function useGetSuggestedFollowersByActor() {
   const {agent} = useSession()
+  const queryClient = useQueryClient()
 
-  return useMutation({
-    mutationFn: async (actor: string) => {
-      const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
-        actor: actor,
+  return React.useCallback(
+    async (actor: string) => {
+      const res = await queryClient.fetchQuery({
+        staleTime: 60 * 1000,
+        queryKey: suggestedFollowsByActorQueryKey(actor),
+        queryFn: async () => {
+          const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
+            actor: actor,
+          })
+          return res.data
+        },
       })
 
-      return res.data
+      return res
     },
-  })
+    [agent, queryClient],
+  )
 }
diff --git a/src/view/com/search/HeaderWithInput.tsx b/src/view/com/search/HeaderWithInput.tsx
deleted file mode 100644
index 04cbeab6e..000000000
--- a/src/view/com/search/HeaderWithInput.tsx
+++ /dev/null
@@ -1,186 +0,0 @@
-import React from 'react'
-import {StyleSheet, TextInput, TouchableOpacity, View} from 'react-native'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {Text} from 'view/com/util/text/Text'
-import {MagnifyingGlassIcon} from 'lib/icons'
-import {useTheme} from 'lib/ThemeContext'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {HITSLOP_10} from 'lib/constants'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useSetDrawerOpen} from '#/state/shell'
-
-interface Props {
-  isInputFocused: boolean
-  query: string
-  setIsInputFocused: (v: boolean) => void
-  onChangeQuery: (v: string) => void
-  onPressClearQuery: () => void
-  onPressCancelSearch: () => void
-  onSubmitQuery: () => void
-  showMenu?: boolean
-}
-export function HeaderWithInput({
-  isInputFocused,
-  query,
-  setIsInputFocused,
-  onChangeQuery,
-  onPressClearQuery,
-  onPressCancelSearch,
-  onSubmitQuery,
-  showMenu = true,
-}: Props) {
-  const setDrawerOpen = useSetDrawerOpen()
-  const theme = useTheme()
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const {track} = useAnalytics()
-  const textInput = React.useRef<TextInput>(null)
-  const {isMobile} = useWebMediaQueries()
-
-  const onPressMenu = React.useCallback(() => {
-    track('ViewHeader:MenuButtonClicked')
-    setDrawerOpen(true)
-  }, [track, setDrawerOpen])
-
-  const onPressCancelSearchInner = React.useCallback(() => {
-    onPressCancelSearch()
-    textInput.current?.blur()
-  }, [onPressCancelSearch, textInput])
-
-  return (
-    <View
-      style={[
-        pal.view,
-        pal.border,
-        styles.header,
-        !isMobile && styles.headerDesktop,
-      ]}>
-      {showMenu && isMobile ? (
-        <TouchableOpacity
-          testID="viewHeaderBackOrMenuBtn"
-          onPress={onPressMenu}
-          hitSlop={HITSLOP_10}
-          style={styles.headerMenuBtn}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Menu`)}
-          accessibilityHint="Access navigation links and settings">
-          <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} />
-        </TouchableOpacity>
-      ) : null}
-      <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}
-          autoFocus={false}
-          accessibilityRole="search"
-          accessibilityLabel={_(msg`Search`)}
-          accessibilityHint=""
-          autoCorrect={false}
-          autoCapitalize="none"
-        />
-        {query ? (
-          <TouchableOpacity
-            testID="searchTextInputClearBtn"
-            onPress={onPressClearQuery}
-            accessibilityRole="button"
-            accessibilityLabel={_(msg`Clear search query`)}
-            accessibilityHint="">
-            <FontAwesomeIcon
-              icon="xmark"
-              size={16}
-              style={pal.textLight as FontAwesomeIconStyle}
-            />
-          </TouchableOpacity>
-        ) : undefined}
-      </View>
-      {query || isInputFocused ? (
-        <View style={styles.headerCancelBtn}>
-          <TouchableOpacity
-            onPress={onPressCancelSearchInner}
-            accessibilityRole="button">
-            <Text style={pal.text}>
-              <Trans>Cancel</Trans>
-            </Text>
-          </TouchableOpacity>
-        </View>
-      ) : undefined}
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  header: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    paddingHorizontal: 12,
-    paddingVertical: 4,
-  },
-  headerDesktop: {
-    borderWidth: 1,
-    borderTopWidth: 0,
-    paddingVertical: 10,
-  },
-  headerMenuBtn: {
-    width: 30,
-    height: 30,
-    borderRadius: 30,
-    marginRight: 6,
-    paddingBottom: 2,
-    alignItems: 'center',
-    justifyContent: 'center',
-  },
-  headerSearchContainer: {
-    flex: 1,
-    flexDirection: 'row',
-    alignItems: 'center',
-    borderRadius: 30,
-    paddingHorizontal: 12,
-    paddingVertical: 8,
-  },
-  headerSearchIcon: {
-    marginRight: 6,
-    alignSelf: 'center',
-  },
-  headerSearchInput: {
-    flex: 1,
-    fontSize: 17,
-  },
-  headerCancelBtn: {
-    paddingLeft: 10,
-  },
-
-  searchPrompt: {
-    textAlign: 'center',
-    paddingTop: 10,
-  },
-
-  suggestions: {
-    marginBottom: 8,
-  },
-})
diff --git a/src/view/com/search/SearchResults.tsx b/src/view/com/search/SearchResults.tsx
deleted file mode 100644
index 87378bba7..000000000
--- a/src/view/com/search/SearchResults.tsx
+++ /dev/null
@@ -1,150 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {SearchUIModel} from 'state/models/ui/search'
-import {CenteredView, ScrollView} from '../util/Views'
-import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager'
-import {TabBar} from 'view/com/pager/TabBar'
-import {Post} from 'view/com/post/Post'
-import {ProfileCardWithFollowBtn} from 'view/com/profile/ProfileCard'
-import {
-  PostFeedLoadingPlaceholder,
-  ProfileCardFeedLoadingPlaceholder,
-} from 'view/com/util/LoadingPlaceholder'
-import {Text} from 'view/com/util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {s} from 'lib/styles'
-
-const SECTIONS = ['Posts', 'Users']
-
-export const SearchResults = observer(function SearchResultsImpl({
-  model,
-}: {
-  model: SearchUIModel
-}) {
-  const pal = usePalette('default')
-  const {isMobile} = useWebMediaQueries()
-
-  const renderTabBar = React.useCallback(
-    (props: RenderTabBarFnProps) => {
-      return (
-        <CenteredView style={[pal.border, pal.view, styles.tabBar]}>
-          <TabBar
-            items={SECTIONS}
-            {...props}
-            key={SECTIONS.join()}
-            indicatorColor={pal.colors.link}
-          />
-        </CenteredView>
-      )
-    },
-    [pal],
-  )
-
-  return (
-    <Pager renderTabBar={renderTabBar} tabBarPosition="top" initialPage={0}>
-      <View
-        style={{
-          paddingTop: isMobile ? 42 : 50,
-        }}>
-        <PostResults key="0" model={model} />
-      </View>
-      <View
-        style={{
-          paddingTop: isMobile ? 42 : 50,
-        }}>
-        <Profiles key="1" model={model} />
-      </View>
-    </Pager>
-  )
-})
-
-const PostResults = observer(function PostResultsImpl({
-  model,
-}: {
-  model: SearchUIModel
-}) {
-  const pal = usePalette('default')
-  if (model.isPostsLoading) {
-    return (
-      <CenteredView>
-        <PostFeedLoadingPlaceholder />
-      </CenteredView>
-    )
-  }
-
-  if (model.posts.length === 0) {
-    return (
-      <CenteredView>
-        <Text type="xl" style={[styles.empty, pal.text]}>
-          No posts found for "{model.query}"
-        </Text>
-      </CenteredView>
-    )
-  }
-
-  return (
-    <ScrollView style={[pal.view]}>
-      {model.posts.map(post => (
-        <Post key={post.resolvedUri} view={post} hideError />
-      ))}
-      <View style={s.footerSpacer} />
-      <View style={s.footerSpacer} />
-      <View style={s.footerSpacer} />
-    </ScrollView>
-  )
-})
-
-const Profiles = observer(function ProfilesImpl({
-  model,
-}: {
-  model: SearchUIModel
-}) {
-  const pal = usePalette('default')
-  if (model.isProfilesLoading) {
-    return (
-      <CenteredView>
-        <ProfileCardFeedLoadingPlaceholder />
-      </CenteredView>
-    )
-  }
-
-  if (model.profiles.length === 0) {
-    return (
-      <CenteredView>
-        <Text type="xl" style={[styles.empty, pal.text]}>
-          No users found for "{model.query}"
-        </Text>
-      </CenteredView>
-    )
-  }
-
-  return (
-    <ScrollView style={pal.view}>
-      {model.profiles.map(item => (
-        <ProfileCardWithFollowBtn key={item.did} profile={item} />
-      ))}
-      <View style={s.footerSpacer} />
-      <View style={s.footerSpacer} />
-      <View style={s.footerSpacer} />
-    </ScrollView>
-  )
-})
-
-const styles = StyleSheet.create({
-  tabBar: {
-    borderBottomWidth: 1,
-    position: 'absolute',
-    zIndex: 1,
-    left: 0,
-    right: 0,
-    top: 0,
-    flexDirection: 'column',
-    alignItems: 'center',
-  },
-  empty: {
-    paddingHorizontal: 14,
-    paddingVertical: 16,
-  },
-})
diff --git a/src/view/com/search/Suggestions.tsx b/src/view/com/search/Suggestions.tsx
deleted file mode 100644
index 2a80d10ae..000000000
--- a/src/view/com/search/Suggestions.tsx
+++ /dev/null
@@ -1,265 +0,0 @@
-import React, {forwardRef, ForwardedRef} from 'react'
-import {RefreshControl, StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {AppBskyActorDefs} from '@atproto/api'
-import {FlatList} from '../util/Views'
-import {FoafsModel} from 'state/models/discovery/foafs'
-import {
-  SuggestedActorsModel,
-  SuggestedActor,
-} from 'state/models/discovery/suggested-actors'
-import {Text} from '../util/text/Text'
-import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
-import {ProfileCardLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs'
-import {usePalette} from 'lib/hooks/usePalette'
-import {s} from 'lib/styles'
-
-interface Heading {
-  _reactKey: string
-  type: 'heading'
-  title: string
-}
-interface RefWrapper {
-  _reactKey: string
-  type: 'ref'
-  ref: RefWithInfoAndFollowers
-}
-interface SuggestWrapper {
-  _reactKey: string
-  type: 'suggested'
-  suggested: SuggestedActor
-}
-interface ProfileView {
-  _reactKey: string
-  type: 'profile-view'
-  view: AppBskyActorDefs.ProfileViewBasic
-}
-interface LoadingPlaceholder {
-  _reactKey: string
-  type: 'loading-placeholder'
-}
-type Item =
-  | Heading
-  | RefWrapper
-  | SuggestWrapper
-  | ProfileView
-  | LoadingPlaceholder
-
-// FIXME(dan): Figure out why the false positives
-/* eslint-disable react/prop-types */
-
-export const Suggestions = observer(
-  forwardRef(function SuggestionsImpl(
-    {
-      foafs,
-      suggestedActors,
-    }: {
-      foafs: FoafsModel
-      suggestedActors: SuggestedActorsModel
-    },
-    flatListRef: ForwardedRef<FlatList>,
-  ) {
-    const pal = usePalette('default')
-    const [refreshing, setRefreshing] = React.useState(false)
-    const data = React.useMemo(() => {
-      let items: Item[] = []
-
-      if (suggestedActors.hasContent) {
-        items = items
-          .concat([
-            {
-              _reactKey: '__suggested_heading__',
-              type: 'heading',
-              title: 'Suggested Follows',
-            },
-          ])
-          .concat(
-            suggestedActors.suggestions.map(suggested => ({
-              _reactKey: `suggested-${suggested.did}`,
-              type: 'suggested',
-              suggested,
-            })),
-          )
-      } else if (suggestedActors.isLoading) {
-        items = items.concat([
-          {
-            _reactKey: '__suggested_heading__',
-            type: 'heading',
-            title: 'Suggested Follows',
-          },
-          {_reactKey: '__suggested_loading__', type: 'loading-placeholder'},
-        ])
-      }
-      if (foafs.isLoading) {
-        items = items.concat([
-          {
-            _reactKey: '__popular_heading__',
-            type: 'heading',
-            title: 'In Your Network',
-          },
-          {_reactKey: '__foafs_loading__', type: 'loading-placeholder'},
-        ])
-      } else {
-        if (foafs.popular.length > 0) {
-          items = items
-            .concat([
-              {
-                _reactKey: '__popular_heading__',
-                type: 'heading',
-                title: 'In Your Network',
-              },
-            ])
-            .concat(
-              foafs.popular.map(ref => ({
-                _reactKey: `popular-${ref.did}`,
-                type: 'ref',
-                ref,
-              })),
-            )
-        }
-        for (const source of foafs.sources) {
-          const item = foafs.foafs.get(source)
-          if (!item || item.follows.length === 0) {
-            continue
-          }
-          items = items
-            .concat([
-              {
-                _reactKey: `__${item.did}_heading__`,
-                type: 'heading',
-                title: `Followed by ${sanitizeDisplayName(
-                  item.displayName || sanitizeHandle(item.handle),
-                )}`,
-              },
-            ])
-            .concat(
-              item.follows.slice(0, 10).map(view => ({
-                _reactKey: `${item.did}-${view.did}`,
-                type: 'profile-view',
-                view,
-              })),
-            )
-        }
-      }
-
-      return items
-    }, [
-      foafs.isLoading,
-      foafs.popular,
-      suggestedActors.isLoading,
-      suggestedActors.hasContent,
-      suggestedActors.suggestions,
-      foafs.sources,
-      foafs.foafs,
-    ])
-
-    const onRefresh = React.useCallback(async () => {
-      setRefreshing(true)
-      try {
-        await foafs.fetch()
-      } finally {
-        setRefreshing(false)
-      }
-    }, [foafs, setRefreshing])
-
-    const renderItem = React.useCallback(
-      ({item}: {item: Item}) => {
-        if (item.type === 'heading') {
-          return (
-            <Text type="title" style={[styles.heading, pal.text]}>
-              {item.title}
-            </Text>
-          )
-        }
-        if (item.type === 'ref') {
-          return (
-            <View style={[styles.card, pal.view, pal.border]}>
-              <ProfileCardWithFollowBtn
-                key={item.ref.did}
-                profile={item.ref}
-                noBg
-                noBorder
-                followers={
-                  item.ref.followers
-                    ? (item.ref.followers as AppBskyActorDefs.ProfileView[])
-                    : undefined
-                }
-              />
-            </View>
-          )
-        }
-        if (item.type === 'profile-view') {
-          return (
-            <View style={[styles.card, pal.view, pal.border]}>
-              <ProfileCardWithFollowBtn
-                key={item.view.did}
-                profile={item.view}
-                noBg
-                noBorder
-              />
-            </View>
-          )
-        }
-        if (item.type === 'suggested') {
-          return (
-            <View style={[styles.card, pal.view, pal.border]}>
-              <ProfileCardWithFollowBtn
-                key={item.suggested.did}
-                profile={item.suggested}
-                noBg
-                noBorder
-              />
-            </View>
-          )
-        }
-        if (item.type === 'loading-placeholder') {
-          return (
-            <View>
-              <ProfileCardLoadingPlaceholder />
-              <ProfileCardLoadingPlaceholder />
-              <ProfileCardLoadingPlaceholder />
-              <ProfileCardLoadingPlaceholder />
-            </View>
-          )
-        }
-        return null
-      },
-      [pal],
-    )
-
-    return (
-      <FlatList
-        ref={flatListRef}
-        data={data}
-        keyExtractor={item => item._reactKey}
-        refreshControl={
-          <RefreshControl
-            refreshing={refreshing}
-            onRefresh={onRefresh}
-            tintColor={pal.colors.text}
-            titleColor={pal.colors.text}
-          />
-        }
-        renderItem={renderItem}
-        initialNumToRender={15}
-        contentContainerStyle={s.contentContainer}
-      />
-    )
-  }),
-)
-
-const styles = StyleSheet.create({
-  heading: {
-    fontWeight: 'bold',
-    paddingHorizontal: 12,
-    paddingBottom: 8,
-    paddingTop: 16,
-  },
-
-  card: {
-    borderTopWidth: 1,
-  },
-})
diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx
deleted file mode 100644
index bf9857df4..000000000
--- a/src/view/screens/Search.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export * from './SearchMobile'
diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx
deleted file mode 100644
index 2d0c0288a..000000000
--- a/src/view/screens/Search.web.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import React from 'react'
-import {View, StyleSheet} from 'react-native'
-import {SearchUIModel} from 'state/models/ui/search'
-import {FoafsModel} from 'state/models/discovery/foafs'
-import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {Suggestions} from 'view/com/search/Suggestions'
-import {SearchResults} from 'view/com/search/SearchResults'
-import {observer} from 'mobx-react-lite'
-import {
-  NativeStackScreenProps,
-  SearchTabNavigatorParams,
-} from 'lib/routes/types'
-import {useStores} from 'state/index'
-import {CenteredView} from 'view/com/util/Views'
-import * as Mobile from './SearchMobile'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-
-type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
-export const SearchScreen = withAuthRequired(
-  observer(function SearchScreenImpl({navigation, route}: Props) {
-    const store = useStores()
-    const params = route.params || {}
-    const foafs = React.useMemo<FoafsModel>(
-      () => new FoafsModel(store),
-      [store],
-    )
-    const suggestedActors = React.useMemo<SuggestedActorsModel>(
-      () => new SuggestedActorsModel(store),
-      [store],
-    )
-    const searchUIModel = React.useMemo<SearchUIModel | undefined>(
-      () => (params.q ? new SearchUIModel(store) : undefined),
-      [params.q, store],
-    )
-
-    React.useEffect(() => {
-      if (params.q && searchUIModel) {
-        searchUIModel.fetch(params.q)
-      }
-      if (!foafs.hasData) {
-        foafs.fetch()
-      }
-      if (!suggestedActors.hasLoaded) {
-        suggestedActors.loadMore(true)
-      }
-    }, [foafs, suggestedActors, searchUIModel, params.q])
-
-    const {isDesktop} = useWebMediaQueries()
-
-    if (searchUIModel) {
-      return (
-        <View style={styles.scrollContainer}>
-          <SearchResults model={searchUIModel} />
-        </View>
-      )
-    }
-
-    if (!isDesktop) {
-      return (
-        <CenteredView style={styles.scrollContainer}>
-          <Mobile.SearchScreen navigation={navigation} route={route} />
-        </CenteredView>
-      )
-    }
-
-    return <Suggestions foafs={foafs} suggestedActors={suggestedActors} />
-  }),
-)
-
-const styles = StyleSheet.create({
-  scrollContainer: {
-    height: '100%',
-    overflowY: 'auto',
-  },
-})
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
new file mode 100644
index 000000000..08f4fbd70
--- /dev/null
+++ b/src/view/screens/Search/Search.tsx
@@ -0,0 +1,639 @@
+import React from 'react'
+import {
+  View,
+  StyleSheet,
+  ActivityIndicator,
+  RefreshControl,
+  TextInput,
+  Pressable,
+} from 'react-native'
+import {FlatList, ScrollView, CenteredView} from '#/view/com/util/Views'
+import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {logger} from '#/logger'
+import {
+  NativeStackScreenProps,
+  SearchTabNavigatorParams,
+} from 'lib/routes/types'
+import {Text} from '#/view/com/util/text/Text'
+import {NotificationFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
+import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
+import {Post} from '#/view/com/post/Post'
+import {Pager} from '#/view/com/pager/Pager'
+import {TabBar} from '#/view/com/pager/TabBar'
+import {HITSLOP_10} from '#/lib/constants'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {useSession} from '#/state/session'
+import {useMyFollowsQuery} from '#/state/queries/my-follows'
+import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows'
+import {useSearchPostsQuery} from '#/state/queries/search-posts'
+import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
+import {useSetDrawerOpen} from '#/state/shell'
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {MagnifyingGlassIcon} from '#/lib/icons'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {SearchResultCard} from '#/view/shell/desktop/Search'
+import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
+import {useStores} from '#/state'
+import {isWeb} from '#/platform/detection'
+
+function Loader() {
+  const pal = usePalette('default')
+  const {isMobile} = useWebMediaQueries()
+  return (
+    <CenteredView
+      style={[
+        // @ts-ignore web only -prf
+        {
+          padding: 18,
+          height: isWeb ? '100vh' : undefined,
+        },
+        pal.border,
+      ]}
+      sideBorders={!isMobile}>
+      <ActivityIndicator />
+    </CenteredView>
+  )
+}
+
+// TODO refactor how to translate?
+function EmptyState({message, error}: {message: string; error?: string}) {
+  const pal = usePalette('default')
+  const {isMobile} = useWebMediaQueries()
+
+  return (
+    <CenteredView
+      sideBorders={!isMobile}
+      style={[
+        pal.border,
+        // @ts-ignore web only -prf
+        {
+          padding: 18,
+          height: isWeb ? '100vh' : undefined,
+        },
+      ]}>
+      <View style={[pal.viewLight, {padding: 18, borderRadius: 8}]}>
+        <Text style={[pal.text]}>
+          <Trans>{message}</Trans>
+        </Text>
+
+        {error && (
+          <>
+            <View
+              style={[
+                {
+                  marginVertical: 12,
+                  height: 1,
+                  width: '100%',
+                  backgroundColor: pal.text.color,
+                  opacity: 0.2,
+                },
+              ]}
+            />
+
+            <Text style={[pal.textLight]}>
+              <Trans>Error:</Trans> {error}
+            </Text>
+          </>
+        )}
+      </View>
+    </CenteredView>
+  )
+}
+
+function SearchScreenSuggestedFollows() {
+  const pal = usePalette('default')
+  const {currentAccount} = useSession()
+  const [dataUpdatedAt, setDataUpdatedAt] = React.useState(0)
+  const [suggestions, setSuggestions] = React.useState<
+    AppBskyActorDefs.ProfileViewBasic[]
+  >([])
+  const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor()
+
+  React.useEffect(() => {
+    async function getSuggestions() {
+      // TODO not quite right, doesn't fetch your follows
+      const friends = await getSuggestedFollowsByActor(
+        currentAccount!.did,
+      ).then(friendsRes => friendsRes.suggestions)
+
+      if (!friends) return // :(
+
+      const friendsOfFriends = (
+        await Promise.all(
+          friends
+            .slice(0, 4)
+            .map(friend =>
+              getSuggestedFollowsByActor(friend.did).then(
+                foafsRes => foafsRes.suggestions,
+              ),
+            ),
+        )
+      ).flat()
+
+      setSuggestions(
+        // dedupe
+        friendsOfFriends.filter(f => !friends.find(f2 => f.did === f2.did)),
+      )
+      setDataUpdatedAt(Date.now())
+    }
+
+    try {
+      getSuggestions()
+    } catch (e) {
+      logger.error(`SearchScreenSuggestedFollows: failed to get suggestions`, {
+        error: e,
+      })
+    }
+  }, [
+    currentAccount,
+    setSuggestions,
+    setDataUpdatedAt,
+    getSuggestedFollowsByActor,
+  ])
+
+  return suggestions.length ? (
+    <FlatList
+      data={suggestions}
+      renderItem={({item}) => (
+        <ProfileCardWithFollowBtn
+          profile={item}
+          noBg
+          dataUpdatedAt={dataUpdatedAt}
+        />
+      )}
+      keyExtractor={item => item.did}
+      // @ts-ignore web only -prf
+      desktopFixedHeight
+      contentContainerStyle={{paddingBottom: 1200}}
+    />
+  ) : (
+    <CenteredView
+      style={[pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]}>
+      <NotificationFeedLoadingPlaceholder />
+    </CenteredView>
+  )
+}
+
+type SearchResultSlice =
+  | {
+      type: 'post'
+      key: string
+      post: AppBskyFeedDefs.PostView
+    }
+  | {
+      type: 'loadingMore'
+      key: string
+    }
+
+function SearchScreenPostResults({query}: {query: string}) {
+  const pal = usePalette('default')
+  const [isPTR, setIsPTR] = React.useState(false)
+  const {
+    isFetched,
+    data: results,
+    isFetching,
+    error,
+    refetch,
+    fetchNextPage,
+    isFetchingNextPage,
+    hasNextPage,
+    dataUpdatedAt,
+  } = useSearchPostsQuery({query})
+
+  const onPullToRefresh = React.useCallback(async () => {
+    setIsPTR(true)
+    await refetch()
+    setIsPTR(false)
+  }, [setIsPTR, refetch])
+  const onEndReached = React.useCallback(() => {
+    if (isFetching || !hasNextPage || error) return
+    fetchNextPage()
+  }, [isFetching, error, hasNextPage, fetchNextPage])
+
+  const posts = React.useMemo(() => {
+    return results?.pages.flatMap(page => page.posts) || []
+  }, [results])
+  const items = React.useMemo(() => {
+    let items: SearchResultSlice[] = []
+
+    for (const post of posts) {
+      items.push({
+        type: 'post',
+        key: post.uri,
+        post,
+      })
+    }
+
+    if (isFetchingNextPage) {
+      items.push({
+        type: 'loadingMore',
+        key: 'loadingMore',
+      })
+    }
+
+    return items
+  }, [posts, isFetchingNextPage])
+
+  return error ? (
+    <EmptyState
+      message="We're sorry, but your search could not be completed. Please try again in a few minutes."
+      error={error.toString()}
+    />
+  ) : (
+    <>
+      {isFetched ? (
+        <>
+          {posts.length ? (
+            <FlatList
+              data={items}
+              renderItem={({item}) => {
+                if (item.type === 'post') {
+                  return <Post post={item.post} dataUpdatedAt={dataUpdatedAt} />
+                } else {
+                  return <Loader />
+                }
+              }}
+              keyExtractor={item => item.key}
+              refreshControl={
+                <RefreshControl
+                  refreshing={isPTR}
+                  onRefresh={onPullToRefresh}
+                  tintColor={pal.colors.text}
+                  titleColor={pal.colors.text}
+                />
+              }
+              onEndReached={onEndReached}
+              // @ts-ignore web only -prf
+              desktopFixedHeight
+              contentContainerStyle={{paddingBottom: 100}}
+            />
+          ) : (
+            <EmptyState message={`No results found for ${query}`} />
+          )}
+        </>
+      ) : (
+        <Loader />
+      )}
+    </>
+  )
+}
+
+function SearchScreenUserResults({query}: {query: string}) {
+  const [isFetched, setIsFetched] = React.useState(false)
+  const [dataUpdatedAt, setDataUpdatedAt] = React.useState(0)
+  const [results, setResults] = React.useState<
+    AppBskyActorDefs.ProfileViewBasic[]
+  >([])
+  const search = useActorAutocompleteFn()
+  // fuzzy search relies on followers
+  const {isFetched: isFollowsFetched} = useMyFollowsQuery()
+
+  React.useEffect(() => {
+    async function getResults() {
+      const results = await search({query, limit: 30})
+
+      if (results) {
+        setDataUpdatedAt(Date.now())
+        setResults(results)
+        setIsFetched(true)
+      }
+    }
+
+    if (query && isFollowsFetched) {
+      getResults()
+    } else {
+      setResults([])
+      setIsFetched(false)
+    }
+  }, [query, isFollowsFetched, setDataUpdatedAt, search])
+
+  return isFetched ? (
+    <>
+      {results.length ? (
+        <FlatList
+          data={results}
+          renderItem={({item}) => (
+            <ProfileCardWithFollowBtn
+              profile={item}
+              noBg
+              dataUpdatedAt={dataUpdatedAt}
+            />
+          )}
+          keyExtractor={item => item.did}
+          // @ts-ignore web only -prf
+          desktopFixedHeight
+          contentContainerStyle={{paddingBottom: 100}}
+        />
+      ) : (
+        <EmptyState message={`No results found for ${query}`} />
+      )}
+    </>
+  ) : (
+    <Loader />
+  )
+}
+
+const SECTIONS = ['Posts', 'Users']
+export function SearchScreenInner({query}: {query?: string}) {
+  const pal = usePalette('default')
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
+
+  const onPageSelected = React.useCallback(
+    (index: number) => {
+      setMinimalShellMode(false)
+      setDrawerSwipeDisabled(index > 0)
+    },
+    [setDrawerSwipeDisabled, setMinimalShellMode],
+  )
+
+  return query ? (
+    <Pager
+      tabBarPosition="top"
+      onPageSelected={onPageSelected}
+      renderTabBar={props => (
+        <CenteredView sideBorders style={pal.border}>
+          <TabBar items={SECTIONS} {...props} />
+        </CenteredView>
+      )}
+      initialPage={0}>
+      <View>
+        <SearchScreenPostResults query={query} />
+      </View>
+      <View>
+        <SearchScreenUserResults query={query} />
+      </View>
+    </Pager>
+  ) : (
+    <View>
+      <CenteredView sideBorders style={pal.border}>
+        <Text
+          type="title"
+          style={[
+            pal.text,
+            pal.border,
+            {
+              display: 'flex',
+              paddingVertical: 12,
+              paddingHorizontal: 18,
+              fontWeight: 'bold',
+            },
+          ]}>
+          <Trans>Suggested Follows</Trans>
+        </Text>
+      </CenteredView>
+      <SearchScreenSuggestedFollows />
+    </View>
+  )
+}
+
+export function SearchScreenDesktop(
+  props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
+) {
+  const {isDesktop} = useWebMediaQueries()
+
+  return isDesktop ? (
+    <SearchScreenInner query={props.route.params?.q} />
+  ) : (
+    <SearchScreenMobile {...props} />
+  )
+}
+
+export function SearchScreenMobile(
+  _props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
+) {
+  const theme = useTheme()
+  const textInput = React.useRef<TextInput>(null)
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const {track} = useAnalytics()
+  const setDrawerOpen = useSetDrawerOpen()
+  const moderationOpts = useModerationOpts()
+  const search = useActorAutocompleteFn()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const store = useStores()
+  const {isTablet} = useWebMediaQueries()
+
+  const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>(
+    undefined,
+  )
+  const [isFetching, setIsFetching] = React.useState<boolean>(false)
+  const [query, setQuery] = React.useState<string>('')
+  const [searchResults, setSearchResults] = React.useState<
+    AppBskyActorDefs.ProfileViewBasic[]
+  >([])
+  const [inputIsFocused, setInputIsFocused] = React.useState(false)
+  const [showAutocompleteResults, setShowAutocompleteResults] =
+    React.useState(false)
+
+  const onPressMenu = React.useCallback(() => {
+    track('ViewHeader:MenuButtonClicked')
+    setDrawerOpen(true)
+  }, [track, setDrawerOpen])
+  const onPressCancelSearch = React.useCallback(() => {
+    textInput.current?.blur()
+    setQuery('')
+    setShowAutocompleteResults(false)
+    if (searchDebounceTimeout.current)
+      clearTimeout(searchDebounceTimeout.current)
+  }, [textInput])
+  const onPressClearQuery = React.useCallback(() => {
+    setQuery('')
+    setShowAutocompleteResults(false)
+  }, [setQuery])
+  const onChangeText = React.useCallback(
+    async (text: string) => {
+      setQuery(text)
+
+      if (text.length > 0) {
+        setIsFetching(true)
+        setShowAutocompleteResults(true)
+
+        if (searchDebounceTimeout.current)
+          clearTimeout(searchDebounceTimeout.current)
+
+        searchDebounceTimeout.current = setTimeout(async () => {
+          const results = await search({query: text, limit: 30})
+
+          if (results) {
+            setSearchResults(results)
+            setIsFetching(false)
+          }
+        }, 300)
+      } else {
+        if (searchDebounceTimeout.current)
+          clearTimeout(searchDebounceTimeout.current)
+        setSearchResults([])
+        setIsFetching(false)
+        setShowAutocompleteResults(false)
+      }
+    },
+    [setQuery, search, setSearchResults],
+  )
+  const onSubmit = React.useCallback(() => {
+    setShowAutocompleteResults(false)
+  }, [setShowAutocompleteResults])
+
+  const onSoftReset = React.useCallback(() => {
+    onPressCancelSearch()
+  }, [onPressCancelSearch])
+
+  useFocusEffect(
+    React.useCallback(() => {
+      const softResetSub = store.onScreenSoftReset(onSoftReset)
+
+      setMinimalShellMode(false)
+
+      return () => {
+        softResetSub.remove()
+      }
+    }, [store, onSoftReset, setMinimalShellMode]),
+  )
+
+  return (
+    <View style={{flex: 1}}>
+      <CenteredView style={[styles.header, pal.border]} sideBorders={isTablet}>
+        <Pressable
+          testID="viewHeaderBackOrMenuBtn"
+          onPress={onPressMenu}
+          hitSlop={HITSLOP_10}
+          style={styles.headerMenuBtn}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Menu`)}
+          accessibilityHint="Access navigation links and settings">
+          <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} />
+        </Pressable>
+
+        <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={() => setInputIsFocused(true)}
+            onBlur={() => setInputIsFocused(false)}
+            onChangeText={onChangeText}
+            onSubmitEditing={onSubmit}
+            autoFocus={false}
+            accessibilityRole="search"
+            accessibilityLabel={_(msg`Search`)}
+            accessibilityHint=""
+            autoCorrect={false}
+            autoCapitalize="none"
+          />
+          {query ? (
+            <Pressable
+              testID="searchTextInputClearBtn"
+              onPress={onPressClearQuery}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Clear search query`)}
+              accessibilityHint="">
+              <FontAwesomeIcon
+                icon="xmark"
+                size={16}
+                style={pal.textLight as FontAwesomeIconStyle}
+              />
+            </Pressable>
+          ) : undefined}
+        </View>
+
+        {query || inputIsFocused ? (
+          <View style={styles.headerCancelBtn}>
+            <Pressable onPress={onPressCancelSearch} accessibilityRole="button">
+              <Text style={[pal.text]}>
+                <Trans>Cancel</Trans>
+              </Text>
+            </Pressable>
+          </View>
+        ) : undefined}
+      </CenteredView>
+
+      {showAutocompleteResults && moderationOpts ? (
+        <>
+          {isFetching ? (
+            <Loader />
+          ) : (
+            <ScrollView style={{height: '100%'}}>
+              {searchResults.length ? (
+                searchResults.map((item, i) => (
+                  <SearchResultCard
+                    key={item.did}
+                    profile={item}
+                    moderation={moderateProfile(item, moderationOpts)}
+                    style={i === 0 ? {borderTopWidth: 0} : {}}
+                  />
+                ))
+              ) : (
+                <EmptyState message={`No results found for ${query}`} />
+              )}
+
+              <View style={{height: 200}} />
+            </ScrollView>
+          )}
+        </>
+      ) : (
+        <SearchScreenInner query={query} />
+      )}
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  header: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 12,
+    paddingVertical: 4,
+  },
+  headerMenuBtn: {
+    width: 30,
+    height: 30,
+    borderRadius: 30,
+    marginRight: 6,
+    paddingBottom: 2,
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+  headerSearchContainer: {
+    flex: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+    borderRadius: 30,
+    paddingHorizontal: 12,
+    paddingVertical: 8,
+  },
+  headerSearchIcon: {
+    marginRight: 6,
+    alignSelf: 'center',
+  },
+  headerSearchInput: {
+    flex: 1,
+    fontSize: 17,
+  },
+  headerCancelBtn: {
+    paddingLeft: 10,
+  },
+})
diff --git a/src/view/screens/Search/index.tsx b/src/view/screens/Search/index.tsx
new file mode 100644
index 000000000..d1b9684ef
--- /dev/null
+++ b/src/view/screens/Search/index.tsx
@@ -0,0 +1 @@
+export {SearchScreenMobile as SearchScreen} from '#/view/screens/Search/Search'
diff --git a/src/view/screens/Search/index.web.tsx b/src/view/screens/Search/index.web.tsx
new file mode 100644
index 000000000..8d09f342a
--- /dev/null
+++ b/src/view/screens/Search/index.web.tsx
@@ -0,0 +1 @@
+export {SearchScreenDesktop as SearchScreen} from '#/view/screens/Search/Search'
diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx
deleted file mode 100644
index 92c255d5b..000000000
--- a/src/view/screens/SearchMobile.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-import React, {useCallback} from 'react'
-import {
-  StyleSheet,
-  TouchableWithoutFeedback,
-  Keyboard,
-  View,
-} from 'react-native'
-import {useFocusEffect} from '@react-navigation/native'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {FlatList, ScrollView} from 'view/com/util/Views'
-import {
-  NativeStackScreenProps,
-  SearchTabNavigatorParams,
-} from 'lib/routes/types'
-import {observer} from 'mobx-react-lite'
-import {Text} from 'view/com/util/text/Text'
-import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
-import {useStores} from 'state/index'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
-import {SearchUIModel} from 'state/models/ui/search'
-import {FoafsModel} from 'state/models/discovery/foafs'
-import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors'
-import {HeaderWithInput} from 'view/com/search/HeaderWithInput'
-import {Suggestions} from 'view/com/search/Suggestions'
-import {SearchResults} from 'view/com/search/SearchResults'
-import {s} from 'lib/styles'
-import {ProfileCard} from 'view/com/profile/ProfileCard'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
-import {isAndroid, isIOS} from 'platform/detection'
-import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
-
-type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
-export const SearchScreen = withAuthRequired(
-  observer<Props>(function SearchScreenImpl({}: Props) {
-    const pal = usePalette('default')
-    const store = useStores()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const setIsDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
-    const scrollViewRef = React.useRef<ScrollView>(null)
-    const flatListRef = React.useRef<FlatList>(null)
-    const [onMainScroll] = useOnMainScroll()
-    const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
-    const [query, setQuery] = React.useState<string>('')
-    const autocompleteView = React.useMemo<UserAutocompleteModel>(
-      () => new UserAutocompleteModel(store),
-      [store],
-    )
-    const foafs = React.useMemo<FoafsModel>(
-      () => new FoafsModel(store),
-      [store],
-    )
-    const suggestedActors = React.useMemo<SuggestedActorsModel>(
-      () => new SuggestedActorsModel(store),
-      [store],
-    )
-    const [searchUIModel, setSearchUIModel] = React.useState<
-      SearchUIModel | undefined
-    >()
-
-    const onChangeQuery = React.useCallback(
-      (text: string) => {
-        setQuery(text)
-        if (text.length > 0) {
-          autocompleteView.setActive(true)
-          autocompleteView.setPrefix(text)
-        } else {
-          autocompleteView.setActive(false)
-        }
-      },
-      [setQuery, autocompleteView],
-    )
-
-    const onPressClearQuery = React.useCallback(() => {
-      setQuery('')
-    }, [setQuery])
-
-    const onPressCancelSearch = React.useCallback(() => {
-      setQuery('')
-      autocompleteView.setActive(false)
-      setSearchUIModel(undefined)
-      setIsDrawerSwipeDisabled(false)
-    }, [setQuery, autocompleteView, setIsDrawerSwipeDisabled])
-
-    const onSubmitQuery = React.useCallback(() => {
-      if (query.length === 0) {
-        return
-      }
-
-      const model = new SearchUIModel(store)
-      model.fetch(query)
-      setSearchUIModel(model)
-      setIsDrawerSwipeDisabled(true)
-    }, [query, setSearchUIModel, store, setIsDrawerSwipeDisabled])
-
-    const onSoftReset = React.useCallback(() => {
-      scrollViewRef.current?.scrollTo({x: 0, y: 0})
-      flatListRef.current?.scrollToOffset({offset: 0})
-      onPressCancelSearch()
-    }, [scrollViewRef, flatListRef, onPressCancelSearch])
-
-    useFocusEffect(
-      React.useCallback(() => {
-        const softResetSub = store.onScreenSoftReset(onSoftReset)
-        const cleanup = () => {
-          softResetSub.remove()
-        }
-
-        setMinimalShellMode(false)
-        autocompleteView.setup()
-        if (!foafs.hasData) {
-          foafs.fetch()
-        }
-        if (!suggestedActors.hasLoaded) {
-          suggestedActors.loadMore(true)
-        }
-
-        return cleanup
-      }, [
-        store,
-        autocompleteView,
-        foafs,
-        suggestedActors,
-        onSoftReset,
-        setMinimalShellMode,
-      ]),
-    )
-
-    const onPress = useCallback(() => {
-      if (isIOS || isAndroid) {
-        Keyboard.dismiss()
-      }
-    }, [])
-
-    const scrollHandler = useAnimatedScrollHandler(onMainScroll)
-    return (
-      <TouchableWithoutFeedback onPress={onPress} accessible={false}>
-        <View style={[pal.view, styles.container]}>
-          <HeaderWithInput
-            isInputFocused={isInputFocused}
-            query={query}
-            setIsInputFocused={setIsInputFocused}
-            onChangeQuery={onChangeQuery}
-            onPressClearQuery={onPressClearQuery}
-            onPressCancelSearch={onPressCancelSearch}
-            onSubmitQuery={onSubmitQuery}
-          />
-          {searchUIModel ? (
-            <SearchResults model={searchUIModel} />
-          ) : !isInputFocused && !query ? (
-            <Suggestions
-              ref={flatListRef}
-              foafs={foafs}
-              suggestedActors={suggestedActors}
-            />
-          ) : (
-            <ScrollView
-              ref={scrollViewRef}
-              testID="searchScrollView"
-              style={pal.view}
-              onScroll={scrollHandler}
-              scrollEventThrottle={1}>
-              {query && autocompleteView.suggestions.length ? (
-                <>
-                  {autocompleteView.suggestions.map((suggestion, index) => (
-                    <ProfileCard
-                      key={suggestion.did}
-                      testID={`searchAutoCompleteResult-${suggestion.handle}`}
-                      profile={suggestion}
-                      noBorder={index === 0}
-                    />
-                  ))}
-                </>
-              ) : query && !autocompleteView.suggestions.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 and posts on the network
-                  </Text>
-                </View>
-              ) : null}
-              <View style={s.footerSpacer} />
-            </ScrollView>
-          )}
-        </View>
-      </TouchableWithoutFeedback>
-    )
-  }),
-)
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-  },
-
-  searchPrompt: {
-    textAlign: 'center',
-    paddingTop: 10,
-  },
-})
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index d1598c3d3..831eda7ca 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -5,6 +5,7 @@ import {
   View,
   StyleSheet,
   TouchableOpacity,
+  ActivityIndicator,
 } from 'react-native'
 import {useNavigation, StackActions} from '@react-navigation/native'
 import {
@@ -12,7 +13,6 @@ import {
   moderateProfile,
   ProfileModeration,
 } from '@atproto/api'
-import {observer} from 'mobx-react-lite'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -84,14 +84,15 @@ export function SearchResultCard({
   )
 }
 
-export const DesktopSearch = observer(function DesktopSearch() {
+export function DesktopSearch() {
   const {_} = useLingui()
   const pal = usePalette('default')
   const navigation = useNavigation<NavigationProp>()
   const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>(
     undefined,
   )
-  const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
+  const [isActive, setIsActive] = React.useState<boolean>(false)
+  const [isFetching, setIsFetching] = React.useState<boolean>(false)
   const [query, setQuery] = React.useState<string>('')
   const [searchResults, setSearchResults] = React.useState<
     AppBskyActorDefs.ProfileViewBasic[]
@@ -104,7 +105,10 @@ export const DesktopSearch = observer(function DesktopSearch() {
     async (text: string) => {
       setQuery(text)
 
-      if (text.length > 0 && isInputFocused) {
+      if (text.length > 0) {
+        setIsFetching(true)
+        setIsActive(true)
+
         if (searchDebounceTimeout.current)
           clearTimeout(searchDebounceTimeout.current)
 
@@ -113,24 +117,34 @@ export const DesktopSearch = observer(function DesktopSearch() {
 
           if (results) {
             setSearchResults(results)
+            setIsFetching(false)
           }
         }, 300)
       } else {
         if (searchDebounceTimeout.current)
           clearTimeout(searchDebounceTimeout.current)
         setSearchResults([])
+        setIsFetching(false)
+        setIsActive(false)
       }
     },
-    [setQuery, isInputFocused, search, setSearchResults],
+    [setQuery, search, setSearchResults],
   )
 
   const onPressCancelSearch = React.useCallback(() => {
-    onChangeText('')
-  }, [onChangeText])
-
+    setQuery('')
+    setIsActive(false)
+    if (searchDebounceTimeout.current)
+      clearTimeout(searchDebounceTimeout.current)
+  }, [setQuery])
   const onSubmit = React.useCallback(() => {
+    setIsActive(false)
+    if (!query.length) return
+    setSearchResults([])
+    if (searchDebounceTimeout.current)
+      clearTimeout(searchDebounceTimeout.current)
     navigation.dispatch(StackActions.push('Search', {q: query}))
-  }, [query, navigation])
+  }, [query, navigation, setSearchResults])
 
   return (
     <View style={[styles.container, pal.view]}>
@@ -149,8 +163,6 @@ export const DesktopSearch = observer(function DesktopSearch() {
             returnKeyType="search"
             value={query}
             style={[pal.textLight, styles.input]}
-            onFocus={() => setIsInputFocused(true)}
-            onBlur={() => setIsInputFocused(false)}
             onChangeText={onChangeText}
             onSubmitEditing={onSubmit}
             accessibilityRole="search"
@@ -174,29 +186,37 @@ export const DesktopSearch = observer(function DesktopSearch() {
         </View>
       </View>
 
-      {query !== '' && (
+      {query !== '' && isActive && moderationOpts && (
         <View style={[pal.view, pal.borderDark, styles.resultsContainer]}>
-          {searchResults.length && moderationOpts ? (
-            searchResults.map((item, i) => (
-              <SearchResultCard
-                key={item.did}
-                profile={item}
-                moderation={moderateProfile(item, moderationOpts)}
-                style={i === 0 ? {borderTopWidth: 0} : {}}
-              />
-            ))
-          ) : (
-            <View>
-              <Text style={[pal.textLight, styles.noResults]}>
-                <Trans>No results found for {query}</Trans>
-              </Text>
+          {isFetching ? (
+            <View style={{padding: 8}}>
+              <ActivityIndicator />
             </View>
+          ) : (
+            <>
+              {searchResults.length ? (
+                searchResults.map((item, i) => (
+                  <SearchResultCard
+                    key={item.did}
+                    profile={item}
+                    moderation={moderateProfile(item, moderationOpts)}
+                    style={i === 0 ? {borderTopWidth: 0} : {}}
+                  />
+                ))
+              ) : (
+                <View>
+                  <Text style={[pal.textLight, styles.noResults]}>
+                    <Trans>No results found for {query}</Trans>
+                  </Text>
+                </View>
+              )}
+            </>
           )}
         </View>
       )}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {