about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--assets/icons/arrowBottom_stroke2_corner0_rounded.svg1
-rw-r--r--src/components/icons/Arrow.tsx4
-rw-r--r--src/state/queries/feed.ts34
-rw-r--r--src/state/queries/suggested-follows.ts13
-rw-r--r--src/view/screens/Search/Explore.tsx556
-rw-r--r--src/view/screens/Search/Search.tsx141
6 files changed, 656 insertions, 93 deletions
diff --git a/assets/icons/arrowBottom_stroke2_corner0_rounded.svg b/assets/icons/arrowBottom_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..5f4a11e09
--- /dev/null
+++ b/assets/icons/arrowBottom_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z" clip-rule="evenodd"/></svg>
diff --git a/src/components/icons/Arrow.tsx b/src/components/icons/Arrow.tsx
index eb753e549..d6fb635e9 100644
--- a/src/components/icons/Arrow.tsx
+++ b/src/components/icons/Arrow.tsx
@@ -7,3 +7,7 @@ export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
 export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
   path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z',
 })
+
+export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z',
+})
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
index fed23f5b1..2981b41b4 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -190,8 +190,10 @@ export const KNOWN_AUTHED_ONLY_FEEDS = [
 
 type GetPopularFeedsOptions = {limit?: number}
 
-export function createGetPopularFeedsQueryKey(...args: any[]) {
-  return ['getPopularFeeds', ...args]
+export function createGetPopularFeedsQueryKey(
+  options?: GetPopularFeedsOptions,
+) {
+  return ['getPopularFeeds', options]
 }
 
 export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
@@ -299,6 +301,34 @@ export function useSearchPopularFeedsMutation() {
   })
 }
 
+const popularFeedsSearchQueryKeyRoot = 'popularFeedsSearch'
+export const createPopularFeedsSearchQueryKey = (query: string) => [
+  popularFeedsSearchQueryKeyRoot,
+  query,
+]
+
+export function usePopularFeedsSearch({
+  query,
+  enabled,
+}: {
+  query: string
+  enabled?: boolean
+}) {
+  const agent = useAgent()
+  return useQuery({
+    enabled,
+    queryKey: createPopularFeedsSearchQueryKey(query),
+    queryFn: async () => {
+      const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
+        limit: 10,
+        query: query,
+      })
+
+      return res.data.feeds
+    },
+  })
+}
+
 export type SavedFeedSourceInfo = FeedSourceInfo & {
   savedFeed: AppBskyActorDefs.SavedFeed
 }
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
index 59b8f7ed5..40251d43d 100644
--- a/src/state/queries/suggested-follows.ts
+++ b/src/state/queries/suggested-follows.ts
@@ -23,7 +23,10 @@ import {useAgent, useSession} from '#/state/session'
 import {useModerationOpts} from '../preferences/moderation-opts'
 
 const suggestedFollowsQueryKeyRoot = 'suggested-follows'
-const suggestedFollowsQueryKey = [suggestedFollowsQueryKeyRoot]
+const suggestedFollowsQueryKey = (options?: SuggestedFollowsOptions) => [
+  suggestedFollowsQueryKeyRoot,
+  options,
+]
 
 const suggestedFollowsByActorQueryKeyRoot = 'suggested-follows-by-actor'
 const suggestedFollowsByActorQueryKey = (did: string) => [
@@ -31,7 +34,9 @@ const suggestedFollowsByActorQueryKey = (did: string) => [
   did,
 ]
 
-export function useSuggestedFollowsQuery() {
+type SuggestedFollowsOptions = {limit?: number}
+
+export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
   const {currentAccount} = useSession()
   const agent = useAgent()
   const moderationOpts = useModerationOpts()
@@ -46,12 +51,12 @@ export function useSuggestedFollowsQuery() {
   >({
     enabled: !!moderationOpts && !!preferences,
     staleTime: STALE.HOURS.ONE,
-    queryKey: suggestedFollowsQueryKey,
+    queryKey: suggestedFollowsQueryKey(options),
     queryFn: async ({pageParam}) => {
       const contentLangs = getContentLanguages().join(',')
       const res = await agent.app.bsky.actor.getSuggestions(
         {
-          limit: 25,
+          limit: options?.limit || 25,
           cursor: pageParam,
         },
         {
diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx
new file mode 100644
index 000000000..f6e998838
--- /dev/null
+++ b/src/view/screens/Search/Explore.tsx
@@ -0,0 +1,556 @@
+import React from 'react'
+import {View} from 'react-native'
+import {
+  AppBskyActorDefs,
+  AppBskyFeedDefs,
+  moderateProfile,
+  ModerationDecision,
+  ModerationOpts,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useGetPopularFeedsQuery} from '#/state/queries/feed'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
+import {useSession} from '#/state/session'
+import {cleanError} from 'lib/strings/errors'
+import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
+import {List} from '#/view/com/util/List'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
+import {
+  FeedFeedLoadingPlaceholder,
+  ProfileCardFeedLoadingPlaceholder,
+} from 'view/com/util/LoadingPlaceholder'
+import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
+import {Button} from '#/components/Button'
+import {ArrowBottom_Stroke2_Corner0_Rounded as ArrowBottom} from '#/components/icons/Arrow'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {Props as SVGIconProps} from '#/components/icons/common'
+import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
+import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+function SuggestedItemsHeader({
+  title,
+  description,
+  style,
+  icon: Icon,
+}: {
+  title: string
+  description: string
+  icon: React.ComponentType<SVGIconProps>
+} & ViewStyleProp) {
+  const t = useTheme()
+
+  return (
+    <View
+      style={[
+        isWeb
+          ? [a.flex_row, a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
+          : [{flexDirection: 'row-reverse'}, a.p_lg, a.pt_2xl, a.gap_md],
+        a.border_b,
+        t.atoms.border_contrast_low,
+        style,
+      ]}>
+      <View style={[a.flex_1, a.gap_sm]}>
+        <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+          <Icon
+            size="lg"
+            fill={t.palette.primary_500}
+            style={{marginLeft: -2}}
+          />
+          <Text style={[a.text_2xl, a.font_bold, t.atoms.text]}>{title}</Text>
+        </View>
+        <Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
+          {description}
+        </Text>
+      </View>
+    </View>
+  )
+}
+
+type LoadMoreItems =
+  | {
+      type: 'profile'
+      key: string
+      avatar: string
+      moderation: ModerationDecision
+    }
+  | {
+      type: 'feed'
+      key: string
+      avatar: string
+      moderation: undefined
+    }
+
+function LoadMore({
+  item,
+  moderationOpts,
+}: {
+  item: ExploreScreenItems & {type: 'loadMore'}
+  moderationOpts?: ModerationOpts
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const items = React.useMemo(() => {
+    return item.items
+      .map(_item => {
+        if (_item.type === 'profile') {
+          return {
+            type: 'profile',
+            key: _item.profile.did,
+            avatar: _item.profile.avatar,
+            moderation: moderateProfile(_item.profile, moderationOpts!),
+          }
+        } else if (_item.type === 'feed') {
+          return {
+            type: 'feed',
+            key: _item.feed.uri,
+            avatar: _item.feed.avatar,
+            moderation: undefined,
+          }
+        }
+        return undefined
+      })
+      .filter(Boolean) as LoadMoreItems[]
+  }, [item.items, moderationOpts])
+  const type = items[0].type
+
+  return (
+    <View style={[]}>
+      <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.px_lg,
+              a.py_md,
+              (hovered || pressed) && t.atoms.bg_contrast_25,
+            ]}>
+            <View
+              style={[
+                a.relative,
+                {
+                  height: 32,
+                  width: 32 + 15 * 3,
+                },
+              ]}>
+              <View
+                style={[
+                  a.align_center,
+                  a.justify_center,
+                  a.border,
+                  t.atoms.bg_contrast_25,
+                  a.absolute,
+                  {
+                    width: 30,
+                    height: 30,
+                    left: 0,
+                    backgroundColor: t.palette.primary_500,
+                    borderColor: t.atoms.bg.backgroundColor,
+                    borderRadius: type === 'profile' ? 999 : 4,
+                    zIndex: 4,
+                  },
+                ]}>
+                <ArrowBottom fill={t.palette.white} />
+              </View>
+              {items.map((_item, i) => {
+                return (
+                  <View
+                    key={_item.key}
+                    style={[
+                      a.border,
+                      t.atoms.bg_contrast_25,
+                      a.absolute,
+                      {
+                        width: 30,
+                        height: 30,
+                        left: (i + 1) * 15,
+                        borderColor: t.atoms.bg.backgroundColor,
+                        borderRadius: _item.type === 'profile' ? 999 : 4,
+                        zIndex: 3 - i,
+                      },
+                    ]}>
+                    {moderationOpts && (
+                      <>
+                        {_item.type === 'profile' ? (
+                          <UserAvatar
+                            size={28}
+                            avatar={_item.avatar}
+                            moderation={_item.moderation.ui('avatar')}
+                          />
+                        ) : _item.type === 'feed' ? (
+                          <UserAvatar
+                            size={28}
+                            avatar={_item.avatar}
+                            type="algo"
+                          />
+                        ) : null}
+                      </>
+                    )}
+                  </View>
+                )
+              })}
+            </View>
+
+            <Text
+              style={[
+                a.pl_sm,
+                a.leading_snug,
+                hovered ? t.atoms.text : t.atoms.text_contrast_medium,
+              ]}>
+              {type === 'profile' ? (
+                <Trans>Load more suggested follows</Trans>
+              ) : (
+                <Trans>Load more suggested feeds</Trans>
+              )}
+            </Text>
+
+            <View style={[a.flex_1, a.align_end]}>
+              {item.isLoadingMore && <Loader size="lg" />}
+            </View>
+          </View>
+        )}
+      </Button>
+    </View>
+  )
+}
+
+type ExploreScreenItems =
+  | {
+      type: 'header'
+      key: string
+      title: string
+      description: string
+      style?: ViewStyleProp['style']
+      icon: React.ComponentType<SVGIconProps>
+    }
+  | {
+      type: 'profile'
+      key: string
+      profile: AppBskyActorDefs.ProfileViewBasic
+    }
+  | {
+      type: 'feed'
+      key: string
+      feed: AppBskyFeedDefs.GeneratorView
+    }
+  | {
+      type: 'loadMore'
+      key: string
+      isLoadingMore: boolean
+      onLoadMore: () => void
+      items: ExploreScreenItems[]
+    }
+  | {
+      type: 'profilePlaceholder'
+      key: string
+    }
+  | {
+      type: 'feedPlaceholder'
+      key: string
+    }
+  | {
+      type: 'error'
+      key: string
+      message: string
+      error: string
+    }
+
+export function Explore() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {hasSession} = useSession()
+  const {data: preferences, error: preferencesError} = usePreferencesQuery()
+  const moderationOpts = useModerationOpts()
+  const {
+    data: profiles,
+    hasNextPage: hasNextProfilesPage,
+    isLoading: isLoadingProfiles,
+    isFetchingNextPage: isFetchingNextProfilesPage,
+    error: profilesError,
+    fetchNextPage: fetchNextProfilesPage,
+  } = useSuggestedFollowsQuery({limit: 3})
+  const {
+    data: feeds,
+    hasNextPage: hasNextFeedsPage,
+    isLoading: isLoadingFeeds,
+    isFetchingNextPage: isFetchingNextFeedsPage,
+    error: feedsError,
+    fetchNextPage: fetchNextFeedsPage,
+  } = useGetPopularFeedsQuery({limit: 3})
+
+  const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
+  const onLoadMoreProfiles = React.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 isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds
+  const onLoadMoreFeeds = React.useCallback(async () => {
+    if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return
+    try {
+      await fetchNextFeedsPage()
+    } catch (err) {
+      logger.error('Failed to load more suggested follows', {message: err})
+    }
+  }, [
+    isFetchingNextFeedsPage,
+    hasNextFeedsPage,
+    feedsError,
+    fetchNextFeedsPage,
+  ])
+
+  const items = React.useMemo<ExploreScreenItems[]>(() => {
+    const i: ExploreScreenItems[] = [
+      {
+        type: 'header',
+        key: 'suggested-follows-header',
+        title: _(msg`Suggested accounts`),
+        description: _(
+          msg`Follow more accounts to get connected to your interests and build your network.`,
+        ),
+        icon: Person,
+      },
+    ]
+
+    if (profiles) {
+      // Currently the responses contain duplicate items.
+      // Needs to be fixed on backend, but let's dedupe to be safe.
+      let seen = new Set()
+      for (const page of profiles.pages) {
+        for (const actor of page.actors) {
+          if (!seen.has(actor.did)) {
+            seen.add(actor.did)
+            i.push({
+              type: 'profile',
+              key: actor.did,
+              profile: actor,
+            })
+          }
+        }
+      }
+
+      i.push({
+        type: 'loadMore',
+        key: 'loadMoreProfiles',
+        isLoadingMore: isLoadingMoreProfiles,
+        onLoadMore: onLoadMoreProfiles,
+        items: i.filter(item => item.type === 'profile').slice(-3),
+      })
+    } else {
+      if (profilesError) {
+        i.push({
+          type: 'error',
+          key: 'profilesError',
+          message: _(msg`Failed to load suggested follows`),
+          error: cleanError(profilesError),
+        })
+      } else {
+        i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
+      }
+    }
+
+    i.push({
+      type: 'header',
+      key: 'suggested-feeds-header',
+      title: _(msg`Discover new feeds`),
+      description: _(
+        msg`Custom feeds built by the community bring you new experiences and help you find the content you love.`,
+      ),
+      style: [a.pt_5xl],
+      icon: ListSparkle,
+    })
+
+    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()
+      for (const page of feeds.pages) {
+        for (const feed of page.feeds) {
+          if (!seen.has(feed.uri)) {
+            seen.add(feed.uri)
+            i.push({
+              type: 'feed',
+              key: feed.uri,
+              feed,
+            })
+          }
+        }
+      }
+
+      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: 'loadMore',
+          key: 'loadMoreFeeds',
+          isLoadingMore: isLoadingMoreFeeds,
+          onLoadMore: onLoadMoreFeeds,
+          items: i.filter(item => item.type === 'feed').slice(-3),
+        })
+      }
+    } 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'})
+      }
+    }
+
+    return i
+  }, [
+    _,
+    profiles,
+    feeds,
+    preferences,
+    onLoadMoreFeeds,
+    onLoadMoreProfiles,
+    isLoadingMoreProfiles,
+    isLoadingMoreFeeds,
+    profilesError,
+    feedsError,
+    preferencesError,
+  ])
+
+  const renderItem = React.useCallback(
+    ({item}: {item: ExploreScreenItems}) => {
+      switch (item.type) {
+        case 'header': {
+          return (
+            <SuggestedItemsHeader
+              title={item.title}
+              description={item.description}
+              style={item.style}
+              icon={item.icon}
+            />
+          )
+        }
+        case 'profile': {
+          return (
+            <View style={[a.border_b, t.atoms.border_contrast_low]}>
+              <ProfileCardWithFollowBtn profile={item.profile} noBg noBorder />
+            </View>
+          )
+        }
+        case 'feed': {
+          return (
+            <View style={[a.border_b, t.atoms.border_contrast_low]}>
+              <FeedSourceCard
+                feedUri={item.feed.uri}
+                showSaveBtn={hasSession}
+                showDescription
+                showLikes
+                pinOnSave
+                hideTopBorder
+              />
+            </View>
+          )
+        }
+        case 'loadMore': {
+          return <LoadMore item={item} moderationOpts={moderationOpts} />
+        }
+        case 'profilePlaceholder': {
+          return <ProfileCardFeedLoadingPlaceholder />
+        }
+        case 'feedPlaceholder': {
+          return <FeedFeedLoadingPlaceholder />
+        }
+        case '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>
+          )
+        }
+      }
+    },
+    [t, hasSession, moderationOpts],
+  )
+
+  return (
+    <List
+      data={items}
+      renderItem={renderItem}
+      keyExtractor={item => item.key}
+      // @ts-ignore web only -prf
+      desktopFixedHeight
+      contentContainerStyle={{paddingBottom: 200}}
+      keyboardShouldPersistTaps="handled"
+      keyboardDismissMode="on-drag"
+    />
+  )
+}
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index b6daf84b3..f1b0301d0 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -29,15 +29,14 @@ import {MagnifyingGlassIcon} from '#/lib/icons'
 import {makeProfileLink} from '#/lib/routes/links'
 import {NavigationProp} from '#/lib/routes/types'
 import {augmentSearchQuery} from '#/lib/strings/helpers'
-import {s} from '#/lib/styles'
 import {logger} from '#/logger'
 import {isIOS, isNative, isWeb} from '#/platform/detection'
 import {listenSoftReset} from '#/state/events'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
 import {useActorSearch} from '#/state/queries/actor-search'
+import {usePopularFeedsSearch} from '#/state/queries/feed'
 import {useSearchPostsQuery} from '#/state/queries/search-posts'
-import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
 import {useSession} from '#/state/session'
 import {useSetDrawerOpen} from '#/state/shell'
 import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
@@ -56,8 +55,9 @@ import {Link} from '#/view/com/util/Link'
 import {List} from '#/view/com/util/List'
 import {Text} from '#/view/com/util/text/Text'
 import {CenteredView, ScrollView} from '#/view/com/util/Views'
+import {Explore} from '#/view/screens/Search/Explore'
 import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
-import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
+import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
 import {atoms as a} from '#/alf'
 import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
 
@@ -122,70 +122,6 @@ function EmptyState({message, error}: {message: string; error?: string}) {
   )
 }
 
-function useSuggestedFollows(): [
-  AppBskyActorDefs.ProfileViewBasic[],
-  () => void,
-] {
-  const {
-    data: suggestions,
-    hasNextPage,
-    isFetchingNextPage,
-    isError,
-    fetchNextPage,
-  } = useSuggestedFollowsQuery()
-
-  const onEndReached = React.useCallback(async () => {
-    if (isFetchingNextPage || !hasNextPage || isError) return
-    try {
-      await fetchNextPage()
-    } catch (err) {
-      logger.error('Failed to load more suggested follows', {message: err})
-    }
-  }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
-
-  const items: AppBskyActorDefs.ProfileViewBasic[] = []
-  if (suggestions) {
-    // Currently the responses contain duplicate items.
-    // Needs to be fixed on backend, but let's dedupe to be safe.
-    let seen = new Set()
-    for (const page of suggestions.pages) {
-      for (const actor of page.actors) {
-        if (!seen.has(actor.did)) {
-          seen.add(actor.did)
-          items.push(actor)
-        }
-      }
-    }
-  }
-  return [items, onEndReached]
-}
-
-let SearchScreenSuggestedFollows = (_props: {}): React.ReactNode => {
-  const pal = usePalette('default')
-  const [suggestions, onEndReached] = useSuggestedFollows()
-
-  return suggestions.length ? (
-    <List
-      data={suggestions}
-      renderItem={({item}) => <ProfileCardWithFollowBtn profile={item} noBg />}
-      keyExtractor={item => item.did}
-      // @ts-ignore web only -prf
-      desktopFixedHeight
-      contentContainerStyle={{paddingBottom: 200}}
-      keyboardShouldPersistTaps="handled"
-      keyboardDismissMode="on-drag"
-      onEndReached={onEndReached}
-      onEndReachedThreshold={2}
-    />
-  ) : (
-    <CenteredView sideBorders style={[pal.border, s.hContentRegion]}>
-      <ProfileCardFeedLoadingPlaceholder />
-      <ProfileCardFeedLoadingPlaceholder />
-    </CenteredView>
-  )
-}
-SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows)
-
 type SearchResultSlice =
   | {
       type: 'post'
@@ -342,6 +278,50 @@ let SearchScreenUserResults = ({
 }
 SearchScreenUserResults = React.memo(SearchScreenUserResults)
 
+let SearchScreenFeedsResults = ({
+  query,
+  active,
+}: {
+  query: string
+  active: boolean
+}): React.ReactNode => {
+  const {_} = useLingui()
+  const {hasSession} = useSession()
+
+  const {data: results, isFetched} = usePopularFeedsSearch({
+    query,
+    enabled: active,
+  })
+
+  return isFetched && results ? (
+    <>
+      {results.length ? (
+        <List
+          data={results}
+          renderItem={({item}) => (
+            <FeedSourceCard
+              feedUri={item.uri}
+              showSaveBtn={hasSession}
+              showDescription
+              showLikes
+              pinOnSave
+            />
+          )}
+          keyExtractor={item => item.did}
+          // @ts-ignore web only -prf
+          desktopFixedHeight
+          contentContainerStyle={{paddingBottom: 100}}
+        />
+      ) : (
+        <EmptyState message={_(msg`No results found for ${query}`)} />
+      )}
+    </>
+  ) : (
+    <Loader />
+  )
+}
+SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults)
+
 let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
   const pal = usePalette('default')
   const setMinimalShellMode = useSetMinimalShellMode()
@@ -389,6 +369,12 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
           <SearchScreenUserResults query={query} active={activeTab === 2} />
         ),
       },
+      {
+        title: _(msg`Feeds`),
+        component: (
+          <SearchScreenFeedsResults query={query} active={activeTab === 3} />
+        ),
+      },
     ]
   }, [_, query, activeTab])
 
@@ -408,26 +394,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
       ))}
     </Pager>
   ) : hasSession ? (
-    <View>
-      <CenteredView sideBorders style={pal.border}>
-        <Text
-          type="title"
-          style={[
-            pal.text,
-            pal.border,
-            {
-              display: 'flex',
-              paddingVertical: 12,
-              paddingHorizontal: 18,
-              fontWeight: 'bold',
-            },
-          ]}>
-          <Trans>Suggested Follows</Trans>
-        </Text>
-      </CenteredView>
-
-      <SearchScreenSuggestedFollows />
-    </View>
+    <Explore />
   ) : (
     <CenteredView sideBorders style={pal.border}>
       <View