about summary refs log tree commit diff
path: root/src/screens
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens')
-rw-r--r--src/screens/Onboarding/StepFinished.tsx10
-rw-r--r--src/screens/Profile/ProfileSearch.tsx7
-rw-r--r--src/screens/Search/Explore.tsx923
-rw-r--r--src/screens/Search/SearchResults.tsx338
-rw-r--r--src/screens/Search/Shell.tsx535
-rw-r--r--src/screens/Search/components/AutocompleteResults.tsx71
-rw-r--r--src/screens/Search/components/ExploreTrendingTopics.tsx142
-rw-r--r--src/screens/Search/components/ModuleHeader.tsx170
-rw-r--r--src/screens/Search/components/SearchHistory.tsx169
-rw-r--r--src/screens/Search/components/SearchLanguageDropdown.tsx120
-rw-r--r--src/screens/Search/components/StarterPackCard.tsx296
-rw-r--r--src/screens/Search/index.tsx13
-rw-r--r--src/screens/Search/modules/ExploreFeedPreviews.tsx264
-rw-r--r--src/screens/Search/modules/ExploreRecommendations.tsx (renamed from src/screens/Search/components/ExploreRecommendations.tsx)12
-rw-r--r--src/screens/Search/modules/ExploreSuggestedAccounts.tsx228
-rw-r--r--src/screens/Search/modules/ExploreTrendingTopics.tsx278
-rw-r--r--src/screens/Search/modules/ExploreTrendingVideos.tsx (renamed from src/screens/Search/components/ExploreTrendingVideos.tsx)109
-rw-r--r--src/screens/Settings/ContentAndMediaSettings.tsx6
18 files changed, 3463 insertions, 228 deletions
diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx
index d0b0cacca..e725b7b80 100644
--- a/src/screens/Onboarding/StepFinished.tsx
+++ b/src/screens/Onboarding/StepFinished.tsx
@@ -1,12 +1,12 @@
 import React from 'react'
 import {View} from 'react-native'
 import {
-  AppBskyActorProfile,
-  AppBskyGraphDefs,
+  type AppBskyActorProfile,
+  type AppBskyGraphDefs,
   AppBskyGraphStarterpack,
-  Un$Typed,
+  type Un$Typed,
 } from '@atproto/api'
-import {SavedFeed} from '@atproto/api/dist/client/types/app/bsky/actor/defs'
+import {type SavedFeed} from '@atproto/api/dist/client/types/app/bsky/actor/defs'
 import {TID} from '@atproto/common-web'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -46,7 +46,7 @@ import {IconCircle} from '#/components/IconCircle'
 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
 import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
 import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
-import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
+import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending'
 import {Loader} from '#/components/Loader'
 import {Text} from '#/components/Typography'
 import * as bsky from '#/types/bsky'
diff --git a/src/screens/Profile/ProfileSearch.tsx b/src/screens/Profile/ProfileSearch.tsx
index d91dc973e..6247e3979 100644
--- a/src/screens/Profile/ProfileSearch.tsx
+++ b/src/screens/Profile/ProfileSearch.tsx
@@ -2,11 +2,14 @@ import {useMemo} from 'react'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {
+  type CommonNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
 import {useProfileQuery} from '#/state/queries/profile'
 import {useResolveDidQuery} from '#/state/queries/resolve-uri'
 import {useSession} from '#/state/session'
-import {SearchScreenShell} from '#/view/screens/Search/Search'
+import {SearchScreenShell} from '#/screens/Search/Shell'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileSearch'>
 export const ProfileSearchScreen = ({route}: Props) => {
diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx
new file mode 100644
index 000000000..86877677a
--- /dev/null
+++ b/src/screens/Search/Explore.tsx
@@ -0,0 +1,923 @@
+import {useCallback, useMemo, useRef, useState} from 'react'
+import {View, type ViewabilityConfig, type ViewToken} from 'react-native'
+import {
+  type AppBskyActorDefs,
+  type AppBskyFeedDefs,
+  type AppBskyGraphDefs,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useGate} from '#/lib/statsig/statsig'
+import {cleanError} from '#/lib/strings/errors'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {logger} from '#/logger'
+import {type MetricEvents} from '#/logger/metrics'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useActorSearchPaginated} from '#/state/queries/actor-search'
+import {useGetPopularFeedsQuery} from '#/state/queries/feed'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
+import {useGetSuggestedFeedsQuery} from '#/state/queries/trending/useGetSuggestedFeedsQuery'
+import {useSuggestedStarterPacksQuery} from '#/state/queries/useSuggestedStarterPacksQuery'
+import {useProgressGuide} from '#/state/shell/progress-guide'
+import {isThreadChildAt, isThreadParentAt} from '#/view/com/posts/PostFeed'
+import {PostFeedItem} from '#/view/com/posts/PostFeedItem'
+import {ViewFullThread} from '#/view/com/posts/ViewFullThread'
+import {List} from '#/view/com/util/List'
+import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
+import {
+  StarterPackCard,
+  StarterPackCardSkeleton,
+} from '#/screens/Search/components/StarterPackCard'
+import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations'
+import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics'
+import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos'
+import {atoms as a, native, useTheme, web} from '#/alf'
+import {Button} from '#/components/Button'
+import * as FeedCard from '#/components/FeedCard'
+import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {type Props as SVGIconProps} from '#/components/icons/common'
+import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
+import {StarterPack} from '#/components/icons/StarterPack'
+import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
+import {Loader} from '#/components/Loader'
+import * as ProfileCard from '#/components/ProfileCard'
+import {Text} from '#/components/Typography'
+import * as ModuleHeader from './components/ModuleHeader'
+import {
+  type FeedPreviewItem,
+  useFeedPreviews,
+} from './modules/ExploreFeedPreviews'
+import {
+  SuggestedAccountsTabBar,
+  SuggestedProfileCard,
+  useLoadEnoughProfiles,
+} from './modules/ExploreSuggestedAccounts'
+
+function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  return (
+    <Button
+      label={_(msg`Load more`)}
+      onPress={item.onLoadMore}
+      style={[a.relative, a.w_full]}>
+      {({hovered, pressed}) => (
+        <View
+          style={[
+            a.flex_1,
+            a.flex_row,
+            a.align_center,
+            a.justify_center,
+            a.px_lg,
+            a.py_md,
+            a.gap_sm,
+            (hovered || pressed) && t.atoms.bg_contrast_25,
+          ]}>
+          <Text
+            style={[
+              a.leading_snug,
+              hovered ? t.atoms.text : t.atoms.text_contrast_medium,
+            ]}>
+            {item.message}
+          </Text>
+          {item.isLoadingMore ? (
+            <Loader size="sm" />
+          ) : (
+            <ChevronDownIcon
+              size="sm"
+              style={hovered ? t.atoms.text : t.atoms.text_contrast_medium}
+            />
+          )}
+        </View>
+      )}
+    </Button>
+  )
+}
+
+type ExploreScreenItems =
+  | {
+      type: 'topBorder'
+      key: string
+    }
+  | {
+      type: 'header'
+      key: string
+      title: string
+      icon: React.ComponentType<SVGIconProps>
+      searchButton?: {
+        label: string
+        metricsTag: MetricEvents['explore:module:searchButtonPress']['module']
+        tab: 'user' | 'profile' | 'feed'
+      }
+    }
+  | {
+      type: 'tabbedHeader'
+      key: string
+      title: string
+      icon: React.ComponentType<SVGIconProps>
+      searchButton?: {
+        label: string
+        metricsTag: MetricEvents['explore:module:searchButtonPress']['module']
+        tab: 'user' | 'profile' | 'feed'
+      }
+    }
+  | {
+      type: 'trendingTopics'
+      key: string
+    }
+  | {
+      type: 'trendingVideos'
+      key: string
+    }
+  | {
+      type: 'recommendations'
+      key: string
+    }
+  | {
+      type: 'profile'
+      key: string
+      profile: AppBskyActorDefs.ProfileView
+      recId?: number
+    }
+  | {
+      type: 'feed'
+      key: string
+      feed: AppBskyFeedDefs.GeneratorView
+    }
+  | {
+      type: 'loadMore'
+      key: string
+      message: string
+      isLoadingMore: boolean
+      onLoadMore: () => void
+    }
+  | {
+      type: 'profilePlaceholder'
+      key: string
+    }
+  | {
+      type: 'feedPlaceholder'
+      key: string
+    }
+  | {
+      type: 'error'
+      key: string
+      message: string
+      error: string
+    }
+  | {
+      type: 'starterPack'
+      key: string
+      view: AppBskyGraphDefs.StarterPackView
+    }
+  | {
+      type: 'starterPackSkeleton'
+      key: string
+    }
+  | FeedPreviewItem
+
+export function Explore({
+  focusSearchInput,
+  headerHeight,
+}: {
+  focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void
+  headerHeight: number
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {data: preferences, error: preferencesError} = usePreferencesQuery()
+  const moderationOpts = useModerationOpts()
+  const gate = useGate()
+  const guide = useProgressGuide('follow-10')
+  const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
+  const {
+    data: suggestedProfiles,
+    hasNextPage: hasNextSuggestedProfilesPage,
+    isLoading: isLoadingSuggestedProfiles,
+    isFetchingNextPage: isFetchingNextSuggestedProfilesPage,
+    error: suggestedProfilesError,
+    fetchNextPage: fetchNextSuggestedProfilesPage,
+  } = useSuggestedFollowsQuery({limit: 3, subsequentPageLimit: 10})
+  const {
+    data: interestProfiles,
+    hasNextPage: hasNextInterestProfilesPage,
+    isLoading: isLoadingInterestProfiles,
+    isFetchingNextPage: isFetchingNextInterestProfilesPage,
+    error: interestProfilesError,
+    fetchNextPage: fetchNextInterestProfilesPage,
+  } = useActorSearchPaginated({
+    query: selectedInterest || '',
+    enabled: !!selectedInterest,
+    limit: 10,
+  })
+  const {isReady: canShowSuggestedProfiles} = useLoadEnoughProfiles({
+    interest: selectedInterest,
+    data: interestProfiles,
+    isLoading: isLoadingInterestProfiles,
+    isFetchingNextPage: isFetchingNextInterestProfilesPage,
+    hasNextPage: hasNextInterestProfilesPage,
+    fetchNextPage: fetchNextInterestProfilesPage,
+  })
+  const {
+    data: feeds,
+    hasNextPage: hasNextFeedsPage,
+    isLoading: isLoadingFeeds,
+    isFetchingNextPage: isFetchingNextFeedsPage,
+    error: feedsError,
+    fetchNextPage: fetchNextFeedsPage,
+  } = useGetPopularFeedsQuery({limit: 10})
+
+  const profiles: typeof suggestedProfiles & typeof interestProfiles =
+    !selectedInterest ? suggestedProfiles : interestProfiles
+  const hasNextProfilesPage = !selectedInterest
+    ? hasNextSuggestedProfilesPage
+    : hasNextInterestProfilesPage
+  const isLoadingProfiles = !selectedInterest
+    ? isLoadingSuggestedProfiles
+    : !canShowSuggestedProfiles
+  const isFetchingNextProfilesPage = !selectedInterest
+    ? isFetchingNextSuggestedProfilesPage
+    : !canShowSuggestedProfiles
+  const profilesError = !selectedInterest
+    ? suggestedProfilesError
+    : interestProfilesError
+  const fetchNextProfilesPage = !selectedInterest
+    ? fetchNextSuggestedProfilesPage
+    : fetchNextInterestProfilesPage
+
+  const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
+  const onLoadMoreProfiles = useCallback(async () => {
+    if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError)
+      return
+    try {
+      await fetchNextProfilesPage()
+    } catch (err) {
+      logger.error('Failed to load more suggested follows', {message: err})
+    }
+  }, [
+    isFetchingNextProfilesPage,
+    hasNextProfilesPage,
+    profilesError,
+    fetchNextProfilesPage,
+  ])
+  const {
+    data: suggestedSPs,
+    isLoading: isLoadingSuggestedSPs,
+    error: suggestedSPsError,
+  } = useSuggestedStarterPacksQuery()
+
+  const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds
+  const [hasPressedLoadMoreFeeds, setHasPressedLoadMoreFeeds] = useState(false)
+  const onLoadMoreFeeds = useCallback(async () => {
+    if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return
+    if (!hasPressedLoadMoreFeeds) {
+      setHasPressedLoadMoreFeeds(true)
+      return
+    }
+    try {
+      await fetchNextFeedsPage()
+    } catch (err) {
+      logger.error('Failed to load more suggested follows', {message: err})
+    }
+  }, [
+    isFetchingNextFeedsPage,
+    hasNextFeedsPage,
+    feedsError,
+    fetchNextFeedsPage,
+    hasPressedLoadMoreFeeds,
+  ])
+
+  const {data: suggestedFeeds} = useGetSuggestedFeedsQuery()
+  const {
+    data: feedPreviewSlices,
+    query: {
+      isPending: isPendingFeedPreviews,
+      isFetchingNextPage: isFetchingNextPageFeedPreviews,
+      fetchNextPage: fetchNextPageFeedPreviews,
+      hasNextPage: hasNextPageFeedPreviews,
+      error: feedPreviewSlicesError,
+    },
+  } = useFeedPreviews(suggestedFeeds?.feeds ?? [])
+
+  const onLoadMoreFeedPreviews = useCallback(async () => {
+    if (
+      isPendingFeedPreviews ||
+      isFetchingNextPageFeedPreviews ||
+      !hasNextPageFeedPreviews ||
+      feedPreviewSlicesError
+    )
+      return
+    try {
+      await fetchNextPageFeedPreviews()
+    } catch (err) {
+      logger.error('Failed to load more feed previews', {message: err})
+    }
+  }, [
+    isPendingFeedPreviews,
+    isFetchingNextPageFeedPreviews,
+    hasNextPageFeedPreviews,
+    feedPreviewSlicesError,
+    fetchNextPageFeedPreviews,
+  ])
+
+  const items = useMemo<ExploreScreenItems[]>(() => {
+    const i: ExploreScreenItems[] = []
+
+    const addTopBorder = () => {
+      i.push({type: 'topBorder', key: 'top-border'})
+    }
+
+    const addTrendingTopicsModule = () => {
+      i.push({
+        type: 'trendingTopics',
+        key: `trending-topics`,
+      })
+
+      // temp - disable trending videos
+      // if (isNative) {
+      //   i.push({
+      //     type: 'trendingVideos',
+      //     key: `trending-videos`,
+      //   })
+      // }
+    }
+
+    const addSuggestedFollowsModule = () => {
+      i.push({
+        type: 'tabbedHeader',
+        key: 'suggested-accounts-header',
+        title: _(msg`Suggested Accounts`),
+        icon: Person,
+        searchButton: {
+          label: _(msg`Search for more accounts`),
+          metricsTag: 'suggestedAccounts',
+          tab: 'user',
+        },
+      })
+
+      if (!canShowSuggestedProfiles) {
+        i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
+      } else if (profilesError) {
+        i.push({
+          type: 'error',
+          key: 'profilesError',
+          message: _(msg`Failed to load suggested follows`),
+          error: cleanError(profilesError),
+        })
+      } else {
+        if (profiles !== undefined) {
+          if (profiles.pages.length > 0 && moderationOpts) {
+            // Currently the responses contain duplicate items.
+            // Needs to be fixed on backend, but let's dedupe to be safe.
+            let seen = new Set()
+            const profileItems: ExploreScreenItems[] = []
+            for (const page of profiles.pages) {
+              for (const actor of page.actors) {
+                if (!seen.has(actor.did) && !actor.viewer?.following) {
+                  seen.add(actor.did)
+                  profileItems.push({
+                    type: 'profile',
+                    key: actor.did,
+                    profile: actor,
+                    recId: page.recId,
+                  })
+                }
+              }
+            }
+
+            if (profileItems.length === 0) {
+              if (!hasNextProfilesPage) {
+                // no items! remove the header
+                i.pop()
+              }
+            } else {
+              i.push(...profileItems)
+            }
+            if (hasNextProfilesPage) {
+              i.push({
+                type: 'loadMore',
+                key: 'loadMoreProfiles',
+                message: _(msg`Load more suggested accounts`),
+                isLoadingMore: isLoadingMoreProfiles,
+                onLoadMore: onLoadMoreProfiles,
+              })
+            }
+          } else {
+            console.log('no pages')
+          }
+        } else {
+          i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
+        }
+      }
+    }
+
+    const addSuggestedFeedsModule = () => {
+      i.push({
+        type: 'header',
+        key: 'suggested-feeds-header',
+        title: _(msg`Discover Feeds`),
+        icon: ListSparkle,
+        searchButton: {
+          label: _(msg`Search for more feeds`),
+          metricsTag: 'suggestedFeeds',
+          tab: 'feed',
+        },
+      })
+
+      if (feeds && preferences) {
+        // Currently the responses contain duplicate items.
+        // Needs to be fixed on backend, but let's dedupe to be safe.
+        let seen = new Set()
+        const feedItems: ExploreScreenItems[] = []
+        for (const page of feeds.pages) {
+          for (const feed of page.feeds) {
+            if (!seen.has(feed.uri)) {
+              seen.add(feed.uri)
+              feedItems.push({
+                type: 'feed',
+                key: feed.uri,
+                feed,
+              })
+            }
+          }
+        }
+
+        // feeds errors can occur during pagination, so feeds is truthy
+        if (feedsError) {
+          i.push({
+            type: 'error',
+            key: 'feedsError',
+            message: _(msg`Failed to load suggested feeds`),
+            error: cleanError(feedsError),
+          })
+        } else if (preferencesError) {
+          i.push({
+            type: 'error',
+            key: 'preferencesError',
+            message: _(msg`Failed to load feeds preferences`),
+            error: cleanError(preferencesError),
+          })
+        } else {
+          if (feedItems.length === 0) {
+            if (!hasNextFeedsPage) {
+              i.pop()
+            }
+          } else {
+            // This query doesn't follow the limit very well, so the first press of the
+            // load more button just unslices the array back to ~10 items
+            if (!hasPressedLoadMoreFeeds) {
+              i.push(...feedItems.slice(0, 3))
+            } else {
+              i.push(...feedItems)
+            }
+          }
+          if (hasNextFeedsPage) {
+            i.push({
+              type: 'loadMore',
+              key: 'loadMoreFeeds',
+              message: _(msg`Load more suggested feeds`),
+              isLoadingMore: isLoadingMoreFeeds,
+              onLoadMore: onLoadMoreFeeds,
+            })
+          }
+        }
+      } else {
+        if (feedsError) {
+          i.push({
+            type: 'error',
+            key: 'feedsError',
+            message: _(msg`Failed to load suggested feeds`),
+            error: cleanError(feedsError),
+          })
+        } else if (preferencesError) {
+          i.push({
+            type: 'error',
+            key: 'preferencesError',
+            message: _(msg`Failed to load feeds preferences`),
+            error: cleanError(preferencesError),
+          })
+        } else {
+          i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'})
+        }
+      }
+    }
+
+    const addSuggestedStarterPacksModule = () => {
+      i.push({
+        type: 'header',
+        key: 'suggested-starterPacks-header',
+        title: _(msg`Starter Packs`),
+        icon: StarterPack,
+      })
+
+      if (isLoadingSuggestedSPs) {
+        Array.from({length: 3}).forEach((_, index) =>
+          i.push({
+            type: 'starterPackSkeleton',
+            key: `starterPackSkeleton-${index}`,
+          }),
+        )
+      } else if (suggestedSPsError || !suggestedSPs) {
+        // just get rid of the section
+        i.pop()
+      } else {
+        suggestedSPs.starterPacks.map(s => {
+          i.push({
+            type: 'starterPack',
+            key: s.uri,
+            view: s,
+          })
+        })
+      }
+    }
+
+    const addFeedPreviews = () => {
+      i.push(...feedPreviewSlices)
+      if (isFetchingNextPageFeedPreviews) {
+        i.push({
+          type: 'preview:loading',
+          key: 'preview-loading-more',
+        })
+      }
+    }
+
+    // Dynamic module ordering
+
+    addTopBorder()
+
+    if (guide?.guide === 'follow-10' && !guide.isComplete) {
+      addSuggestedFollowsModule()
+      addSuggestedStarterPacksModule()
+      addTrendingTopicsModule()
+    } else {
+      addTrendingTopicsModule()
+      addSuggestedFollowsModule()
+      addSuggestedStarterPacksModule()
+    }
+
+    if (gate('explore_show_suggested_feeds')) {
+      addSuggestedFeedsModule()
+    }
+
+    addFeedPreviews()
+
+    return i
+  }, [
+    _,
+    profiles,
+    feeds,
+    preferences,
+    onLoadMoreFeeds,
+    onLoadMoreProfiles,
+    isLoadingMoreProfiles,
+    isLoadingMoreFeeds,
+    profilesError,
+    feedsError,
+    preferencesError,
+    hasNextProfilesPage,
+    hasNextFeedsPage,
+    guide,
+    gate,
+    moderationOpts,
+    hasPressedLoadMoreFeeds,
+    suggestedSPs,
+    isLoadingSuggestedSPs,
+    suggestedSPsError,
+    feedPreviewSlices,
+    isFetchingNextPageFeedPreviews,
+    canShowSuggestedProfiles,
+  ])
+
+  const renderItem = useCallback(
+    ({item, index}: {item: ExploreScreenItems; index: number}) => {
+      switch (item.type) {
+        case 'topBorder':
+          return (
+            <View
+              style={[
+                a.w_full,
+                t.atoms.border_contrast_low,
+                a.border_t,
+                headerHeight &&
+                  web({
+                    position: 'sticky',
+                    top: headerHeight,
+                  }),
+              ]}
+            />
+          )
+        case 'header': {
+          return (
+            <ModuleHeader.Container>
+              <ModuleHeader.Icon icon={item.icon} />
+              <ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText>
+              {item.searchButton && (
+                <ModuleHeader.SearchButton
+                  {...item.searchButton}
+                  onPress={() =>
+                    focusSearchInput(item.searchButton?.tab || 'user')
+                  }
+                />
+              )}
+            </ModuleHeader.Container>
+          )
+        }
+        case 'tabbedHeader': {
+          return (
+            <View style={[a.pb_md]}>
+              <ModuleHeader.Container style={[a.pb_xs]}>
+                <ModuleHeader.Icon icon={item.icon} />
+                <ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText>
+                {item.searchButton && (
+                  <ModuleHeader.SearchButton
+                    {...item.searchButton}
+                    onPress={() =>
+                      focusSearchInput(item.searchButton?.tab || 'user')
+                    }
+                  />
+                )}
+              </ModuleHeader.Container>
+              <SuggestedAccountsTabBar
+                selectedInterest={selectedInterest}
+                onSelectInterest={setSelectedInterest}
+              />
+            </View>
+          )
+        }
+        case 'trendingTopics': {
+          return (
+            <View style={[a.pb_md]}>
+              <ExploreTrendingTopics />
+            </View>
+          )
+        }
+        case 'trendingVideos': {
+          return <ExploreTrendingVideos />
+        }
+        case 'recommendations': {
+          return <ExploreRecommendations />
+        }
+        case 'profile': {
+          return (
+            <SuggestedProfileCard
+              profile={item.profile}
+              moderationOpts={moderationOpts!}
+              recId={item.recId}
+              position={index}
+            />
+          )
+        }
+        case 'feed': {
+          return (
+            <View
+              style={[
+                a.border_t,
+                t.atoms.border_contrast_low,
+                a.px_lg,
+                a.py_lg,
+              ]}>
+              <FeedCard.Default view={item.feed} />
+            </View>
+          )
+        }
+        case 'starterPack': {
+          return (
+            <View style={[a.px_lg, a.pb_lg]}>
+              <StarterPackCard view={item.view} />
+            </View>
+          )
+        }
+        case 'starterPackSkeleton': {
+          return (
+            <View style={[a.px_lg, a.pb_lg]}>
+              <StarterPackCardSkeleton />
+            </View>
+          )
+        }
+        case 'loadMore': {
+          return (
+            <View style={[a.border_t, t.atoms.border_contrast_low]}>
+              <LoadMore item={item} />
+            </View>
+          )
+        }
+        case 'profilePlaceholder': {
+          return (
+            <>
+              {Array.from({length: 3}).map((_, index) => (
+                <View
+                  style={[
+                    a.px_lg,
+                    a.py_lg,
+                    a.border_t,
+                    t.atoms.border_contrast_low,
+                  ]}
+                  key={index}>
+                  <ProfileCard.Outer>
+                    <ProfileCard.Header>
+                      <ProfileCard.AvatarPlaceholder />
+                      <ProfileCard.NameAndHandlePlaceholder />
+                    </ProfileCard.Header>
+                    <ProfileCard.DescriptionPlaceholder numberOfLines={2} />
+                  </ProfileCard.Outer>
+                </View>
+              ))}
+            </>
+          )
+        }
+        case 'feedPlaceholder': {
+          return <FeedFeedLoadingPlaceholder />
+        }
+        case 'error':
+        case 'preview:error': {
+          return (
+            <View
+              style={[
+                a.border_t,
+                a.pt_md,
+                a.px_md,
+                t.atoms.border_contrast_low,
+              ]}>
+              <View
+                style={[
+                  a.flex_row,
+                  a.gap_md,
+                  a.p_lg,
+                  a.rounded_sm,
+                  t.atoms.bg_contrast_25,
+                ]}>
+                <CircleInfo size="md" fill={t.palette.negative_400} />
+                <View style={[a.flex_1, a.gap_sm]}>
+                  <Text style={[a.font_bold, a.leading_snug]}>
+                    {item.message}
+                  </Text>
+                  <Text
+                    style={[
+                      a.italic,
+                      a.leading_snug,
+                      t.atoms.text_contrast_medium,
+                    ]}>
+                    {item.error}
+                  </Text>
+                </View>
+              </View>
+            </View>
+          )
+        }
+        // feed previews
+        case 'preview:empty': {
+          return null // what should we do here?
+        }
+        case 'preview:loading': {
+          return (
+            <View style={[a.py_2xl, a.flex_1, a.align_center]}>
+              <Loader size="lg" />
+            </View>
+          )
+        }
+        case 'preview:header': {
+          return (
+            <ModuleHeader.Container
+              headerHeight={headerHeight}
+              style={[a.pt_xs, a.border_b, t.atoms.border_contrast_low]}>
+              <ModuleHeader.FeedLink feed={item.feed}>
+                <ModuleHeader.FeedAvatar feed={item.feed} />
+                <View style={[a.flex_1, a.gap_xs]}>
+                  <ModuleHeader.TitleText style={[a.text_lg]}>
+                    {item.feed.displayName}
+                  </ModuleHeader.TitleText>
+                  <ModuleHeader.SubtitleText>
+                    <Trans>
+                      By {sanitizeHandle(item.feed.creator.handle, '@')}
+                    </Trans>
+                  </ModuleHeader.SubtitleText>
+                </View>
+              </ModuleHeader.FeedLink>
+              <ModuleHeader.PinButton feed={item.feed} />
+            </ModuleHeader.Container>
+          )
+        }
+        case 'preview:footer': {
+          return <View style={[a.w_full, a.pt_2xl]} />
+        }
+        case 'preview:sliceItem': {
+          const slice = item.slice
+          const indexInSlice = item.indexInSlice
+          const subItem = slice.items[indexInSlice]
+          return (
+            <PostFeedItem
+              post={subItem.post}
+              record={subItem.record}
+              reason={indexInSlice === 0 ? slice.reason : undefined}
+              feedContext={slice.feedContext}
+              moderation={subItem.moderation}
+              parentAuthor={subItem.parentAuthor}
+              showReplyTo={item.showReplyTo}
+              isThreadParent={isThreadParentAt(slice.items, indexInSlice)}
+              isThreadChild={isThreadChildAt(slice.items, indexInSlice)}
+              isThreadLastChild={
+                isThreadChildAt(slice.items, indexInSlice) &&
+                slice.items.length === indexInSlice + 1
+              }
+              isParentBlocked={subItem.isParentBlocked}
+              isParentNotFound={subItem.isParentNotFound}
+              hideTopBorder={item.hideTopBorder}
+              rootPost={slice.items[0].post}
+            />
+          )
+        }
+        case 'preview:sliceViewFullThread': {
+          return <ViewFullThread uri={item.uri} />
+        }
+        case 'preview:loadMoreError': {
+          return (
+            <LoadMoreRetryBtn
+              label={_(
+                msg`There was an issue fetching posts. Tap here to try again.`,
+              )}
+              onPress={fetchNextPageFeedPreviews}
+            />
+          )
+        }
+      }
+    },
+    [
+      t,
+      focusSearchInput,
+      moderationOpts,
+      selectedInterest,
+      _,
+      fetchNextPageFeedPreviews,
+      headerHeight,
+    ],
+  )
+
+  const stickyHeaderIndices = useMemo(
+    () =>
+      items.reduce(
+        (acc, curr) =>
+          ['topBorder', 'preview:header'].includes(curr.type)
+            ? acc.concat(items.indexOf(curr))
+            : acc,
+        [] as number[],
+      ),
+    [items],
+  )
+
+  // track headers and report module viewability
+  const alreadyReportedRef = useRef<Map<string, string>>(new Map())
+  const onViewableItemsChanged = useCallback(
+    ({
+      viewableItems,
+    }: {
+      viewableItems: ViewToken<ExploreScreenItems>[]
+      changed: ViewToken<ExploreScreenItems>[]
+    }) => {
+      for (const {item} of viewableItems.filter(vi => vi.isViewable)) {
+        let module: MetricEvents['explore:module:seen']['module']
+        if (item.type === 'trendingTopics' || item.type === 'trendingVideos') {
+          module = item.type
+        } else if (item.type === 'profile') {
+          module = 'suggestedAccounts'
+        } else if (item.type === 'feed') {
+          module = 'suggestedFeeds'
+        } else if (item.type === 'preview:header') {
+          module = `feed:feedgen|${item.feed.uri}`
+        } else {
+          continue
+        }
+        if (!alreadyReportedRef.current.has(module)) {
+          alreadyReportedRef.current.set(module, module)
+          logger.metric('explore:module:seen', {module})
+        }
+      }
+    },
+    [],
+  )
+
+  return (
+    <List
+      data={items}
+      renderItem={renderItem}
+      keyExtractor={item => item.key}
+      desktopFixedHeight
+      contentContainerStyle={{paddingBottom: 100}}
+      keyboardShouldPersistTaps="handled"
+      keyboardDismissMode="on-drag"
+      stickyHeaderIndices={native(stickyHeaderIndices)}
+      viewabilityConfig={viewabilityConfig}
+      onViewableItemsChanged={onViewableItemsChanged}
+      onEndReached={onLoadMoreFeedPreviews}
+      onEndReachedThreshold={2}
+    />
+  )
+}
+
+const viewabilityConfig: ViewabilityConfig = {
+  itemVisiblePercentThreshold: 100,
+}
diff --git a/src/screens/Search/SearchResults.tsx b/src/screens/Search/SearchResults.tsx
new file mode 100644
index 000000000..bb51d2deb
--- /dev/null
+++ b/src/screens/Search/SearchResults.tsx
@@ -0,0 +1,338 @@
+import {memo, useCallback, useMemo, useState} from 'react'
+import {ActivityIndicator, View} from 'react-native'
+import {type AppBskyFeedDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {augmentSearchQuery} from '#/lib/strings/helpers'
+import {useActorSearch} from '#/state/queries/actor-search'
+import {usePopularFeedsSearch} from '#/state/queries/feed'
+import {useSearchPostsQuery} from '#/state/queries/search-posts'
+import {useSession} from '#/state/session'
+import {Pager} 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 {List} from '#/view/com/util/List'
+import {atoms as a, useTheme, web} from '#/alf'
+import * as FeedCard from '#/components/FeedCard'
+import * as Layout from '#/components/Layout'
+import {Text} from '#/components/Typography'
+
+let SearchResults = ({
+  query,
+  queryWithParams,
+  activeTab,
+  onPageSelected,
+  headerHeight,
+}: {
+  query: string
+  queryWithParams: string
+  activeTab: number
+  onPageSelected: (page: number) => void
+  headerHeight: number
+}): React.ReactNode => {
+  const {_} = useLingui()
+
+  const sections = useMemo(() => {
+    if (!queryWithParams) return []
+    const noParams = queryWithParams === query
+    return [
+      {
+        title: _(msg`Top`),
+        component: (
+          <SearchScreenPostResults
+            query={queryWithParams}
+            sort="top"
+            active={activeTab === 0}
+          />
+        ),
+      },
+      {
+        title: _(msg`Latest`),
+        component: (
+          <SearchScreenPostResults
+            query={queryWithParams}
+            sort="latest"
+            active={activeTab === 1}
+          />
+        ),
+      },
+      noParams && {
+        title: _(msg`People`),
+        component: (
+          <SearchScreenUserResults query={query} active={activeTab === 2} />
+        ),
+      },
+      noParams && {
+        title: _(msg`Feeds`),
+        component: (
+          <SearchScreenFeedsResults query={query} active={activeTab === 3} />
+        ),
+      },
+    ].filter(Boolean) as {
+      title: string
+      component: React.ReactNode
+    }[]
+  }, [_, query, queryWithParams, activeTab])
+
+  return (
+    <Pager
+      onPageSelected={onPageSelected}
+      renderTabBar={props => (
+        <Layout.Center style={[a.z_10, web([a.sticky, {top: headerHeight}])]}>
+          <TabBar items={sections.map(section => section.title)} {...props} />
+        </Layout.Center>
+      )}
+      initialPage={0}>
+      {sections.map((section, i) => (
+        <View key={i}>{section.component}</View>
+      ))}
+    </Pager>
+  )
+}
+SearchResults = memo(SearchResults)
+export {SearchResults}
+
+function Loader() {
+  return (
+    <Layout.Content>
+      <View style={[a.py_xl]}>
+        <ActivityIndicator />
+      </View>
+    </Layout.Content>
+  )
+}
+
+function EmptyState({message, error}: {message: string; error?: string}) {
+  const t = useTheme()
+
+  return (
+    <Layout.Content>
+      <View style={[a.p_xl]}>
+        <View style={[t.atoms.bg_contrast_25, a.rounded_sm, a.p_lg]}>
+          <Text style={[a.text_md]}>{message}</Text>
+
+          {error && (
+            <>
+              <View
+                style={[
+                  {
+                    marginVertical: 12,
+                    height: 1,
+                    width: '100%',
+                    backgroundColor: t.atoms.text.color,
+                    opacity: 0.2,
+                  },
+                ]}
+              />
+
+              <Text style={[t.atoms.text_contrast_medium]}>
+                <Trans>Error: {error}</Trans>
+              </Text>
+            </>
+          )}
+        </View>
+      </View>
+    </Layout.Content>
+  )
+}
+
+type SearchResultSlice =
+  | {
+      type: 'post'
+      key: string
+      post: AppBskyFeedDefs.PostView
+    }
+  | {
+      type: 'loadingMore'
+      key: string
+    }
+
+let SearchScreenPostResults = ({
+  query,
+  sort,
+  active,
+}: {
+  query: string
+  sort?: 'top' | 'latest'
+  active: boolean
+}): React.ReactNode => {
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const [isPTR, setIsPTR] = useState(false)
+
+  const augmentedQuery = useMemo(() => {
+    return augmentSearchQuery(query || '', {did: currentAccount?.did})
+  }, [query, currentAccount])
+
+  const {
+    isFetched,
+    data: results,
+    isFetching,
+    error,
+    refetch,
+    fetchNextPage,
+    isFetchingNextPage,
+    hasNextPage,
+  } = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active})
+
+  const onPullToRefresh = useCallback(async () => {
+    setIsPTR(true)
+    await refetch()
+    setIsPTR(false)
+  }, [setIsPTR, refetch])
+  const onEndReached = useCallback(() => {
+    if (isFetching || !hasNextPage || error) return
+    fetchNextPage()
+  }, [isFetching, error, hasNextPage, fetchNextPage])
+
+  const posts = useMemo(() => {
+    return results?.pages.flatMap(page => page.posts) || []
+  }, [results])
+  const items = useMemo(() => {
+    let temp: SearchResultSlice[] = []
+
+    const seenUris = new Set()
+    for (const post of posts) {
+      if (seenUris.has(post.uri)) {
+        continue
+      }
+      temp.push({
+        type: 'post',
+        key: post.uri,
+        post,
+      })
+      seenUris.add(post.uri)
+    }
+
+    if (isFetchingNextPage) {
+      temp.push({
+        type: 'loadingMore',
+        key: 'loadingMore',
+      })
+    }
+
+    return temp
+  }, [posts, isFetchingNextPage])
+
+  return error ? (
+    <EmptyState
+      message={_(
+        msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`,
+      )}
+      error={error.toString()}
+    />
+  ) : (
+    <>
+      {isFetched ? (
+        <>
+          {posts.length ? (
+            <List
+              data={items}
+              renderItem={({item}) => {
+                if (item.type === 'post') {
+                  return <Post post={item.post} />
+                } else {
+                  return null
+                }
+              }}
+              keyExtractor={item => item.key}
+              refreshing={isPTR}
+              onRefresh={onPullToRefresh}
+              onEndReached={onEndReached}
+              desktopFixedHeight
+              contentContainerStyle={{paddingBottom: 100}}
+            />
+          ) : (
+            <EmptyState message={_(msg`No results found for ${query}`)} />
+          )}
+        </>
+      ) : (
+        <Loader />
+      )}
+    </>
+  )
+}
+SearchScreenPostResults = memo(SearchScreenPostResults)
+
+let SearchScreenUserResults = ({
+  query,
+  active,
+}: {
+  query: string
+  active: boolean
+}): React.ReactNode => {
+  const {_} = useLingui()
+
+  const {data: results, isFetched} = useActorSearch({
+    query,
+    enabled: active,
+  })
+
+  return isFetched && results ? (
+    <>
+      {results.length ? (
+        <List
+          data={results}
+          renderItem={({item}) => (
+            <ProfileCardWithFollowBtn profile={item} noBg />
+          )}
+          keyExtractor={item => item.did}
+          desktopFixedHeight
+          contentContainerStyle={{paddingBottom: 100}}
+        />
+      ) : (
+        <EmptyState message={_(msg`No results found for ${query}`)} />
+      )}
+    </>
+  ) : (
+    <Loader />
+  )
+}
+SearchScreenUserResults = memo(SearchScreenUserResults)
+
+let SearchScreenFeedsResults = ({
+  query,
+  active,
+}: {
+  query: string
+  active: boolean
+}): React.ReactNode => {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  const {data: results, isFetched} = usePopularFeedsSearch({
+    query,
+    enabled: active,
+  })
+
+  return isFetched && results ? (
+    <>
+      {results.length ? (
+        <List
+          data={results}
+          renderItem={({item}) => (
+            <View
+              style={[
+                a.border_b,
+                t.atoms.border_contrast_low,
+                a.px_lg,
+                a.py_lg,
+              ]}>
+              <FeedCard.Default view={item} />
+            </View>
+          )}
+          keyExtractor={item => item.uri}
+          desktopFixedHeight
+          contentContainerStyle={{paddingBottom: 100}}
+        />
+      ) : (
+        <EmptyState message={_(msg`No results found for ${query}`)} />
+      )}
+    </>
+  ) : (
+    <Loader />
+  )
+}
+SearchScreenFeedsResults = memo(SearchScreenFeedsResults)
diff --git a/src/screens/Search/Shell.tsx b/src/screens/Search/Shell.tsx
new file mode 100644
index 000000000..e930b8289
--- /dev/null
+++ b/src/screens/Search/Shell.tsx
@@ -0,0 +1,535 @@
+import {
+  memo,
+  useCallback,
+  useLayoutEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react'
+import {
+  type StyleProp,
+  type TextInput,
+  View,
+  type ViewStyle,
+} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native'
+import {useQueryClient} from '@tanstack/react-query'
+
+import {HITSLOP_20} from '#/lib/constants'
+import {HITSLOP_10} from '#/lib/constants'
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {MagnifyingGlassIcon} from '#/lib/icons'
+import {type NavigationProp} from '#/lib/routes/types'
+import {isWeb} from '#/platform/detection'
+import {listenSoftReset} from '#/state/events'
+import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
+import {
+  unstableCacheProfileView,
+  useProfilesQuery,
+} from '#/state/queries/profile'
+import {useSession} from '#/state/session'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {
+  makeSearchQuery,
+  type Params,
+  parseSearchQuery,
+} from '#/screens/Search/utils'
+import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {SearchInput} from '#/components/forms/SearchInput'
+import * as Layout from '#/components/Layout'
+import {Text} from '#/components/Typography'
+import {account, useStorage} from '#/storage'
+import type * as bsky from '#/types/bsky'
+import {AutocompleteResults} from './components/AutocompleteResults'
+import {SearchHistory} from './components/SearchHistory'
+import {SearchLanguageDropdown} from './components/SearchLanguageDropdown'
+import {Explore} from './Explore'
+import {SearchResults} from './SearchResults'
+
+export function SearchScreenShell({
+  queryParam,
+  testID,
+  fixedParams,
+  navButton = 'menu',
+  inputPlaceholder,
+}: {
+  queryParam: string
+  testID: string
+  fixedParams?: Params
+  navButton?: 'back' | 'menu'
+  inputPlaceholder?: string
+}) {
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+  const navigation = useNavigation<NavigationProp>()
+  const route = useRoute()
+  const textInput = useRef<TextInput>(null)
+  const {_} = useLingui()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {currentAccount} = useSession()
+  const queryClient = useQueryClient()
+
+  // Query terms
+  const [searchText, setSearchText] = useState<string>(queryParam)
+  const {data: autocompleteData, isFetching: isAutocompleteFetching} =
+    useActorAutocompleteQuery(searchText, true)
+
+  const [showAutocomplete, setShowAutocomplete] = useState(false)
+
+  const [termHistory = [], setTermHistory] = useStorage(account, [
+    currentAccount?.did ?? 'pwi',
+    'searchTermHistory',
+  ] as const)
+  const [accountHistory = [], setAccountHistory] = useStorage(account, [
+    currentAccount?.did ?? 'pwi',
+    'searchAccountHistory',
+  ])
+
+  const {data: accountHistoryProfiles} = useProfilesQuery({
+    handles: accountHistory,
+    maintainData: true,
+  })
+
+  const updateSearchHistory = useCallback(
+    async (item: string) => {
+      if (!item) return
+      const newSearchHistory = [
+        item,
+        ...termHistory.filter(search => search !== item),
+      ].slice(0, 6)
+      setTermHistory(newSearchHistory)
+    },
+    [termHistory, setTermHistory],
+  )
+
+  const updateProfileHistory = useCallback(
+    async (item: bsky.profile.AnyProfileView) => {
+      const newAccountHistory = [
+        item.did,
+        ...accountHistory.filter(p => p !== item.did),
+      ].slice(0, 5)
+      setAccountHistory(newAccountHistory)
+    },
+    [accountHistory, setAccountHistory],
+  )
+
+  const deleteSearchHistoryItem = useCallback(
+    async (item: string) => {
+      setTermHistory(termHistory.filter(search => search !== item))
+    },
+    [termHistory, setTermHistory],
+  )
+  const deleteProfileHistoryItem = useCallback(
+    async (item: bsky.profile.AnyProfileView) => {
+      setAccountHistory(accountHistory.filter(p => p !== item.did))
+    },
+    [accountHistory, setAccountHistory],
+  )
+
+  const {params, query, queryWithParams} = useQueryManager({
+    initialQuery: queryParam,
+    fixedParams,
+  })
+  const showFilters = Boolean(queryWithParams && !showAutocomplete)
+
+  // web only - measure header height for sticky positioning
+  const [headerHeight, setHeaderHeight] = useState(0)
+  const headerRef = useRef(null)
+  useLayoutEffect(() => {
+    if (isWeb) {
+      if (!headerRef.current) return
+      const measurement = (headerRef.current as Element).getBoundingClientRect()
+      setHeaderHeight(measurement.height)
+    }
+  }, [])
+
+  useFocusEffect(
+    useNonReactiveCallback(() => {
+      if (isWeb) {
+        setSearchText(queryParam)
+      }
+    }),
+  )
+
+  const onPressClearQuery = useCallback(() => {
+    scrollToTopWeb()
+    setSearchText('')
+    textInput.current?.focus()
+  }, [])
+
+  const onChangeText = useCallback(async (text: string) => {
+    scrollToTopWeb()
+    setSearchText(text)
+  }, [])
+
+  const navigateToItem = useCallback(
+    (item: string) => {
+      scrollToTopWeb()
+      setShowAutocomplete(false)
+      updateSearchHistory(item)
+
+      if (isWeb) {
+        // @ts-expect-error route is not typesafe
+        navigation.push(route.name, {...route.params, q: item})
+      } else {
+        textInput.current?.blur()
+        navigation.setParams({q: item})
+      }
+    },
+    [updateSearchHistory, navigation, route],
+  )
+
+  const onPressCancelSearch = useCallback(() => {
+    scrollToTopWeb()
+    textInput.current?.blur()
+    setShowAutocomplete(false)
+    if (isWeb) {
+      // Empty params resets the URL to be /search rather than /search?q=
+
+      const {q: _q, ...parameters} = (route.params ?? {}) as {
+        [key: string]: string
+      }
+      // @ts-expect-error route is not typesafe
+      navigation.replace(route.name, parameters)
+    } else {
+      setSearchText('')
+      navigation.setParams({q: ''})
+    }
+  }, [setShowAutocomplete, setSearchText, navigation, route.params, route.name])
+
+  const onSubmit = useCallback(() => {
+    navigateToItem(searchText)
+  }, [navigateToItem, searchText])
+
+  const onAutocompleteResultPress = useCallback(() => {
+    if (isWeb) {
+      setShowAutocomplete(false)
+    } else {
+      textInput.current?.blur()
+    }
+  }, [])
+
+  const handleHistoryItemClick = useCallback(
+    (item: string) => {
+      setSearchText(item)
+      navigateToItem(item)
+    },
+    [navigateToItem],
+  )
+
+  const handleProfileClick = useCallback(
+    (profile: bsky.profile.AnyProfileView) => {
+      unstableCacheProfileView(queryClient, profile)
+      // Slight delay to avoid updating during push nav animation.
+      setTimeout(() => {
+        updateProfileHistory(profile)
+      }, 400)
+    },
+    [updateProfileHistory, queryClient],
+  )
+
+  const onSoftReset = useCallback(() => {
+    if (isWeb) {
+      // Empty params resets the URL to be /search rather than /search?q=
+
+      const {q: _q, ...parameters} = (route.params ?? {}) as {
+        [key: string]: string
+      }
+      // @ts-expect-error route is not typesafe
+      navigation.replace(route.name, parameters)
+    } else {
+      setSearchText('')
+      navigation.setParams({q: ''})
+      textInput.current?.focus()
+    }
+  }, [navigation, route])
+
+  useFocusEffect(
+    useCallback(() => {
+      setMinimalShellMode(false)
+      return listenSoftReset(onSoftReset)
+    }, [onSoftReset, setMinimalShellMode]),
+  )
+
+  const onSearchInputFocus = useCallback(() => {
+    if (isWeb) {
+      // Prevent a jump on iPad by ensuring that
+      // the initial focused render has no result list.
+      requestAnimationFrame(() => {
+        setShowAutocomplete(true)
+      })
+    } else {
+      setShowAutocomplete(true)
+    }
+  }, [setShowAutocomplete])
+
+  const focusSearchInput = useCallback(() => {
+    textInput.current?.focus()
+  }, [])
+
+  const showHeader = !gtMobile || navButton !== 'menu'
+
+  return (
+    <Layout.Screen testID={testID}>
+      <View
+        ref={headerRef}
+        onLayout={evt => {
+          if (isWeb) setHeaderHeight(evt.nativeEvent.layout.height)
+        }}
+        style={[
+          a.relative,
+          a.z_10,
+          web({
+            position: 'sticky',
+            top: 0,
+          }),
+        ]}>
+        <Layout.Center style={t.atoms.bg}>
+          {showHeader && (
+            <View
+              // HACK: shift up search input. we can't remove the top padding
+              // on the search input because it messes up the layout animation
+              // if we add it only when the header is hidden
+              style={{marginBottom: tokens.space.xs * -1}}>
+              <Layout.Header.Outer noBottomBorder>
+                {navButton === 'menu' ? (
+                  <Layout.Header.MenuButton />
+                ) : (
+                  <Layout.Header.BackButton />
+                )}
+                <Layout.Header.Content align="left">
+                  <Layout.Header.TitleText>
+                    <Trans>Search</Trans>
+                  </Layout.Header.TitleText>
+                </Layout.Header.Content>
+                {showFilters ? (
+                  <SearchLanguageDropdown
+                    value={params.lang}
+                    onChange={params.setLang}
+                  />
+                ) : (
+                  <Layout.Header.Slot />
+                )}
+              </Layout.Header.Outer>
+            </View>
+          )}
+          <View style={[a.px_md, a.pt_sm, a.pb_sm, a.overflow_hidden]}>
+            <View style={[a.gap_sm]}>
+              <View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs]}>
+                <View style={[a.flex_1]}>
+                  <SearchInput
+                    ref={textInput}
+                    value={searchText}
+                    onFocus={onSearchInputFocus}
+                    onChangeText={onChangeText}
+                    onClearText={onPressClearQuery}
+                    onSubmitEditing={onSubmit}
+                    placeholder={
+                      inputPlaceholder ??
+                      _(msg`Search for posts, users, or feeds`)
+                    }
+                    hitSlop={{...HITSLOP_20, top: 0}}
+                  />
+                </View>
+                {showAutocomplete && (
+                  <Button
+                    label={_(msg`Cancel search`)}
+                    size="large"
+                    variant="ghost"
+                    color="secondary"
+                    style={[a.px_sm]}
+                    onPress={onPressCancelSearch}
+                    hitSlop={HITSLOP_10}>
+                    <ButtonText>
+                      <Trans>Cancel</Trans>
+                    </ButtonText>
+                  </Button>
+                )}
+              </View>
+
+              {showFilters && !showHeader && (
+                <View
+                  style={[
+                    a.flex_row,
+                    a.align_center,
+                    a.justify_between,
+                    a.gap_sm,
+                  ]}>
+                  <SearchLanguageDropdown
+                    value={params.lang}
+                    onChange={params.setLang}
+                  />
+                </View>
+              )}
+            </View>
+          </View>
+        </Layout.Center>
+      </View>
+
+      <View
+        style={{
+          display: showAutocomplete && !fixedParams ? 'flex' : 'none',
+          flex: 1,
+        }}>
+        {searchText.length > 0 ? (
+          <AutocompleteResults
+            isAutocompleteFetching={isAutocompleteFetching}
+            autocompleteData={autocompleteData}
+            searchText={searchText}
+            onSubmit={onSubmit}
+            onResultPress={onAutocompleteResultPress}
+            onProfileClick={handleProfileClick}
+          />
+        ) : (
+          <SearchHistory
+            searchHistory={termHistory}
+            selectedProfiles={accountHistoryProfiles?.profiles || []}
+            onItemClick={handleHistoryItemClick}
+            onProfileClick={handleProfileClick}
+            onRemoveItemClick={deleteSearchHistoryItem}
+            onRemoveProfileClick={deleteProfileHistoryItem}
+          />
+        )}
+      </View>
+      <View
+        style={{
+          display: showAutocomplete ? 'none' : 'flex',
+          flex: 1,
+        }}>
+        <SearchScreenInner
+          query={query}
+          queryWithParams={queryWithParams}
+          headerHeight={headerHeight}
+          focusSearchInput={focusSearchInput}
+        />
+      </View>
+    </Layout.Screen>
+  )
+}
+
+let SearchScreenInner = ({
+  query,
+  queryWithParams,
+  headerHeight,
+  focusSearchInput,
+}: {
+  query: string
+  queryWithParams: string
+  headerHeight: number
+  focusSearchInput: () => void
+}): React.ReactNode => {
+  const t = useTheme()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {hasSession} = useSession()
+  const {gtTablet} = useBreakpoints()
+  const [activeTab, setActiveTab] = useState(0)
+  const {_} = useLingui()
+
+  const onPageSelected = useCallback(
+    (index: number) => {
+      setMinimalShellMode(false)
+      setActiveTab(index)
+    },
+    [setMinimalShellMode],
+  )
+
+  return queryWithParams ? (
+    <SearchResults
+      query={query}
+      queryWithParams={queryWithParams}
+      activeTab={activeTab}
+      headerHeight={headerHeight}
+      onPageSelected={onPageSelected}
+    />
+  ) : hasSession ? (
+    <Explore focusSearchInput={focusSearchInput} headerHeight={headerHeight} />
+  ) : (
+    <Layout.Center>
+      <View style={a.flex_1}>
+        {gtTablet && (
+          <View
+            style={[
+              a.border_b,
+              t.atoms.border_contrast_low,
+              a.px_lg,
+              a.pt_sm,
+              a.pb_lg,
+            ]}>
+            <Text style={[a.text_2xl, a.font_heavy]}>
+              <Trans>Search</Trans>
+            </Text>
+          </View>
+        )}
+
+        <View style={[a.align_center, a.justify_center, a.py_4xl, a.gap_lg]}>
+          <MagnifyingGlassIcon
+            strokeWidth={3}
+            size={60}
+            style={t.atoms.text_contrast_medium as StyleProp<ViewStyle>}
+          />
+          <Text style={[t.atoms.text_contrast_medium, a.text_md]}>
+            <Trans>Find posts, users, and feeds on Bluesky</Trans>
+          </Text>
+        </View>
+      </View>
+    </Layout.Center>
+  )
+}
+SearchScreenInner = memo(SearchScreenInner)
+
+function useQueryManager({
+  initialQuery,
+  fixedParams,
+}: {
+  initialQuery: string
+  fixedParams?: Params
+}) {
+  const {query, params: initialParams} = useMemo(() => {
+    return parseSearchQuery(initialQuery || '')
+  }, [initialQuery])
+  const [prevInitialQuery, setPrevInitialQuery] = useState(initialQuery)
+  const [lang, setLang] = useState(initialParams.lang || '')
+
+  if (initialQuery !== prevInitialQuery) {
+    // handle new queryParam change (from manual search entry)
+    setPrevInitialQuery(initialQuery)
+    setLang(initialParams.lang || '')
+  }
+
+  const params = useMemo(
+    () => ({
+      // default stuff
+      ...initialParams,
+      // managed stuff
+      lang,
+      ...fixedParams,
+    }),
+    [lang, initialParams, fixedParams],
+  )
+  const handlers = useMemo(
+    () => ({
+      setLang,
+    }),
+    [setLang],
+  )
+
+  return useMemo(() => {
+    return {
+      query,
+      queryWithParams: makeSearchQuery(query, params),
+      params: {
+        ...params,
+        ...handlers,
+      },
+    }
+  }, [query, params, handlers])
+}
+
+function scrollToTopWeb() {
+  if (isWeb) {
+    window.scrollTo(0, 0)
+  }
+}
diff --git a/src/screens/Search/components/AutocompleteResults.tsx b/src/screens/Search/components/AutocompleteResults.tsx
new file mode 100644
index 000000000..58a0dec77
--- /dev/null
+++ b/src/screens/Search/components/AutocompleteResults.tsx
@@ -0,0 +1,71 @@
+import {memo} from 'react'
+import {ActivityIndicator, View} from 'react-native'
+import {type AppBskyActorDefs, moderateProfile} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {isNative} from '#/platform/detection'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
+import {atoms as a, native} from '#/alf'
+import * as Layout from '#/components/Layout'
+
+let AutocompleteResults = ({
+  isAutocompleteFetching,
+  autocompleteData,
+  searchText,
+  onSubmit,
+  onResultPress,
+  onProfileClick,
+}: {
+  isAutocompleteFetching: boolean
+  autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined
+  searchText: string
+  onSubmit: () => void
+  onResultPress: () => void
+  onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void
+}): React.ReactNode => {
+  const moderationOpts = useModerationOpts()
+  const {_} = useLingui()
+  return (
+    <>
+      {(isAutocompleteFetching && !autocompleteData?.length) ||
+      !moderationOpts ? (
+        <Layout.Content>
+          <View style={[a.py_xl]}>
+            <ActivityIndicator />
+          </View>
+        </Layout.Content>
+      ) : (
+        <Layout.Content
+          keyboardShouldPersistTaps="handled"
+          keyboardDismissMode="on-drag">
+          <SearchLinkCard
+            label={_(msg`Search for "${searchText}"`)}
+            onPress={native(onSubmit)}
+            to={
+              isNative
+                ? undefined
+                : `/search?q=${encodeURIComponent(searchText)}`
+            }
+            style={{borderBottomWidth: 1}}
+          />
+          {autocompleteData?.map(item => (
+            <SearchProfileCard
+              key={item.did}
+              profile={item}
+              moderation={moderateProfile(item, moderationOpts)}
+              onPress={() => {
+                onProfileClick(item)
+                onResultPress()
+              }}
+            />
+          ))}
+          <View style={{height: 200}} />
+        </Layout.Content>
+      )}
+    </>
+  )
+}
+AutocompleteResults = memo(AutocompleteResults)
+export {AutocompleteResults}
diff --git a/src/screens/Search/components/ExploreTrendingTopics.tsx b/src/screens/Search/components/ExploreTrendingTopics.tsx
deleted file mode 100644
index a010ad8dc..000000000
--- a/src/screens/Search/components/ExploreTrendingTopics.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-import React from 'react'
-import {View} from 'react-native'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {logEvent} from '#/lib/statsig/statsig'
-import {isWeb} from '#/platform/detection'
-import {
-  useTrendingSettings,
-  useTrendingSettingsApi,
-} from '#/state/preferences/trending'
-import {
-  DEFAULT_LIMIT as TRENDING_TOPICS_COUNT,
-  useTrendingTopics,
-} from '#/state/queries/trending/useTrendingTopics'
-import {useTrendingConfig} from '#/state/trending-config'
-import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
-import {Button, ButtonIcon} from '#/components/Button'
-import {GradientFill} from '#/components/GradientFill'
-import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
-import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
-import * as Prompt from '#/components/Prompt'
-import {
-  TrendingTopic,
-  TrendingTopicLink,
-  TrendingTopicSkeleton,
-} from '#/components/TrendingTopics'
-import {Text} from '#/components/Typography'
-
-export function ExploreTrendingTopics() {
-  const {enabled} = useTrendingConfig()
-  const {trendingDisabled} = useTrendingSettings()
-  return enabled && !trendingDisabled ? <Inner /> : null
-}
-
-function Inner() {
-  const t = useTheme()
-  const {_} = useLingui()
-  const gutters = useGutters([0, 'compact'])
-  const {data: trending, error, isLoading} = useTrendingTopics()
-  const noTopics = !isLoading && !error && !trending?.topics?.length
-  const {setTrendingDisabled} = useTrendingSettingsApi()
-  const trendingPrompt = Prompt.usePromptControl()
-
-  const onConfirmHide = React.useCallback(() => {
-    logEvent('trendingTopics:hide', {context: 'explore:trending'})
-    setTrendingDisabled(true)
-  }, [setTrendingDisabled])
-
-  return error || noTopics ? null : (
-    <>
-      <View
-        style={[
-          a.flex_row,
-          isWeb
-            ? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
-            : [a.p_lg, a.pt_2xl, a.gap_md],
-          a.border_b,
-          t.atoms.border_contrast_low,
-        ]}>
-        <View style={[a.flex_1, a.gap_sm]}>
-          <View style={[a.flex_row, a.align_center, a.gap_sm]}>
-            <Trending
-              size="lg"
-              fill={t.palette.primary_500}
-              style={{marginLeft: -2}}
-            />
-            <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>
-              <Trans>Trending</Trans>
-            </Text>
-            <View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}>
-              <GradientFill gradient={tokens.gradients.primary} />
-              <Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}>
-                <Trans>BETA</Trans>
-              </Text>
-            </View>
-          </View>
-          <Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
-            <Trans>What people are posting about.</Trans>
-          </Text>
-        </View>
-        <Button
-          label={_(msg`Hide trending topics`)}
-          size="small"
-          variant="ghost"
-          color="secondary"
-          shape="round"
-          onPress={() => trendingPrompt.open()}>
-          <ButtonIcon icon={X} />
-        </Button>
-      </View>
-
-      <View style={[a.pt_md, a.pb_lg]}>
-        <View
-          style={[
-            a.flex_row,
-            a.justify_start,
-            a.flex_wrap,
-            {rowGap: 8, columnGap: 6},
-            gutters,
-          ]}>
-          {isLoading ? (
-            Array(TRENDING_TOPICS_COUNT)
-              .fill(0)
-              .map((_, i) => <TrendingTopicSkeleton key={i} index={i} />)
-          ) : !trending?.topics ? null : (
-            <>
-              {trending.topics.map(topic => (
-                <TrendingTopicLink
-                  key={topic.link}
-                  topic={topic}
-                  onPress={() => {
-                    logEvent('trendingTopic:click', {context: 'explore'})
-                  }}>
-                  {({hovered}) => (
-                    <TrendingTopic
-                      topic={topic}
-                      style={[
-                        hovered && [
-                          t.atoms.border_contrast_high,
-                          t.atoms.bg_contrast_25,
-                        ],
-                      ]}
-                    />
-                  )}
-                </TrendingTopicLink>
-              ))}
-            </>
-          )}
-        </View>
-      </View>
-
-      <Prompt.Basic
-        control={trendingPrompt}
-        title={_(msg`Hide trending topics?`)}
-        description={_(msg`You can update this later from your settings.`)}
-        confirmButtonCta={_(msg`Hide`)}
-        onConfirm={onConfirmHide}
-      />
-    </>
-  )
-}
diff --git a/src/screens/Search/components/ModuleHeader.tsx b/src/screens/Search/components/ModuleHeader.tsx
new file mode 100644
index 000000000..cbd0a856b
--- /dev/null
+++ b/src/screens/Search/components/ModuleHeader.tsx
@@ -0,0 +1,170 @@
+import {useMemo} from 'react'
+import {View} from 'react-native'
+import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
+
+import {PressableScale} from '#/lib/custom-animations/PressableScale'
+import {makeCustomFeedLink} from '#/lib/routes/links'
+import {logger} from '#/logger'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {
+  atoms as a,
+  native,
+  useGutters,
+  useTheme,
+  type ViewStyleProp,
+  web,
+} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import * as FeedCard from '#/components/FeedCard'
+import {sizes as iconSizes} from '#/components/icons/common'
+import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
+import {Link} from '#/components/Link'
+import {Text, type TextProps} from '#/components/Typography'
+
+export function Container({
+  style,
+  children,
+  headerHeight,
+}: {children: React.ReactNode; headerHeight?: number} & ViewStyleProp) {
+  const t = useTheme()
+  const gutters = useGutters([0, 'base'])
+  return (
+    <View
+      style={[
+        gutters,
+        a.flex_row,
+        a.align_center,
+        a.pt_2xl,
+        a.pb_md,
+        a.gap_sm,
+        t.atoms.bg,
+        headerHeight && web({position: 'sticky', top: headerHeight}),
+        style,
+      ]}>
+      {children}
+    </View>
+  )
+}
+
+export function FeedLink({
+  feed,
+  children,
+}: {
+  feed: AppBskyFeedDefs.GeneratorView
+  children?: React.ReactNode
+}) {
+  const t = useTheme()
+  const {host: did, rkey} = useMemo(() => new AtUri(feed.uri), [feed.uri])
+  return (
+    <Link
+      to={makeCustomFeedLink(did, rkey)}
+      label={feed.displayName}
+      style={[a.flex_1]}>
+      {({focused, hovered, pressed}) => (
+        <View
+          style={[
+            a.flex_1,
+            a.flex_row,
+            a.align_center,
+            {gap: 10},
+            a.rounded_md,
+            a.p_xs,
+            {marginLeft: -6},
+            (focused || hovered || pressed) && t.atoms.bg_contrast_25,
+          ]}>
+          {children}
+        </View>
+      )}
+    </Link>
+  )
+}
+
+export function FeedAvatar({feed}: {feed: AppBskyFeedDefs.GeneratorView}) {
+  return <UserAvatar type="algo" size={38} avatar={feed.avatar} />
+}
+
+export function Icon({
+  icon: Comp,
+  size = 'lg',
+}: Pick<React.ComponentProps<typeof ButtonIcon>, 'icon' | 'size'>) {
+  const iconSize = iconSizes[size]
+
+  return (
+    <View style={[a.z_20, {width: iconSize, height: iconSize, marginLeft: -2}]}>
+      <Comp width={iconSize} />
+    </View>
+  )
+}
+
+export function TitleText({style, ...props}: TextProps) {
+  return (
+    <Text style={[a.font_bold, a.flex_1, a.text_xl, style]} emoji {...props} />
+  )
+}
+
+export function SubtitleText({style, ...props}: TextProps) {
+  const t = useTheme()
+  return (
+    <Text
+      style={[
+        t.atoms.text_contrast_medium,
+        a.leading_tight,
+        a.flex_1,
+        a.text_sm,
+        style,
+      ]}
+      {...props}
+    />
+  )
+}
+
+export function SearchButton({
+  label,
+  metricsTag,
+  onPress,
+}: {
+  label: string
+  metricsTag: 'suggestedAccounts' | 'suggestedFeeds'
+  onPress?: () => void
+}) {
+  return (
+    <Button
+      label={label}
+      size="small"
+      variant="ghost"
+      color="secondary"
+      shape="round"
+      PressableComponent={native(PressableScale)}
+      onPress={() => {
+        logger.metric(
+          'explore:module:searchButtonPress',
+          {module: metricsTag},
+          {statsig: true},
+        )
+        onPress?.()
+      }}
+      style={[
+        {
+          right: -4,
+        },
+      ]}>
+      <ButtonIcon icon={SearchIcon} size="lg" />
+    </Button>
+  )
+}
+
+export function PinButton({feed}: {feed: AppBskyFeedDefs.GeneratorView}) {
+  return (
+    <View style={[a.z_20, {marginRight: -6}]}>
+      <FeedCard.SaveButton
+        pin
+        view={feed}
+        size="large"
+        color="secondary"
+        variant="ghost"
+        shape="square"
+        text={false}
+      />
+    </View>
+  )
+}
diff --git a/src/screens/Search/components/SearchHistory.tsx b/src/screens/Search/components/SearchHistory.tsx
new file mode 100644
index 000000000..5e62f2cd0
--- /dev/null
+++ b/src/screens/Search/components/SearchHistory.tsx
@@ -0,0 +1,169 @@
+import {Pressable, ScrollView, StyleSheet, View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {createHitslop, HITSLOP_10} from '#/lib/constants'
+import {makeProfileLink} from '#/lib/routes/links'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {Link} from '#/view/com/util/Link'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
+import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
+import * as Layout from '#/components/Layout'
+import {Text} from '#/components/Typography'
+import type * as bsky from '#/types/bsky'
+
+export function SearchHistory({
+  searchHistory,
+  selectedProfiles,
+  onItemClick,
+  onProfileClick,
+  onRemoveItemClick,
+  onRemoveProfileClick,
+}: {
+  searchHistory: string[]
+  selectedProfiles: bsky.profile.AnyProfileView[]
+  onItemClick: (item: string) => void
+  onProfileClick: (profile: bsky.profile.AnyProfileView) => void
+  onRemoveItemClick: (item: string) => void
+  onRemoveProfileClick: (profile: bsky.profile.AnyProfileView) => void
+}) {
+  const {gtMobile} = useBreakpoints()
+  const t = useTheme()
+  const {_} = useLingui()
+
+  return (
+    <Layout.Content
+      keyboardDismissMode="interactive"
+      keyboardShouldPersistTaps="handled">
+      <View style={[a.w_full, a.px_md]}>
+        {(searchHistory.length > 0 || selectedProfiles.length > 0) && (
+          <Text style={[a.text_md, a.font_bold, a.p_md]}>
+            <Trans>Recent Searches</Trans>
+          </Text>
+        )}
+        {selectedProfiles.length > 0 && (
+          <View
+            style={[
+              styles.selectedProfilesContainer,
+              !gtMobile && styles.selectedProfilesContainerMobile,
+            ]}>
+            <BlockDrawerGesture>
+              <ScrollView
+                horizontal
+                keyboardShouldPersistTaps="handled"
+                style={[
+                  a.flex_row,
+                  a.flex_nowrap,
+                  {marginHorizontal: tokens.space._2xl * -1},
+                ]}
+                contentContainerStyle={[a.px_2xl, a.border_0]}>
+                {selectedProfiles.slice(0, 5).map((profile, index) => (
+                  <View
+                    key={index}
+                    style={[
+                      styles.profileItem,
+                      !gtMobile && styles.profileItemMobile,
+                    ]}>
+                    <Link
+                      href={makeProfileLink(profile)}
+                      title={profile.handle}
+                      asAnchor
+                      anchorNoUnderline
+                      onBeforePress={() => onProfileClick(profile)}
+                      style={[a.align_center, a.w_full]}>
+                      <UserAvatar
+                        avatar={profile.avatar}
+                        type={profile.associated?.labeler ? 'labeler' : 'user'}
+                        size={60}
+                      />
+                      <Text
+                        emoji
+                        style={[a.text_xs, a.text_center, styles.profileName]}
+                        numberOfLines={1}>
+                        {sanitizeDisplayName(
+                          profile.displayName || profile.handle,
+                        )}
+                      </Text>
+                    </Link>
+                    <Pressable
+                      accessibilityRole="button"
+                      accessibilityLabel={_(msg`Remove profile`)}
+                      accessibilityHint={_(
+                        msg`Removes profile from search history`,
+                      )}
+                      onPress={() => onRemoveProfileClick(profile)}
+                      hitSlop={createHitslop(6)}
+                      style={styles.profileRemoveBtn}>
+                      <XIcon size="xs" style={t.atoms.text_contrast_low} />
+                    </Pressable>
+                  </View>
+                ))}
+              </ScrollView>
+            </BlockDrawerGesture>
+          </View>
+        )}
+        {searchHistory.length > 0 && (
+          <View style={[a.pl_md, a.pr_xs, a.mt_md]}>
+            {searchHistory.slice(0, 5).map((historyItem, index) => (
+              <View key={index} style={[a.flex_row, a.align_center, a.mt_xs]}>
+                <Pressable
+                  accessibilityRole="button"
+                  onPress={() => onItemClick(historyItem)}
+                  hitSlop={HITSLOP_10}
+                  style={[a.flex_1, a.py_md]}>
+                  <Text style={[a.text_md]}>{historyItem}</Text>
+                </Pressable>
+                <Button
+                  label={_(msg`Remove ${historyItem}`)}
+                  onPress={() => onRemoveItemClick(historyItem)}
+                  size="small"
+                  variant="ghost"
+                  color="secondary"
+                  shape="round">
+                  <ButtonIcon icon={XIcon} />
+                </Button>
+              </View>
+            ))}
+          </View>
+        )}
+      </View>
+    </Layout.Content>
+  )
+}
+
+const styles = StyleSheet.create({
+  selectedProfilesContainer: {
+    marginTop: 10,
+    paddingHorizontal: 12,
+    height: 80,
+  },
+  selectedProfilesContainerMobile: {
+    height: 100,
+  },
+  profileItem: {
+    alignItems: 'center',
+    marginRight: 15,
+    width: 78,
+  },
+  profileItemMobile: {
+    width: 70,
+  },
+  profileName: {
+    width: 78,
+    marginTop: 6,
+  },
+  profileRemoveBtn: {
+    position: 'absolute',
+    top: 0,
+    right: 5,
+    backgroundColor: 'white',
+    borderRadius: 10,
+    width: 18,
+    height: 18,
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+})
diff --git a/src/screens/Search/components/SearchLanguageDropdown.tsx b/src/screens/Search/components/SearchLanguageDropdown.tsx
new file mode 100644
index 000000000..5c5a4b74f
--- /dev/null
+++ b/src/screens/Search/components/SearchLanguageDropdown.tsx
@@ -0,0 +1,120 @@
+import {useMemo} from 'react'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {languageName} from '#/locale/helpers'
+import {APP_LANGUAGES, LANGUAGES} from '#/locale/languages'
+import {useLanguagePrefs} from '#/state/preferences'
+import {atoms as a, native, platform, tokens} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {
+  ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
+  ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon,
+} from '#/components/icons/Chevron'
+import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe'
+import * as Menu from '#/components/Menu'
+
+export function SearchLanguageDropdown({
+  value,
+  onChange,
+}: {
+  value: string
+  onChange(value: string): void
+}) {
+  const {_} = useLingui()
+  const {appLanguage, contentLanguages} = useLanguagePrefs()
+
+  const languages = useMemo(() => {
+    return LANGUAGES.filter(
+      (lang, index, self) =>
+        Boolean(lang.code2) && // reduce to the code2 varieties
+        index === self.findIndex(t => t.code2 === lang.code2), // remove dupes (which will happen)
+    )
+      .map(l => ({
+        label: languageName(l, appLanguage),
+        value: l.code2,
+        key: l.code2 + l.code3,
+      }))
+      .sort((a, b) => {
+        // prioritize user's languages
+        const aIsUser = contentLanguages.includes(a.value)
+        const bIsUser = contentLanguages.includes(b.value)
+        if (aIsUser && !bIsUser) return -1
+        if (bIsUser && !aIsUser) return 1
+        // prioritize "common" langs in the network
+        const aIsCommon = !!APP_LANGUAGES.find(
+          al =>
+            // skip `ast`, because it uses a 3-letter code which conflicts with `as`
+            // it begins with `a` anyway so still is top of the list
+            al.code2 !== 'ast' && al.code2.startsWith(a.value),
+        )
+        const bIsCommon = !!APP_LANGUAGES.find(
+          al =>
+            // ditto
+            al.code2 !== 'ast' && al.code2.startsWith(b.value),
+        )
+        if (aIsCommon && !bIsCommon) return -1
+        if (bIsCommon && !aIsCommon) return 1
+        // fall back to alphabetical
+        return a.label.localeCompare(b.label)
+      })
+  }, [appLanguage, contentLanguages])
+
+  const currentLanguageLabel =
+    languages.find(lang => lang.value === value)?.label ?? _(msg`All languages`)
+
+  return (
+    <Menu.Root>
+      <Menu.Trigger
+        label={_(
+          msg`Filter search by language (currently: ${currentLanguageLabel})`,
+        )}>
+        {({props}) => (
+          <Button
+            {...props}
+            label={props.accessibilityLabel}
+            size="small"
+            color={platform({native: 'primary', default: 'secondary'})}
+            variant={platform({native: 'ghost', default: 'solid'})}
+            style={native([
+              a.py_sm,
+              a.px_sm,
+              {marginRight: tokens.space.sm * -1},
+            ])}>
+            <ButtonIcon icon={EarthIcon} />
+            <ButtonText>{currentLanguageLabel}</ButtonText>
+            <ButtonIcon
+              icon={platform({
+                native: ChevronUpDownIcon,
+                default: ChevronDownIcon,
+              })}
+            />
+          </Button>
+        )}
+      </Menu.Trigger>
+      <Menu.Outer>
+        <Menu.LabelText>
+          <Trans>Filter search by language</Trans>
+        </Menu.LabelText>
+        <Menu.Item label={_(msg`All languages`)} onPress={() => onChange('')}>
+          <Menu.ItemText>
+            <Trans>All languages</Trans>
+          </Menu.ItemText>
+          <Menu.ItemRadio selected={value === ''} />
+        </Menu.Item>
+        <Menu.Divider />
+        <Menu.Group>
+          {languages.map(lang => (
+            <Menu.Item
+              key={lang.key}
+              label={lang.label}
+              onPress={() => onChange(lang.value)}>
+              <Menu.ItemText>{lang.label}</Menu.ItemText>
+              <Menu.ItemRadio selected={value === lang.value} />
+            </Menu.Item>
+          ))}
+        </Menu.Group>
+      </Menu.Outer>
+    </Menu.Root>
+  )
+}
diff --git a/src/screens/Search/components/StarterPackCard.tsx b/src/screens/Search/components/StarterPackCard.tsx
new file mode 100644
index 000000000..9520dd5a7
--- /dev/null
+++ b/src/screens/Search/components/StarterPackCard.tsx
@@ -0,0 +1,296 @@
+import React from 'react'
+import {View} from 'react-native'
+import {
+  type AppBskyGraphDefs,
+  AppBskyGraphStarterpack,
+  moderateProfile,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useSession} from '#/state/session'
+import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import {ButtonText} from '#/components/Button'
+import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {Link} from '#/components/Link'
+import {MediaInsetBorder} from '#/components/MediaInsetBorder'
+import {useStarterPackLink} from '#/components/StarterPack/StarterPackCard'
+import {Text} from '#/components/Typography'
+import * as bsky from '#/types/bsky'
+
+export function StarterPackCard({
+  view,
+}: {
+  view: AppBskyGraphDefs.StarterPackView
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const {gtPhone} = useBreakpoints()
+  const link = useStarterPackLink({view})
+
+  if (
+    !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
+      view.record,
+      AppBskyGraphStarterpack.isRecord,
+    )
+  ) {
+    return null
+  }
+
+  const profileCount = gtPhone ? 11 : 8
+  const profiles = view.listItemsSample
+    ?.slice(0, profileCount)
+    .map(item => item.subject)
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.p_lg,
+        a.gap_md,
+        a.border,
+        a.rounded_sm,
+        a.overflow_hidden,
+        t.atoms.border_contrast_low,
+      ]}>
+      <View aria-hidden style={[a.absolute, a.inset_0, a.z_40]}>
+        <Link
+          to={link.to}
+          label={link.label}
+          style={[a.absolute, a.inset_0]}
+          onHoverIn={link.precache}
+          onPress={link.precache}>
+          <View />
+        </Link>
+      </View>
+
+      <AvatarStack
+        profiles={profiles ?? []}
+        numPending={profileCount}
+        total={view.list?.listItemCount}
+      />
+
+      <View
+        style={[
+          a.w_full,
+          a.flex_row,
+          a.align_start,
+          a.gap_lg,
+          web({
+            position: 'static',
+            zIndex: 'unset',
+          }),
+        ]}>
+        <View style={[a.flex_1]}>
+          <Text
+            emoji
+            style={[a.text_md, a.font_bold, a.leading_snug]}
+            numberOfLines={1}>
+            {view.record.name}
+          </Text>
+          <Text
+            emoji
+            style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}
+            numberOfLines={1}>
+            {view.creator?.did === currentAccount?.did
+              ? _(msg`By you`)
+              : _(msg`By ${sanitizeHandle(view.creator.handle, '@')}`)}
+          </Text>
+        </View>
+        <Link
+          to={link.to}
+          label={link.label}
+          onHoverIn={link.precache}
+          onPress={link.precache}
+          variant="solid"
+          color="secondary"
+          size="small"
+          style={[a.z_50]}>
+          <ButtonText>
+            <Trans>Open pack</Trans>
+          </ButtonText>
+        </Link>
+      </View>
+    </View>
+  )
+}
+
+export function AvatarStack({
+  profiles,
+  numPending,
+  total,
+}: {
+  profiles: bsky.profile.AnyProfileView[]
+  numPending: number
+  total?: number
+}) {
+  const t = useTheme()
+  const {gtPhone} = useBreakpoints()
+  const moderationOpts = useModerationOpts()
+  const computedTotal = (total ?? numPending) - numPending
+  const circlesCount = numPending + 1 // add total at end
+  const widthPerc = 100 / circlesCount
+  const [size, setSize] = React.useState<number | null>(null)
+
+  const isPending = (numPending && profiles.length === 0) || !moderationOpts
+
+  const items = isPending
+    ? Array.from({length: numPending ?? circlesCount}).map((_, i) => ({
+        key: i,
+        profile: null,
+        moderation: null,
+      }))
+    : profiles.map(item => ({
+        key: item.did,
+        profile: item,
+        moderation: moderateProfile(item, moderationOpts),
+      }))
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.flex_row,
+        a.align_center,
+        a.relative,
+        {width: `${100 - widthPerc * 0.2}%`},
+      ]}>
+      {items.map((item, i) => (
+        <View
+          key={item.key}
+          style={[
+            {
+              width: `${widthPerc}%`,
+              zIndex: 100 - i,
+            },
+          ]}>
+          <View
+            style={[
+              a.relative,
+              {
+                width: '120%',
+              },
+            ]}>
+            <View
+              onLayout={e => setSize(e.nativeEvent.layout.width)}
+              style={[
+                a.rounded_full,
+                t.atoms.bg_contrast_25,
+                {
+                  paddingTop: '100%',
+                },
+              ]}>
+              {size && item.profile ? (
+                <UserAvatar
+                  size={size}
+                  avatar={item.profile.avatar}
+                  type={item.profile.associated?.labeler ? 'labeler' : 'user'}
+                  moderation={item.moderation.ui('avatar')}
+                  style={[a.absolute, a.inset_0]}
+                />
+              ) : (
+                <MediaInsetBorder style={[a.rounded_full]} />
+              )}
+            </View>
+          </View>
+        </View>
+      ))}
+      <View
+        style={[
+          {
+            width: `${widthPerc}%`,
+            zIndex: 1,
+          },
+        ]}>
+        <View
+          style={[
+            a.relative,
+            {
+              width: '120%',
+            },
+          ]}>
+          <View
+            style={[
+              {
+                paddingTop: '100%',
+              },
+            ]}>
+            <View
+              style={[
+                a.absolute,
+                a.inset_0,
+                a.rounded_full,
+                a.align_center,
+                a.justify_center,
+                {
+                  backgroundColor: t.atoms.text_contrast_low.color,
+                },
+              ]}>
+              {computedTotal > 0 ? (
+                <Text
+                  style={[
+                    gtPhone ? a.text_md : a.text_sm,
+                    a.font_bold,
+                    a.leading_snug,
+                    {color: 'white'},
+                  ]}>
+                  <Trans comment="Indicates the number of additional profiles are in the Starter Pack e.g. +12">
+                    +{computedTotal}
+                  </Trans>
+                </Text>
+              ) : (
+                <Plus fill="white" />
+              )}
+            </View>
+          </View>
+        </View>
+      </View>
+    </View>
+  )
+}
+
+export function StarterPackCardSkeleton() {
+  const t = useTheme()
+  const {gtPhone} = useBreakpoints()
+
+  const profileCount = gtPhone ? 11 : 8
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.p_lg,
+        a.gap_md,
+        a.border,
+        a.rounded_sm,
+        a.overflow_hidden,
+        t.atoms.border_contrast_low,
+      ]}>
+      <AvatarStack profiles={[]} numPending={profileCount} />
+
+      <View
+        style={[
+          a.w_full,
+          a.flex_row,
+          a.align_start,
+          a.gap_lg,
+          web({
+            position: 'static',
+            zIndex: 'unset',
+          }),
+        ]}>
+        <View style={[a.flex_1, a.gap_xs]}>
+          <LoadingPlaceholder width={180} height={18} />
+          <LoadingPlaceholder width={120} height={14} />
+        </View>
+
+        <LoadingPlaceholder width={100} height={33} />
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Search/index.tsx b/src/screens/Search/index.tsx
new file mode 100644
index 000000000..429f1e5c7
--- /dev/null
+++ b/src/screens/Search/index.tsx
@@ -0,0 +1,13 @@
+import {
+  type NativeStackScreenProps,
+  type SearchTabNavigatorParams,
+} from '#/lib/routes/types'
+import {SearchScreenShell} from './Shell'
+
+export function SearchScreen(
+  props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
+) {
+  const queryParam = props.route?.params?.q ?? ''
+
+  return <SearchScreenShell queryParam={queryParam} testID="searchScreen" />
+}
diff --git a/src/screens/Search/modules/ExploreFeedPreviews.tsx b/src/screens/Search/modules/ExploreFeedPreviews.tsx
new file mode 100644
index 000000000..30aa00a3f
--- /dev/null
+++ b/src/screens/Search/modules/ExploreFeedPreviews.tsx
@@ -0,0 +1,264 @@
+import {useMemo} from 'react'
+import {type AppBskyFeedDefs, moderatePost} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useInfiniteQuery} from '@tanstack/react-query'
+
+import {CustomFeedAPI} from '#/lib/api/feed/custom'
+import {aggregateUserInterests} from '#/lib/api/feed/utils'
+import {FeedTuner} from '#/lib/api/feed-manip'
+import {cleanError} from '#/lib/strings/errors'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {
+  type FeedPostSlice,
+  type FeedPostSliceItem,
+} from '#/state/queries/post-feed'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {useAgent} from '#/state/session'
+
+const RQKEY_ROOT = 'feed-previews'
+const RQKEY = (feeds: string[]) => [RQKEY_ROOT, feeds]
+
+const LIMIT = 8 // sliced to 6, overfetch to account for moderation
+
+export type FeedPreviewItem =
+  | {
+      type: 'topBorder'
+      key: string
+    }
+  | {
+      type: 'preview:loading'
+      key: string
+    }
+  | {
+      type: 'preview:error'
+      key: string
+      message: string
+      error: string
+    }
+  | {
+      type: 'preview:loadMoreError'
+      key: string
+    }
+  | {
+      type: 'preview:empty'
+      key: string
+    }
+  | {
+      type: 'preview:header'
+      key: string
+      feed: AppBskyFeedDefs.GeneratorView
+    }
+  | {
+      type: 'preview:footer'
+      key: string
+    }
+  // copied from PostFeed.tsx
+  | {
+      type: 'preview:sliceItem'
+      key: string
+      slice: FeedPostSlice
+      indexInSlice: number
+      showReplyTo: boolean
+      hideTopBorder: boolean
+    }
+  | {
+      type: 'preview:sliceViewFullThread'
+      key: string
+      uri: string
+    }
+
+export function useFeedPreviews(feeds: AppBskyFeedDefs.GeneratorView[]) {
+  const uris = feeds.map(feed => feed.uri)
+  const {_} = useLingui()
+  const agent = useAgent()
+  const {data: preferences} = usePreferencesQuery()
+  const userInterests = aggregateUserInterests(preferences)
+  const moderationOpts = useModerationOpts()
+  const enabled = feeds.length > 0
+
+  const query = useInfiniteQuery({
+    enabled,
+    queryKey: RQKEY(uris),
+    queryFn: async ({pageParam}) => {
+      const feed = feeds[pageParam]
+      const api = new CustomFeedAPI({
+        agent,
+        feedParams: {feed: feed.uri},
+        userInterests,
+      })
+      const data = await api.fetch({cursor: undefined, limit: LIMIT})
+      return {
+        feed,
+        posts: data.feed,
+      }
+    },
+    initialPageParam: 0,
+    getNextPageParam: (_p, _a, count) =>
+      count < feeds.length ? count + 1 : undefined,
+  })
+
+  const {data, isFetched, isError, isPending, error} = query
+
+  return {
+    query,
+    data: useMemo<FeedPreviewItem[]>(() => {
+      const items: FeedPreviewItem[] = []
+
+      if (!enabled) return items
+
+      const isEmpty =
+        !isPending && !data?.pages?.some(page => page.posts.length)
+
+      if (isFetched) {
+        if (isError && isEmpty) {
+          items.push({
+            type: 'preview:error',
+            key: 'error',
+            message: _(msg`An error occurred while fetching the feed.`),
+            error: cleanError(error),
+          })
+        } else if (isEmpty) {
+          items.push({
+            type: 'preview:empty',
+            key: 'empty',
+          })
+        } else if (data) {
+          for (let pageIndex = 0; pageIndex < data.pages.length; pageIndex++) {
+            const page = data.pages[pageIndex]
+            // default feed tuner - we just want it to slice up the feed
+            const tuner = new FeedTuner([])
+            const slices: FeedPreviewItem[] = []
+
+            let rowIndex = 0
+            for (const item of tuner.tune(page.posts)) {
+              if (item.isFallbackMarker) continue
+
+              const moderations = item.items.map(item =>
+                moderatePost(item.post, moderationOpts!),
+              )
+
+              // apply moderation filters
+              item.items = item.items.filter((_, i) => {
+                return !moderations[i]?.ui('contentList').filter
+              })
+
+              const slice = {
+                _reactKey: item._reactKey,
+                _isFeedPostSlice: true,
+                isFallbackMarker: false,
+                isIncompleteThread: item.isIncompleteThread,
+                feedContext: item.feedContext,
+                reason: item.reason,
+                feedPostUri: item.feedPostUri,
+                items: item.items.slice(0, 6).map((subItem, i) => {
+                  const feedPostSliceItem: FeedPostSliceItem = {
+                    _reactKey: `${item._reactKey}-${i}-${subItem.post.uri}`,
+                    uri: subItem.post.uri,
+                    post: subItem.post,
+                    record: subItem.record,
+                    moderation: moderations[i],
+                    parentAuthor: subItem.parentAuthor,
+                    isParentBlocked: subItem.isParentBlocked,
+                    isParentNotFound: subItem.isParentNotFound,
+                  }
+                  return feedPostSliceItem
+                }),
+              }
+              if (slice.isIncompleteThread && slice.items.length >= 3) {
+                const beforeLast = slice.items.length - 2
+                const last = slice.items.length - 1
+                slices.push({
+                  type: 'preview:sliceItem',
+                  key: slice.items[0]._reactKey,
+                  slice: slice,
+                  indexInSlice: 0,
+                  showReplyTo: false,
+                  hideTopBorder: rowIndex === 0,
+                })
+                slices.push({
+                  type: 'preview:sliceViewFullThread',
+                  key: slice._reactKey + '-viewFullThread',
+                  uri: slice.items[0].uri,
+                })
+                slices.push({
+                  type: 'preview:sliceItem',
+                  key: slice.items[beforeLast]._reactKey,
+                  slice: slice,
+                  indexInSlice: beforeLast,
+                  showReplyTo:
+                    slice.items[beforeLast].parentAuthor?.did !==
+                    slice.items[beforeLast].post.author.did,
+                  hideTopBorder: false,
+                })
+                slices.push({
+                  type: 'preview:sliceItem',
+                  key: slice.items[last]._reactKey,
+                  slice: slice,
+                  indexInSlice: last,
+                  showReplyTo: false,
+                  hideTopBorder: false,
+                })
+              } else {
+                for (let i = 0; i < slice.items.length; i++) {
+                  slices.push({
+                    type: 'preview:sliceItem',
+                    key: slice.items[i]._reactKey,
+                    slice: slice,
+                    indexInSlice: i,
+                    showReplyTo: i === 0,
+                    hideTopBorder: i === 0 && rowIndex === 0,
+                  })
+                }
+              }
+
+              rowIndex++
+            }
+
+            if (slices.length > 0) {
+              if (pageIndex > 0) {
+                items.push({
+                  type: 'topBorder',
+                  key: `topBorder-${page.feed.uri}`,
+                })
+              }
+              items.push(
+                {
+                  type: 'preview:footer',
+                  key: `footer-${page.feed.uri}`,
+                },
+                {
+                  type: 'preview:header',
+                  key: `header-${page.feed.uri}`,
+                  feed: page.feed,
+                },
+                ...slices,
+              )
+            }
+          }
+        } else if (isError && !isEmpty) {
+          items.push({
+            type: 'preview:loadMoreError',
+            key: 'loadMoreError',
+          })
+        }
+      } else {
+        items.push({
+          type: 'preview:loading',
+          key: 'loading',
+        })
+      }
+
+      return items
+    }, [
+      enabled,
+      data,
+      isFetched,
+      isError,
+      isPending,
+      moderationOpts,
+      _,
+      error,
+    ]),
+  }
+}
diff --git a/src/screens/Search/components/ExploreRecommendations.tsx b/src/screens/Search/modules/ExploreRecommendations.tsx
index 602bab87d..4cf84269a 100644
--- a/src/screens/Search/components/ExploreRecommendations.tsx
+++ b/src/screens/Search/modules/ExploreRecommendations.tsx
@@ -1,8 +1,8 @@
 import {View} from 'react-native'
-import {AppBskyUnspeccedDefs} from '@atproto/api'
+import {type AppBskyUnspeccedDefs} from '@atproto/api'
 import {Trans} from '@lingui/macro'
 
-import {logEvent} from '#/lib/statsig/statsig'
+import {logger} from '#/logger'
 import {isWeb} from '#/platform/detection'
 import {
   DEFAULT_LIMIT as RECOMMENDATIONS_COUNT,
@@ -18,6 +18,8 @@ import {
 } from '#/components/TrendingTopics'
 import {Text} from '#/components/Typography'
 
+// Note: This module is not currently used and may be removed in the future.
+
 export function ExploreRecommendations() {
   const {enabled} = useTrendingConfig()
   return enabled ? <Inner /> : null
@@ -86,7 +88,11 @@ function Inner() {
                   key={topic.link}
                   topic={topic}
                   onPress={() => {
-                    logEvent('recommendedTopic:click', {context: 'explore'})
+                    logger.metric(
+                      'recommendedTopic:click',
+                      {context: 'explore'},
+                      {statsig: true},
+                    )
                   }}>
                   {({hovered}) => (
                     <TrendingTopic
diff --git a/src/screens/Search/modules/ExploreSuggestedAccounts.tsx b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx
new file mode 100644
index 000000000..070d75910
--- /dev/null
+++ b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx
@@ -0,0 +1,228 @@
+import {memo, useEffect} from 'react'
+import {View} from 'react-native'
+import {type AppBskyActorSearchActors, type ModerationOpts} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {type InfiniteData} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
+import {
+  popularInterests,
+  useInterestsDisplayNames,
+} from '#/screens/Onboarding/state'
+import {useTheme} from '#/alf'
+import {atoms as a} from '#/alf'
+import {Button} from '#/components/Button'
+import * as ProfileCard from '#/components/ProfileCard'
+import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog'
+import {Text} from '#/components/Typography'
+import type * as bsky from '#/types/bsky'
+
+export function useLoadEnoughProfiles({
+  interest,
+  data,
+  isLoading,
+  isFetchingNextPage,
+  hasNextPage,
+  fetchNextPage,
+}: {
+  interest: string | null
+  data?: InfiniteData<AppBskyActorSearchActors.OutputSchema>
+  isLoading: boolean
+  isFetchingNextPage: boolean
+  hasNextPage: boolean
+  fetchNextPage: () => Promise<unknown>
+}) {
+  const profileCount =
+    data?.pages.flatMap(page =>
+      page.actors.filter(actor => !actor.viewer?.following),
+    ).length || 0
+  const isAnyLoading = isLoading || isFetchingNextPage
+  const isEnoughProfiles = profileCount > 3
+  const shouldFetchMore = !isEnoughProfiles && hasNextPage && !!interest
+  useEffect(() => {
+    if (shouldFetchMore && !isAnyLoading) {
+      logger.info('Not enough suggested accounts - fetching more')
+      fetchNextPage()
+    }
+  }, [shouldFetchMore, fetchNextPage, isAnyLoading, interest])
+
+  return {
+    isReady: !shouldFetchMore,
+  }
+}
+
+export function SuggestedAccountsTabBar({
+  selectedInterest,
+  onSelectInterest,
+}: {
+  selectedInterest: string | null
+  onSelectInterest: (interest: string | null) => void
+}) {
+  const {_} = useLingui()
+  const interestsDisplayNames = useInterestsDisplayNames()
+  const {data: preferences} = usePreferencesQuery()
+  const personalizedInterests = preferences?.interests?.tags
+  const interests = Object.keys(interestsDisplayNames)
+    .sort(boostInterests(popularInterests))
+    .sort(boostInterests(personalizedInterests))
+  return (
+    <BlockDrawerGesture>
+      <Tabs
+        interests={['all', ...interests]}
+        selectedInterest={selectedInterest || 'all'}
+        onSelectTab={tab => {
+          logger.metric(
+            'explore:suggestedAccounts:tabPressed',
+            {tab: tab},
+            {statsig: true},
+          )
+          onSelectInterest(tab === 'all' ? null : tab)
+        }}
+        hasSearchText={false}
+        interestsDisplayNames={{
+          all: _(msg`All`),
+          ...interestsDisplayNames,
+        }}
+        TabComponent={Tab}
+      />
+    </BlockDrawerGesture>
+  )
+}
+
+let Tab = ({
+  onSelectTab,
+  interest,
+  active,
+  index,
+  interestsDisplayName,
+  onLayout,
+}: {
+  onSelectTab: (index: number) => void
+  interest: string
+  active: boolean
+  index: number
+  interestsDisplayName: string
+  onLayout: (index: number, x: number, width: number) => void
+}): React.ReactNode => {
+  const t = useTheme()
+  const {_} = useLingui()
+  const activeText = active ? _(msg` (active)`) : ''
+  return (
+    <View
+      key={interest}
+      onLayout={e =>
+        onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
+      }>
+      <Button
+        label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
+        onPress={() => onSelectTab(index)}>
+        {({hovered, pressed, focused}) => (
+          <View
+            style={[
+              a.rounded_full,
+              a.px_lg,
+              a.py_sm,
+              a.border,
+              active || hovered || pressed || focused
+                ? [
+                    t.atoms.bg_contrast_25,
+                    {borderColor: t.atoms.bg_contrast_25.backgroundColor},
+                  ]
+                : [t.atoms.bg, t.atoms.border_contrast_low],
+            ]}>
+            <Text
+              style={[
+                /* TODO: medium weight */
+                active || hovered || pressed || focused
+                  ? t.atoms.text
+                  : t.atoms.text_contrast_medium,
+              ]}>
+              {interestsDisplayName}
+            </Text>
+          </View>
+        )}
+      </Button>
+    </View>
+  )
+}
+Tab = memo(Tab)
+
+/**
+ * Profile card for suggested accounts. Note: border is on the bottom edge
+ */
+let SuggestedProfileCard = ({
+  profile,
+  moderationOpts,
+  recId,
+  position,
+}: {
+  profile: bsky.profile.AnyProfileView
+  moderationOpts: ModerationOpts
+  recId?: number
+  position: number
+}): React.ReactNode => {
+  const t = useTheme()
+  return (
+    <ProfileCard.Link
+      profile={profile}
+      style={[a.flex_1]}
+      onPress={() => {
+        logger.metric(
+          'suggestedUser:press',
+          {
+            logContext: 'Explore',
+            recId,
+            position,
+          },
+          {statsig: true},
+        )
+      }}>
+      <View
+        style={[
+          a.w_full,
+          a.py_lg,
+          a.px_lg,
+          a.border_t,
+          t.atoms.border_contrast_low,
+          a.flex_1,
+        ]}>
+        <ProfileCard.Outer>
+          <ProfileCard.Header>
+            <ProfileCard.Avatar
+              profile={profile}
+              moderationOpts={moderationOpts}
+            />
+            <ProfileCard.NameAndHandle
+              profile={profile}
+              moderationOpts={moderationOpts}
+            />
+            <ProfileCard.FollowButton
+              profile={profile}
+              moderationOpts={moderationOpts}
+              withIcon={false}
+              logContext="ExploreSuggestedAccounts"
+              onFollow={() => {
+                logger.metric(
+                  'suggestedUser:follow',
+                  {
+                    logContext: 'Explore',
+                    location: 'Card',
+                    recId,
+                    position,
+                  },
+                  {statsig: true},
+                )
+              }}
+            />
+          </ProfileCard.Header>
+          <ProfileCard.Description profile={profile} numberOfLines={2} />
+        </ProfileCard.Outer>
+      </View>
+    </ProfileCard.Link>
+  )
+}
+SuggestedProfileCard = memo(SuggestedProfileCard)
+export {SuggestedProfileCard}
diff --git a/src/screens/Search/modules/ExploreTrendingTopics.tsx b/src/screens/Search/modules/ExploreTrendingTopics.tsx
new file mode 100644
index 000000000..88d16b393
--- /dev/null
+++ b/src/screens/Search/modules/ExploreTrendingTopics.tsx
@@ -0,0 +1,278 @@
+import {Pressable, View} from 'react-native'
+import {type AppBskyUnspeccedDefs} from '@atproto/api'
+import {msg, plural, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {logger} from '#/logger'
+import {useTrendingSettings} from '#/state/preferences/trending'
+import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
+import {useTrendingConfig} from '#/state/trending-config'
+import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import {formatCount} from '#/view/com/util/numeric/format'
+import {atoms as a, useGutters, useTheme, type ViewStyleProp, web} from '#/alf'
+import {AvatarStack} from '#/components/AvatarStack'
+import {type Props as SVGIconProps} from '#/components/icons/common'
+import {Flame_Stroke2_Corner1_Rounded as FlameIcon} from '#/components/icons/Flame'
+import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+const TOPIC_COUNT = 5
+
+export function ExploreTrendingTopics() {
+  const {enabled} = useTrendingConfig()
+  const {trendingDisabled} = useTrendingSettings()
+  return enabled && !trendingDisabled ? <Inner /> : null
+}
+
+function Inner() {
+  const {data: trending, error, isLoading} = useGetTrendsQuery()
+  const noTopics = !isLoading && !error && !trending?.trends?.length
+
+  return isLoading ? (
+    Array.from({length: TOPIC_COUNT}).map((__, i) => (
+      <TrendingTopicRowSkeleton key={i} withPosts={i === 0} />
+    ))
+  ) : error || !trending?.trends || noTopics ? null : (
+    <>
+      {trending.trends.map((trend, index) => (
+        <TrendRow
+          key={trend.link}
+          trend={trend}
+          rank={index + 1}
+          onPress={() => {
+            logger.metric('trendingTopic:click', {context: 'explore'})
+          }}
+        />
+      ))}
+    </>
+  )
+}
+
+export function TrendRow({
+  trend,
+  rank,
+  children,
+  onPress,
+}: ViewStyleProp & {
+  trend: AppBskyUnspeccedDefs.TrendView
+  rank: number
+  children?: React.ReactNode
+  onPress?: () => void
+}) {
+  const t = useTheme()
+  const {_, i18n} = useLingui()
+  const gutters = useGutters([0, 'base'])
+
+  const category = useCategoryDisplayName(trend?.category || 'other')
+  const age = Math.floor(
+    (Date.now() - new Date(trend.startedAt || Date.now()).getTime()) /
+      (1000 * 60 * 60),
+  )
+  const badgeType = trend.status === 'hot' ? 'hot' : age < 2 ? 'new' : age
+  const postCount = trend.postCount
+    ? _(
+        plural(trend.postCount, {
+          other: `${formatCount(i18n, trend.postCount)} posts`,
+        }),
+      )
+    : null
+
+  return (
+    <Link
+      testID={trend.link}
+      label={_(msg`Browse topic ${trend.displayName}`)}
+      to={trend.link}
+      onPress={onPress}
+      style={[a.border_b, t.atoms.border_contrast_low]}
+      PressableComponent={Pressable}>
+      {({hovered, pressed}) => (
+        <>
+          <View
+            style={[
+              gutters,
+              a.w_full,
+              a.py_lg,
+              a.flex_row,
+              a.gap_2xs,
+              (hovered || pressed) && t.atoms.bg_contrast_25,
+            ]}>
+            <View style={[a.flex_1, a.gap_xs]}>
+              <View style={[a.flex_row]}>
+                <Text
+                  style={[a.text_md, a.font_bold, a.leading_snug, {width: 20}]}>
+                  <Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'>
+                    {rank}.
+                  </Trans>
+                </Text>
+                <Text
+                  style={[a.text_md, a.font_bold, a.leading_snug]}
+                  numberOfLines={1}>
+                  {trend.displayName}
+                </Text>
+              </View>
+              <View
+                style={[
+                  a.flex_row,
+                  a.gap_sm,
+                  a.align_center,
+                  {paddingLeft: 20},
+                ]}>
+                {trend.actors.length > 0 && (
+                  <AvatarStack size={20} profiles={trend.actors} />
+                )}
+                <Text
+                  style={[
+                    a.text_sm,
+                    t.atoms.text_contrast_medium,
+                    web(a.leading_snug),
+                  ]}
+                  numberOfLines={1}>
+                  {postCount}
+                  {postCount && category && <> &middot; </>}
+                  {category}
+                </Text>
+              </View>
+            </View>
+            <View style={[a.flex_shrink_0]}>
+              <TrendingIndicator type={badgeType} />
+            </View>
+          </View>
+
+          {children}
+        </>
+      )}
+    </Link>
+  )
+}
+
+type TrendingIndicatorType = 'hot' | 'new' | number
+
+function TrendingIndicator({type}: {type: TrendingIndicatorType | 'skeleton'}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const pillStyles = [
+    a.flex_row,
+    a.align_center,
+    a.gap_xs,
+    a.rounded_full,
+    a.px_sm,
+    {
+      height: 28,
+    },
+  ]
+
+  let Icon: React.ComponentType<SVGIconProps> | null = null
+  let text: string | null = null
+  let color: string | null = null
+  let backgroundColor: string | null = null
+
+  switch (type) {
+    case 'skeleton': {
+      return (
+        <View
+          style={[
+            pillStyles,
+            {backgroundColor: t.palette.contrast_25, width: 65, height: 28},
+          ]}
+        />
+      )
+    }
+    case 'hot': {
+      Icon = FlameIcon
+      color =
+        t.scheme === 'light' ? t.palette.negative_500 : t.palette.negative_950
+      backgroundColor =
+        t.scheme === 'light' ? t.palette.negative_50 : t.palette.negative_200
+      text = _(msg`Hot`)
+      break
+    }
+    case 'new': {
+      Icon = TrendingIcon
+      text = _(msg`New`)
+      color = t.palette.positive_700
+      backgroundColor = t.palette.positive_50
+      break
+    }
+    default: {
+      text = _(
+        msg({
+          message: `${type}h ago`,
+          comment:
+            'trending topic time spent trending. should be as short as possible to fit in a pill',
+        }),
+      )
+      color = t.atoms.text_contrast_medium.color
+      backgroundColor = t.atoms.bg_contrast_25.backgroundColor
+      break
+    }
+  }
+
+  return (
+    <View style={[pillStyles, {backgroundColor}]}>
+      {Icon && <Icon size="sm" style={{color}} />}
+      <Text style={[a.text_sm, {color}]}>{text}</Text>
+    </View>
+  )
+}
+
+function useCategoryDisplayName(
+  category: AppBskyUnspeccedDefs.TrendView['category'],
+) {
+  const {_} = useLingui()
+
+  switch (category) {
+    case 'sports':
+      return _(msg`Sports`)
+    case 'politics':
+      return _(msg`Politics`)
+    case 'video-games':
+      return _(msg`Video Games`)
+    case 'pop-culture':
+      return _(msg`Entertainment`)
+    case 'news':
+      return _(msg`News`)
+    case 'other':
+    default:
+      return null
+  }
+}
+
+export function TrendingTopicRowSkeleton({}: {withPosts: boolean}) {
+  const t = useTheme()
+  const gutters = useGutters([0, 'base'])
+
+  return (
+    <View
+      style={[
+        gutters,
+        a.w_full,
+        a.py_lg,
+        a.flex_row,
+        a.gap_2xs,
+        a.border_b,
+        t.atoms.border_contrast_low,
+      ]}>
+      <View style={[a.flex_1, a.gap_sm]}>
+        <View style={[a.flex_row, a.align_center]}>
+          <View style={[{width: 20}]}>
+            <LoadingPlaceholder
+              width={12}
+              height={12}
+              style={[a.rounded_full]}
+            />
+          </View>
+          <LoadingPlaceholder width={90} height={18} />
+        </View>
+        <View style={[a.flex_row, a.gap_sm, a.align_center, {paddingLeft: 20}]}>
+          <LoadingPlaceholder width={70} height={18} />
+          <LoadingPlaceholder width={40} height={18} />
+          <LoadingPlaceholder width={60} height={18} />
+        </View>
+      </View>
+      <View style={[a.flex_shrink_0]}>
+        <TrendingIndicator type="skeleton" />
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Search/components/ExploreTrendingVideos.tsx b/src/screens/Search/modules/ExploreTrendingVideos.tsx
index 00fa76dbf..54eb73312 100644
--- a/src/screens/Search/components/ExploreTrendingVideos.tsx
+++ b/src/screens/Search/modules/ExploreTrendingVideos.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import {useMemo} from 'react'
 import {ScrollView, View} from 'react-native'
 import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
@@ -8,18 +8,12 @@ import {useQueryClient} from '@tanstack/react-query'
 
 import {VIDEO_FEED_URI} from '#/lib/constants'
 import {makeCustomFeedLink} from '#/lib/routes/links'
-import {logEvent} from '#/lib/statsig/statsig'
-import {isWeb} from '#/platform/detection'
-import {useSavedFeeds} from '#/state/queries/feed'
+import {logger} from '#/logger'
 import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed'
-import {useAddSavedFeedsMutation} from '#/state/queries/preferences'
 import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
 import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
-import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import {GradientFill} from '#/components/GradientFill'
+import {ButtonIcon} from '#/components/Button'
 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
-import {Pin_Stroke2_Corner0_Rounded as Pin} from '#/components/icons/Pin'
-import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
 import {Link} from '#/components/Link'
 import {Text} from '#/components/Typography'
 import {
@@ -37,7 +31,6 @@ const FEED_PARAMS: {
 }
 
 export function ExploreTrendingVideos() {
-  const t = useTheme()
   const {_} = useLingui()
   const gutters = useGutters([0, 'base'])
   const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
@@ -55,30 +48,30 @@ export function ExploreTrendingVideos() {
     }
   })
 
-  const {data: saved} = useSavedFeeds()
-  const isSavedAlready = React.useMemo(() => {
-    return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI)
-  }, [saved])
-
-  const {mutateAsync: addSavedFeeds, isPending: isPinPending} =
-    useAddSavedFeedsMutation()
-  const pinFeed = React.useCallback(
-    (e: any) => {
-      e.preventDefault()
-
-      addSavedFeeds([
-        {
-          type: 'feed',
-          value: VIDEO_FEED_URI,
-          pinned: true,
-        },
-      ])
-
-      // prevent navigation
-      return false
-    },
-    [addSavedFeeds],
-  )
+  // const {data: saved} = useSavedFeeds()
+  // const isSavedAlready = useMemo(() => {
+  //   return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI)
+  // }, [saved])
+
+  // const {mutateAsync: addSavedFeeds, isPending: isPinPending} =
+  //   useAddSavedFeedsMutation()
+  // const pinFeed = useCallback(
+  //   (e: any) => {
+  //     e.preventDefault()
+
+  //     addSavedFeeds([
+  //       {
+  //         type: 'feed',
+  //         value: VIDEO_FEED_URI,
+  //         pinned: true,
+  //       },
+  //     ])
+
+  //     // prevent navigation
+  //     return false
+  //   },
+  //   [addSavedFeeds],
+  // )
 
   if (error) {
     return null
@@ -86,38 +79,6 @@ export function ExploreTrendingVideos() {
 
   return (
     <View style={[a.pb_xl]}>
-      <View
-        style={[
-          a.flex_row,
-          isWeb
-            ? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
-            : [a.p_lg, a.pt_xl, a.gap_md],
-          a.border_b,
-          t.atoms.border_contrast_low,
-        ]}>
-        <View style={[a.flex_1, a.gap_sm]}>
-          <View style={[a.flex_row, a.align_center, a.gap_sm]}>
-            <Graph
-              size="lg"
-              fill={t.palette.primary_500}
-              style={{marginLeft: -2}}
-            />
-            <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>
-              <Trans>Trending Videos</Trans>
-            </Text>
-            <View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}>
-              <GradientFill gradient={tokens.gradients.primary} />
-              <Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}>
-                <Trans>BETA</Trans>
-              </Text>
-            </View>
-          </View>
-          <Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
-            <Trans>Popular videos in your network.</Trans>
-          </Text>
-        </View>
-      </View>
-
       <BlockDrawerGesture>
         <ScrollView
           horizontal
@@ -153,7 +114,7 @@ export function ExploreTrendingVideos() {
         </ScrollView>
       </BlockDrawerGesture>
 
-      {!isSavedAlready && (
+      {/* {!isSavedAlready && (
         <View
           style={[
             gutters,
@@ -179,7 +140,7 @@ export function ExploreTrendingVideos() {
             <ButtonIcon icon={Pin} position="right" />
           </Button>
         </View>
-      )}
+      )} */}
     </View>
   )
 }
@@ -191,7 +152,7 @@ function VideoCards({
 }) {
   const t = useTheme()
   const {_} = useLingui()
-  const items = React.useMemo(() => {
+  const items = useMemo(() => {
     return data.pages
       .flatMap(page => page.slices)
       .map(slice => slice.items[0])
@@ -199,7 +160,7 @@ function VideoCards({
       .filter(item => AppBskyEmbedVideo.isView(item.post.embed))
       .slice(0, 8)
   }, [data])
-  const href = React.useMemo(() => {
+  const href = useMemo(() => {
     const urip = new AtUri(VIDEO_FEED_URI)
     return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore')
   }, [])
@@ -217,9 +178,11 @@ function VideoCards({
               sourceInterstitial: 'explore',
             }}
             onInteract={() => {
-              logEvent('videoCard:click', {
-                context: 'interstitial:explore',
-              })
+              logger.metric(
+                'videoCard:click',
+                {context: 'interstitial:explore'},
+                {statsig: true},
+              )
             }}
           />
         </View>
diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx
index e28c98803..57b86fb2b 100644
--- a/src/screens/Settings/ContentAndMediaSettings.tsx
+++ b/src/screens/Settings/ContentAndMediaSettings.tsx
@@ -1,8 +1,8 @@
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {type NativeStackScreenProps} from '@react-navigation/native-stack'
 
-import {CommonNavigatorParams} from '#/lib/routes/types'
+import {type CommonNavigatorParams} from '#/lib/routes/types'
 import {logEvent} from '#/lib/statsig/statsig'
 import {isNative} from '#/platform/detection'
 import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
@@ -22,7 +22,7 @@ import {Hashtag_Stroke2_Corner0_Rounded as HashtagIcon} from '#/components/icons
 import {Home_Stroke2_Corner2_Rounded as HomeIcon} from '#/components/icons/Home'
 import {Macintosh_Stroke2_Corner2_Rounded as MacintoshIcon} from '#/components/icons/Macintosh'
 import {Play_Stroke2_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
-import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
+import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending'
 import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window'
 import * as Layout from '#/components/Layout'