about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/FeedCard.tsx198
-rw-r--r--src/components/KnownFollowers.tsx2
-rw-r--r--src/components/Prompt.tsx17
-rw-r--r--src/components/dms/LeaveConvoPrompt.tsx2
-rw-r--r--src/components/icons/Arrow.tsx4
-rw-r--r--src/screens/Profile/Header/ProfileHeaderLabeler.tsx2
-rw-r--r--src/state/cache/profile-shadow.ts2
-rw-r--r--src/state/queries/feed.ts130
-rw-r--r--src/state/queries/known-followers.ts32
-rw-r--r--src/state/queries/suggested-follows.ts13
-rw-r--r--src/view/com/home/HomeHeaderLayout.web.tsx3
-rw-r--r--src/view/com/util/post-embeds/GifEmbed.tsx2
-rw-r--r--src/view/screens/Feeds.tsx68
-rw-r--r--src/view/screens/Search/Explore.tsx556
-rw-r--r--src/view/screens/Search/Search.tsx141
15 files changed, 1011 insertions, 161 deletions
diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx
new file mode 100644
index 000000000..2745ed7c9
--- /dev/null
+++ b/src/components/FeedCard.tsx
@@ -0,0 +1,198 @@
+import React from 'react'
+import {GestureResponderEvent, View} from 'react-native'
+import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
+import {msg, plural, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {logger} from '#/logger'
+import {
+  useAddSavedFeedsMutation,
+  usePreferencesQuery,
+  useRemoveFeedMutation,
+} from '#/state/queries/preferences'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import * as Toast from 'view/com/util/Toast'
+import {useTheme} from '#/alf'
+import {atoms as a} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {useRichText} from '#/components/hooks/useRichText'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+import {Link as InternalLink} from '#/components/Link'
+import {Loader} from '#/components/Loader'
+import * as Prompt from '#/components/Prompt'
+import {RichText} from '#/components/RichText'
+import {Text} from '#/components/Typography'
+
+export function Default({feed}: {feed: AppBskyFeedDefs.GeneratorView}) {
+  return (
+    <Link feed={feed}>
+      <Outer>
+        <Header>
+          <Avatar src={feed.avatar} />
+          <TitleAndByline title={feed.displayName} creator={feed.creator} />
+          <Action uri={feed.uri} pin />
+        </Header>
+        <Description description={feed.description} />
+        <Likes count={feed.likeCount || 0} />
+      </Outer>
+    </Link>
+  )
+}
+
+export function Link({
+  children,
+  feed,
+}: {
+  children: React.ReactElement
+  feed: AppBskyFeedDefs.GeneratorView
+}) {
+  const href = React.useMemo(() => {
+    const urip = new AtUri(feed.uri)
+    const handleOrDid = feed.creator.handle || feed.creator.did
+    return `/profile/${handleOrDid}/feed/${urip.rkey}`
+  }, [feed])
+  return <InternalLink to={href}>{children}</InternalLink>
+}
+
+export function Outer({children}: {children: React.ReactNode}) {
+  return <View style={[a.flex_1, a.gap_md]}>{children}</View>
+}
+
+export function Header({children}: {children: React.ReactNode}) {
+  return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
+}
+
+export function Avatar({src}: {src: string | undefined}) {
+  return <UserAvatar type="algo" size={40} avatar={src} />
+}
+
+export function TitleAndByline({
+  title,
+  creator,
+}: {
+  title: string
+  creator: AppBskyActorDefs.ProfileViewBasic
+}) {
+  const t = useTheme()
+
+  return (
+    <View style={[a.flex_1]}>
+      <Text
+        style={[a.text_md, a.font_bold, a.flex_1, a.leading_snug]}
+        numberOfLines={1}>
+        {title}
+      </Text>
+      <Text
+        style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}
+        numberOfLines={1}>
+        <Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
+      </Text>
+    </View>
+  )
+}
+
+export function Description({description}: {description?: string}) {
+  const [rt, isResolving] = useRichText(description || '')
+  if (!description) return null
+  return isResolving ? (
+    <RichText value={description} style={[a.leading_snug]} />
+  ) : (
+    <RichText value={rt} style={[a.leading_snug]} />
+  )
+}
+
+export function Likes({count}: {count: number}) {
+  const t = useTheme()
+  return (
+    <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
+      {plural(count || 0, {
+        one: 'Liked by # user',
+        other: 'Liked by # users',
+      })}
+    </Text>
+  )
+}
+
+export function Action({uri, pin}: {uri: string; pin?: boolean}) {
+  const {_} = useLingui()
+  const {data: preferences} = usePreferencesQuery()
+  const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
+    useAddSavedFeedsMutation()
+  const {isPending: isRemovePending, mutateAsync: removeFeed} =
+    useRemoveFeedMutation()
+  const savedFeedConfig = React.useMemo(() => {
+    return preferences?.savedFeeds?.find(
+      feed => feed.type === 'feed' && feed.value === uri,
+    )
+  }, [preferences?.savedFeeds, uri])
+  const removePromptControl = Prompt.usePromptControl()
+  const isPending = isAddSavedFeedPending || isRemovePending
+
+  const toggleSave = React.useCallback(
+    async (e: GestureResponderEvent) => {
+      e.preventDefault()
+      e.stopPropagation()
+
+      try {
+        if (savedFeedConfig) {
+          await removeFeed(savedFeedConfig)
+        } else {
+          await saveFeeds([
+            {
+              type: 'feed',
+              value: uri,
+              pinned: pin || false,
+            },
+          ])
+        }
+        Toast.show(_(msg`Feeds updated!`))
+      } catch (e: any) {
+        logger.error(e, {context: `FeedCard: failed to update feeds`, pin})
+        Toast.show(_(msg`Failed to update feeds`))
+      }
+    },
+    [_, pin, saveFeeds, removeFeed, uri, savedFeedConfig],
+  )
+
+  const onPrompRemoveFeed = React.useCallback(
+    async (e: GestureResponderEvent) => {
+      e.preventDefault()
+      e.stopPropagation()
+
+      removePromptControl.open()
+    },
+    [removePromptControl],
+  )
+
+  return (
+    <>
+      <Button
+        disabled={isPending}
+        label={_(msg`Add this feed to your feeds`)}
+        size="small"
+        variant="ghost"
+        color="secondary"
+        shape="square"
+        onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}>
+        {savedFeedConfig ? (
+          <ButtonIcon size="md" icon={isPending ? Loader : Trash} />
+        ) : (
+          <ButtonIcon size="md" icon={isPending ? Loader : Plus} />
+        )}
+      </Button>
+
+      <Prompt.Basic
+        control={removePromptControl}
+        title={_(msg`Remove from my feeds?`)}
+        description={_(
+          msg`Are you sure you want to remove this from your feeds?`,
+        )}
+        onConfirm={toggleSave}
+        confirmButtonCta={_(msg`Remove`)}
+        confirmButtonColor="negative"
+      />
+    </>
+  )
+}
diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx
index a8bdb763d..63f61ce85 100644
--- a/src/components/KnownFollowers.tsx
+++ b/src/components/KnownFollowers.tsx
@@ -100,7 +100,7 @@ function KnownFollowersInner({
       moderation,
     }
   })
-  const count = cachedKnownFollowers.count - Math.min(slice.length, 2)
+  const count = cachedKnownFollowers.count
 
   return (
     <Link
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index d05cab5ab..315ad0dfd 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -1,10 +1,10 @@
 import React from 'react'
-import {View} from 'react-native'
+import {GestureResponderEvent, View} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
-import {Button, ButtonColor, ButtonText} from '#/components/Button'
+import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
 import {Text} from '#/components/Typography'
 
@@ -136,7 +136,7 @@ export function Action({
    * Note: The dialog will close automatically when the action is pressed, you
    * should NOT close the dialog as a side effect of this method.
    */
-  onPress: () => void
+  onPress: ButtonProps['onPress']
   color?: ButtonColor
   /**
    * Optional i18n string. If undefined, it will default to "Confirm".
@@ -147,9 +147,12 @@ export function Action({
   const {_} = useLingui()
   const {gtMobile} = useBreakpoints()
   const {close} = Dialog.useDialogContext()
-  const handleOnPress = React.useCallback(() => {
-    close(onPress)
-  }, [close, onPress])
+  const handleOnPress = React.useCallback(
+    (e: GestureResponderEvent) => {
+      close(() => onPress?.(e))
+    },
+    [close, onPress],
+  )
 
   return (
     <Button
@@ -186,7 +189,7 @@ export function Basic({
    * Note: The dialog will close automatically when the action is pressed, you
    * should NOT close the dialog as a side effect of this method.
    */
-  onConfirm: () => void
+  onConfirm: ButtonProps['onPress']
   confirmButtonColor?: ButtonColor
   showCancel?: boolean
 }>) {
diff --git a/src/components/dms/LeaveConvoPrompt.tsx b/src/components/dms/LeaveConvoPrompt.tsx
index 1c42dbca0..7abc76f34 100644
--- a/src/components/dms/LeaveConvoPrompt.tsx
+++ b/src/components/dms/LeaveConvoPrompt.tsx
@@ -49,7 +49,7 @@ export function LeaveConvoPrompt({
       )}
       confirmButtonCta={_(msg`Leave`)}
       confirmButtonColor="negative"
-      onConfirm={leaveConvo}
+      onConfirm={() => leaveConvo()}
     />
   )
 }
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/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
index 64bf71027..6588eb2e1 100644
--- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
@@ -333,7 +333,7 @@ function CantSubscribePrompt({
         </Trans>
       </Prompt.DescriptionText>
       <Prompt.Actions>
-        <Prompt.Action onPress={control.close} cta={_(msg`OK`)} />
+        <Prompt.Action onPress={() => control.close()} cta={_(msg`OK`)} />
       </Prompt.Actions>
     </Prompt.Outer>
   )
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
index 0a618ab3b..dc907664e 100644
--- a/src/state/cache/profile-shadow.ts
+++ b/src/state/cache/profile-shadow.ts
@@ -5,6 +5,7 @@ import EventEmitter from 'eventemitter3'
 
 import {batchedUpdates} from '#/lib/batchedUpdates'
 import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '../queries/actor-search'
+import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '../queries/known-followers'
 import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '../queries/list-members'
 import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '../queries/messages/list-converations'
 import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '../queries/my-blocked-accounts'
@@ -111,4 +112,5 @@ function* findProfilesInCache(
   yield* findAllProfilesInListConvosQueryData(queryClient, did)
   yield* findAllProfilesInFeedsQueryData(queryClient, did)
   yield* findAllProfilesInPostThreadQueryData(queryClient, did)
+  yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
 }
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
index b599ac1a0..2981b41b4 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -1,3 +1,4 @@
+import {useCallback, useEffect, useMemo, useRef} from 'react'
 import {
   AppBskyActorDefs,
   AppBskyFeedDefs,
@@ -171,28 +172,119 @@ export function useFeedSourceInfoQuery({uri}: {uri: string}) {
   })
 }
 
-export const useGetPopularFeedsQueryKey = ['getPopularFeeds']
+// HACK
+// the protocol doesn't yet tell us which feeds are personalized
+// this list is used to filter out feed recommendations from logged out users
+// for the ones we know need it
+// -prf
+export const KNOWN_AUTHED_ONLY_FEEDS = [
+  'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends', // popular with friends, by bsky.app
+  'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/mutuals', // mutuals, by skyfeed
+  'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/only-posts', // only posts, by skyfeed
+  'at://did:plc:wzsilnxf24ehtmmc3gssy5bu/app.bsky.feed.generator/mentions', // mentions, by flicknow
+  'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/bangers', // my bangers, by jaz
+  'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/mutuals', // mutuals, by bluesky
+  'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/my-followers', // followers, by jaz
+  'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/followpics', // the gram, by why
+]
+
+type GetPopularFeedsOptions = {limit?: number}
 
-export function useGetPopularFeedsQuery() {
+export function createGetPopularFeedsQueryKey(
+  options?: GetPopularFeedsOptions,
+) {
+  return ['getPopularFeeds', options]
+}
+
+export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
+  const {hasSession} = useSession()
   const agent = useAgent()
-  return useInfiniteQuery<
+  const limit = options?.limit || 10
+  const {data: preferences} = usePreferencesQuery()
+
+  // Make sure this doesn't invalidate unless really needed.
+  const selectArgs = useMemo(
+    () => ({
+      hasSession,
+      savedFeeds: preferences?.savedFeeds || [],
+    }),
+    [hasSession, preferences?.savedFeeds],
+  )
+  const lastPageCountRef = useRef(0)
+
+  const query = useInfiniteQuery<
     AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema,
     Error,
     InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>,
     QueryKey,
     string | undefined
   >({
-    queryKey: useGetPopularFeedsQueryKey,
+    queryKey: createGetPopularFeedsQueryKey(options),
     queryFn: async ({pageParam}) => {
       const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
-        limit: 10,
+        limit,
         cursor: pageParam,
       })
       return res.data
     },
     initialPageParam: undefined,
     getNextPageParam: lastPage => lastPage.cursor,
+    select: useCallback(
+      (
+        data: InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>,
+      ) => {
+        const {savedFeeds, hasSession: hasSessionInner} = selectArgs
+        data?.pages.map(page => {
+          page.feeds = page.feeds.filter(feed => {
+            if (
+              !hasSessionInner &&
+              KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri)
+            ) {
+              return false
+            }
+            const alreadySaved = Boolean(
+              savedFeeds?.find(f => {
+                return f.value === feed.uri
+              }),
+            )
+            return !alreadySaved
+          })
+
+          return page
+        })
+
+        return data
+      },
+      [selectArgs /* Don't change. Everything needs to go into selectArgs. */],
+    ),
   })
+
+  useEffect(() => {
+    const {isFetching, hasNextPage, data} = query
+    if (isFetching || !hasNextPage) {
+      return
+    }
+
+    // avoid double-fires of fetchNextPage()
+    if (
+      lastPageCountRef.current !== 0 &&
+      lastPageCountRef.current === data?.pages?.length
+    ) {
+      return
+    }
+
+    // fetch next page if we haven't gotten a full page of content
+    let count = 0
+    for (const page of data?.pages || []) {
+      count += page.feeds.length
+    }
+    if (count < limit && (data?.pages.length || 0) < 6) {
+      query.fetchNextPage()
+      lastPageCountRef.current = data?.pages?.length || 0
+    }
+  }, [query, limit])
+
+  return query
 }
 
 export function useSearchPopularFeedsMutation() {
@@ -209,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/known-followers.ts b/src/state/queries/known-followers.ts
index adcbf4b50..fedd9b40f 100644
--- a/src/state/queries/known-followers.ts
+++ b/src/state/queries/known-followers.ts
@@ -1,5 +1,10 @@
-import {AppBskyGraphGetKnownFollowers} from '@atproto/api'
-import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
+import {AppBskyActorDefs, AppBskyGraphGetKnownFollowers} from '@atproto/api'
+import {
+  InfiniteData,
+  QueryClient,
+  QueryKey,
+  useInfiniteQuery,
+} from '@tanstack/react-query'
 
 import {useAgent} from '#/state/session'
 
@@ -32,3 +37,26 @@ export function useProfileKnownFollowersQuery(did: string | undefined) {
     enabled: !!did,
   })
 }
+
+export function* findAllProfilesInQueryData(
+  queryClient: QueryClient,
+  did: string,
+): Generator<AppBskyActorDefs.ProfileView, void> {
+  const queryDatas = queryClient.getQueriesData<
+    InfiniteData<AppBskyGraphGetKnownFollowers.OutputSchema>
+  >({
+    queryKey: [RQKEY_ROOT],
+  })
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData?.pages) {
+      continue
+    }
+    for (const page of queryData?.pages) {
+      for (const follow of page.followers) {
+        if (follow.did === did) {
+          yield follow
+        }
+      }
+    }
+  }
+}
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/com/home/HomeHeaderLayout.web.tsx b/src/view/com/home/HomeHeaderLayout.web.tsx
index 77bdba51f..28f29ec78 100644
--- a/src/view/com/home/HomeHeaderLayout.web.tsx
+++ b/src/view/com/home/HomeHeaderLayout.web.tsx
@@ -57,6 +57,7 @@ function HomeHeaderLayoutDesktopAndTablet({
             t.atoms.bg,
             t.atoms.border_contrast_low,
             styles.bar,
+            kawaii && {paddingTop: 22, paddingBottom: 16},
           ]}>
           <View
             style={[
@@ -66,7 +67,7 @@ function HomeHeaderLayoutDesktopAndTablet({
               a.m_auto,
               kawaii && {paddingTop: 4, paddingBottom: 0},
               {
-                width: kawaii ? 60 : 28,
+                width: kawaii ? 84 : 28,
               },
             ]}>
             <Logo width={kawaii ? 60 : 28} />
diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx
index 1c0cf3d39..f2e2a8b0e 100644
--- a/src/view/com/util/post-embeds/GifEmbed.tsx
+++ b/src/view/com/util/post-embeds/GifEmbed.tsx
@@ -181,7 +181,7 @@ function AltText({text}: {text: string}) {
         <Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
         <Prompt.Actions>
           <Prompt.Action
-            onPress={control.close}
+            onPress={() => control.close()}
             cta={_(msg`Close`)}
             color="secondary"
           />
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
index 76ff4268f..134521177 100644
--- a/src/view/screens/Feeds.tsx
+++ b/src/view/screens/Feeds.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native'
-import {AppBskyActorDefs} from '@atproto/api'
+import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
@@ -25,7 +25,6 @@ import {ComposeIcon2} from 'lib/icons'
 import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
 import {cleanError} from 'lib/strings/errors'
 import {s} from 'lib/styles'
-import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
 import {FAB} from 'view/com/util/fab/FAB'
 import {SearchInput} from 'view/com/util/forms/SearchInput'
@@ -46,6 +45,8 @@ import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/compon
 import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass'
 import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle'
 import hairlineWidth = StyleSheet.hairlineWidth
+import {Divider} from '#/components/Divider'
+import * as FeedCard from '#/components/FeedCard'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'>
 
@@ -94,6 +95,7 @@ type FlatlistSlice =
       type: 'popularFeed'
       key: string
       feedUri: string
+      feed: AppBskyFeedDefs.GeneratorView
     }
   | {
       type: 'popularFeedsLoadingMore'
@@ -104,22 +106,6 @@ type FlatlistSlice =
       key: string
     }
 
-// HACK
-// the protocol doesn't yet tell us which feeds are personalized
-// this list is used to filter out feed recommendations from logged out users
-// for the ones we know need it
-// -prf
-const KNOWN_AUTHED_ONLY_FEEDS = [
-  'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends', // popular with friends, by bsky.app
-  'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/mutuals', // mutuals, by skyfeed
-  'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/only-posts', // only posts, by skyfeed
-  'at://did:plc:wzsilnxf24ehtmmc3gssy5bu/app.bsky.feed.generator/mentions', // mentions, by flicknow
-  'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/bangers', // my bangers, by jaz
-  'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/mutuals', // mutuals, by bluesky
-  'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/my-followers', // followers, by jaz
-  'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/followpics', // the gram, by why
-]
-
 export function FeedsScreen(_props: Props) {
   const pal = usePalette('default')
   const {openComposer} = useComposerControls()
@@ -316,6 +302,7 @@ export function FeedsScreen(_props: Props) {
                 key: `popularFeed:${feed.uri}`,
                 type: 'popularFeed',
                 feedUri: feed.uri,
+                feed,
               })),
             )
           }
@@ -327,10 +314,7 @@ export function FeedsScreen(_props: Props) {
             type: 'popularFeedsLoading',
           })
         } else {
-          if (
-            !popularFeeds?.pages ||
-            popularFeeds?.pages[0]?.feeds?.length === 0
-          ) {
+          if (!popularFeeds?.pages) {
             slices.push({
               key: 'popularFeedsNoResults',
               type: 'popularFeedsNoResults',
@@ -338,26 +322,12 @@ export function FeedsScreen(_props: Props) {
           } else {
             for (const page of popularFeeds.pages || []) {
               slices = slices.concat(
-                page.feeds
-                  .filter(feed => {
-                    if (
-                      !hasSession &&
-                      KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri)
-                    ) {
-                      return false
-                    }
-                    const alreadySaved = Boolean(
-                      preferences?.savedFeeds?.find(f => {
-                        return f.value === feed.uri
-                      }),
-                    )
-                    return !alreadySaved
-                  })
-                  .map(feed => ({
-                    key: `popularFeed:${feed.uri}`,
-                    type: 'popularFeed',
-                    feedUri: feed.uri,
-                  })),
+                page.feeds.map(feed => ({
+                  key: `popularFeed:${feed.uri}`,
+                  type: 'popularFeed',
+                  feedUri: feed.uri,
+                  feed,
+                })),
               )
             }
 
@@ -495,7 +465,7 @@ export function FeedsScreen(_props: Props) {
         return (
           <>
             <FeedsAboutHeader />
-            <View style={{paddingHorizontal: 12, paddingBottom: 12}}>
+            <View style={{paddingHorizontal: 12, paddingBottom: 4}}>
               <SearchInput
                 query={query}
                 onChangeQuery={onChangeQuery}
@@ -510,13 +480,10 @@ export function FeedsScreen(_props: Props) {
         return <FeedFeedLoadingPlaceholder />
       } else if (item.type === 'popularFeed') {
         return (
-          <FeedSourceCard
-            feedUri={item.feedUri}
-            showSaveBtn={hasSession}
-            showDescription
-            showLikes
-            pinOnSave
-          />
+          <View style={[a.px_lg, a.pt_lg, a.gap_lg]}>
+            <FeedCard.Default feed={item.feed} />
+            <Divider />
+          </View>
         )
       } else if (item.type === 'popularFeedsNoResults') {
         return (
@@ -559,7 +526,6 @@ export function FeedsScreen(_props: Props) {
       onPressCancelSearch,
       onSubmitQuery,
       onChangeSearchFocus,
-      hasSession,
     ],
   )
 
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..ed132d24e 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.uri}
+          // @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