about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2024-06-21 16:50:23 -0500
committerGitHub <noreply@github.com>2024-06-22 00:50:23 +0300
commit4d6787009ccbae2812aaeddefe6dc77742363f36 (patch)
tree3e7200c5e783e58602e8965b43fe312315db9e7d /src
parentcb376479493dbc3a24876449f6466789ddcef6ea (diff)
downloadvoidsky-4d6787009ccbae2812aaeddefe6dc77742363f36.tar.zst
Pinned feeds cards (#4526)
* Add lists support to FeedCard

* Add useSavedFeeds query, similar to usePinnedFeedInfos

* Integrate into Feeds screen

* Fix alignment on mobile

* Update usages

* Add placeholder loading state

* Handle no feeds state

* Reuse previous data for placeholder

* Staged loading

* Improve staged loading

* Use setQueryData approach to pre-caching

* Add types for a little more safety

* Fix precaching

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/components/FeedCard.tsx135
-rw-r--r--src/state/queries/feed.ts139
-rw-r--r--src/state/queries/resolve-uri.ts15
-rw-r--r--src/view/screens/Feeds.tsx387
-rw-r--r--src/view/screens/Search/Explore.tsx2
-rw-r--r--src/view/screens/Search/Search.tsx2
6 files changed, 447 insertions, 233 deletions
diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx
index 94d97cb62..bd0649097 100644
--- a/src/components/FeedCard.tsx
+++ b/src/components/FeedCard.tsx
@@ -1,6 +1,11 @@
 import React from 'react'
 import {GestureResponderEvent, View} from 'react-native'
-import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
+import {
+  AppBskyActorDefs,
+  AppBskyFeedDefs,
+  AppBskyGraphDefs,
+  AtUri,
+} from '@atproto/api'
 import {msg, plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -20,23 +25,35 @@ 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 {Link as InternalLink, LinkProps} 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}) {
+export function Default({
+  type,
+  view,
+}:
+  | {
+      type: 'feed'
+      view: AppBskyFeedDefs.GeneratorView
+    }
+  | {
+      type: 'list'
+      view: AppBskyGraphDefs.ListView
+    }) {
+  const displayName = type === 'feed' ? view.displayName : view.name
   return (
-    <Link feed={feed}>
+    <Link feed={view}>
       <Outer>
         <Header>
-          <Avatar src={feed.avatar} />
-          <TitleAndByline title={feed.displayName} creator={feed.creator} />
-          <Action uri={feed.uri} pin />
+          <Avatar src={view.avatar} />
+          <TitleAndByline title={displayName} creator={view.creator} />
+          <Action uri={view.uri} pin />
         </Header>
-        <Description description={feed.description} />
-        <Likes count={feed.likeCount || 0} />
+        <Description description={view.description} />
+        {type === 'feed' && <Likes count={view.likeCount || 0} />}
       </Outer>
     </Link>
   )
@@ -46,13 +63,10 @@ export function Link({
   children,
   feed,
 }: {
-  children: React.ReactElement
-  feed: AppBskyFeedDefs.GeneratorView
-}) {
+  feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
+} & Omit<LinkProps, 'to'>) {
   const href = React.useMemo(() => {
-    const urip = new AtUri(feed.uri)
-    const handleOrDid = feed.creator.handle || feed.creator.did
-    return `/profile/${handleOrDid}/feed/${urip.rkey}`
+    return createProfileFeedHref({feed})
   }, [feed])
   return <InternalLink to={href}>{children}</InternalLink>
 }
@@ -62,11 +76,33 @@ export function Outer({children}: {children: React.ReactNode}) {
 }
 
 export function Header({children}: {children: React.ReactNode}) {
-  return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
+  return (
+    <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_md]}>
+      {children}
+    </View>
+  )
+}
+
+export type AvatarProps = {src: string | undefined; size?: number}
+
+export function Avatar({src, size = 40}: AvatarProps) {
+  return <UserAvatar type="algo" size={size} avatar={src} />
 }
 
-export function Avatar({src}: {src: string | undefined}) {
-  return <UserAvatar type="algo" size={40} avatar={src} />
+export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        t.atoms.bg_contrast_25,
+        {
+          width: size,
+          height: size,
+          borderRadius: 8,
+        },
+      ]}
+    />
+  )
 }
 
 export function TitleAndByline({
@@ -74,22 +110,54 @@ export function TitleAndByline({
   creator,
 }: {
   title: string
-  creator: AppBskyActorDefs.ProfileViewBasic
+  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}>
+      <Text style={[a.text_md, a.font_bold, 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>
+      {creator && (
+        <Text
+          style={[a.leading_snug, t.atoms.text_contrast_medium]}
+          numberOfLines={1}>
+          <Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
+        </Text>
+      )}
+    </View>
+  )
+}
+
+export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) {
+  const t = useTheme()
+
+  return (
+    <View style={[a.flex_1, a.gap_xs]}>
+      <View
+        style={[
+          a.rounded_xs,
+          t.atoms.bg_contrast_50,
+          {
+            width: '60%',
+            height: 14,
+          },
+        ]}
+      />
+
+      {creator && (
+        <View
+          style={[
+            a.rounded_xs,
+            t.atoms.bg_contrast_25,
+            {
+              width: '40%',
+              height: 10,
+            },
+          ]}
+        />
+      )}
     </View>
   )
 }
@@ -203,3 +271,16 @@ function ActionInner({uri, pin}: {uri: string; pin?: boolean}) {
     </>
   )
 }
+
+export function createProfileFeedHref({
+  feed,
+}: {
+  feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
+}) {
+  const urip = new AtUri(feed.uri)
+  const type = urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'list'
+  const handleOrDid = feed.creator.handle || feed.creator.did
+  return `/profile/${handleOrDid}/${type === 'feed' ? 'feed' : 'lists'}/${
+    urip.rkey
+  }`
+}
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
index 83d6a7634..972dbf995 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -9,20 +9,24 @@ import {
 } from '@atproto/api'
 import {
   InfiniteData,
+  QueryClient,
   QueryKey,
   useInfiniteQuery,
   useMutation,
   useQuery,
+  useQueryClient,
 } from '@tanstack/react-query'
 
 import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {STALE} from '#/state/queries'
+import {RQKEY as listQueryKey} from '#/state/queries/list'
 import {usePreferencesQuery} from '#/state/queries/preferences'
 import {useAgent, useSession} from '#/state/session'
 import {router} from '#/routes'
 import {FeedDescriptor} from './post-feed'
+import {precacheResolvedUri} from './resolve-uri'
 
 export type FeedSourceFeedInfo = {
   type: 'feed'
@@ -201,6 +205,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
   const agent = useAgent()
   const limit = options?.limit || 10
   const {data: preferences} = usePreferencesQuery()
+  const queryClient = useQueryClient()
 
   // Make sure this doesn't invalidate unless really needed.
   const selectArgs = useMemo(
@@ -225,6 +230,13 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
         limit,
         cursor: pageParam,
       })
+
+      // precache feeds
+      for (const feed of res.data.feeds) {
+        const hydratedFeed = hydrateFeedGenerator(feed)
+        precacheFeed(queryClient, hydratedFeed)
+      }
+
       return res.data
     },
     initialPageParam: undefined,
@@ -449,3 +461,130 @@ export function usePinnedFeedsInfos() {
     },
   })
 }
+
+export type SavedFeedItem =
+  | {
+      type: 'feed'
+      config: AppBskyActorDefs.SavedFeed
+      view: AppBskyFeedDefs.GeneratorView
+    }
+  | {
+      type: 'list'
+      config: AppBskyActorDefs.SavedFeed
+      view: AppBskyGraphDefs.ListView
+    }
+  | {
+      type: 'timeline'
+      config: AppBskyActorDefs.SavedFeed
+      view: undefined
+    }
+
+export function useSavedFeeds() {
+  const agent = useAgent()
+  const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery()
+  const savedItems = preferences?.savedFeeds ?? []
+  const queryClient = useQueryClient()
+
+  return useQuery({
+    staleTime: STALE.INFINITY,
+    enabled: !isLoadingPrefs,
+    queryKey: [pinnedFeedInfosQueryKeyRoot, ...savedItems],
+    placeholderData: previousData => {
+      return (
+        previousData || {
+          count: savedItems.length,
+          feeds: [],
+        }
+      )
+    },
+    queryFn: async () => {
+      const resolvedFeeds = new Map<string, AppBskyFeedDefs.GeneratorView>()
+      const resolvedLists = new Map<string, AppBskyGraphDefs.ListView>()
+
+      const savedFeeds = savedItems.filter(feed => feed.type === 'feed')
+      const savedLists = savedItems.filter(feed => feed.type === 'list')
+
+      let feedsPromise = Promise.resolve()
+      if (savedFeeds.length > 0) {
+        feedsPromise = agent.app.bsky.feed
+          .getFeedGenerators({
+            feeds: savedFeeds.map(f => f.value),
+          })
+          .then(res => {
+            res.data.feeds.forEach(f => {
+              resolvedFeeds.set(f.uri, f)
+            })
+          })
+      }
+
+      const listsPromises = savedLists.map(list =>
+        agent.app.bsky.graph
+          .getList({
+            list: list.value,
+            limit: 1,
+          })
+          .then(res => {
+            const listView = res.data.list
+            resolvedLists.set(listView.uri, listView)
+          }),
+      )
+
+      await Promise.allSettled([feedsPromise, ...listsPromises])
+
+      resolvedFeeds.forEach(feed => {
+        const hydratedFeed = hydrateFeedGenerator(feed)
+        precacheFeed(queryClient, hydratedFeed)
+      })
+      resolvedLists.forEach(list => {
+        precacheList(queryClient, list)
+      })
+
+      const res: SavedFeedItem[] = savedItems.map(s => {
+        if (s.type === 'timeline') {
+          return {
+            type: 'timeline',
+            config: s,
+            view: undefined,
+          }
+        }
+
+        return {
+          type: s.type,
+          config: s,
+          view:
+            s.type === 'feed'
+              ? resolvedFeeds.get(s.value)
+              : resolvedLists.get(s.value),
+        }
+      }) as SavedFeedItem[]
+
+      return {
+        count: savedItems.length,
+        feeds: res,
+      }
+    },
+  })
+}
+
+function precacheFeed(queryClient: QueryClient, hydratedFeed: FeedSourceInfo) {
+  precacheResolvedUri(
+    queryClient,
+    hydratedFeed.creatorHandle,
+    hydratedFeed.creatorDid,
+  )
+  queryClient.setQueryData<FeedSourceInfo>(
+    feedSourceInfoQueryKey({uri: hydratedFeed.uri}),
+    hydratedFeed,
+  )
+}
+
+function precacheList(
+  queryClient: QueryClient,
+  list: AppBskyGraphDefs.ListView,
+) {
+  precacheResolvedUri(queryClient, list.creator.handle, list.creator.did)
+  queryClient.setQueryData<AppBskyGraphDefs.ListView>(
+    listQueryKey(list.uri),
+    list,
+  )
+}
diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts
index 7bd26435c..c1fd8e240 100644
--- a/src/state/queries/resolve-uri.ts
+++ b/src/state/queries/resolve-uri.ts
@@ -1,5 +1,10 @@
 import {AppBskyActorDefs, AtUri} from '@atproto/api'
-import {useQuery, useQueryClient, UseQueryResult} from '@tanstack/react-query'
+import {
+  QueryClient,
+  useQuery,
+  useQueryClient,
+  UseQueryResult,
+} from '@tanstack/react-query'
 
 import {STALE} from '#/state/queries'
 import {useAgent} from '#/state/session'
@@ -50,3 +55,11 @@ export function useResolveDidQuery(didOrHandle: string | undefined) {
     enabled: !!didOrHandle,
   })
 }
+
+export function precacheResolvedUri(
+  queryClient: QueryClient,
+  handle: string,
+  did: string,
+) {
+  queryClient.setQueryData<string>(RQKEY(handle), did)
+}
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
index 134521177..70437a9e7 100644
--- a/src/view/screens/Feeds.tsx
+++ b/src/view/screens/Feeds.tsx
@@ -1,8 +1,6 @@
 import React from 'react'
 import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native'
-import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
+import {AppBskyFeedDefs} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useFocusEffect} from '@react-navigation/native'
@@ -10,12 +8,11 @@ import debounce from 'lodash.debounce'
 
 import {isNative, isWeb} from '#/platform/detection'
 import {
-  getAvatarTypeFromUri,
-  useFeedSourceInfoQuery,
+  SavedFeedItem,
   useGetPopularFeedsQuery,
+  useSavedFeeds,
   useSearchPopularFeedsMutation,
 } from '#/state/queries/feed'
-import {usePreferencesQuery} from '#/state/queries/preferences'
 import {useSession} from '#/state/session'
 import {useSetMinimalShellMode} from '#/state/shell'
 import {useComposerControls} from '#/state/shell/composer'
@@ -28,14 +25,10 @@ import {s} from 'lib/styles'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
 import {FAB} from 'view/com/util/fab/FAB'
 import {SearchInput} from 'view/com/util/forms/SearchInput'
-import {Link, TextLink} from 'view/com/util/Link'
+import {TextLink} from 'view/com/util/Link'
 import {List} from 'view/com/util/List'
-import {
-  FeedFeedLoadingPlaceholder,
-  LoadingPlaceholder,
-} from 'view/com/util/LoadingPlaceholder'
+import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
 import {Text} from 'view/com/util/text/Text'
-import {UserAvatar} from 'view/com/util/UserAvatar'
 import {ViewHeader} from 'view/com/util/ViewHeader'
 import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
 import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
@@ -47,6 +40,7 @@ import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkl
 import hairlineWidth = StyleSheet.hairlineWidth
 import {Divider} from '#/components/Divider'
 import * as FeedCard from '#/components/FeedCard'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'>
 
@@ -61,9 +55,8 @@ type FlatlistSlice =
       key: string
     }
   | {
-      type: 'savedFeedsLoading'
+      type: 'savedFeedPlaceholder'
       key: string
-      // pendingItems: number,
     }
   | {
       type: 'savedFeedNoResults'
@@ -72,8 +65,7 @@ type FlatlistSlice =
   | {
       type: 'savedFeed'
       key: string
-      feedUri: string
-      savedFeedConfig: AppBskyActorDefs.SavedFeed
+      savedFeed: SavedFeedItem
     }
   | {
       type: 'savedFeedsLoadMore'
@@ -113,11 +105,11 @@ export function FeedsScreen(_props: Props) {
   const [query, setQuery] = React.useState('')
   const [isPTR, setIsPTR] = React.useState(false)
   const {
-    data: preferences,
-    isLoading: isPreferencesLoading,
-    error: preferencesError,
-    refetch: refetchPreferences,
-  } = usePreferencesQuery()
+    data: savedFeeds,
+    isPlaceholderData: isSavedFeedsPlaceholder,
+    error: savedFeedsError,
+    refetch: refetchSavedFeeds,
+  } = useSavedFeeds()
   const {
     data: popularFeeds,
     isFetching: isPopularFeedsFetching,
@@ -173,11 +165,11 @@ export function FeedsScreen(_props: Props) {
   const onPullToRefresh = React.useCallback(async () => {
     setIsPTR(true)
     await Promise.all([
-      refetchPreferences().catch(_e => undefined),
+      refetchSavedFeeds().catch(_e => undefined),
       refetchPopularFeeds().catch(_e => undefined),
     ])
     setIsPTR(false)
-  }, [setIsPTR, refetchPreferences, refetchPopularFeeds])
+  }, [setIsPTR, refetchSavedFeeds, refetchPopularFeeds])
   const onEndReached = React.useCallback(() => {
     if (
       isPopularFeedsFetching ||
@@ -203,6 +195,11 @@ export function FeedsScreen(_props: Props) {
 
   const items = React.useMemo(() => {
     let slices: FlatlistSlice[] = []
+    const hasActualSavedCount =
+      !isSavedFeedsPlaceholder ||
+      (isSavedFeedsPlaceholder && (savedFeeds?.count || 0) > 0)
+    const canShowDiscoverSection =
+      !hasSession || (hasSession && hasActualSavedCount)
 
     if (hasSession) {
       slices.push({
@@ -210,47 +207,63 @@ export function FeedsScreen(_props: Props) {
         type: 'savedFeedsHeader',
       })
 
-      if (preferencesError) {
+      if (savedFeedsError) {
         slices.push({
           key: 'savedFeedsError',
           type: 'error',
-          error: cleanError(preferencesError.toString()),
+          error: cleanError(savedFeedsError.toString()),
         })
       } else {
-        if (isPreferencesLoading || !preferences?.savedFeeds) {
-          slices.push({
-            key: 'savedFeedsLoading',
-            type: 'savedFeedsLoading',
-            // pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
-          })
+        if (isSavedFeedsPlaceholder && !savedFeeds?.feeds.length) {
+          /*
+           * Initial render in placeholder state is 0 on a cold page load,
+           * because preferences haven't loaded yet.
+           *
+           * In practice, `savedFeeds` is always defined, but we check for TS
+           * and for safety.
+           *
+           * In both cases, we show 4 as the the loading state.
+           */
+          const min = 8
+          const count = savedFeeds
+            ? savedFeeds.count === 0
+              ? min
+              : savedFeeds.count
+            : min
+          Array(count)
+            .fill(0)
+            .forEach((_, i) => {
+              slices.push({
+                key: 'savedFeedPlaceholder' + i,
+                type: 'savedFeedPlaceholder',
+              })
+            })
         } else {
-          if (preferences.savedFeeds?.length) {
-            const noFollowingFeed = preferences.savedFeeds.every(
+          if (savedFeeds?.feeds?.length) {
+            const noFollowingFeed = savedFeeds.feeds.every(
               f => f.type !== 'timeline',
             )
 
             slices = slices.concat(
-              preferences.savedFeeds
-                .filter(f => {
-                  return f.pinned
+              savedFeeds.feeds
+                .filter(s => {
+                  return s.config.pinned
                 })
-                .map(feed => ({
-                  key: `savedFeed:${feed.value}:${feed.id}`,
+                .map(s => ({
+                  key: `savedFeed:${s.view?.uri}:${s.config.id}`,
                   type: 'savedFeed',
-                  feedUri: feed.value,
-                  savedFeedConfig: feed,
+                  savedFeed: s,
                 })),
             )
             slices = slices.concat(
-              preferences.savedFeeds
-                .filter(f => {
-                  return !f.pinned
+              savedFeeds.feeds
+                .filter(s => {
+                  return !s.config.pinned
                 })
-                .map(feed => ({
-                  key: `savedFeed:${feed.value}:${feed.id}`,
+                .map(s => ({
+                  key: `savedFeed:${s.view?.uri}:${s.config.id}`,
                   type: 'savedFeed',
-                  feedUri: feed.value,
-                  savedFeedConfig: feed,
+                  savedFeed: s,
                 })),
             )
 
@@ -270,59 +283,36 @@ export function FeedsScreen(_props: Props) {
       }
     }
 
-    slices.push({
-      key: 'popularFeedsHeader',
-      type: 'popularFeedsHeader',
-    })
-
-    if (popularFeedsError || searchError) {
+    if (!hasSession || (hasSession && canShowDiscoverSection)) {
       slices.push({
-        key: 'popularFeedsError',
-        type: 'error',
-        error: cleanError(
-          popularFeedsError?.toString() ?? searchError?.toString() ?? '',
-        ),
+        key: 'popularFeedsHeader',
+        type: 'popularFeedsHeader',
       })
-    } else {
-      if (isUserSearching) {
-        if (isSearchPending || !searchResults) {
-          slices.push({
-            key: 'popularFeedsLoading',
-            type: 'popularFeedsLoading',
-          })
-        } else {
-          if (!searchResults || searchResults?.length === 0) {
-            slices.push({
-              key: 'popularFeedsNoResults',
-              type: 'popularFeedsNoResults',
-            })
-          } else {
-            slices = slices.concat(
-              searchResults.map(feed => ({
-                key: `popularFeed:${feed.uri}`,
-                type: 'popularFeed',
-                feedUri: feed.uri,
-                feed,
-              })),
-            )
-          }
-        }
+
+      if (popularFeedsError || searchError) {
+        slices.push({
+          key: 'popularFeedsError',
+          type: 'error',
+          error: cleanError(
+            popularFeedsError?.toString() ?? searchError?.toString() ?? '',
+          ),
+        })
       } else {
-        if (isPopularFeedsFetching && !popularFeeds?.pages) {
-          slices.push({
-            key: 'popularFeedsLoading',
-            type: 'popularFeedsLoading',
-          })
-        } else {
-          if (!popularFeeds?.pages) {
+        if (isUserSearching) {
+          if (isSearchPending || !searchResults) {
             slices.push({
-              key: 'popularFeedsNoResults',
-              type: 'popularFeedsNoResults',
+              key: 'popularFeedsLoading',
+              type: 'popularFeedsLoading',
             })
           } else {
-            for (const page of popularFeeds.pages || []) {
+            if (!searchResults || searchResults?.length === 0) {
+              slices.push({
+                key: 'popularFeedsNoResults',
+                type: 'popularFeedsNoResults',
+              })
+            } else {
               slices = slices.concat(
-                page.feeds.map(feed => ({
+                searchResults.map(feed => ({
                   key: `popularFeed:${feed.uri}`,
                   type: 'popularFeed',
                   feedUri: feed.uri,
@@ -330,12 +320,37 @@ export function FeedsScreen(_props: Props) {
                 })),
               )
             }
-
-            if (isPopularFeedsFetchingNextPage) {
+          }
+        } else {
+          if (isPopularFeedsFetching && !popularFeeds?.pages) {
+            slices.push({
+              key: 'popularFeedsLoading',
+              type: 'popularFeedsLoading',
+            })
+          } else {
+            if (!popularFeeds?.pages) {
               slices.push({
-                key: 'popularFeedsLoadingMore',
-                type: 'popularFeedsLoadingMore',
+                key: 'popularFeedsNoResults',
+                type: 'popularFeedsNoResults',
               })
+            } else {
+              for (const page of popularFeeds.pages || []) {
+                slices = slices.concat(
+                  page.feeds.map(feed => ({
+                    key: `popularFeed:${feed.uri}`,
+                    type: 'popularFeed',
+                    feedUri: feed.uri,
+                    feed,
+                  })),
+                )
+              }
+
+              if (isPopularFeedsFetchingNextPage) {
+                slices.push({
+                  key: 'popularFeedsLoadingMore',
+                  type: 'popularFeedsLoadingMore',
+                })
+              }
             }
           }
         }
@@ -345,9 +360,9 @@ export function FeedsScreen(_props: Props) {
     return slices
   }, [
     hasSession,
-    preferences,
-    isPreferencesLoading,
-    preferencesError,
+    savedFeeds,
+    isSavedFeedsPlaceholder,
+    savedFeedsError,
     popularFeeds,
     isPopularFeedsFetching,
     popularFeedsError,
@@ -407,10 +422,7 @@ export function FeedsScreen(_props: Props) {
     ({item}: {item: FlatlistSlice}) => {
       if (item.type === 'error') {
         return <ErrorMessage message={item.error} />
-      } else if (
-        item.type === 'popularFeedsLoadingMore' ||
-        item.type === 'savedFeedsLoading'
-      ) {
+      } else if (item.type === 'popularFeedsLoadingMore') {
         return (
           <View style={s.p10}>
             <ActivityIndicator size="large" />
@@ -459,8 +471,10 @@ export function FeedsScreen(_props: Props) {
             <NoSavedFeedsOfAnyType />
           </View>
         )
+      } else if (item.type === 'savedFeedPlaceholder') {
+        return <SavedFeedPlaceholder />
       } else if (item.type === 'savedFeed') {
-        return <FeedOrFollowing savedFeedConfig={item.savedFeedConfig} />
+        return <FeedOrFollowing savedFeed={item.savedFeed} />
       } else if (item.type === 'popularFeedsHeader') {
         return (
           <>
@@ -481,7 +495,7 @@ export function FeedsScreen(_props: Props) {
       } else if (item.type === 'popularFeed') {
         return (
           <View style={[a.px_lg, a.pt_lg, a.gap_lg]}>
-            <FeedCard.Default feed={item.feed} />
+            <FeedCard.Default type="feed" view={item.feed} />
             <Divider />
           </View>
         )
@@ -571,136 +585,103 @@ export function FeedsScreen(_props: Props) {
   )
 }
 
-function FeedOrFollowing({
-  savedFeedConfig: feed,
-}: {
-  savedFeedConfig: AppBskyActorDefs.SavedFeed
-}) {
-  return feed.type === 'timeline' ? (
+function FeedOrFollowing({savedFeed}: {savedFeed: SavedFeedItem}) {
+  return savedFeed.type === 'timeline' ? (
     <FollowingFeed />
   ) : (
-    <SavedFeed savedFeedConfig={feed} />
+    <SavedFeed savedFeed={savedFeed} />
   )
 }
 
 function FollowingFeed() {
-  const pal = usePalette('default')
   const t = useTheme()
-  const {isMobile} = useWebMediaQueries()
+  const {_} = useLingui()
   return (
     <View
-      testID={`saved-feed-timeline`}
       style={[
-        pal.border,
-        styles.savedFeed,
-        isMobile && styles.savedFeedMobile,
+        a.flex_1,
+        a.px_lg,
+        a.py_md,
+        a.border_b,
+        t.atoms.border_contrast_low,
       ]}>
-      <View
-        style={[
-          a.align_center,
-          a.justify_center,
-          {
-            width: 28,
-            height: 28,
-            borderRadius: 3,
-            backgroundColor: t.palette.primary_500,
-          },
-        ]}>
-        <FilterTimeline
+      <FeedCard.Header>
+        <View
           style={[
+            a.align_center,
+            a.justify_center,
             {
-              width: 18,
-              height: 18,
+              width: 28,
+              height: 28,
+              borderRadius: 3,
+              backgroundColor: t.palette.primary_500,
             },
-          ]}
-          fill={t.palette.white}
-        />
-      </View>
-      <View
-        style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}>
-        <Text type="lg-medium" style={pal.text} numberOfLines={1}>
-          <Trans>Following</Trans>
-        </Text>
-      </View>
+          ]}>
+          <FilterTimeline
+            style={[
+              {
+                width: 18,
+                height: 18,
+              },
+            ]}
+            fill={t.palette.white}
+          />
+        </View>
+        <FeedCard.TitleAndByline title={_(msg`Following`)} />
+      </FeedCard.Header>
     </View>
   )
 }
 
 function SavedFeed({
-  savedFeedConfig: feed,
+  savedFeed,
 }: {
-  savedFeedConfig: AppBskyActorDefs.SavedFeed
+  savedFeed: SavedFeedItem & {type: 'feed' | 'list'}
 }) {
-  const pal = usePalette('default')
-  const {isMobile} = useWebMediaQueries()
-  const {data: info, error} = useFeedSourceInfoQuery({uri: feed.value})
-  const typeAvatar = getAvatarTypeFromUri(feed.value)
-
-  if (!info)
-    return (
-      <SavedFeedLoadingPlaceholder
-        key={`savedFeedLoadingPlaceholder:${feed.value}`}
-      />
-    )
+  const t = useTheme()
+  const {view: feed} = savedFeed
+  const displayName =
+    savedFeed.type === 'feed' ? savedFeed.view.displayName : savedFeed.view.name
 
   return (
-    <Link
-      testID={`saved-feed-${info.displayName}`}
-      href={info.route.href}
-      style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]}
-      hoverStyle={pal.viewLight}
-      accessibilityLabel={info.displayName}
-      accessibilityHint=""
-      asAnchor
-      anchorNoUnderline>
-      {error ? (
+    <FeedCard.Link testID={`saved-feed-${feed.displayName}`} feed={feed}>
+      {({hovered, pressed}) => (
         <View
-          style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}>
-          <FontAwesomeIcon
-            icon="exclamation-circle"
-            color={pal.colors.textLight}
-          />
+          style={[
+            a.flex_1,
+            a.px_lg,
+            a.py_md,
+            a.border_b,
+            t.atoms.border_contrast_low,
+            (hovered || pressed) && t.atoms.bg_contrast_25,
+          ]}>
+          <FeedCard.Header>
+            <FeedCard.Avatar src={feed.avatar} size={28} />
+            <FeedCard.TitleAndByline title={displayName} />
+
+            <ChevronRight size="sm" fill={t.atoms.text_contrast_low.color} />
+          </FeedCard.Header>
         </View>
-      ) : (
-        <UserAvatar type={typeAvatar} size={28} avatar={info.avatar} />
       )}
-      <View
-        style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}>
-        <Text type="lg-medium" style={pal.text} numberOfLines={1}>
-          {info.displayName}
-        </Text>
-        {error ? (
-          <View style={[styles.offlineSlug, pal.borderDark]}>
-            <Text type="xs" style={pal.textLight}>
-              <Trans>Feed offline</Trans>
-            </Text>
-          </View>
-        ) : null}
-      </View>
-
-      {isMobile && (
-        <FontAwesomeIcon
-          icon="chevron-right"
-          size={14}
-          style={pal.textLight as FontAwesomeIconStyle}
-        />
-      )}
-    </Link>
+    </FeedCard.Link>
   )
 }
 
-function SavedFeedLoadingPlaceholder() {
-  const pal = usePalette('default')
-  const {isMobile} = useWebMediaQueries()
+function SavedFeedPlaceholder() {
+  const t = useTheme()
   return (
     <View
       style={[
-        pal.border,
-        styles.savedFeed,
-        isMobile && styles.savedFeedMobile,
+        a.flex_1,
+        a.px_lg,
+        a.py_md,
+        a.border_b,
+        t.atoms.border_contrast_low,
       ]}>
-      <LoadingPlaceholder width={28} height={28} style={{borderRadius: 4}} />
-      <LoadingPlaceholder width={140} height={12} />
+      <FeedCard.Header>
+        <FeedCard.AvatarPlaceholder size={28} />
+        <FeedCard.TitleAndBylinePlaceholder />
+      </FeedCard.Header>
     </View>
   )
 }
diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx
index f6988548b..8f6f6d4ba 100644
--- a/src/view/screens/Search/Explore.tsx
+++ b/src/view/screens/Search/Explore.tsx
@@ -505,7 +505,7 @@ export function Explore() {
                 a.px_lg,
                 a.py_lg,
               ]}>
-              <FeedCard.Default feed={item.feed} />
+              <FeedCard.Default type="feed" view={item.feed} />
             </View>
           )
         }
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index 0b1fe37aa..76ffba935 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -306,7 +306,7 @@ let SearchScreenFeedsResults = ({
                 a.px_lg,
                 a.py_lg,
               ]}>
-              <FeedCard.Default feed={item} />
+              <FeedCard.Default type="feed" view={item} />
             </View>
           )}
           keyExtractor={item => item.uri}