about summary refs log tree commit diff
path: root/src/screens/Search/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Search/components')
-rw-r--r--src/screens/Search/components/AutocompleteResults.tsx71
-rw-r--r--src/screens/Search/components/ExploreRecommendations.tsx117
-rw-r--r--src/screens/Search/components/ExploreTrendingTopics.tsx142
-rw-r--r--src/screens/Search/components/ExploreTrendingVideos.tsx271
-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
8 files changed, 826 insertions, 530 deletions
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/ExploreRecommendations.tsx b/src/screens/Search/components/ExploreRecommendations.tsx
deleted file mode 100644
index 602bab87d..000000000
--- a/src/screens/Search/components/ExploreRecommendations.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-import {View} from 'react-native'
-import {AppBskyUnspeccedDefs} from '@atproto/api'
-import {Trans} from '@lingui/macro'
-
-import {logEvent} from '#/lib/statsig/statsig'
-import {isWeb} from '#/platform/detection'
-import {
-  DEFAULT_LIMIT as RECOMMENDATIONS_COUNT,
-  useTrendingTopics,
-} from '#/state/queries/trending/useTrendingTopics'
-import {useTrendingConfig} from '#/state/trending-config'
-import {atoms as a, useGutters, useTheme} from '#/alf'
-import {Hashtag_Stroke2_Corner0_Rounded} from '#/components/icons/Hashtag'
-import {
-  TrendingTopic,
-  TrendingTopicLink,
-  TrendingTopicSkeleton,
-} from '#/components/TrendingTopics'
-import {Text} from '#/components/Typography'
-
-export function ExploreRecommendations() {
-  const {enabled} = useTrendingConfig()
-  return enabled ? <Inner /> : null
-}
-
-function Inner() {
-  const t = useTheme()
-  const gutters = useGutters([0, 'compact'])
-  const {data: trending, error, isLoading} = useTrendingTopics()
-  const noRecs = !isLoading && !error && !trending?.suggested?.length
-  const allFeeds = trending?.suggested && isAllFeeds(trending.suggested)
-
-  return error || noRecs ? 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]}>
-            <Hashtag_Stroke2_Corner0_Rounded
-              size="lg"
-              fill={t.palette.primary_500}
-              style={{marginLeft: -2}}
-            />
-            <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>
-              <Trans>Recommended</Trans>
-            </Text>
-          </View>
-          {!allFeeds ? (
-            <Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
-              <Trans>
-                Content from across the network we think you might like.
-              </Trans>
-            </Text>
-          ) : (
-            <Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
-              <Trans>Feeds we think you might like.</Trans>
-            </Text>
-          )}
-        </View>
-      </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(RECOMMENDATIONS_COUNT)
-              .fill(0)
-              .map((_, i) => <TrendingTopicSkeleton key={i} index={i} />)
-          ) : !trending?.suggested ? null : (
-            <>
-              {trending.suggested.map(topic => (
-                <TrendingTopicLink
-                  key={topic.link}
-                  topic={topic}
-                  onPress={() => {
-                    logEvent('recommendedTopic:click', {context: 'explore'})
-                  }}>
-                  {({hovered}) => (
-                    <TrendingTopic
-                      topic={topic}
-                      style={[
-                        hovered && [
-                          t.atoms.border_contrast_high,
-                          t.atoms.bg_contrast_25,
-                        ],
-                      ]}
-                    />
-                  )}
-                </TrendingTopicLink>
-              ))}
-            </>
-          )}
-        </View>
-      </View>
-    </>
-  )
-}
-
-function isAllFeeds(topics: AppBskyUnspeccedDefs.TrendingTopic[]) {
-  return topics.every(topic => {
-    const segments = topic.link.split('/').slice(1)
-    return segments[0] === 'profile' && segments[2] === 'feed'
-  })
-}
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/ExploreTrendingVideos.tsx b/src/screens/Search/components/ExploreTrendingVideos.tsx
deleted file mode 100644
index 00fa76dbf..000000000
--- a/src/screens/Search/components/ExploreTrendingVideos.tsx
+++ /dev/null
@@ -1,271 +0,0 @@
-import React from 'react'
-import {ScrollView, View} from 'react-native'
-import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useFocusEffect} from '@react-navigation/native'
-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 {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 {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 {
-  CompactVideoPostCard,
-  CompactVideoPostCardPlaceholder,
-} from '#/components/VideoPostCard'
-
-const CARD_WIDTH = 100
-
-const FEED_DESC = `feedgen|${VIDEO_FEED_URI}`
-const FEED_PARAMS: {
-  feedCacheKey: 'explore'
-} = {
-  feedCacheKey: 'explore',
-}
-
-export function ExploreTrendingVideos() {
-  const t = useTheme()
-  const {_} = useLingui()
-  const gutters = useGutters([0, 'base'])
-  const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
-
-  // Refetch on tab change if nothing else is using this query.
-  const queryClient = useQueryClient()
-  useFocusEffect(() => {
-    return () => {
-      const query = queryClient
-        .getQueryCache()
-        .find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)})
-      if (query && query.getObserversCount() <= 1) {
-        query.fetch()
-      }
-    }
-  })
-
-  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],
-  )
-
-  if (error) {
-    return null
-  }
-
-  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
-          showsHorizontalScrollIndicator={false}
-          decelerationRate="fast"
-          snapToInterval={CARD_WIDTH + tokens.space.sm}>
-          <View
-            style={[
-              a.pt_lg,
-              a.flex_row,
-              a.gap_sm,
-              {
-                paddingLeft: gutters.paddingLeft,
-                paddingRight: gutters.paddingRight,
-              },
-            ]}>
-            {isLoading ? (
-              Array(10)
-                .fill(0)
-                .map((_, i) => (
-                  <View key={i} style={[{width: CARD_WIDTH}]}>
-                    <CompactVideoPostCardPlaceholder />
-                  </View>
-                ))
-            ) : error || !data ? (
-              <Text>
-                <Trans>Whoops! Trending videos failed to load.</Trans>
-              </Text>
-            ) : (
-              <VideoCards data={data} />
-            )}
-          </View>
-        </ScrollView>
-      </BlockDrawerGesture>
-
-      {!isSavedAlready && (
-        <View
-          style={[
-            gutters,
-            a.pt_lg,
-            a.flex_row,
-            a.align_center,
-            a.justify_between,
-            a.gap_xl,
-          ]}>
-          <Text style={[a.flex_1, a.text_sm, a.leading_snug]}>
-            <Trans>
-              Pin the trending videos feed to your home screen for easy access
-            </Trans>
-          </Text>
-          <Button
-            disabled={isPinPending}
-            label={_(msg`Pin`)}
-            size="small"
-            variant="outline"
-            color="secondary"
-            onPress={pinFeed}>
-            <ButtonText>{_(msg`Pin`)}</ButtonText>
-            <ButtonIcon icon={Pin} position="right" />
-          </Button>
-        </View>
-      )}
-    </View>
-  )
-}
-
-function VideoCards({
-  data,
-}: {
-  data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined>
-}) {
-  const t = useTheme()
-  const {_} = useLingui()
-  const items = React.useMemo(() => {
-    return data.pages
-      .flatMap(page => page.slices)
-      .map(slice => slice.items[0])
-      .filter(Boolean)
-      .filter(item => AppBskyEmbedVideo.isView(item.post.embed))
-      .slice(0, 8)
-  }, [data])
-  const href = React.useMemo(() => {
-    const urip = new AtUri(VIDEO_FEED_URI)
-    return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore')
-  }, [])
-
-  return (
-    <>
-      {items.map(item => (
-        <View key={item.post.uri} style={[{width: CARD_WIDTH}]}>
-          <CompactVideoPostCard
-            post={item.post}
-            moderation={item.moderation}
-            sourceContext={{
-              type: 'feedgen',
-              uri: VIDEO_FEED_URI,
-              sourceInterstitial: 'explore',
-            }}
-            onInteract={() => {
-              logEvent('videoCard:click', {
-                context: 'interstitial:explore',
-              })
-            }}
-          />
-        </View>
-      ))}
-
-      <View style={[{width: CARD_WIDTH * 2}]}>
-        <Link
-          to={href}
-          label={_(msg`View more`)}
-          style={[
-            a.justify_center,
-            a.align_center,
-            a.flex_1,
-            a.rounded_md,
-            t.atoms.bg_contrast_25,
-          ]}>
-          {({pressed}) => (
-            <View
-              style={[
-                a.flex_row,
-                a.align_center,
-                a.gap_md,
-                {
-                  opacity: pressed ? 0.6 : 1,
-                },
-              ]}>
-              <Text style={[a.text_md]}>
-                <Trans>View more</Trans>
-              </Text>
-              <View
-                style={[
-                  a.align_center,
-                  a.justify_center,
-                  a.rounded_full,
-                  {
-                    width: 34,
-                    height: 34,
-                    backgroundColor: t.palette.primary_500,
-                  },
-                ]}>
-                <ButtonIcon icon={ChevronRight} />
-              </View>
-            </View>
-          )}
-        </Link>
-      </View>
-    </>
-  )
-}
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>
+  )
+}