about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2023-11-13 15:53:57 -0600
committerGitHub <noreply@github.com>2023-11-13 13:53:57 -0800
commit06eb8b9a4caf3b4163451c538fa327a880c3a2d2 (patch)
tree07d7f2a068920640b4c536b7020146556086dbec /src
parenta01463788d5e38ffca81fd0d50886838b7a3baba (diff)
downloadvoidsky-06eb8b9a4caf3b4163451c538fa327a880c3a2d2.tar.zst
Factor our feed source model (#1887)
* Refactor first onboarding step

* Replace old FeedSourceCard

* Clean up CustomFeedEmbed

* Remove discover feeds model

* Refactor ProfileFeed screen

* Remove useCustomFeed

* Delete some unused models

* Rip out more prefs

* Factor out treeView from thread comp

* Improve last commit
Diffstat (limited to 'src')
-rw-r--r--src/lib/hooks/useCustomFeed.ts18
-rw-r--r--src/state/models/content/feed-source.ts231
-rw-r--r--src/state/models/discovery/feeds.ts148
-rw-r--r--src/state/models/lists/actor-feeds.ts123
-rw-r--r--src/state/models/ui/preferences.ts27
-rw-r--r--src/state/models/ui/profile.ts255
-rw-r--r--src/state/queries/feed.ts68
-rw-r--r--src/state/queries/like.ts24
-rw-r--r--src/state/queries/suggested-feeds.ts29
-rw-r--r--src/view/com/auth/onboarding/RecommendedFeeds.tsx36
-rw-r--r--src/view/com/auth/onboarding/RecommendedFeedsItem.tsx50
-rw-r--r--src/view/com/feeds/FeedSourceCard.tsx125
-rw-r--r--src/view/com/post-thread/PostThread.tsx27
-rw-r--r--src/view/com/posts/FeedErrorMessage.tsx8
-rw-r--r--src/view/com/util/forms/Button.tsx4
-rw-r--r--src/view/com/util/post-embeds/CustomFeedEmbed.tsx38
-rw-r--r--src/view/com/util/post-embeds/index.tsx17
-rw-r--r--src/view/screens/Feeds.tsx4
-rw-r--r--src/view/screens/PostThread.tsx1
-rw-r--r--src/view/screens/ProfileFeed.tsx613
-rw-r--r--src/view/screens/SavedFeeds.tsx4
21 files changed, 510 insertions, 1340 deletions
diff --git a/src/lib/hooks/useCustomFeed.ts b/src/lib/hooks/useCustomFeed.ts
deleted file mode 100644
index 04201b9a1..000000000
--- a/src/lib/hooks/useCustomFeed.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import {useEffect, useState} from 'react'
-import {useStores} from 'state/index'
-import {FeedSourceModel} from 'state/models/content/feed-source'
-
-export function useCustomFeed(uri: string): FeedSourceModel | undefined {
-  const store = useStores()
-  const [item, setItem] = useState<FeedSourceModel | undefined>()
-  useEffect(() => {
-    async function buildFeedItem() {
-      const model = new FeedSourceModel(store, uri)
-      await model.setup()
-      setItem(model)
-    }
-    buildFeedItem()
-  }, [store, uri])
-
-  return item
-}
diff --git a/src/state/models/content/feed-source.ts b/src/state/models/content/feed-source.ts
deleted file mode 100644
index cd8c08b56..000000000
--- a/src/state/models/content/feed-source.ts
+++ /dev/null
@@ -1,231 +0,0 @@
-import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api'
-import {makeAutoObservable, runInAction} from 'mobx'
-import {RootStoreModel} from 'state/models/root-store'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {bundleAsync} from 'lib/async/bundle'
-import {cleanError} from 'lib/strings/errors'
-import {track} from 'lib/analytics/analytics'
-import {logger} from '#/logger'
-
-export class FeedSourceModel {
-  // state
-  _reactKey: string
-  hasLoaded = false
-  error: string | undefined
-
-  // data
-  uri: string
-  cid: string = ''
-  type: 'feed-generator' | 'list' | 'unsupported' = 'unsupported'
-  avatar: string | undefined = ''
-  displayName: string = ''
-  descriptionRT: RichText | null = null
-  creatorDid: string = ''
-  creatorHandle: string = ''
-  likeCount: number | undefined = 0
-  likeUri: string | undefined = ''
-
-  constructor(public rootStore: RootStoreModel, uri: string) {
-    this._reactKey = uri
-    this.uri = uri
-
-    try {
-      const urip = new AtUri(uri)
-      if (urip.collection === 'app.bsky.feed.generator') {
-        this.type = 'feed-generator'
-      } else if (urip.collection === 'app.bsky.graph.list') {
-        this.type = 'list'
-      }
-    } catch {}
-    this.displayName = uri.split('/').pop() || ''
-
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-      },
-      {autoBind: true},
-    )
-  }
-
-  get href() {
-    const urip = new AtUri(this.uri)
-    const collection =
-      urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists'
-    return `/profile/${urip.hostname}/${collection}/${urip.rkey}`
-  }
-
-  get isSaved() {
-    return this.rootStore.preferences.savedFeeds.includes(this.uri)
-  }
-
-  get isPinned() {
-    return false
-  }
-
-  get isLiked() {
-    return !!this.likeUri
-  }
-
-  get isOwner() {
-    return this.creatorDid === this.rootStore.me.did
-  }
-
-  setup = bundleAsync(async () => {
-    try {
-      if (this.type === 'feed-generator') {
-        const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
-          feed: this.uri,
-        })
-        this.hydrateFeedGenerator(res.data.view)
-      } else if (this.type === 'list') {
-        const res = await this.rootStore.agent.app.bsky.graph.getList({
-          list: this.uri,
-          limit: 1,
-        })
-        this.hydrateList(res.data.list)
-      }
-    } catch (e) {
-      runInAction(() => {
-        this.error = cleanError(e)
-      })
-    }
-  })
-
-  hydrateFeedGenerator(view: AppBskyFeedDefs.GeneratorView) {
-    this.uri = view.uri
-    this.cid = view.cid
-    this.avatar = view.avatar
-    this.displayName = view.displayName
-      ? sanitizeDisplayName(view.displayName)
-      : `Feed by ${sanitizeHandle(view.creator.handle, '@')}`
-    this.descriptionRT = new RichText({
-      text: view.description || '',
-      facets: (view.descriptionFacets || [])?.slice(),
-    })
-    this.creatorDid = view.creator.did
-    this.creatorHandle = view.creator.handle
-    this.likeCount = view.likeCount
-    this.likeUri = view.viewer?.like
-    this.hasLoaded = true
-  }
-
-  hydrateList(view: AppBskyGraphDefs.ListView) {
-    this.uri = view.uri
-    this.cid = view.cid
-    this.avatar = view.avatar
-    this.displayName = view.name
-      ? sanitizeDisplayName(view.name)
-      : `User List by ${sanitizeHandle(view.creator.handle, '@')}`
-    this.descriptionRT = new RichText({
-      text: view.description || '',
-      facets: (view.descriptionFacets || [])?.slice(),
-    })
-    this.creatorDid = view.creator.did
-    this.creatorHandle = view.creator.handle
-    this.likeCount = undefined
-    this.hasLoaded = true
-  }
-
-  async save() {
-    if (this.type !== 'feed-generator') {
-      return
-    }
-    try {
-      await this.rootStore.preferences.addSavedFeed(this.uri)
-    } catch (error) {
-      logger.error('Failed to save feed', {error})
-    } finally {
-      track('CustomFeed:Save')
-    }
-  }
-
-  async unsave() {
-    // TODO TEMPORARY — see PRF's comment in content/list.ts togglePin
-    if (this.type !== 'feed-generator' && this.type !== 'list') {
-      return
-    }
-    try {
-      await this.rootStore.preferences.removeSavedFeed(this.uri)
-    } catch (error) {
-      logger.error('Failed to unsave feed', {error})
-    } finally {
-      track('CustomFeed:Unsave')
-    }
-  }
-
-  async pin() {
-    try {
-      await this.rootStore.preferences.addPinnedFeed(this.uri)
-    } catch (error) {
-      logger.error('Failed to pin feed', {error})
-    } finally {
-      track('CustomFeed:Pin', {
-        name: this.displayName,
-        uri: this.uri,
-      })
-    }
-  }
-
-  async togglePin() {
-    if (!this.isPinned) {
-      track('CustomFeed:Pin', {
-        name: this.displayName,
-        uri: this.uri,
-      })
-      return this.rootStore.preferences.addPinnedFeed(this.uri)
-    } else {
-      track('CustomFeed:Unpin', {
-        name: this.displayName,
-        uri: this.uri,
-      })
-
-      if (this.type === 'list') {
-        // TODO TEMPORARY — see PRF's comment in content/list.ts togglePin
-        return this.unsave()
-      } else {
-        return this.rootStore.preferences.removePinnedFeed(this.uri)
-      }
-    }
-  }
-
-  async like() {
-    if (this.type !== 'feed-generator') {
-      return
-    }
-    try {
-      this.likeUri = 'pending'
-      this.likeCount = (this.likeCount || 0) + 1
-      const res = await this.rootStore.agent.like(this.uri, this.cid)
-      this.likeUri = res.uri
-    } catch (e: any) {
-      this.likeUri = undefined
-      this.likeCount = (this.likeCount || 1) - 1
-      logger.error('Failed to like feed', {error: e})
-    } finally {
-      track('CustomFeed:Like')
-    }
-  }
-
-  async unlike() {
-    if (this.type !== 'feed-generator') {
-      return
-    }
-    if (!this.likeUri) {
-      return
-    }
-    const uri = this.likeUri
-    try {
-      this.likeUri = undefined
-      this.likeCount = (this.likeCount || 1) - 1
-      await this.rootStore.agent.deleteLike(uri!)
-    } catch (e: any) {
-      this.likeUri = uri
-      this.likeCount = (this.likeCount || 0) + 1
-      logger.error('Failed to unlike feed', {error: e})
-    } finally {
-      track('CustomFeed:Unlike')
-    }
-  }
-}
diff --git a/src/state/models/discovery/feeds.ts b/src/state/models/discovery/feeds.ts
deleted file mode 100644
index a7c94e40d..000000000
--- a/src/state/models/discovery/feeds.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-import {makeAutoObservable} from 'mobx'
-import {AppBskyUnspeccedGetPopularFeedGenerators} from '@atproto/api'
-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'
-
-const DEFAULT_LIMIT = 50
-
-export class FeedsDiscoveryModel {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasLoaded = false
-  error = ''
-  loadMoreCursor: string | undefined = undefined
-
-  // data
-  feeds: FeedSourceModel[] = []
-
-  constructor(public rootStore: RootStoreModel) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-      },
-      {autoBind: true},
-    )
-  }
-
-  get hasMore() {
-    if (this.loadMoreCursor) {
-      return true
-    }
-    return false
-  }
-
-  get hasContent() {
-    return this.feeds.length > 0
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  // public api
-  // =
-
-  refresh = bundleAsync(async () => {
-    this._xLoading()
-    try {
-      const res =
-        await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({
-          limit: DEFAULT_LIMIT,
-        })
-      this._replaceAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  })
-
-  loadMore = bundleAsync(async () => {
-    if (!this.hasMore) {
-      return
-    }
-    this._xLoading()
-    try {
-      const res =
-        await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({
-          limit: DEFAULT_LIMIT,
-          cursor: this.loadMoreCursor,
-        })
-      this._append(res)
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-    this._xIdle()
-  })
-
-  search = async (query: string) => {
-    this._xLoading(false)
-    try {
-      const results =
-        await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({
-          limit: DEFAULT_LIMIT,
-          query: query,
-        })
-      this._replaceAll(results)
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-    this._xIdle()
-  }
-
-  clear() {
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = false
-    this.error = ''
-    this.feeds = []
-  }
-
-  // state transitions
-  // =
-
-  _xLoading(isRefreshing = true) {
-    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 popular feeds', {error: err})
-    }
-  }
-
-  // helper functions
-  // =
-
-  _replaceAll(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
-    // 1. set feeds data to empty array
-    this.feeds = []
-    // 2. call this._append()
-    this._append(res)
-  }
-
-  _append(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
-    // 1. push data into feeds array
-    for (const f of res.data.feeds) {
-      const model = new FeedSourceModel(this.rootStore, f.uri)
-      model.hydrateFeedGenerator(f)
-      this.feeds.push(model)
-    }
-    // 2. set loadMoreCursor
-    this.loadMoreCursor = res.data.cursor
-  }
-}
diff --git a/src/state/models/lists/actor-feeds.ts b/src/state/models/lists/actor-feeds.ts
deleted file mode 100644
index 29c01e536..000000000
--- a/src/state/models/lists/actor-feeds.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import {makeAutoObservable} from 'mobx'
-import {AppBskyFeedGetActorFeeds as GetActorFeeds} from '@atproto/api'
-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'
-
-const PAGE_SIZE = 30
-
-export class ActorFeedsModel {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasLoaded = false
-  error = ''
-  hasMore = true
-  loadMoreCursor?: string
-
-  // data
-  feeds: FeedSourceModel[] = []
-
-  constructor(
-    public rootStore: RootStoreModel,
-    public params: GetActorFeeds.QueryParams,
-  ) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-      },
-      {autoBind: true},
-    )
-  }
-
-  get hasContent() {
-    return this.feeds.length > 0
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  // public api
-  // =
-
-  async refresh() {
-    return this.loadMore(true)
-  }
-
-  clear() {
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = false
-    this.error = ''
-    this.hasMore = true
-    this.loadMoreCursor = undefined
-    this.feeds = []
-  }
-
-  loadMore = bundleAsync(async (replace: boolean = false) => {
-    if (!replace && !this.hasMore) {
-      return
-    }
-    this._xLoading(replace)
-    try {
-      const res = await this.rootStore.agent.app.bsky.feed.getActorFeeds({
-        actor: this.params.actor,
-        limit: PAGE_SIZE,
-        cursor: replace ? undefined : this.loadMoreCursor,
-      })
-      if (replace) {
-        this._replaceAll(res)
-      } else {
-        this._appendAll(res)
-      }
-      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 followers', {error: err})
-    }
-  }
-
-  // helper functions
-  // =
-
-  _replaceAll(res: GetActorFeeds.Response) {
-    this.feeds = []
-    this._appendAll(res)
-  }
-
-  _appendAll(res: GetActorFeeds.Response) {
-    this.loadMoreCursor = res.data.cursor
-    this.hasMore = !!this.loadMoreCursor
-    for (const f of res.data.feeds) {
-      const model = new FeedSourceModel(this.rootStore, f.uri)
-      model.hydrateFeedGenerator(f)
-      this.feeds.push(model)
-    }
-  }
-}
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index 1068ac651..a4c3517cc 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -126,33 +126,6 @@ export class PreferencesModel {
       ],
     }
   }
-
-  // feeds
-  // =
-
-  isPinnedFeed(uri: string) {
-    return this.pinnedFeeds.includes(uri)
-  }
-
-  /**
-   * @deprecated use `useAddSavedFeedMutation` from `#/state/queries/preferences` instead
-   */
-  async addSavedFeed(_v: string) {}
-
-  /**
-   * @deprecated use `useRemoveSavedFeedMutation` from `#/state/queries/preferences` instead
-   */
-  async removeSavedFeed(_v: string) {}
-
-  /**
-   * @deprecated use `usePinFeedMutation` from `#/state/queries/preferences` instead
-   */
-  async addPinnedFeed(_v: string) {}
-
-  /**
-   * @deprecated use `useUnpinFeedMutation` from `#/state/queries/preferences` instead
-   */
-  async removePinnedFeed(_v: string) {}
 }
 
 // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts
deleted file mode 100644
index d6ea0c084..000000000
--- a/src/state/models/ui/profile.ts
+++ /dev/null
@@ -1,255 +0,0 @@
-import {makeAutoObservable, runInAction} from 'mobx'
-import {RootStoreModel} from '../root-store'
-import {ProfileModel} from '../content/profile'
-import {ActorFeedsModel} from '../lists/actor-feeds'
-import {logger} from '#/logger'
-
-export enum Sections {
-  PostsNoReplies = 'Posts',
-  PostsWithReplies = 'Posts & replies',
-  PostsWithMedia = 'Media',
-  Likes = 'Likes',
-  CustomAlgorithms = 'Feeds',
-  Lists = 'Lists',
-}
-
-export interface ProfileUiParams {
-  user: string
-}
-
-export class ProfileUiModel {
-  static LOADING_ITEM = {_reactKey: '__loading__'}
-  static END_ITEM = {_reactKey: '__end__'}
-  static EMPTY_ITEM = {_reactKey: '__empty__'}
-
-  isAuthenticatedUser = false
-
-  // data
-  profile: ProfileModel
-  feed: PostsFeedModel
-  algos: ActorFeedsModel
-  lists: ListsListModel
-
-  // ui state
-  selectedViewIndex = 0
-
-  constructor(
-    public rootStore: RootStoreModel,
-    public params: ProfileUiParams,
-  ) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-        params: false,
-      },
-      {autoBind: true},
-    )
-    this.profile = new ProfileModel(rootStore, {actor: params.user})
-    this.feed = new PostsFeedModel(rootStore, 'author', {
-      actor: params.user,
-      limit: 10,
-      filter: 'posts_no_replies',
-    })
-    this.algos = new ActorFeedsModel(rootStore, {actor: params.user})
-    this.lists = new ListsListModel(rootStore, params.user)
-  }
-
-  get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel {
-    if (
-      this.selectedView === Sections.PostsNoReplies ||
-      this.selectedView === Sections.PostsWithReplies ||
-      this.selectedView === Sections.PostsWithMedia ||
-      this.selectedView === Sections.Likes
-    ) {
-      return this.feed
-    } else if (this.selectedView === Sections.Lists) {
-      return this.lists
-    }
-    if (this.selectedView === Sections.CustomAlgorithms) {
-      return this.algos
-    }
-    throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
-  }
-
-  get isInitialLoading() {
-    const view = this.currentView
-    return view.isLoading && !view.isRefreshing && !view.hasContent
-  }
-
-  get isRefreshing() {
-    return this.profile.isRefreshing || this.currentView.isRefreshing
-  }
-
-  get selectorItems() {
-    const items = [
-      Sections.PostsNoReplies,
-      Sections.PostsWithReplies,
-      Sections.PostsWithMedia,
-      this.isAuthenticatedUser && Sections.Likes,
-    ].filter(Boolean) as string[]
-    if (this.algos.hasLoaded && !this.algos.isEmpty) {
-      items.push(Sections.CustomAlgorithms)
-    }
-    if (this.lists.hasLoaded && !this.lists.isEmpty) {
-      items.push(Sections.Lists)
-    }
-    return items
-  }
-
-  get selectedView() {
-    // If, for whatever reason, the selected view index is not available, default back to posts
-    // This can happen when the user was focused on a view but performed an action that caused
-    // the view to disappear (e.g. deleting the last list in their list of lists https://imgflip.com/i/7txu1y)
-    return this.selectorItems[this.selectedViewIndex] || Sections.PostsNoReplies
-  }
-
-  get uiItems() {
-    let arr: any[] = []
-    // if loading, return loading item to show loading spinner
-    if (this.isInitialLoading) {
-      arr = arr.concat([ProfileUiModel.LOADING_ITEM])
-    } else if (this.currentView.hasError) {
-      // if error, return error item to show error message
-      arr = arr.concat([
-        {
-          _reactKey: '__error__',
-          error: this.currentView.error,
-        },
-      ])
-    } else {
-      if (
-        this.selectedView === Sections.PostsNoReplies ||
-        this.selectedView === Sections.PostsWithReplies ||
-        this.selectedView === Sections.PostsWithMedia ||
-        this.selectedView === Sections.Likes
-      ) {
-        if (this.feed.hasContent) {
-          arr = this.feed.slices.slice()
-          if (!this.feed.hasMore) {
-            arr = arr.concat([ProfileUiModel.END_ITEM])
-          }
-        } else if (this.feed.isEmpty) {
-          arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
-        }
-      } else if (this.selectedView === Sections.CustomAlgorithms) {
-        if (this.algos.hasContent) {
-          arr = this.algos.feeds
-        } else if (this.algos.isEmpty) {
-          arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
-        }
-      } else if (this.selectedView === Sections.Lists) {
-        if (this.lists.hasContent) {
-          arr = this.lists.lists
-        } else if (this.lists.isEmpty) {
-          arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
-        }
-      } else {
-        // fallback, add empty item, to show empty message
-        arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
-      }
-    }
-    return arr
-  }
-
-  get showLoadingMoreFooter() {
-    if (
-      this.selectedView === Sections.PostsNoReplies ||
-      this.selectedView === Sections.PostsWithReplies ||
-      this.selectedView === Sections.PostsWithMedia ||
-      this.selectedView === Sections.Likes
-    ) {
-      return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading
-    } else if (this.selectedView === Sections.Lists) {
-      return this.lists.hasContent && this.lists.hasMore && this.lists.isLoading
-    }
-    return false
-  }
-
-  // public api
-  // =
-
-  setSelectedViewIndex(index: number) {
-    // ViewSelector fires onSelectView on mount
-    if (index === this.selectedViewIndex) return
-
-    this.selectedViewIndex = index
-
-    if (
-      this.selectedView === Sections.PostsNoReplies ||
-      this.selectedView === Sections.PostsWithReplies ||
-      this.selectedView === Sections.PostsWithMedia
-    ) {
-      let filter = 'posts_no_replies'
-      if (this.selectedView === Sections.PostsWithReplies) {
-        filter = 'posts_with_replies'
-      } else if (this.selectedView === Sections.PostsWithMedia) {
-        filter = 'posts_with_media'
-      }
-
-      this.feed = new PostsFeedModel(
-        this.rootStore,
-        'author',
-        {
-          actor: this.params.user,
-          limit: 10,
-          filter,
-        },
-        {
-          isSimpleFeed: ['posts_with_media'].includes(filter),
-        },
-      )
-
-      this.feed.setup()
-    } else if (this.selectedView === Sections.Likes) {
-      this.feed = new PostsFeedModel(
-        this.rootStore,
-        'likes',
-        {
-          actor: this.params.user,
-          limit: 10,
-        },
-        {
-          isSimpleFeed: true,
-        },
-      )
-
-      this.feed.setup()
-    }
-  }
-
-  async setup() {
-    await Promise.all([
-      this.profile
-        .setup()
-        .catch(err => logger.error('Failed to fetch profile', {error: err})),
-      this.feed
-        .setup()
-        .catch(err => logger.error('Failed to fetch feed', {error: err})),
-    ])
-    runInAction(() => {
-      this.isAuthenticatedUser =
-        this.profile.did === this.rootStore.session.currentSession?.did
-    })
-    this.algos.refresh()
-    // HACK: need to use the DID as a param, not the username -prf
-    this.lists.source = this.profile.did
-    this.lists
-      .loadMore()
-      .catch(err => logger.error('Failed to fetch lists', {error: err}))
-  }
-
-  async refresh() {
-    await Promise.all([this.profile.refresh(), this.currentView.refresh()])
-  }
-
-  async loadMore() {
-    if (
-      !this.currentView.isLoading &&
-      !this.currentView.hasError &&
-      !this.currentView.isEmpty
-    ) {
-      await this.currentView.loadMore()
-    }
-  }
-}
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
index dde37315d..4ec82c6fb 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -21,39 +21,41 @@ import {sanitizeHandle} from '#/lib/strings/handles'
 import {useSession} from '#/state/session'
 import {usePreferencesQuery} from '#/state/queries/preferences'
 
-export type FeedSourceInfo =
-  | {
-      type: 'feed'
-      uri: string
-      route: {
-        href: string
-        name: string
-        params: Record<string, string>
-      }
-      cid: string
-      avatar: string | undefined
-      displayName: string
-      description: RichText
-      creatorDid: string
-      creatorHandle: string
-      likeCount: number | undefined
-      likeUri: string | undefined
-    }
-  | {
-      type: 'list'
-      uri: string
-      route: {
-        href: string
-        name: string
-        params: Record<string, string>
-      }
-      cid: string
-      avatar: string | undefined
-      displayName: string
-      description: RichText
-      creatorDid: string
-      creatorHandle: string
-    }
+export type FeedSourceFeedInfo = {
+  type: 'feed'
+  uri: string
+  route: {
+    href: string
+    name: string
+    params: Record<string, string>
+  }
+  cid: string
+  avatar: string | undefined
+  displayName: string
+  description: RichText
+  creatorDid: string
+  creatorHandle: string
+  likeCount: number | undefined
+  likeUri: string | undefined
+}
+
+export type FeedSourceListInfo = {
+  type: 'list'
+  uri: string
+  route: {
+    href: string
+    name: string
+    params: Record<string, string>
+  }
+  cid: string
+  avatar: string | undefined
+  displayName: string
+  description: RichText
+  creatorDid: string
+  creatorHandle: string
+}
+
+export type FeedSourceInfo = FeedSourceFeedInfo | FeedSourceListInfo
 
 export const feedSourceInfoQueryKey = ({uri}: {uri: string}) => [
   'getFeedSourceInfo',
diff --git a/src/state/queries/like.ts b/src/state/queries/like.ts
new file mode 100644
index 000000000..187d8fb82
--- /dev/null
+++ b/src/state/queries/like.ts
@@ -0,0 +1,24 @@
+import {useMutation} from '@tanstack/react-query'
+
+import {useSession} from '#/state/session'
+
+export function useLikeMutation() {
+  const {agent} = useSession()
+
+  return useMutation({
+    mutationFn: async ({uri, cid}: {uri: string; cid: string}) => {
+      const res = await agent.like(uri, cid)
+      return {uri: res.uri}
+    },
+  })
+}
+
+export function useUnlikeMutation() {
+  const {agent} = useSession()
+
+  return useMutation({
+    mutationFn: async ({uri}: {uri: string}) => {
+      await agent.deleteLike(uri)
+    },
+  })
+}
diff --git a/src/state/queries/suggested-feeds.ts b/src/state/queries/suggested-feeds.ts
new file mode 100644
index 000000000..e148c97c3
--- /dev/null
+++ b/src/state/queries/suggested-feeds.ts
@@ -0,0 +1,29 @@
+import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {AppBskyFeedGetSuggestedFeeds} from '@atproto/api'
+
+import {useSession} from '#/state/session'
+
+export const suggestedFeedsQueryKey = ['suggestedFeeds']
+
+export function useSuggestedFeedsQuery() {
+  const {agent} = useSession()
+
+  return useInfiniteQuery<
+    AppBskyFeedGetSuggestedFeeds.OutputSchema,
+    Error,
+    InfiniteData<AppBskyFeedGetSuggestedFeeds.OutputSchema>,
+    QueryKey,
+    string | undefined
+  >({
+    queryKey: suggestedFeedsQueryKey,
+    queryFn: async ({pageParam}) => {
+      const res = await agent.app.bsky.feed.getSuggestedFeeds({
+        limit: 10,
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+  })
+}
diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
index 400b836d0..d134dae9f 100644
--- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
@@ -10,10 +10,8 @@ import {Button} from 'view/com/util/forms/Button'
 import {RecommendedFeedsItem} from './RecommendedFeedsItem'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useQuery} from '@tanstack/react-query'
-import {useStores} from 'state/index'
-import {FeedSourceModel} from 'state/models/content/feed-source'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
+import {useSuggestedFeedsQuery} from '#/state/queries/suggested-feeds'
 
 type Props = {
   next: () => void
@@ -21,35 +19,11 @@ type Props = {
 export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
   next,
 }: Props) {
-  const store = useStores()
   const pal = usePalette('default')
   const {isTabletOrMobile} = useWebMediaQueries()
-  const {isLoading, data: recommendedFeeds} = useQuery({
-    staleTime: Infinity, // fixed list rn, never refetch
-    queryKey: ['onboarding', 'recommended_feeds'],
-    async queryFn() {
-      try {
-        const {
-          data: {feeds},
-          success,
-        } = await store.agent.app.bsky.feed.getSuggestedFeeds()
+  const {isLoading, data} = useSuggestedFeedsQuery()
 
-        if (!success) {
-          return []
-        }
-
-        return (feeds.length ? feeds : []).map(feed => {
-          const model = new FeedSourceModel(store, feed.uri)
-          model.hydrateFeedGenerator(feed)
-          return model
-        })
-      } catch (e) {
-        return []
-      }
-    },
-  })
-
-  const hasFeeds = recommendedFeeds && recommendedFeeds.length
+  const hasFeeds = data && data?.pages?.[0]?.feeds?.length
 
   const title = (
     <>
@@ -118,7 +92,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
           contentStyle={{paddingHorizontal: 0}}>
           {hasFeeds ? (
             <FlatList
-              data={recommendedFeeds}
+              data={data.pages[0].feeds}
               renderItem={({item}) => <RecommendedFeedsItem item={item} />}
               keyExtractor={item => item.uri}
               style={{flex: 1}}
@@ -146,7 +120,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
 
           {hasFeeds ? (
             <FlatList
-              data={recommendedFeeds}
+              data={data.pages[0].feeds}
               renderItem={({item}) => <RecommendedFeedsItem item={item} />}
               keyExtractor={item => item.uri}
               style={{flex: 1}}
diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
index bee23c953..2eaf3cf2d 100644
--- a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
@@ -2,6 +2,7 @@ import React from 'react'
 import {View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {AppBskyFeedDefs, RichText as BskRichText} from '@atproto/api'
 import {Text} from 'view/com/util/text/Text'
 import {RichText} from 'view/com/util/text/RichText'
 import {Button} from 'view/com/util/forms/Button'
@@ -11,33 +12,58 @@ import {HeartIcon} from 'lib/icons'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {sanitizeHandle} from 'lib/strings/handles'
-import {FeedSourceModel} from 'state/models/content/feed-source'
+import {
+  usePreferencesQuery,
+  usePinFeedMutation,
+  useRemoveFeedMutation,
+} from '#/state/queries/preferences'
+import {logger} from '#/logger'
 
 export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
   item,
 }: {
-  item: FeedSourceModel
+  item: AppBskyFeedDefs.GeneratorView
 }) {
   const {isMobile} = useWebMediaQueries()
   const pal = usePalette('default')
-  if (!item) return null
+  const {data: preferences} = usePreferencesQuery()
+  const {
+    mutateAsync: pinFeed,
+    variables: pinnedFeed,
+    reset: resetPinFeed,
+  } = usePinFeedMutation()
+  const {
+    mutateAsync: removeFeed,
+    variables: removedFeed,
+    reset: resetRemoveFeed,
+  } = useRemoveFeedMutation()
+
+  if (!item || !preferences) return null
+
+  const isPinned =
+    !removedFeed?.uri &&
+    (pinnedFeed?.uri || preferences.feeds.saved.includes(item.uri))
+
   const onToggle = async () => {
-    if (item.isSaved) {
+    if (isPinned) {
       try {
-        await item.unsave()
+        await removeFeed({uri: item.uri})
+        resetRemoveFeed()
       } catch (e) {
         Toast.show('There was an issue contacting your server')
-        console.error('Failed to unsave feed', {e})
+        logger.error('Failed to unsave feed', {error: e})
       }
     } else {
       try {
-        await item.pin()
+        await pinFeed({uri: item.uri})
+        resetPinFeed()
       } catch (e) {
         Toast.show('There was an issue contacting your server')
-        console.error('Failed to pin feed', {e})
+        logger.error('Failed to pin feed', {error: e})
       }
     }
   }
+
   return (
     <View testID={`feed-${item.displayName}`}>
       <View
@@ -66,10 +92,10 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
           </Text>
 
           <Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}>
-            by {sanitizeHandle(item.creatorHandle, '@')}
+            by {sanitizeHandle(item.creator.handle, '@')}
           </Text>
 
-          {item.descriptionRT ? (
+          {item.description ? (
             <RichText
               type="xl"
               style={[
@@ -80,7 +106,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
                   marginBottom: 18,
                 },
               ]}
-              richText={item.descriptionRT}
+              richText={new BskRichText({text: item.description || ''})}
               numberOfLines={6}
             />
           ) : null}
@@ -97,7 +123,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
                   paddingRight: 2,
                   gap: 6,
                 }}>
-                {item.isSaved ? (
+                {isPinned ? (
                   <>
                     <FontAwesomeIcon
                       icon="check"
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index 6f9687be5..aaafd1959 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -7,7 +7,6 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
 import {UserAvatar} from '../util/UserAvatar'
 import {observer} from 'mobx-react-lite'
-import {FeedSourceModel} from 'state/models/content/feed-source'
 import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
 import {pluralize} from 'lib/strings/helpers'
@@ -23,7 +22,7 @@ import {
 } from '#/state/queries/preferences'
 import {useFeedSourceInfoQuery} from '#/state/queries/feed'
 
-export const NewFeedSourceCard = observer(function FeedSourceCardImpl({
+export const FeedSourceCard = observer(function FeedSourceCardImpl({
   feedUri,
   style,
   showSaveBtn = false,
@@ -162,128 +161,6 @@ export const NewFeedSourceCard = observer(function FeedSourceCardImpl({
   )
 })
 
-export const FeedSourceCard = observer(function FeedSourceCardImpl({
-  item,
-  style,
-  showSaveBtn = false,
-  showDescription = false,
-  showLikes = false,
-}: {
-  item: FeedSourceModel
-  style?: StyleProp<ViewStyle>
-  showSaveBtn?: boolean
-  showDescription?: boolean
-  showLikes?: boolean
-}) {
-  const pal = usePalette('default')
-  const navigation = useNavigation<NavigationProp>()
-  const {openModal} = useModalControls()
-
-  const onToggleSaved = React.useCallback(async () => {
-    if (item.isSaved) {
-      openModal({
-        name: 'confirm',
-        title: 'Remove from my feeds',
-        message: `Remove ${item.displayName} from my feeds?`,
-        onPressConfirm: async () => {
-          try {
-            await item.unsave()
-            Toast.show('Removed from my feeds')
-          } catch (e) {
-            Toast.show('There was an issue contacting your server')
-            logger.error('Failed to unsave feed', {error: e})
-          }
-        },
-      })
-    } else {
-      try {
-        await item.save()
-        Toast.show('Added to my feeds')
-      } catch (e) {
-        Toast.show('There was an issue contacting your server')
-        logger.error('Failed to save feed', {error: e})
-      }
-    }
-  }, [openModal, item])
-
-  return (
-    <Pressable
-      testID={`feed-${item.displayName}`}
-      accessibilityRole="button"
-      style={[styles.container, pal.border, style]}
-      onPress={() => {
-        if (item.type === 'feed-generator') {
-          navigation.push('ProfileFeed', {
-            name: item.creatorDid,
-            rkey: new AtUri(item.uri).rkey,
-          })
-        } else if (item.type === 'list') {
-          navigation.push('ProfileList', {
-            name: item.creatorDid,
-            rkey: new AtUri(item.uri).rkey,
-          })
-        }
-      }}
-      key={item.uri}>
-      <View style={[styles.headerContainer]}>
-        <View style={[s.mr10]}>
-          <UserAvatar type="algo" size={36} avatar={item.avatar} />
-        </View>
-        <View style={[styles.headerTextContainer]}>
-          <Text style={[pal.text, s.bold]} numberOfLines={3}>
-            {item.displayName}
-          </Text>
-          <Text style={[pal.textLight]} numberOfLines={3}>
-            by {sanitizeHandle(item.creatorHandle, '@')}
-          </Text>
-        </View>
-        {showSaveBtn && (
-          <View>
-            <Pressable
-              accessibilityRole="button"
-              accessibilityLabel={
-                item.isSaved ? 'Remove from my feeds' : 'Add to my feeds'
-              }
-              accessibilityHint=""
-              onPress={onToggleSaved}
-              hitSlop={15}
-              style={styles.btn}>
-              {item.isSaved ? (
-                <FontAwesomeIcon
-                  icon={['far', 'trash-can']}
-                  size={19}
-                  color={pal.colors.icon}
-                />
-              ) : (
-                <FontAwesomeIcon
-                  icon="plus"
-                  size={18}
-                  color={pal.colors.link}
-                />
-              )}
-            </Pressable>
-          </View>
-        )}
-      </View>
-
-      {showDescription && item.descriptionRT ? (
-        <RichText
-          style={[pal.textLight, styles.description]}
-          richText={item.descriptionRT}
-          numberOfLines={3}
-        />
-      ) : null}
-
-      {showLikes ? (
-        <Text type="sm-medium" style={[pal.text, pal.textLight]}>
-          Liked by {item.likeCount || 0}{' '}
-          {pluralize(item.likeCount || 0, 'user')}
-        </Text>
-      ) : null}
-    </Pressable>
-  )
-})
-
 const styles = StyleSheet.create({
   container: {
     paddingHorizontal: 18,
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 1e85b3e31..b0e6f1a31 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -32,9 +32,12 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {NavigationProp} from 'lib/routes/types'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {cleanError} from '#/lib/strings/errors'
-import {useStores} from '#/state'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {
+  UsePreferencesQueryResponse,
+  usePreferencesQuery,
+} from '#/state/queries/preferences'
 
 // const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} TODO
 
@@ -59,11 +62,9 @@ type YieldedItem =
 export function PostThread({
   uri,
   onPressReply,
-  treeView,
 }: {
   uri: string | undefined
   onPressReply: () => void
-  treeView: boolean
 }) {
   const {
     isLoading,
@@ -74,6 +75,7 @@ export function PostThread({
     data: thread,
     dataUpdatedAt,
   } = usePostThreadQuery(uri)
+  const {data: preferences} = usePreferencesQuery()
   const rootPost = thread?.type === 'post' ? thread.post : undefined
   const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
 
@@ -96,7 +98,7 @@ export function PostThread({
   if (AppBskyFeedDefs.isBlockedPost(thread)) {
     return <PostThreadBlocked />
   }
-  if (!thread || isLoading) {
+  if (!thread || isLoading || !preferences) {
     return (
       <CenteredView>
         <View style={s.p20}>
@@ -110,7 +112,7 @@ export function PostThread({
       thread={thread}
       isRefetching={isRefetching}
       dataUpdatedAt={dataUpdatedAt}
-      treeView={treeView}
+      threadViewPrefs={preferences.threadViewPrefs}
       onRefresh={refetch}
       onPressReply={onPressReply}
     />
@@ -121,20 +123,19 @@ function PostThreadLoaded({
   thread,
   isRefetching,
   dataUpdatedAt,
-  treeView,
+  threadViewPrefs,
   onRefresh,
   onPressReply,
 }: {
   thread: ThreadNode
   isRefetching: boolean
   dataUpdatedAt: number
-  treeView: boolean
+  threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs']
   onRefresh: () => void
   onPressReply: () => void
 }) {
   const {_} = useLingui()
   const pal = usePalette('default')
-  const store = useStores()
   const {isTablet, isDesktop} = useWebMediaQueries()
   const ref = useRef<FlatList>(null)
   // const hasScrolledIntoView = useRef<boolean>(false) TODO
@@ -162,16 +163,14 @@ function PostThreadLoaded({
   // const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
   const posts = React.useMemo(() => {
     let arr = [TOP_COMPONENT].concat(
-      Array.from(
-        flattenThreadSkeleton(sortThread(thread, store.preferences.thread)),
-      ),
+      Array.from(flattenThreadSkeleton(sortThread(thread, threadViewPrefs))),
     )
     if (arr.length > maxVisible) {
       arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
     }
     arr.push(BOTTOM_COMPONENT)
     return arr
-  }, [thread, maxVisible, store.preferences.thread])
+  }, [thread, maxVisible, threadViewPrefs])
 
   // TODO
   /*const onContentSizeChange = React.useCallback(() => {
@@ -297,7 +296,7 @@ function PostThreadLoaded({
             post={item.post}
             record={item.record}
             dataUpdatedAt={dataUpdatedAt}
-            treeView={treeView}
+            treeView={threadViewPrefs.lab_treeViewEnabled}
             depth={item.ctx.depth}
             isHighlightedPost={item.ctx.isHighlightedPost}
             hasMore={item.ctx.hasMore}
@@ -322,7 +321,7 @@ function PostThreadLoaded({
       pal.colors.border,
       posts,
       onRefresh,
-      treeView,
+      threadViewPrefs.lab_treeViewEnabled,
       dataUpdatedAt,
       _,
     ],
diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx
index e29b35f8a..0ace06e9a 100644
--- a/src/view/com/posts/FeedErrorMessage.tsx
+++ b/src/view/com/posts/FeedErrorMessage.tsx
@@ -8,12 +8,12 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
-import {useStores} from 'state/index'
 import {logger} from '#/logger'
 import {useModalControls} from '#/state/modals'
 import {FeedDescriptor} from '#/state/queries/post-feed'
 import {EmptyState} from '../util/EmptyState'
 import {cleanError} from '#/lib/strings/errors'
+import {useRemoveFeedMutation} from '#/state/queries/preferences'
 
 enum KnownError {
   Block,
@@ -86,12 +86,12 @@ function FeedgenErrorMessage({
   knownError: KnownError
 }) {
   const pal = usePalette('default')
-  const store = useStores()
   const navigation = useNavigation<NavigationProp>()
   const msg = MESSAGES[knownError]
   const [_, uri] = feedDesc.split('|')
   const [ownerDid] = safeParseFeedgenUri(uri)
   const {openModal, closeModal} = useModalControls()
+  const {mutateAsync: removeFeed} = useRemoveFeedMutation()
 
   const onViewProfile = React.useCallback(() => {
     navigation.navigate('Profile', {name: ownerDid})
@@ -104,7 +104,7 @@ function FeedgenErrorMessage({
       message: 'Remove this feed from your saved feeds?',
       async onPressConfirm() {
         try {
-          await store.preferences.removeSavedFeed(uri)
+          await removeFeed({uri})
         } catch (err) {
           Toast.show(
             'There was an an issue removing this feed. Please check your internet connection and try again.',
@@ -116,7 +116,7 @@ function FeedgenErrorMessage({
         closeModal()
       },
     })
-  }, [store, openModal, closeModal, uri])
+  }, [openModal, closeModal, uri, removeFeed])
 
   return (
     <View
diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx
index 270d98317..8f24f8288 100644
--- a/src/view/com/util/forms/Button.tsx
+++ b/src/view/com/util/forms/Button.tsx
@@ -52,6 +52,7 @@ export function Button({
   accessibilityLabelledBy,
   onAccessibilityEscape,
   withLoading = false,
+  disabled = false,
 }: React.PropsWithChildren<{
   type?: ButtonType
   label?: string
@@ -65,6 +66,7 @@ export function Button({
   accessibilityLabelledBy?: string
   onAccessibilityEscape?: () => void
   withLoading?: boolean
+  disabled?: boolean
 }>) {
   const theme = useTheme()
   const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
@@ -198,7 +200,7 @@ export function Button({
     <Pressable
       style={getStyle}
       onPress={onPressWrapped}
-      disabled={isLoading}
+      disabled={disabled || isLoading}
       testID={testID}
       accessibilityRole="button"
       accessibilityLabel={accessibilityLabel}
diff --git a/src/view/com/util/post-embeds/CustomFeedEmbed.tsx b/src/view/com/util/post-embeds/CustomFeedEmbed.tsx
deleted file mode 100644
index 624157436..000000000
--- a/src/view/com/util/post-embeds/CustomFeedEmbed.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import React, {useMemo} from 'react'
-import {AppBskyFeedDefs} from '@atproto/api'
-import {usePalette} from 'lib/hooks/usePalette'
-import {StyleSheet} from 'react-native'
-import {useStores} from 'state/index'
-import {FeedSourceModel} from 'state/models/content/feed-source'
-import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
-
-export function CustomFeedEmbed({
-  record,
-}: {
-  record: AppBskyFeedDefs.GeneratorView
-}) {
-  const pal = usePalette('default')
-  const store = useStores()
-  const item = useMemo(() => {
-    const model = new FeedSourceModel(store, record.uri)
-    model.hydrateFeedGenerator(record)
-    return model
-  }, [store, record])
-  return (
-    <FeedSourceCard
-      item={item}
-      style={[pal.view, pal.border, styles.customFeedOuter]}
-      showLikes
-    />
-  )
-}
-
-const styles = StyleSheet.create({
-  customFeedOuter: {
-    borderWidth: 1,
-    borderRadius: 8,
-    marginTop: 4,
-    paddingHorizontal: 12,
-    paddingVertical: 12,
-  },
-})
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 6c13bc2bb..b4c7c45ae 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -28,9 +28,9 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed'
 import {getYoutubeVideoId} from 'lib/strings/url-helpers'
 import {MaybeQuoteEmbed} from './QuoteEmbed'
 import {AutoSizedImage} from '../images/AutoSizedImage'
-import {CustomFeedEmbed} from './CustomFeedEmbed'
 import {ListEmbed} from './ListEmbed'
 import {isCauseALabelOnUri} from 'lib/moderation'
+import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
 
 type Embed =
   | AppBskyEmbedRecord.View
@@ -72,7 +72,13 @@ export function PostEmbeds({
     // custom feed embed (i.e. generator view)
     // =
     if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
-      return <CustomFeedEmbed record={embed.record} />
+      return (
+        <FeedSourceCard
+          feedUri={embed.record.uri}
+          style={[pal.view, pal.border, styles.customFeedOuter]}
+          showLikes
+        />
+      )
     }
 
     // list embed
@@ -206,4 +212,11 @@ const styles = StyleSheet.create({
     fontSize: 10,
     fontWeight: 'bold',
   },
+  customFeedOuter: {
+    borderWidth: 1,
+    borderRadius: 8,
+    marginTop: 4,
+    paddingHorizontal: 12,
+    paddingVertical: 12,
+  },
 })
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
index 5f60322bd..9c3d89005 100644
--- a/src/view/screens/Feeds.tsx
+++ b/src/view/screens/Feeds.tsx
@@ -23,7 +23,7 @@ import debounce from 'lodash.debounce'
 import {Text} from 'view/com/util/text/Text'
 import {FlatList} from 'view/com/util/Views'
 import {useFocusEffect} from '@react-navigation/native'
-import {NewFeedSourceCard} from 'view/com/feeds/FeedSourceCard'
+import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useSetMinimalShellMode} from '#/state/shell'
@@ -412,7 +412,7 @@ export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
         return <FeedFeedLoadingPlaceholder />
       } else if (item.type === 'popularFeed') {
         return (
-          <NewFeedSourceCard
+          <FeedSourceCard
             feedUri={item.feedUri}
             showSaveBtn
             showDescription
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index 9536e86e7..c76bf44e3 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -84,7 +84,6 @@ export const PostThreadScreen = withAuthRequired(
             <PostThreadComponent
               uri={resolvedUri?.uri}
               onPressReply={onPressReply}
-              treeView={!!store.preferences.thread.lab_treeViewEnabled}
             />
           )}
         </View>
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
index 4c56b6674..537fe7362 100644
--- a/src/view/screens/ProfileFeed.tsx
+++ b/src/view/screens/ProfileFeed.tsx
@@ -17,7 +17,6 @@ import {makeRecordUri} from 'lib/strings/url-helpers'
 import {colors, s} from 'lib/styles'
 import {observer} from 'mobx-react-lite'
 import {useStores} from 'state/index'
-import {FeedSourceModel} from 'state/models/content/feed-source'
 import {FeedDescriptor} from '#/state/queries/post-feed'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
@@ -32,7 +31,6 @@ import {FAB} from 'view/com/util/fab/FAB'
 import {EmptyState} from 'view/com/util/EmptyState'
 import * as Toast from 'view/com/util/Toast'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
-import {useCustomFeed} from 'lib/hooks/useCustomFeed'
 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
 import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
 import {shareUrl} from 'lib/sharing'
@@ -40,7 +38,6 @@ import {toShareUrl} from 'lib/strings/url-helpers'
 import {Haptics} from 'lib/haptics'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
-import {resolveName} from 'lib/api'
 import {makeCustomFeedLink} from 'lib/routes/links'
 import {pluralize} from 'lib/strings/helpers'
 import {CenteredView, ScrollView} from 'view/com/util/Views'
@@ -53,6 +50,18 @@ import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useModalControls} from '#/state/modals'
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
+import {useFeedSourceInfoQuery, FeedSourceFeedInfo} from '#/state/queries/feed'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {
+  UsePreferencesQueryResponse,
+  usePreferencesQuery,
+  useSaveFeedMutation,
+  useRemoveFeedMutation,
+  usePinFeedMutation,
+  useUnpinFeedMutation,
+} from '#/state/queries/preferences'
+import {useSession} from '#/state/session'
+import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
 
 const SECTION_TITLES = ['Posts', 'About']
 
@@ -63,15 +72,17 @@ interface SectionRef {
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'>
 export const ProfileFeedScreen = withAuthRequired(
   observer(function ProfileFeedScreenImpl(props: Props) {
+    const {rkey, name: handleOrDid} = props.route.params
+
     const pal = usePalette('default')
-    const store = useStores()
     const {_} = useLingui()
     const navigation = useNavigation<NavigationProp>()
 
-    const {name: handleOrDid} = props.route.params
-
-    const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>()
-    const [error, setError] = React.useState<string | undefined>()
+    const uri = useMemo(
+      () => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey),
+      [rkey, handleOrDid],
+    )
+    const {error, data: resolvedUri} = useResolveUriQuery(uri)
 
     const onPressBack = React.useCallback(() => {
       if (navigation.canGoBack()) {
@@ -81,24 +92,6 @@ export const ProfileFeedScreen = withAuthRequired(
       }
     }, [navigation])
 
-    React.useEffect(() => {
-      /*
-       * We must resolve the DID of the feed owner before we can fetch the feed.
-       */
-      async function fetchDid() {
-        try {
-          const did = await resolveName(store, handleOrDid)
-          setFeedOwnerDid(did)
-        } catch (e) {
-          setError(
-            `We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`,
-          )
-        }
-      }
-
-      fetchDid()
-    }, [store, handleOrDid, setFeedOwnerDid])
-
     if (error) {
       return (
         <CenteredView>
@@ -107,7 +100,7 @@ export const ProfileFeedScreen = withAuthRequired(
               <Trans>Could not load feed</Trans>
             </Text>
             <Text type="md" style={[pal.text, s.mb20]}>
-              {error}
+              {error.toString()}
             </Text>
 
             <View style={{flexDirection: 'row'}}>
@@ -127,8 +120,8 @@ export const ProfileFeedScreen = withAuthRequired(
       )
     }
 
-    return feedOwnerDid ? (
-      <ProfileFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} />
+    return resolvedUri ? (
+      <ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} />
     ) : (
       <CenteredView>
         <View style={s.p20}>
@@ -139,255 +132,305 @@ export const ProfileFeedScreen = withAuthRequired(
   }),
 )
 
-export const ProfileFeedScreenInner = observer(
-  function ProfileFeedScreenInnerImpl({
-    route,
-    feedOwnerDid,
-  }: Props & {feedOwnerDid: string}) {
-    const {openModal} = useModalControls()
-    const pal = usePalette('default')
-    const store = useStores()
-    const {track} = useAnalytics()
-    const {_} = useLingui()
-    const feedSectionRef = React.useRef<SectionRef>(null)
-    const {rkey, name: handleOrDid} = route.params
-    const uri = useMemo(
-      () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey),
-      [rkey, feedOwnerDid],
+function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
+  const {data: preferences} = usePreferencesQuery()
+  const {data: info} = useFeedSourceInfoQuery({uri: feedUri})
+
+  if (!preferences || !info) {
+    return (
+      <CenteredView>
+        <View style={s.p20}>
+          <ActivityIndicator size="large" />
+        </View>
+      </CenteredView>
     )
-    const feedInfo = useCustomFeed(uri)
-    const isPinned = store.preferences.isPinnedFeed(uri)
-    useSetTitle(feedInfo?.displayName)
-
-    // events
-    // =
-
-    const onToggleSaved = React.useCallback(async () => {
-      try {
-        Haptics.default()
-        if (feedInfo?.isSaved) {
-          await feedInfo?.unsave()
-        } else {
-          await feedInfo?.save()
-        }
-      } catch (err) {
-        Toast.show(
-          'There was an an issue updating your feeds, please check your internet connection and try again.',
-        )
-        logger.error('Failed up update feeds', {error: err})
-      }
-    }, [feedInfo])
+  }
 
-    const onToggleLiked = React.useCallback(async () => {
+  return (
+    <ProfileFeedScreenInner
+      preferences={preferences}
+      feedInfo={info as FeedSourceFeedInfo}
+    />
+  )
+}
+
+export const ProfileFeedScreenInner = function ProfileFeedScreenInnerImpl({
+  preferences,
+  feedInfo,
+}: {
+  preferences: UsePreferencesQueryResponse
+  feedInfo: FeedSourceFeedInfo
+}) {
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const store = useStores()
+  const {currentAccount} = useSession()
+  const {openModal} = useModalControls()
+  const {track} = useAnalytics()
+  const feedSectionRef = React.useRef<SectionRef>(null)
+
+  const {
+    mutateAsync: saveFeed,
+    variables: savedFeed,
+    reset: resetSaveFeed,
+    isPending: isSavePending,
+  } = useSaveFeedMutation()
+  const {
+    mutateAsync: removeFeed,
+    variables: removedFeed,
+    reset: resetRemoveFeed,
+    isPending: isRemovePending,
+  } = useRemoveFeedMutation()
+  const {
+    mutateAsync: pinFeed,
+    variables: pinnedFeed,
+    reset: resetPinFeed,
+    isPending: isPinPending,
+  } = usePinFeedMutation()
+  const {
+    mutateAsync: unpinFeed,
+    variables: unpinnedFeed,
+    reset: resetUnpinFeed,
+    isPending: isUnpinPending,
+  } = useUnpinFeedMutation()
+
+  const isSaved =
+    !removedFeed &&
+    (!!savedFeed || preferences.feeds.saved.includes(feedInfo.uri))
+  const isPinned =
+    !unpinnedFeed &&
+    (!!pinnedFeed || preferences.feeds.pinned.includes(feedInfo.uri))
+
+  useSetTitle(feedInfo?.displayName)
+
+  const onToggleSaved = React.useCallback(async () => {
+    try {
       Haptics.default()
-      try {
-        if (feedInfo?.isLiked) {
-          await feedInfo?.unlike()
-        } else {
-          await feedInfo?.like()
-        }
-      } catch (err) {
-        Toast.show(
-          'There was an an issue contacting the server, please check your internet connection and try again.',
-        )
-        logger.error('Failed up toggle like', {error: err})
+
+      if (isSaved) {
+        await removeFeed({uri: feedInfo.uri})
+        resetRemoveFeed()
+      } else {
+        await saveFeed({uri: feedInfo.uri})
+        resetSaveFeed()
       }
-    }, [feedInfo])
+    } catch (err) {
+      Toast.show(
+        'There was an an issue updating your feeds, please check your internet connection and try again.',
+      )
+      logger.error('Failed up update feeds', {error: err})
+    }
+  }, [feedInfo, isSaved, saveFeed, removeFeed, resetSaveFeed, resetRemoveFeed])
 
-    const onTogglePinned = React.useCallback(async () => {
+  const onTogglePinned = React.useCallback(async () => {
+    try {
       Haptics.default()
-      if (feedInfo) {
-        feedInfo.togglePin().catch(e => {
-          Toast.show('There was an issue contacting the server')
-          logger.error('Failed to toggle pinned feed', {error: e})
-        })
+
+      if (isPinned) {
+        await unpinFeed({uri: feedInfo.uri})
+        resetUnpinFeed()
+      } else {
+        await pinFeed({uri: feedInfo.uri})
+        resetPinFeed()
       }
-    }, [feedInfo])
-
-    const onPressShare = React.useCallback(() => {
-      const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`)
-      shareUrl(url)
-      track('CustomFeed:Share')
-    }, [handleOrDid, rkey, track])
-
-    const onPressReport = React.useCallback(() => {
-      if (!feedInfo) return
-      openModal({
-        name: 'report',
-        uri: feedInfo.uri,
-        cid: feedInfo.cid,
-      })
-    }, [openModal, feedInfo])
-
-    const onCurrentPageSelected = React.useCallback(
-      (index: number) => {
-        if (index === 0) {
-          feedSectionRef.current?.scrollToTop()
-        }
-      },
-      [feedSectionRef],
-    )
+    } catch (e) {
+      Toast.show('There was an issue contacting the server')
+      logger.error('Failed to toggle pinned feed', {error: e})
+    }
+  }, [isPinned, feedInfo, pinFeed, unpinFeed, resetPinFeed, resetUnpinFeed])
+
+  const onPressShare = React.useCallback(() => {
+    const url = toShareUrl(feedInfo.route.href)
+    shareUrl(url)
+    track('CustomFeed:Share')
+  }, [feedInfo, track])
+
+  const onPressReport = React.useCallback(() => {
+    if (!feedInfo) return
+    openModal({
+      name: 'report',
+      uri: feedInfo.uri,
+      cid: feedInfo.cid,
+    })
+  }, [openModal, feedInfo])
+
+  const onCurrentPageSelected = React.useCallback(
+    (index: number) => {
+      if (index === 0) {
+        feedSectionRef.current?.scrollToTop()
+      }
+    },
+    [feedSectionRef],
+  )
 
-    // render
-    // =
-
-    const dropdownItems: DropdownItem[] = React.useMemo(() => {
-      return [
-        {
-          testID: 'feedHeaderDropdownToggleSavedBtn',
-          label: feedInfo?.isSaved ? 'Remove from my feeds' : 'Add to my feeds',
-          onPress: onToggleSaved,
-          icon: feedInfo?.isSaved
-            ? {
-                ios: {
-                  name: 'trash',
-                },
-                android: 'ic_delete',
-                web: ['far', 'trash-can'],
-              }
-            : {
-                ios: {
-                  name: 'plus',
-                },
-                android: '',
-                web: 'plus',
+  // render
+  // =
+
+  const dropdownItems: DropdownItem[] = React.useMemo(() => {
+    return [
+      {
+        testID: 'feedHeaderDropdownToggleSavedBtn',
+        label: isSaved ? 'Remove from my feeds' : 'Add to my feeds',
+        onPress: isSavePending || isRemovePending ? undefined : onToggleSaved,
+        icon: isSaved
+          ? {
+              ios: {
+                name: 'trash',
               },
-        },
-        {
-          testID: 'feedHeaderDropdownReportBtn',
-          label: 'Report feed',
-          onPress: onPressReport,
-          icon: {
-            ios: {
-              name: 'exclamationmark.triangle',
+              android: 'ic_delete',
+              web: ['far', 'trash-can'],
+            }
+          : {
+              ios: {
+                name: 'plus',
+              },
+              android: '',
+              web: 'plus',
             },
-            android: 'ic_menu_report_image',
-            web: 'circle-exclamation',
+      },
+      {
+        testID: 'feedHeaderDropdownReportBtn',
+        label: 'Report feed',
+        onPress: onPressReport,
+        icon: {
+          ios: {
+            name: 'exclamationmark.triangle',
           },
+          android: 'ic_menu_report_image',
+          web: 'circle-exclamation',
         },
-        {
-          testID: 'feedHeaderDropdownShareBtn',
-          label: 'Share link',
-          onPress: onPressShare,
-          icon: {
-            ios: {
-              name: 'square.and.arrow.up',
-            },
-            android: 'ic_menu_share',
-            web: 'share',
+      },
+      {
+        testID: 'feedHeaderDropdownShareBtn',
+        label: 'Share link',
+        onPress: onPressShare,
+        icon: {
+          ios: {
+            name: 'square.and.arrow.up',
           },
+          android: 'ic_menu_share',
+          web: 'share',
         },
-      ] as DropdownItem[]
-    }, [feedInfo, onToggleSaved, onPressReport, onPressShare])
-
-    const renderHeader = useCallback(() => {
-      return (
-        <ProfileSubpageHeader
-          isLoading={!feedInfo?.hasLoaded}
-          href={makeCustomFeedLink(feedOwnerDid, rkey)}
-          title={feedInfo?.displayName}
-          avatar={feedInfo?.avatar}
-          isOwner={feedInfo?.isOwner}
-          creator={
-            feedInfo
-              ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle}
-              : undefined
-          }
-          avatarType="algo">
-          {feedInfo && (
-            <>
-              <Button
-                type="default"
-                label={feedInfo?.isSaved ? 'Unsave' : 'Save'}
-                onPress={onToggleSaved}
-                style={styles.btn}
-              />
-              <Button
-                type={isPinned ? 'default' : 'inverted'}
-                label={isPinned ? 'Unpin' : 'Pin to home'}
-                onPress={onTogglePinned}
-                style={styles.btn}
-              />
-            </>
-          )}
-          <NativeDropdown
-            testID="headerDropdownBtn"
-            items={dropdownItems}
-            accessibilityLabel={_(msg`More options`)}
-            accessibilityHint="">
-            <View style={[pal.viewLight, styles.btn]}>
-              <FontAwesomeIcon
-                icon="ellipsis"
-                size={20}
-                color={pal.colors.text}
-              />
-            </View>
-          </NativeDropdown>
-        </ProfileSubpageHeader>
-      )
-    }, [
-      pal,
-      feedOwnerDid,
-      rkey,
-      feedInfo,
-      isPinned,
-      onTogglePinned,
-      onToggleSaved,
-      dropdownItems,
-      _,
-    ])
-
+      },
+    ] as DropdownItem[]
+  }, [
+    onToggleSaved,
+    onPressReport,
+    onPressShare,
+    isSaved,
+    isSavePending,
+    isRemovePending,
+  ])
+
+  const renderHeader = useCallback(() => {
     return (
-      <View style={s.hContentRegion}>
-        <PagerWithHeader
-          items={SECTION_TITLES}
-          isHeaderReady={feedInfo?.hasLoaded ?? false}
-          renderHeader={renderHeader}
-          onCurrentPageSelected={onCurrentPageSelected}>
-          {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
-            <FeedSection
-              ref={feedSectionRef}
-              feed={`feedgen|${uri}`}
-              onScroll={onScroll}
-              headerHeight={headerHeight}
-              isScrolledDown={isScrolledDown}
-              scrollElRef={
-                scrollElRef as React.MutableRefObject<FlatList<any> | null>
-              }
+      <ProfileSubpageHeader
+        isLoading={false}
+        href={feedInfo.route.href}
+        title={feedInfo?.displayName}
+        avatar={feedInfo?.avatar}
+        isOwner={feedInfo.creatorDid === currentAccount?.did}
+        creator={
+          feedInfo
+            ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle}
+            : undefined
+        }
+        avatarType="algo">
+        {feedInfo && (
+          <>
+            <Button
+              disabled={isSavePending || isRemovePending}
+              type="default"
+              label={isSaved ? 'Unsave' : 'Save'}
+              onPress={onToggleSaved}
+              style={styles.btn}
             />
-          )}
-          {({onScroll, headerHeight, scrollElRef}) => (
-            <AboutSection
-              feedOwnerDid={feedOwnerDid}
-              feedRkey={rkey}
-              feedInfo={feedInfo}
-              headerHeight={headerHeight}
-              onToggleLiked={onToggleLiked}
-              onScroll={onScroll}
-              scrollElRef={
-                scrollElRef as React.MutableRefObject<ScrollView | null>
-              }
+            <Button
+              disabled={isPinPending || isUnpinPending}
+              type={isPinned ? 'default' : 'inverted'}
+              label={isPinned ? 'Unpin' : 'Pin to home'}
+              onPress={onTogglePinned}
+              style={styles.btn}
             />
-          )}
-        </PagerWithHeader>
-        <FAB
-          testID="composeFAB"
-          onPress={() => store.shell.openComposer({})}
-          icon={
-            <ComposeIcon2
-              strokeWidth={1.5}
-              size={29}
-              style={{color: 'white'}}
+          </>
+        )}
+        <NativeDropdown
+          testID="headerDropdownBtn"
+          items={dropdownItems}
+          accessibilityLabel={_(msg`More options`)}
+          accessibilityHint="">
+          <View style={[pal.viewLight, styles.btn]}>
+            <FontAwesomeIcon
+              icon="ellipsis"
+              size={20}
+              color={pal.colors.text}
             />
-          }
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`New post`)}
-          accessibilityHint=""
-        />
-      </View>
+          </View>
+        </NativeDropdown>
+      </ProfileSubpageHeader>
     )
-  },
-)
+  }, [
+    _,
+    pal,
+    feedInfo,
+    isPinned,
+    onTogglePinned,
+    onToggleSaved,
+    dropdownItems,
+    currentAccount?.did,
+    isPinPending,
+    isRemovePending,
+    isSavePending,
+    isSaved,
+    isUnpinPending,
+  ])
+
+  return (
+    <View style={s.hContentRegion}>
+      <PagerWithHeader
+        items={SECTION_TITLES}
+        isHeaderReady={true}
+        renderHeader={renderHeader}
+        onCurrentPageSelected={onCurrentPageSelected}>
+        {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+          <FeedSection
+            ref={feedSectionRef}
+            feed={`feedgen|${feedInfo.uri}`}
+            onScroll={onScroll}
+            headerHeight={headerHeight}
+            isScrolledDown={isScrolledDown}
+            scrollElRef={
+              scrollElRef as React.MutableRefObject<FlatList<any> | null>
+            }
+          />
+        )}
+        {({onScroll, headerHeight, scrollElRef}) => (
+          <AboutSection
+            feedOwnerDid={feedInfo.creatorDid}
+            feedRkey={feedInfo.route.params.rkey}
+            feedInfo={feedInfo}
+            headerHeight={headerHeight}
+            onScroll={onScroll}
+            scrollElRef={
+              scrollElRef as React.MutableRefObject<ScrollView | null>
+            }
+            isOwner={feedInfo.creatorDid === currentAccount?.did}
+          />
+        )}
+      </PagerWithHeader>
+      <FAB
+        testID="composeFAB"
+        onPress={() => store.shell.openComposer({})}
+        icon={
+          <ComposeIcon2 strokeWidth={1.5} size={29} style={{color: 'white'}} />
+        }
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`New post`)}
+        accessibilityHint=""
+      />
+    </View>
+  )
+}
 
 interface FeedSectionProps {
   feed: FeedDescriptor
@@ -447,25 +490,49 @@ const AboutSection = observer(function AboutPageImpl({
   feedRkey,
   feedInfo,
   headerHeight,
-  onToggleLiked,
   onScroll,
   scrollElRef,
+  isOwner,
 }: {
   feedOwnerDid: string
   feedRkey: string
-  feedInfo: FeedSourceModel | undefined
+  feedInfo: FeedSourceFeedInfo
   headerHeight: number
-  onToggleLiked: () => void
   onScroll: OnScrollHandler
   scrollElRef: React.MutableRefObject<ScrollView | null>
+  isOwner: boolean
 }) {
   const pal = usePalette('default')
   const {_} = useLingui()
   const scrollHandler = useAnimatedScrollHandler(onScroll)
+  const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri)
 
-  if (!feedInfo) {
-    return <View />
-  }
+  const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation()
+  const {mutateAsync: unlikeFeed, isPending: isUnlikePending} =
+    useUnlikeMutation()
+
+  const isLiked = !!likeUri
+  const likeCount =
+    isLiked && likeUri ? (feedInfo.likeCount || 0) + 1 : feedInfo.likeCount
+
+  const onToggleLiked = React.useCallback(async () => {
+    try {
+      Haptics.default()
+
+      if (isLiked && likeUri) {
+        await unlikeFeed({uri: likeUri})
+        setLikeUri('')
+      } else {
+        const res = await likeFeed({uri: feedInfo.uri, cid: feedInfo.cid})
+        setLikeUri(res.uri)
+      }
+    } catch (err) {
+      Toast.show(
+        'There was an an issue contacting the server, please check your internet connection and try again.',
+      )
+      logger.error('Failed up toggle like', {error: err})
+    }
+  }, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed])
 
   return (
     <ScrollView
@@ -486,12 +553,12 @@ const AboutSection = observer(function AboutPageImpl({
           },
           pal.border,
         ]}>
-        {feedInfo.descriptionRT ? (
+        {feedInfo.description ? (
           <RichText
             testID="listDescription"
             type="lg"
             style={pal.text}
-            richText={feedInfo.descriptionRT}
+            richText={feedInfo.description}
           />
         ) : (
           <Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}>
@@ -504,28 +571,26 @@ const AboutSection = observer(function AboutPageImpl({
             testID="toggleLikeBtn"
             accessibilityLabel={_(msg`Like this feed`)}
             accessibilityHint=""
+            disabled={isLikePending || isUnlikePending}
             onPress={onToggleLiked}
             style={{paddingHorizontal: 10}}>
-            {feedInfo?.isLiked ? (
+            {isLiked ? (
               <HeartIconSolid size={19} style={styles.liked} />
             ) : (
               <HeartIcon strokeWidth={3} size={19} style={pal.textLight} />
             )}
           </Button>
-          {typeof feedInfo.likeCount === 'number' && (
+          {typeof likeCount === 'number' && (
             <TextLink
               href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')}
-              text={`Liked by ${feedInfo.likeCount} ${pluralize(
-                feedInfo.likeCount,
-                'user',
-              )}`}
+              text={`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`}
               style={[pal.textLight, s.semiBold]}
             />
           )}
         </View>
         <Text type="md" style={[pal.textLight]} numberOfLines={1}>
           Created by{' '}
-          {feedInfo.isOwner ? (
+          {isOwner ? (
             'you'
           ) : (
             <TextLink
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index 8ca2383d2..c7abcf090 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -21,7 +21,7 @@ import {ViewHeader} from 'view/com/util/ViewHeader'
 import {ScrollView, CenteredView} from 'view/com/util/Views'
 import {Text} from 'view/com/util/text/Text'
 import {s, colors} from 'lib/styles'
-import {NewFeedSourceCard} from 'view/com/feeds/FeedSourceCard'
+import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import * as Toast from 'view/com/util/Toast'
 import {Haptics} from 'lib/haptics'
@@ -250,7 +250,7 @@ const ListItem = observer(function ListItemImpl({
           </TouchableOpacity>
         </View>
       ) : null}
-      <NewFeedSourceCard
+      <FeedSourceCard
         key={feedUri}
         feedUri={feedUri}
         style={styles.noBorder}