about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2023-11-12 18:26:02 -0600
committerGitHub <noreply@github.com>2023-11-12 16:26:02 -0800
commitc584a3378d66459c04eee7d98560920e09c5f09f (patch)
treeeafe583f664254ab8af530ec3c67859851e6dc46 /src
parentd9e0a927c1c98ebd6aa3885ab517af27e7de2522 (diff)
downloadvoidsky-c584a3378d66459c04eee7d98560920e09c5f09f.tar.zst
Refactor My Feeds (#1877)
* Refactor My Feeds screen

* Remove unused feed UI models

* Add back PTR
Diffstat (limited to 'src')
-rw-r--r--src/state/models/me.ts6
-rw-r--r--src/state/models/ui/my-feeds.ts182
-rw-r--r--src/state/models/ui/saved-feeds.ts122
-rw-r--r--src/state/queries/feed.ts70
-rw-r--r--src/view/screens/Feeds.tsx692
5 files changed, 531 insertions, 541 deletions
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index 4bbb5a04b..c17fcf183 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -5,7 +5,6 @@ import {
 } from '@atproto/api'
 import {RootStoreModel} from './root-store'
 import {NotificationsFeedModel} from './feeds/notifications'
-import {MyFeedsUIModel} from './ui/my-feeds'
 import {MyFollowsCache} from './cache/my-follows'
 import {isObj, hasProp} from 'lib/type-guards'
 import {logger} from '#/logger'
@@ -22,7 +21,6 @@ export class MeModel {
   followsCount: number | undefined
   followersCount: number | undefined
   notifications: NotificationsFeedModel
-  myFeeds: MyFeedsUIModel
   follows: MyFollowsCache
   invites: ComAtprotoServerDefs.InviteCode[] = []
   appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = []
@@ -40,13 +38,11 @@ export class MeModel {
       {autoBind: true},
     )
     this.notifications = new NotificationsFeedModel(this.rootStore)
-    this.myFeeds = new MyFeedsUIModel(this.rootStore)
     this.follows = new MyFollowsCache(this.rootStore)
   }
 
   clear() {
     this.notifications.clear()
-    this.myFeeds.clear()
     this.follows.clear()
     this.rootStore.profiles.cache.clear()
     this.rootStore.posts.cache.clear()
@@ -113,8 +109,6 @@ export class MeModel {
           error: e,
         })
       })
-      this.myFeeds.clear()
-      /* dont await */ this.myFeeds.saved.refresh()
       this.rootStore.emitSessionLoaded()
       await this.fetchInviteCodes()
       await this.fetchAppPasswords()
diff --git a/src/state/models/ui/my-feeds.ts b/src/state/models/ui/my-feeds.ts
deleted file mode 100644
index ade686338..000000000
--- a/src/state/models/ui/my-feeds.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-import {makeAutoObservable, reaction} from 'mobx'
-import {SavedFeedsModel} from './saved-feeds'
-import {FeedsDiscoveryModel} from '../discovery/feeds'
-import {FeedSourceModel} from '../content/feed-source'
-import {RootStoreModel} from '../root-store'
-
-export type MyFeedsItem =
-  | {
-      _reactKey: string
-      type: 'spinner'
-    }
-  | {
-      _reactKey: string
-      type: 'saved-feeds-loading'
-      numItems: number
-    }
-  | {
-      _reactKey: string
-      type: 'discover-feeds-loading'
-    }
-  | {
-      _reactKey: string
-      type: 'error'
-      error: string
-    }
-  | {
-      _reactKey: string
-      type: 'saved-feeds-header'
-    }
-  | {
-      _reactKey: string
-      type: 'saved-feed'
-      feed: FeedSourceModel
-    }
-  | {
-      _reactKey: string
-      type: 'saved-feeds-load-more'
-    }
-  | {
-      _reactKey: string
-      type: 'discover-feeds-header'
-    }
-  | {
-      _reactKey: string
-      type: 'discover-feeds-no-results'
-    }
-  | {
-      _reactKey: string
-      type: 'discover-feed'
-      feed: FeedSourceModel
-    }
-
-export class MyFeedsUIModel {
-  saved: SavedFeedsModel
-  discovery: FeedsDiscoveryModel
-
-  constructor(public rootStore: RootStoreModel) {
-    makeAutoObservable(this)
-    this.saved = new SavedFeedsModel(this.rootStore)
-    this.discovery = new FeedsDiscoveryModel(this.rootStore)
-  }
-
-  get isRefreshing() {
-    return !this.saved.isLoading && this.saved.isRefreshing
-  }
-
-  get isLoading() {
-    return this.saved.isLoading || this.discovery.isLoading
-  }
-
-  async setup() {
-    if (!this.saved.hasLoaded) {
-      await this.saved.refresh()
-    }
-    if (!this.discovery.hasLoaded) {
-      await this.discovery.refresh()
-    }
-  }
-
-  clear() {
-    this.saved.clear()
-    this.discovery.clear()
-  }
-
-  registerListeners() {
-    const dispose1 = reaction(
-      () => this.rootStore.preferences.savedFeeds,
-      () => this.saved.refresh(),
-    )
-    const dispose2 = reaction(
-      () => this.rootStore.preferences.pinnedFeeds,
-      () => this.saved.refresh(),
-    )
-    return () => {
-      dispose1()
-      dispose2()
-    }
-  }
-
-  async refresh() {
-    return Promise.all([this.saved.refresh(), this.discovery.refresh()])
-  }
-
-  async loadMore() {
-    return this.discovery.loadMore()
-  }
-
-  get items() {
-    let items: MyFeedsItem[] = []
-
-    items.push({
-      _reactKey: '__saved_feeds_header__',
-      type: 'saved-feeds-header',
-    })
-    if (this.saved.isLoading && !this.saved.hasContent) {
-      items.push({
-        _reactKey: '__saved_feeds_loading__',
-        type: 'saved-feeds-loading',
-        numItems: this.rootStore.preferences.savedFeeds.length || 3,
-      })
-    } else if (this.saved.hasError) {
-      items.push({
-        _reactKey: '__saved_feeds_error__',
-        type: 'error',
-        error: this.saved.error,
-      })
-    } else {
-      const savedSorted = this.saved.all
-        .slice()
-        .sort((a, b) => a.displayName.localeCompare(b.displayName))
-      items = items.concat(
-        savedSorted.map(feed => ({
-          _reactKey: `saved-${feed.uri}`,
-          type: 'saved-feed',
-          feed,
-        })),
-      )
-      items.push({
-        _reactKey: '__saved_feeds_load_more__',
-        type: 'saved-feeds-load-more',
-      })
-    }
-
-    items.push({
-      _reactKey: '__discover_feeds_header__',
-      type: 'discover-feeds-header',
-    })
-    if (this.discovery.isLoading && !this.discovery.hasContent) {
-      items.push({
-        _reactKey: '__discover_feeds_loading__',
-        type: 'discover-feeds-loading',
-      })
-    } else if (this.discovery.hasError) {
-      items.push({
-        _reactKey: '__discover_feeds_error__',
-        type: 'error',
-        error: this.discovery.error,
-      })
-    } else if (this.discovery.isEmpty) {
-      items.push({
-        _reactKey: '__discover_feeds_no_results__',
-        type: 'discover-feeds-no-results',
-      })
-    } else {
-      items = items.concat(
-        this.discovery.feeds.map(feed => ({
-          _reactKey: `discover-${feed.uri}`,
-          type: 'discover-feed',
-          feed,
-        })),
-      )
-      if (this.discovery.isLoading) {
-        items.push({
-          _reactKey: '__discover_feeds_loading_more__',
-          type: 'spinner',
-        })
-      }
-    }
-
-    return items
-  }
-}
diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts
deleted file mode 100644
index cf4cf6d71..000000000
--- a/src/state/models/ui/saved-feeds.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import {makeAutoObservable, runInAction} from 'mobx'
-import {RootStoreModel} from '../root-store'
-import {bundleAsync} from 'lib/async/bundle'
-import {cleanError} from 'lib/strings/errors'
-import {FeedSourceModel} from '../content/feed-source'
-import {logger} from '#/logger'
-
-export class SavedFeedsModel {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasLoaded = false
-  error = ''
-
-  // data
-  all: FeedSourceModel[] = []
-
-  constructor(public rootStore: RootStoreModel) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-      },
-      {autoBind: true},
-    )
-  }
-
-  get hasContent() {
-    return this.all.length > 0
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  get pinned(): FeedSourceModel[] {
-    return this.rootStore.preferences.savedFeeds
-      .filter(feed => this.rootStore.preferences.isPinnedFeed(feed))
-      .map(uri => this.all.find(f => f.uri === uri))
-      .filter(Boolean) as FeedSourceModel[]
-  }
-
-  get unpinned(): FeedSourceModel[] {
-    return this.rootStore.preferences.savedFeeds
-      .filter(feed => !this.rootStore.preferences.isPinnedFeed(feed))
-      .map(uri => this.all.find(f => f.uri === uri))
-      .filter(Boolean) as FeedSourceModel[]
-  }
-
-  get pinnedFeedNames() {
-    return this.pinned.map(f => f.displayName)
-  }
-
-  // public api
-  // =
-
-  clear() {
-    this.all = []
-  }
-
-  /**
-   * Refresh the preferences then reload all feed infos
-   */
-  refresh = bundleAsync(async () => {
-    this._xLoading(true)
-    try {
-      const uris = dedup(
-        this.rootStore.preferences.pinnedFeeds.concat(
-          this.rootStore.preferences.savedFeeds,
-        ),
-      )
-      const feeds = uris.map(uri => new FeedSourceModel(this.rootStore, uri))
-      await Promise.all(feeds.map(f => f.setup()))
-      runInAction(() => {
-        this.all = feeds
-        this._updatePinSortOrder()
-      })
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  })
-
-  // state transitions
-  // =
-
-  _xLoading(isRefreshing = false) {
-    this.isLoading = true
-    this.isRefreshing = isRefreshing
-    this.error = ''
-  }
-
-  _xIdle(err?: any) {
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = true
-    this.error = cleanError(err)
-    if (err) {
-      logger.error('Failed to fetch user feeds', {err})
-    }
-  }
-
-  // helpers
-  // =
-
-  _updatePinSortOrder(order?: string[]) {
-    order ??= this.rootStore.preferences.pinnedFeeds.concat(
-      this.rootStore.preferences.savedFeeds,
-    )
-    this.all.sort((a, b) => {
-      return order!.indexOf(a.uri) - order!.indexOf(b.uri)
-    })
-  }
-}
-
-function dedup(strings: string[]): string[] {
-  return Array.from(new Set(strings))
-}
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
index 0ba323314..5754d2c70 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -1,5 +1,17 @@
-import {useQuery} from '@tanstack/react-query'
-import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api'
+import {
+  useQuery,
+  useInfiniteQuery,
+  InfiniteData,
+  QueryKey,
+  useMutation,
+} from '@tanstack/react-query'
+import {
+  AtUri,
+  RichText,
+  AppBskyFeedDefs,
+  AppBskyGraphDefs,
+  AppBskyUnspeccedGetPopularFeedGenerators,
+} from '@atproto/api'
 
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
@@ -10,6 +22,7 @@ type FeedSourceInfo =
       type: 'feed'
       uri: string
       cid: string
+      href: string
       avatar: string | undefined
       displayName: string
       description: RichText
@@ -22,6 +35,7 @@ type FeedSourceInfo =
       type: 'list'
       uri: string
       cid: string
+      href: string
       avatar: string | undefined
       displayName: string
       description: RichText
@@ -42,10 +56,16 @@ const feedSourceNSIDs = {
 function hydrateFeedGenerator(
   view: AppBskyFeedDefs.GeneratorView,
 ): FeedSourceInfo {
+  const urip = new AtUri(view.uri)
+  const collection =
+    urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists'
+  const href = `/profile/${urip.hostname}/${collection}/${urip.rkey}`
+
   return {
     type: 'feed',
     uri: view.uri,
     cid: view.cid,
+    href,
     avatar: view.avatar,
     displayName: view.displayName
       ? sanitizeDisplayName(view.displayName)
@@ -62,10 +82,16 @@ function hydrateFeedGenerator(
 }
 
 function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo {
+  const urip = new AtUri(view.uri)
+  const collection =
+    urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists'
+  const href = `/profile/${urip.hostname}/${collection}/${urip.rkey}`
+
   return {
     type: 'list',
     uri: view.uri,
     cid: view.cid,
+    href,
     avatar: view.avatar,
     description: new RichText({
       text: view.description || '',
@@ -104,3 +130,43 @@ export function useFeedSourceInfoQuery({uri}: {uri: string}) {
     },
   })
 }
+
+export const useGetPopularFeedsQueryKey = ['getPopularFeeds']
+
+export function useGetPopularFeedsQuery() {
+  const {agent} = useSession()
+
+  return useInfiniteQuery<
+    AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema,
+    Error,
+    InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>,
+    QueryKey,
+    string | undefined
+  >({
+    queryKey: useGetPopularFeedsQueryKey,
+    queryFn: async ({pageParam}) => {
+      const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
+        limit: 10,
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+  })
+}
+
+export function useSearchPopularFeedsMutation() {
+  const {agent} = useSession()
+
+  return useMutation({
+    mutationFn: async (query: string) => {
+      const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
+        limit: 10,
+        query: query,
+      })
+
+      return res.data.feeds
+    },
+  })
+}
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
index c2ec9208f..c78f44cd1 100644
--- a/src/view/screens/Feeds.tsx
+++ b/src/view/screens/Feeds.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native'
+import {ActivityIndicator, StyleSheet, View, RefreshControl} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
@@ -7,7 +7,6 @@ import {ViewHeader} from 'view/com/util/ViewHeader'
 import {FAB} from 'view/com/util/fab/FAB'
 import {Link} from 'view/com/util/Link'
 import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types'
-import {observer} from 'mobx-react-lite'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@@ -22,266 +21,501 @@ import {
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
 import debounce from 'lodash.debounce'
 import {Text} from 'view/com/util/text/Text'
-import {MyFeedsItem} from 'state/models/ui/my-feeds'
-import {FeedSourceModel} from 'state/models/content/feed-source'
 import {FlatList} from 'view/com/util/Views'
 import {useFocusEffect} from '@react-navigation/native'
-import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
+import {NewFeedSourceCard} from 'view/com/feeds/FeedSourceCard'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {
+  useFeedSourceInfoQuery,
+  useGetPopularFeedsQuery,
+  useSearchPopularFeedsMutation,
+} from '#/state/queries/feed'
+import {cleanError} from 'lib/strings/errors'
 
 type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
-export const FeedsScreen = withAuthRequired(
-  observer<Props>(function FeedsScreenImpl({}: Props) {
-    const pal = usePalette('default')
-    const store = useStores()
-    const {_} = useLingui()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
-    const myFeeds = store.me.myFeeds
-    const [query, setQuery] = React.useState<string>('')
-    const debouncedSearchFeeds = React.useMemo(
-      () => debounce(q => myFeeds.discovery.search(q), 500), // debounce for 500ms
-      [myFeeds],
-    )
 
-    useFocusEffect(
-      React.useCallback(() => {
-        setMinimalShellMode(false)
-        myFeeds.setup()
+type FlatlistSlice =
+  | {
+      type: 'error'
+      key: string
+      error: string
+    }
+  | {
+      type: 'savedFeedsHeader'
+      key: string
+    }
+  | {
+      type: 'savedFeedsLoading'
+      key: string
+      // pendingItems: number,
+    }
+  | {
+      type: 'savedFeedNoResults'
+      key: string
+    }
+  | {
+      type: 'savedFeed'
+      key: string
+      feedUri: string
+    }
+  | {
+      type: 'savedFeedsLoadMore'
+      key: string
+    }
+  | {
+      type: 'popularFeedsHeader'
+      key: string
+    }
+  | {
+      type: 'popularFeedsLoading'
+      key: string
+    }
+  | {
+      type: 'popularFeedsNoResults'
+      key: string
+    }
+  | {
+      type: 'popularFeed'
+      key: string
+      feedUri: string
+    }
+  | {
+      type: 'popularFeedsLoadingMore'
+      key: string
+    }
 
-        const softResetSub = store.onScreenSoftReset(() => myFeeds.refresh())
-        return () => {
-          softResetSub.remove()
-        }
-      }, [store, myFeeds, setMinimalShellMode]),
-    )
-    React.useEffect(() => {
-      // watch for changes to saved/pinned feeds
-      return myFeeds.registerListeners()
-    }, [myFeeds])
+export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
+  _props: Props,
+) {
+  const store = useStores()
+  const pal = usePalette('default')
+  const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
+  const [query, setQuery] = React.useState('')
+  const [isPTR, setIsPTR] = React.useState(false)
+  const {
+    data: preferences,
+    isLoading: isPreferencesLoading,
+    error: preferencesError,
+  } = usePreferencesQuery()
+  const {
+    data: popularFeeds,
+    isFetching: isPopularFeedsFetching,
+    error: popularFeedsError,
+    refetch: refetchPopularFeeds,
+    fetchNextPage: fetchNextPopularFeedsPage,
+    isFetchingNextPage: isPopularFeedsFetchingNextPage,
+  } = useGetPopularFeedsQuery()
+  const {_} = useLingui()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {
+    data: searchResults,
+    mutate: search,
+    reset: resetSearch,
+    isPending: isSearchPending,
+    error: searchError,
+  } = useSearchPopularFeedsMutation()
 
-    const onPressCompose = React.useCallback(() => {
-      store.shell.openComposer({})
-    }, [store])
-    const onChangeQuery = React.useCallback(
-      (text: string) => {
-        setQuery(text)
-        if (text.length > 1) {
-          debouncedSearchFeeds(text)
-        } else {
-          myFeeds.discovery.refresh()
-        }
-      },
-      [debouncedSearchFeeds, myFeeds.discovery],
-    )
-    const onPressCancelSearch = React.useCallback(() => {
-      setQuery('')
-      myFeeds.discovery.refresh()
-    }, [myFeeds])
-    const onSubmitQuery = React.useCallback(() => {
-      debouncedSearchFeeds(query)
-      debouncedSearchFeeds.flush()
-    }, [debouncedSearchFeeds, query])
+  /**
+   * A search query is present. We may not have search results yet.
+   */
+  const isUserSearching = query.length > 1
+  const debouncedSearch = React.useMemo(
+    () => debounce(q => search(q), 500), // debounce for 500ms
+    [search],
+  )
+  const onPressCompose = React.useCallback(() => {
+    store.shell.openComposer({})
+  }, [store])
+  const onChangeQuery = React.useCallback(
+    (text: string) => {
+      setQuery(text)
+      if (text.length > 1) {
+        debouncedSearch(text)
+      } else {
+        refetchPopularFeeds()
+        resetSearch()
+      }
+    },
+    [setQuery, refetchPopularFeeds, debouncedSearch, resetSearch],
+  )
+  const onPressCancelSearch = React.useCallback(() => {
+    setQuery('')
+    refetchPopularFeeds()
+    resetSearch()
+  }, [refetchPopularFeeds, setQuery, resetSearch])
+  const onSubmitQuery = React.useCallback(() => {
+    debouncedSearch(query)
+  }, [query, debouncedSearch])
+  const onPullToRefresh = React.useCallback(async () => {
+    setIsPTR(true)
+    await refetchPopularFeeds()
+    setIsPTR(false)
+  }, [setIsPTR, refetchPopularFeeds])
 
-    const renderHeaderBtn = React.useCallback(() => {
-      return (
-        <Link
-          href="/settings/saved-feeds"
-          hitSlop={10}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Edit Saved Feeds`)}
-          accessibilityHint="Opens screen to edit Saved Feeds">
-          <CogIcon size={22} strokeWidth={2} style={pal.textLight} />
-        </Link>
-      )
-    }, [pal, _])
+  useFocusEffect(
+    React.useCallback(() => {
+      setMinimalShellMode(false)
+    }, [setMinimalShellMode]),
+  )
 
-    const onRefresh = React.useCallback(() => {
-      myFeeds.refresh()
-    }, [myFeeds])
+  const items = React.useMemo(() => {
+    let slices: FlatlistSlice[] = []
 
-    const renderItem = React.useCallback(
-      ({item}: {item: MyFeedsItem}) => {
-        if (item.type === 'discover-feeds-loading') {
-          return <FeedFeedLoadingPlaceholder />
-        } else if (item.type === 'spinner') {
-          return (
-            <View style={s.p10}>
-              <ActivityIndicator />
-            </View>
+    slices.push({
+      key: 'savedFeedsHeader',
+      type: 'savedFeedsHeader',
+    })
+
+    if (preferencesError) {
+      slices.push({
+        key: 'savedFeedsError',
+        type: 'error',
+        error: cleanError(preferencesError.toString()),
+      })
+    } else {
+      if (isPreferencesLoading || !preferences?.feeds?.saved) {
+        slices.push({
+          key: 'savedFeedsLoading',
+          type: 'savedFeedsLoading',
+          // pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
+        })
+      } else {
+        if (preferences?.feeds?.saved.length === 0) {
+          slices.push({
+            key: 'savedFeedNoResults',
+            type: 'savedFeedNoResults',
+          })
+        } else {
+          const {saved, pinned} = preferences.feeds
+
+          slices = slices.concat(
+            pinned.map(uri => ({
+              key: `savedFeed:${uri}`,
+              type: 'savedFeed',
+              feedUri: uri,
+            })),
           )
-        } else if (item.type === 'error') {
-          return <ErrorMessage message={item.error} />
-        } else if (item.type === 'saved-feeds-header') {
-          if (!isMobile) {
-            return (
-              <View
-                style={[
-                  pal.view,
-                  styles.header,
-                  pal.border,
-                  {
-                    borderBottomWidth: 1,
-                  },
-                ]}>
-                <Text type="title-lg" style={[pal.text, s.bold]}>
-                  <Trans>My Feeds</Trans>
-                </Text>
-                <Link
-                  href="/settings/saved-feeds"
-                  accessibilityLabel={_(msg`Edit My Feeds`)}
-                  accessibilityHint="">
-                  <CogIcon strokeWidth={1.5} style={pal.icon} size={28} />
-                </Link>
-              </View>
+
+          slices = slices.concat(
+            saved
+              .filter(uri => !pinned.includes(uri))
+              .map(uri => ({
+                key: `savedFeed:${uri}`,
+                type: 'savedFeed',
+                feedUri: uri,
+              })),
+          )
+        }
+      }
+    }
+
+    slices.push({
+      key: 'popularFeedsHeader',
+      type: 'popularFeedsHeader',
+    })
+
+    if (popularFeedsError || searchError) {
+      slices.push({
+        key: 'popularFeedsError',
+        type: 'error',
+        error: cleanError(
+          popularFeedsError?.toString() ?? searchError?.toString() ?? '',
+        ),
+      })
+    } 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,
+              })),
             )
           }
-          return <View />
-        } else if (item.type === 'saved-feeds-loading') {
-          return (
-            <>
-              {Array.from(Array(item.numItems)).map((_i, i) => (
-                <SavedFeedLoadingPlaceholder key={`placeholder-${i}`} />
-              ))}
-            </>
-          )
-        } else if (item.type === 'saved-feed') {
-          return <SavedFeed feed={item.feed} />
-        } else if (item.type === 'discover-feeds-header') {
-          return (
-            <>
-              <View
-                style={[
-                  pal.view,
-                  styles.header,
-                  {
-                    marginTop: 16,
-                    paddingLeft: isMobile ? 12 : undefined,
-                    paddingRight: 10,
-                    paddingBottom: isMobile ? 6 : undefined,
-                  },
-                ]}>
-                <Text type="title-lg" style={[pal.text, s.bold]}>
-                  <Trans>Discover new feeds</Trans>
-                </Text>
-                {!isMobile && (
-                  <SearchInput
-                    query={query}
-                    onChangeQuery={onChangeQuery}
-                    onPressCancelSearch={onPressCancelSearch}
-                    onSubmitQuery={onSubmitQuery}
-                    style={{flex: 1, maxWidth: 250}}
-                  />
-                )}
-              </View>
-              {isMobile && (
-                <View style={{paddingHorizontal: 8, paddingBottom: 10}}>
-                  <SearchInput
-                    query={query}
-                    onChangeQuery={onChangeQuery}
-                    onPressCancelSearch={onPressCancelSearch}
-                    onSubmitQuery={onSubmitQuery}
-                  />
-                </View>
-              )}
-            </>
-          )
-        } else if (item.type === 'discover-feed') {
-          return (
-            <FeedSourceCard
-              item={item.feed}
-              showSaveBtn
-              showDescription
-              showLikes
-            />
-          )
-        } else if (item.type === 'discover-feeds-no-results') {
+        }
+      } else {
+        if (isPopularFeedsFetching && !popularFeeds?.pages) {
+          slices.push({
+            key: 'popularFeedsLoading',
+            type: 'popularFeedsLoading',
+          })
+        } else {
+          if (
+            !popularFeeds?.pages ||
+            popularFeeds?.pages[0]?.feeds?.length === 0
+          ) {
+            slices.push({
+              key: 'popularFeedsNoResults',
+              type: 'popularFeedsNoResults',
+            })
+          } else {
+            for (const page of popularFeeds.pages || []) {
+              slices = slices.concat(
+                page.feeds
+                  .filter(feed => !preferences?.feeds?.saved.includes(feed.uri))
+                  .map(feed => ({
+                    key: `popularFeed:${feed.uri}`,
+                    type: 'popularFeed',
+                    feedUri: feed.uri,
+                  })),
+              )
+            }
+
+            if (isPopularFeedsFetchingNextPage) {
+              slices.push({
+                key: 'popularFeedsLoadingMore',
+                type: 'popularFeedsLoadingMore',
+              })
+            }
+          }
+        }
+      }
+    }
+
+    return slices
+  }, [
+    preferences,
+    isPreferencesLoading,
+    preferencesError,
+    popularFeeds,
+    isPopularFeedsFetching,
+    popularFeedsError,
+    isPopularFeedsFetchingNextPage,
+    searchResults,
+    isSearchPending,
+    searchError,
+    isUserSearching,
+  ])
+
+  const renderHeaderBtn = React.useCallback(() => {
+    return (
+      <Link
+        href="/settings/saved-feeds"
+        hitSlop={10}
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Edit Saved Feeds`)}
+        accessibilityHint="Opens screen to edit Saved Feeds">
+        <CogIcon size={22} strokeWidth={2} style={pal.textLight} />
+      </Link>
+    )
+  }, [pal, _])
+
+  const renderItem = React.useCallback(
+    ({item}: {item: FlatlistSlice}) => {
+      if (item.type === 'error') {
+        return <ErrorMessage message={item.error} />
+      } else if (
+        item.type === 'popularFeedsLoadingMore' ||
+        item.type === 'savedFeedsLoading'
+      ) {
+        return (
+          <View style={s.p10}>
+            <ActivityIndicator />
+          </View>
+        )
+      } else if (item.type === 'savedFeedsHeader') {
+        if (!isMobile) {
           return (
             <View
-              style={{
-                paddingHorizontal: 16,
-                paddingTop: 10,
-                paddingBottom: '150%',
-              }}>
-              <Text type="lg" style={pal.textLight}>
-                <Trans>No results found for "{query}"</Trans>
+              style={[
+                pal.view,
+                styles.header,
+                pal.border,
+                {
+                  borderBottomWidth: 1,
+                },
+              ]}>
+              <Text type="title-lg" style={[pal.text, s.bold]}>
+                <Trans>My Feeds</Trans>
               </Text>
+              <Link
+                href="/settings/saved-feeds"
+                accessibilityLabel={_(msg`Edit My Feeds`)}
+                accessibilityHint="">
+                <CogIcon strokeWidth={1.5} style={pal.icon} size={28} />
+              </Link>
             </View>
           )
         }
-        return null
-      },
-      [
-        isMobile,
-        pal,
-        query,
-        onChangeQuery,
-        onPressCancelSearch,
-        onSubmitQuery,
-        _,
-      ],
-    )
+        return <View />
+      } else if (item.type === 'savedFeedNoResults') {
+        return (
+          <View
+            style={{
+              paddingHorizontal: 16,
+              paddingTop: 10,
+            }}>
+            <Text type="lg" style={pal.textLight}>
+              <Trans>You don't have any saved feeds!</Trans>
+            </Text>
+          </View>
+        )
+      } else if (item.type === 'savedFeed') {
+        return <SavedFeed feedUri={item.feedUri} />
+      } else if (item.type === 'popularFeedsHeader') {
+        return (
+          <>
+            <View
+              style={[
+                pal.view,
+                styles.header,
+                {
+                  marginTop: 16,
+                  paddingLeft: isMobile ? 12 : undefined,
+                  paddingRight: 10,
+                  paddingBottom: isMobile ? 6 : undefined,
+                },
+              ]}>
+              <Text type="title-lg" style={[pal.text, s.bold]}>
+                <Trans>Discover new feeds</Trans>
+              </Text>
 
-    return (
-      <View style={[pal.view, styles.container]}>
-        {isMobile && (
-          <ViewHeader
-            title="Feeds"
-            canGoBack={false}
-            renderButton={renderHeaderBtn}
-            showBorder
+              {!isMobile && (
+                <SearchInput
+                  query={query}
+                  onChangeQuery={onChangeQuery}
+                  onPressCancelSearch={onPressCancelSearch}
+                  onSubmitQuery={onSubmitQuery}
+                  style={{flex: 1, maxWidth: 250}}
+                />
+              )}
+            </View>
+
+            {isMobile && (
+              <View style={{paddingHorizontal: 8, paddingBottom: 10}}>
+                <SearchInput
+                  query={query}
+                  onChangeQuery={onChangeQuery}
+                  onPressCancelSearch={onPressCancelSearch}
+                  onSubmitQuery={onSubmitQuery}
+                />
+              </View>
+            )}
+          </>
+        )
+      } else if (item.type === 'popularFeedsLoading') {
+        return <FeedFeedLoadingPlaceholder />
+      } else if (item.type === 'popularFeed') {
+        return (
+          <NewFeedSourceCard
+            feedUri={item.feedUri}
+            showSaveBtn
+            showDescription
+            showLikes
           />
-        )}
+        )
+      } else if (item.type === 'popularFeedsNoResults') {
+        return (
+          <View
+            style={{
+              paddingHorizontal: 16,
+              paddingTop: 10,
+              paddingBottom: '150%',
+            }}>
+            <Text type="lg" style={pal.textLight}>
+              <Trans>No results found for "{query}"</Trans>
+            </Text>
+          </View>
+        )
+      }
+      return null
+    },
+    [
+      _,
+      isMobile,
+      pal,
+      query,
+      onChangeQuery,
+      onPressCancelSearch,
+      onSubmitQuery,
+    ],
+  )
 
-        <FlatList
-          style={[!isTabletOrDesktop && s.flex1, styles.list]}
-          data={myFeeds.items}
-          keyExtractor={item => item._reactKey}
-          contentContainerStyle={styles.contentContainer}
-          refreshControl={
-            <RefreshControl
-              refreshing={myFeeds.isRefreshing}
-              onRefresh={onRefresh}
-              tintColor={pal.colors.text}
-              titleColor={pal.colors.text}
-            />
-          }
-          renderItem={renderItem}
-          initialNumToRender={10}
-          onEndReached={() => myFeeds.loadMore()}
-          extraData={myFeeds.isLoading}
-          // @ts-ignore our .web version only -prf
-          desktopFixedHeight
-        />
-        <FAB
-          testID="composeFAB"
-          onPress={onPressCompose}
-          icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`New post`)}
-          accessibilityHint=""
+  return (
+    <View style={[pal.view, styles.container]}>
+      {isMobile && (
+        <ViewHeader
+          title="Feeds"
+          canGoBack={false}
+          renderButton={renderHeaderBtn}
+          showBorder
         />
-      </View>
-    )
-  }),
-)
+      )}
+
+      {preferences ? <View /> : <ActivityIndicator />}
+
+      <FlatList
+        style={[!isTabletOrDesktop && s.flex1, styles.list]}
+        data={items}
+        keyExtractor={item => item.key}
+        contentContainerStyle={styles.contentContainer}
+        renderItem={renderItem}
+        refreshControl={
+          <RefreshControl
+            refreshing={isPTR}
+            onRefresh={isUserSearching ? undefined : onPullToRefresh}
+            tintColor={pal.colors.text}
+            titleColor={pal.colors.text}
+          />
+        }
+        initialNumToRender={10}
+        onEndReached={() =>
+          isUserSearching ? undefined : fetchNextPopularFeedsPage()
+        }
+        // @ts-ignore our .web version only -prf
+        desktopFixedHeight
+      />
+
+      <FAB
+        testID="composeFAB"
+        onPress={onPressCompose}
+        icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`New post`)}
+        accessibilityHint=""
+      />
+    </View>
+  )
+})
 
-function SavedFeed({feed}: {feed: FeedSourceModel}) {
+function SavedFeed({feedUri}: {feedUri: string}) {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
+  const {data: info, error} = useFeedSourceInfoQuery({uri: feedUri})
+
+  if (!info)
+    return (
+      <SavedFeedLoadingPlaceholder
+        key={`savedFeedLoadingPlaceholder:${feedUri}`}
+      />
+    )
+
   return (
     <Link
-      testID={`saved-feed-${feed.displayName}`}
-      href={feed.href}
+      testID={`saved-feed-${info.displayName}`}
+      href={info.href}
       style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]}
       hoverStyle={pal.viewLight}
-      accessibilityLabel={feed.displayName}
+      accessibilityLabel={info.displayName}
       accessibilityHint=""
       asAnchor
       anchorNoUnderline>
-      {feed.error ? (
+      {error ? (
         <View
           style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}>
           <FontAwesomeIcon
@@ -290,14 +524,14 @@ function SavedFeed({feed}: {feed: FeedSourceModel}) {
           />
         </View>
       ) : (
-        <UserAvatar type="algo" size={28} avatar={feed.avatar} />
+        <UserAvatar type="algo" size={28} avatar={info.avatar} />
       )}
       <View
         style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}>
         <Text type="lg-medium" style={pal.text} numberOfLines={1}>
-          {feed.displayName}
+          {info.displayName}
         </Text>
-        {feed.error ? (
+        {error ? (
           <View style={[styles.offlineSlug, pal.borderDark]}>
             <Text type="xs" style={pal.textLight}>
               <Trans>Feed offline</Trans>