about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--package.json2
-rw-r--r--src/lib/api/feed-manip.ts17
-rw-r--r--src/lib/api/feed/author.ts25
-rw-r--r--src/lib/api/feed/custom.ts25
-rw-r--r--src/lib/api/feed/following.ts26
-rw-r--r--src/lib/api/feed/likes.ts25
-rw-r--r--src/lib/api/feed/list.ts25
-rw-r--r--src/lib/api/feed/merge.ts88
-rw-r--r--src/lib/api/feed/types.ts21
-rw-r--r--src/state/models/feeds/posts-slice.ts91
-rw-r--r--src/state/models/feeds/posts.ts429
-rw-r--r--src/state/models/me.ts10
-rw-r--r--src/state/models/ui/profile.ts1
-rw-r--r--src/state/preferences/feed-tuners.tsx48
-rw-r--r--src/state/queries/post-feed.ts176
-rw-r--r--src/state/queries/post-thread.ts10
-rw-r--r--src/state/queries/post.ts98
-rw-r--r--src/state/queries/resolve-uri.ts17
-rw-r--r--src/view/com/composer/Composer.tsx2
-rw-r--r--src/view/com/feeds/FeedPage.tsx91
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx14
-rw-r--r--src/view/com/posts/Feed.tsx144
-rw-r--r--src/view/com/posts/FeedErrorMessage.tsx91
-rw-r--r--src/view/com/posts/FeedItem.tsx273
-rw-r--r--src/view/com/posts/FeedSlice.tsx92
-rw-r--r--src/view/com/testing/TestCtrls.e2e.tsx4
-rw-r--r--src/view/screens/Home.tsx48
-rw-r--r--src/view/screens/Profile.tsx1
-rw-r--r--src/view/screens/ProfileFeed.tsx25
-rw-r--r--src/view/screens/ProfileList.tsx45
-rw-r--r--yarn.lock21
31 files changed, 904 insertions, 1081 deletions
diff --git a/package.json b/package.json
index 585e1e23e..88d0c15ec 100644
--- a/package.json
+++ b/package.json
@@ -63,7 +63,7 @@
     "@segment/analytics-react-native": "^2.10.1",
     "@segment/sovran-react-native": "^0.4.5",
     "@sentry/react-native": "5.10.0",
-    "@tanstack/react-query": "^4.33.0",
+    "@tanstack/react-query": "^5.8.1",
     "@tiptap/core": "^2.0.0-beta.220",
     "@tiptap/extension-document": "^2.0.0-beta.220",
     "@tiptap/extension-hard-break": "^2.0.3",
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts
index 8f259a910..7dfc9258a 100644
--- a/src/lib/api/feed-manip.ts
+++ b/src/lib/api/feed-manip.ts
@@ -4,7 +4,7 @@ import {
   AppBskyEmbedRecordWithMedia,
   AppBskyEmbedRecord,
 } from '@atproto/api'
-import {FeedSourceInfo} from './feed/types'
+import {ReasonFeedSource} from './feed/types'
 import {isPostInLanguage} from '../../locale/helpers'
 type FeedViewPost = AppBskyFeedDefs.FeedViewPost
 
@@ -65,9 +65,9 @@ export class FeedViewPostsSlice {
     )
   }
 
-  get source(): FeedSourceInfo | undefined {
+  get source(): ReasonFeedSource | undefined {
     return this.items.find(item => '__source' in item && !!item.__source)
-      ?.__source as FeedSourceInfo
+      ?.__source as ReasonFeedSource
   }
 
   containsUri(uri: string) {
@@ -116,6 +116,17 @@ export class FeedViewPostsSlice {
   }
 }
 
+export class NoopFeedTuner {
+  reset() {}
+  tune(
+    feed: FeedViewPost[],
+    _tunerFns: FeedTunerFn[] = [],
+    _opts?: {dryRun: boolean; maintainOrder: boolean},
+  ): FeedViewPostsSlice[] {
+    return feed.map(item => new FeedViewPostsSlice([item]))
+  }
+}
+
 export class FeedTuner {
   seenUris: Set<string> = new Set()
 
diff --git a/src/lib/api/feed/author.ts b/src/lib/api/feed/author.ts
index ec8795e1a..77c167869 100644
--- a/src/lib/api/feed/author.ts
+++ b/src/lib/api/feed/author.ts
@@ -1,38 +1,37 @@
 import {
   AppBskyFeedDefs,
   AppBskyFeedGetAuthorFeed as GetAuthorFeed,
+  BskyAgent,
 } from '@atproto/api'
-import {RootStoreModel} from 'state/index'
 import {FeedAPI, FeedAPIResponse} from './types'
 
 export class AuthorFeedAPI implements FeedAPI {
-  cursor: string | undefined
-
   constructor(
-    public rootStore: RootStoreModel,
+    public agent: BskyAgent,
     public params: GetAuthorFeed.QueryParams,
   ) {}
 
-  reset() {
-    this.cursor = undefined
-  }
-
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.rootStore.agent.getAuthorFeed({
+    const res = await this.agent.getAuthorFeed({
       ...this.params,
       limit: 1,
     })
     return res.data.feed[0]
   }
 
-  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
-    const res = await this.rootStore.agent.getAuthorFeed({
+  async fetch({
+    cursor,
+    limit,
+  }: {
+    cursor: string | undefined
+    limit: number
+  }): Promise<FeedAPIResponse> {
+    const res = await this.agent.getAuthorFeed({
       ...this.params,
-      cursor: this.cursor,
+      cursor,
       limit,
     })
     if (res.success) {
-      this.cursor = res.data.cursor
       return {
         cursor: res.data.cursor,
         feed: this._filter(res.data.feed),
diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts
index d05d5acd6..0be98fb4a 100644
--- a/src/lib/api/feed/custom.ts
+++ b/src/lib/api/feed/custom.ts
@@ -1,38 +1,37 @@
 import {
   AppBskyFeedDefs,
   AppBskyFeedGetFeed as GetCustomFeed,
+  BskyAgent,
 } from '@atproto/api'
-import {RootStoreModel} from 'state/index'
 import {FeedAPI, FeedAPIResponse} from './types'
 
 export class CustomFeedAPI implements FeedAPI {
-  cursor: string | undefined
-
   constructor(
-    public rootStore: RootStoreModel,
+    public agent: BskyAgent,
     public params: GetCustomFeed.QueryParams,
   ) {}
 
-  reset() {
-    this.cursor = undefined
-  }
-
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.rootStore.agent.app.bsky.feed.getFeed({
+    const res = await this.agent.app.bsky.feed.getFeed({
       ...this.params,
       limit: 1,
     })
     return res.data.feed[0]
   }
 
-  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
-    const res = await this.rootStore.agent.app.bsky.feed.getFeed({
+  async fetch({
+    cursor,
+    limit,
+  }: {
+    cursor: string | undefined
+    limit: number
+  }): Promise<FeedAPIResponse> {
+    const res = await this.agent.app.bsky.feed.getFeed({
       ...this.params,
-      cursor: this.cursor,
+      cursor,
       limit,
     })
     if (res.success) {
-      this.cursor = res.data.cursor
       // NOTE
       // some custom feeds fail to enforce the pagination limit
       // so we manually truncate here
diff --git a/src/lib/api/feed/following.ts b/src/lib/api/feed/following.ts
index f14807a57..13f06c7ab 100644
--- a/src/lib/api/feed/following.ts
+++ b/src/lib/api/feed/following.ts
@@ -1,30 +1,28 @@
-import {AppBskyFeedDefs} from '@atproto/api'
-import {RootStoreModel} from 'state/index'
+import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
 import {FeedAPI, FeedAPIResponse} from './types'
 
 export class FollowingFeedAPI implements FeedAPI {
-  cursor: string | undefined
-
-  constructor(public rootStore: RootStoreModel) {}
-
-  reset() {
-    this.cursor = undefined
-  }
+  constructor(public agent: BskyAgent) {}
 
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.rootStore.agent.getTimeline({
+    const res = await this.agent.getTimeline({
       limit: 1,
     })
     return res.data.feed[0]
   }
 
-  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
-    const res = await this.rootStore.agent.getTimeline({
-      cursor: this.cursor,
+  async fetch({
+    cursor,
+    limit,
+  }: {
+    cursor: string | undefined
+    limit: number
+  }): Promise<FeedAPIResponse> {
+    const res = await this.agent.getTimeline({
+      cursor,
       limit,
     })
     if (res.success) {
-      this.cursor = res.data.cursor
       return {
         cursor: res.data.cursor,
         feed: res.data.feed,
diff --git a/src/lib/api/feed/likes.ts b/src/lib/api/feed/likes.ts
index e9bb14b0b..434ed7719 100644
--- a/src/lib/api/feed/likes.ts
+++ b/src/lib/api/feed/likes.ts
@@ -1,38 +1,37 @@
 import {
   AppBskyFeedDefs,
   AppBskyFeedGetActorLikes as GetActorLikes,
+  BskyAgent,
 } from '@atproto/api'
-import {RootStoreModel} from 'state/index'
 import {FeedAPI, FeedAPIResponse} from './types'
 
 export class LikesFeedAPI implements FeedAPI {
-  cursor: string | undefined
-
   constructor(
-    public rootStore: RootStoreModel,
+    public agent: BskyAgent,
     public params: GetActorLikes.QueryParams,
   ) {}
 
-  reset() {
-    this.cursor = undefined
-  }
-
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.rootStore.agent.getActorLikes({
+    const res = await this.agent.getActorLikes({
       ...this.params,
       limit: 1,
     })
     return res.data.feed[0]
   }
 
-  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
-    const res = await this.rootStore.agent.getActorLikes({
+  async fetch({
+    cursor,
+    limit,
+  }: {
+    cursor: string | undefined
+    limit: number
+  }): Promise<FeedAPIResponse> {
+    const res = await this.agent.getActorLikes({
       ...this.params,
-      cursor: this.cursor,
+      cursor,
       limit,
     })
     if (res.success) {
-      this.cursor = res.data.cursor
       return {
         cursor: res.data.cursor,
         feed: res.data.feed,
diff --git a/src/lib/api/feed/list.ts b/src/lib/api/feed/list.ts
index e58494675..6cb0730e7 100644
--- a/src/lib/api/feed/list.ts
+++ b/src/lib/api/feed/list.ts
@@ -1,38 +1,37 @@
 import {
   AppBskyFeedDefs,
   AppBskyFeedGetListFeed as GetListFeed,
+  BskyAgent,
 } from '@atproto/api'
-import {RootStoreModel} from 'state/index'
 import {FeedAPI, FeedAPIResponse} from './types'
 
 export class ListFeedAPI implements FeedAPI {
-  cursor: string | undefined
-
   constructor(
-    public rootStore: RootStoreModel,
+    public agent: BskyAgent,
     public params: GetListFeed.QueryParams,
   ) {}
 
-  reset() {
-    this.cursor = undefined
-  }
-
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.rootStore.agent.app.bsky.feed.getListFeed({
+    const res = await this.agent.app.bsky.feed.getListFeed({
       ...this.params,
       limit: 1,
     })
     return res.data.feed[0]
   }
 
-  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
-    const res = await this.rootStore.agent.app.bsky.feed.getListFeed({
+  async fetch({
+    cursor,
+    limit,
+  }: {
+    cursor: string | undefined
+    limit: number
+  }): Promise<FeedAPIResponse> {
+    const res = await this.agent.app.bsky.feed.getListFeed({
       ...this.params,
-      cursor: this.cursor,
+      cursor,
       limit,
     })
     if (res.success) {
-      this.cursor = res.data.cursor
       return {
         cursor: res.data.cursor,
         feed: res.data.feed,
diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts
index e0fbcecd8..7a0f02887 100644
--- a/src/lib/api/feed/merge.ts
+++ b/src/lib/api/feed/merge.ts
@@ -1,11 +1,12 @@
-import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
+import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api'
 import shuffle from 'lodash.shuffle'
-import {RootStoreModel} from 'state/index'
 import {timeout} from 'lib/async/timeout'
 import {bundleAsync} from 'lib/async/bundle'
 import {feedUriToHref} from 'lib/strings/url-helpers'
 import {FeedTuner} from '../feed-manip'
-import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types'
+import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
+import {FeedParams} from '#/state/queries/post-feed'
+import {FeedTunerFn} from '../feed-manip'
 
 const REQUEST_WAIT_MS = 500 // 500ms
 const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
@@ -17,28 +18,49 @@ export class MergeFeedAPI implements FeedAPI {
   itemCursor = 0
   sampleCursor = 0
 
-  constructor(public rootStore: RootStoreModel) {
-    this.following = new MergeFeedSource_Following(this.rootStore)
+  constructor(
+    public agent: BskyAgent,
+    public params: FeedParams,
+    public feedTuners: FeedTunerFn[],
+  ) {
+    this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
   }
 
   reset() {
-    this.following = new MergeFeedSource_Following(this.rootStore)
+    this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
     this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
     this.feedCursor = 0
     this.itemCursor = 0
     this.sampleCursor = 0
+    if (this.params.mergeFeedEnabled && this.params.mergeFeedSources) {
+      this.customFeeds = shuffle(
+        this.params.mergeFeedSources.map(
+          feedUri =>
+            new MergeFeedSource_Custom(this.agent, feedUri, this.feedTuners),
+        ),
+      )
+    } else {
+      this.customFeeds = []
+    }
   }
 
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.rootStore.agent.getTimeline({
+    const res = await this.agent.getTimeline({
       limit: 1,
     })
     return res.data.feed[0]
   }
 
-  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
-    // we capture here to ensure the data has loaded
-    this._captureFeedsIfNeeded()
+  async fetch({
+    cursor,
+    limit,
+  }: {
+    cursor: string | undefined
+    limit: number
+  }): Promise<FeedAPIResponse> {
+    if (!cursor) {
+      this.reset()
+    }
 
     const promises = []
 
@@ -76,7 +98,7 @@ export class MergeFeedAPI implements FeedAPI {
     }
 
     return {
-      cursor: posts.length ? 'fake' : undefined,
+      cursor: posts.length ? String(this.itemCursor) : undefined,
       feed: posts,
     }
   }
@@ -107,28 +129,15 @@ export class MergeFeedAPI implements FeedAPI {
     // provide follow
     return this.following.take(1)
   }
-
-  _captureFeedsIfNeeded() {
-    if (!this.rootStore.preferences.homeFeed.lab_mergeFeedEnabled) {
-      return
-    }
-    if (this.customFeeds.length === 0) {
-      this.customFeeds = shuffle(
-        this.rootStore.preferences.savedFeeds.map(
-          feedUri => new MergeFeedSource_Custom(this.rootStore, feedUri),
-        ),
-      )
-    }
-  }
 }
 
 class MergeFeedSource {
-  sourceInfo: FeedSourceInfo | undefined
+  sourceInfo: ReasonFeedSource | undefined
   cursor: string | undefined = undefined
   queue: AppBskyFeedDefs.FeedViewPost[] = []
   hasMore = true
 
-  constructor(public rootStore: RootStoreModel) {}
+  constructor(public agent: BskyAgent, public feedTuners: FeedTunerFn[]) {}
 
   get numReady() {
     return this.queue.length
@@ -190,16 +199,12 @@ class MergeFeedSource_Following extends MergeFeedSource {
     cursor: string | undefined,
     limit: number,
   ): Promise<AppBskyFeedGetTimeline.Response> {
-    const res = await this.rootStore.agent.getTimeline({cursor, limit})
+    const res = await this.agent.getTimeline({cursor, limit})
     // run the tuner pre-emptively to ensure better mixing
-    const slices = this.tuner.tune(
-      res.data.feed,
-      this.rootStore.preferences.getFeedTuners('home'),
-      {
-        dryRun: false,
-        maintainOrder: true,
-      },
-    )
+    const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
+      dryRun: false,
+      maintainOrder: true,
+    })
     res.data.feed = slices.map(slice => slice.rootItem)
     return res
   }
@@ -208,14 +213,19 @@ class MergeFeedSource_Following extends MergeFeedSource {
 class MergeFeedSource_Custom extends MergeFeedSource {
   minDate: Date
 
-  constructor(public rootStore: RootStoreModel, public feedUri: string) {
-    super(rootStore)
+  constructor(
+    public agent: BskyAgent,
+    public feedUri: string,
+    public feedTuners: FeedTunerFn[],
+  ) {
+    super(agent, feedTuners)
     this.sourceInfo = {
+      $type: 'reasonFeedSource',
       displayName: feedUri.split('/').pop() || '',
       uri: feedUriToHref(feedUri),
     }
     this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
-    this.rootStore.agent.app.bsky.feed
+    this.agent.app.bsky.feed
       .getFeedGenerator({
         feed: feedUri,
       })
@@ -234,7 +244,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
     limit: number,
   ): Promise<AppBskyFeedGetTimeline.Response> {
     try {
-      const res = await this.rootStore.agent.app.bsky.feed.getFeed({
+      const res = await this.agent.app.bsky.feed.getFeed({
         cursor,
         limit,
         feed: this.feedUri,
diff --git a/src/lib/api/feed/types.ts b/src/lib/api/feed/types.ts
index 006344334..5d2a90c1d 100644
--- a/src/lib/api/feed/types.ts
+++ b/src/lib/api/feed/types.ts
@@ -6,12 +6,27 @@ export interface FeedAPIResponse {
 }
 
 export interface FeedAPI {
-  reset(): void
   peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost>
-  fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse>
+  fetch({
+    cursor,
+    limit,
+  }: {
+    cursor: string | undefined
+    limit: number
+  }): Promise<FeedAPIResponse>
 }
 
-export interface FeedSourceInfo {
+export interface ReasonFeedSource {
+  $type: 'reasonFeedSource'
   uri: string
   displayName: string
 }
+
+export function isReasonFeedSource(v: unknown): v is ReasonFeedSource {
+  return (
+    !!v &&
+    typeof v === 'object' &&
+    '$type' in v &&
+    v.$type === 'reasonFeedSource'
+  )
+}
diff --git a/src/state/models/feeds/posts-slice.ts b/src/state/models/feeds/posts-slice.ts
deleted file mode 100644
index 2501cef6f..000000000
--- a/src/state/models/feeds/posts-slice.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import {makeAutoObservable} from 'mobx'
-import {RootStoreModel} from '../root-store'
-import {FeedViewPostsSlice} from 'lib/api/feed-manip'
-import {PostsFeedItemModel} from './post'
-import {FeedSourceInfo} from 'lib/api/feed/types'
-
-export class PostsFeedSliceModel {
-  // ui state
-  _reactKey: string = ''
-
-  // data
-  items: PostsFeedItemModel[] = []
-  source: FeedSourceInfo | undefined
-
-  constructor(public rootStore: RootStoreModel, slice: FeedViewPostsSlice) {
-    this._reactKey = slice._reactKey
-    this.source = slice.source
-    for (let i = 0; i < slice.items.length; i++) {
-      this.items.push(
-        new PostsFeedItemModel(
-          rootStore,
-          `${this._reactKey} - ${i}`,
-          slice.items[i],
-        ),
-      )
-    }
-    makeAutoObservable(this, {rootStore: false})
-  }
-
-  get uri() {
-    if (this.isReply) {
-      return this.items[1].post.uri
-    }
-    return this.items[0].post.uri
-  }
-
-  get isThread() {
-    return (
-      this.items.length > 1 &&
-      this.items.every(
-        item => item.post.author.did === this.items[0].post.author.did,
-      )
-    )
-  }
-
-  get isReply() {
-    return this.items.length > 1 && !this.isThread
-  }
-
-  get rootItem() {
-    if (this.isReply) {
-      return this.items[1]
-    }
-    return this.items[0]
-  }
-
-  get moderation() {
-    // prefer the most stringent item
-    const topItem = this.items.find(item => item.moderation.content.filter)
-    if (topItem) {
-      return topItem.moderation
-    }
-    // otherwise just use the first one
-    return this.items[0].moderation
-  }
-
-  shouldFilter(ignoreFilterForDid: string | undefined): boolean {
-    const mods = this.items
-      .filter(item => item.post.author.did !== ignoreFilterForDid)
-      .map(item => item.moderation)
-    return !!mods.find(mod => mod.content.filter)
-  }
-
-  containsUri(uri: string) {
-    return !!this.items.find(item => item.post.uri === uri)
-  }
-
-  isThreadParentAt(i: number) {
-    if (this.items.length === 1) {
-      return false
-    }
-    return i < this.items.length - 1
-  }
-
-  isThreadChildAt(i: number) {
-    if (this.items.length === 1) {
-      return false
-    }
-    return i > 0
-  }
-}
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
deleted file mode 100644
index 0a06c581c..000000000
--- a/src/state/models/feeds/posts.ts
+++ /dev/null
@@ -1,429 +0,0 @@
-import {makeAutoObservable, runInAction} from 'mobx'
-import {
-  AppBskyFeedGetTimeline as GetTimeline,
-  AppBskyFeedGetAuthorFeed as GetAuthorFeed,
-  AppBskyFeedGetFeed as GetCustomFeed,
-  AppBskyFeedGetActorLikes as GetActorLikes,
-  AppBskyFeedGetListFeed as GetListFeed,
-} from '@atproto/api'
-import AwaitLock from 'await-lock'
-import {bundleAsync} from 'lib/async/bundle'
-import {RootStoreModel} from '../root-store'
-import {cleanError} from 'lib/strings/errors'
-import {FeedTuner} from 'lib/api/feed-manip'
-import {PostsFeedSliceModel} from './posts-slice'
-import {track} from 'lib/analytics/analytics'
-import {FeedViewPostsSlice} from 'lib/api/feed-manip'
-
-import {FeedAPI, FeedAPIResponse} from 'lib/api/feed/types'
-import {FollowingFeedAPI} from 'lib/api/feed/following'
-import {AuthorFeedAPI} from 'lib/api/feed/author'
-import {LikesFeedAPI} from 'lib/api/feed/likes'
-import {CustomFeedAPI} from 'lib/api/feed/custom'
-import {ListFeedAPI} from 'lib/api/feed/list'
-import {MergeFeedAPI} from 'lib/api/feed/merge'
-import {logger} from '#/logger'
-
-const PAGE_SIZE = 30
-
-type FeedType = 'home' | 'following' | 'author' | 'custom' | 'likes' | 'list'
-
-export enum KnownError {
-  FeedgenDoesNotExist,
-  FeedgenMisconfigured,
-  FeedgenBadResponse,
-  FeedgenOffline,
-  FeedgenUnknown,
-  Unknown,
-}
-
-type Options = {
-  /**
-   * Formats the feed in a flat array with no threading of replies, just
-   * top-level posts.
-   */
-  isSimpleFeed?: boolean
-}
-
-type QueryParams =
-  | GetTimeline.QueryParams
-  | GetAuthorFeed.QueryParams
-  | GetActorLikes.QueryParams
-  | GetCustomFeed.QueryParams
-  | GetListFeed.QueryParams
-
-export class PostsFeedModel {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasNewLatest = false
-  hasLoaded = false
-  isBlocking = false
-  isBlockedBy = false
-  error = ''
-  knownError: KnownError | undefined
-  loadMoreError = ''
-  params: QueryParams
-  hasMore = true
-  pollCursor: string | undefined
-  api: FeedAPI
-  tuner = new FeedTuner()
-  pageSize = PAGE_SIZE
-  options: Options = {}
-
-  // used to linearize async modifications to state
-  lock = new AwaitLock()
-
-  // used to track if a feed is coming up empty
-  emptyFetches = 0
-
-  // data
-  slices: PostsFeedSliceModel[] = []
-
-  constructor(
-    public rootStore: RootStoreModel,
-    public feedType: FeedType,
-    params: QueryParams,
-    options?: Options,
-  ) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-        params: false,
-      },
-      {autoBind: true},
-    )
-    this.params = params
-    this.options = options || {}
-    if (feedType === 'home') {
-      this.api = new MergeFeedAPI(rootStore)
-    } else if (feedType === 'following') {
-      this.api = new FollowingFeedAPI(rootStore)
-    } else if (feedType === 'author') {
-      this.api = new AuthorFeedAPI(
-        rootStore,
-        params as GetAuthorFeed.QueryParams,
-      )
-    } else if (feedType === 'likes') {
-      this.api = new LikesFeedAPI(
-        rootStore,
-        params as GetActorLikes.QueryParams,
-      )
-    } else if (feedType === 'custom') {
-      this.api = new CustomFeedAPI(
-        rootStore,
-        params as GetCustomFeed.QueryParams,
-      )
-    } else if (feedType === 'list') {
-      this.api = new ListFeedAPI(rootStore, params as GetListFeed.QueryParams)
-    } else {
-      this.api = new FollowingFeedAPI(rootStore)
-    }
-  }
-
-  get reactKey() {
-    if (this.feedType === 'author') {
-      return (this.params as GetAuthorFeed.QueryParams).actor
-    }
-    if (this.feedType === 'custom') {
-      return (this.params as GetCustomFeed.QueryParams).feed
-    }
-    if (this.feedType === 'list') {
-      return (this.params as GetListFeed.QueryParams).list
-    }
-    return this.feedType
-  }
-
-  get hasContent() {
-    return this.slices.length !== 0
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  get isLoadingMore() {
-    return this.isLoading && !this.isRefreshing && this.hasContent
-  }
-
-  setHasNewLatest(v: boolean) {
-    this.hasNewLatest = v
-  }
-
-  // public api
-  // =
-
-  /**
-   * Nuke all data
-   */
-  clear() {
-    logger.debug('FeedModel:clear')
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasNewLatest = false
-    this.hasLoaded = false
-    this.error = ''
-    this.hasMore = true
-    this.pollCursor = undefined
-    this.slices = []
-    this.tuner.reset()
-  }
-
-  /**
-   * Load for first render
-   */
-  setup = bundleAsync(async (isRefreshing: boolean = false) => {
-    logger.debug('FeedModel:setup', {isRefreshing})
-    if (isRefreshing) {
-      this.isRefreshing = true // set optimistically for UI
-    }
-    await this.lock.acquireAsync()
-    try {
-      this.setHasNewLatest(false)
-      this.api.reset()
-      this.tuner.reset()
-      this._xLoading(isRefreshing)
-      try {
-        const res = await this.api.fetchNext({limit: this.pageSize})
-        await this._replaceAll(res)
-        this._xIdle()
-      } catch (e: any) {
-        this._xIdle(e)
-      }
-    } finally {
-      this.lock.release()
-    }
-  })
-
-  /**
-   * Register any event listeners. Returns a cleanup function.
-   */
-  registerListeners() {
-    const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this))
-    return () => sub.remove()
-  }
-
-  /**
-   * Reset and load
-   */
-  async refresh() {
-    await this.setup(true)
-  }
-
-  /**
-   * Load more posts to the end of the feed
-   */
-  loadMore = bundleAsync(async () => {
-    await this.lock.acquireAsync()
-    try {
-      if (!this.hasMore || this.hasError) {
-        return
-      }
-      this._xLoading()
-      try {
-        const res = await this.api.fetchNext({
-          limit: this.pageSize,
-        })
-        await this._appendAll(res)
-        this._xIdle()
-      } catch (e: any) {
-        this._xIdle(undefined, e)
-        runInAction(() => {
-          this.hasMore = false
-        })
-      }
-    } finally {
-      this.lock.release()
-      if (this.feedType === 'custom') {
-        track('CustomFeed:LoadMore')
-      }
-    }
-  })
-
-  /**
-   * Attempt to load more again after a failure
-   */
-  async retryLoadMore() {
-    this.loadMoreError = ''
-    this.hasMore = true
-    return this.loadMore()
-  }
-
-  /**
-   * Check if new posts are available
-   */
-  async checkForLatest() {
-    if (!this.hasLoaded || this.hasNewLatest || this.isLoading) {
-      return
-    }
-    const post = await this.api.peekLatest()
-    if (post) {
-      const slices = this.tuner.tune(
-        [post],
-        this.rootStore.preferences.getFeedTuners(this.feedType),
-        {
-          dryRun: true,
-          maintainOrder: true,
-        },
-      )
-      if (slices[0]) {
-        const sliceModel = new PostsFeedSliceModel(this.rootStore, slices[0])
-        if (sliceModel.moderation.content.filter) {
-          return
-        }
-        this.setHasNewLatest(sliceModel.uri !== this.pollCursor)
-      }
-    }
-  }
-
-  /**
-   * Updates the UI after the user has created a post
-   */
-  onPostCreated() {
-    if (!this.slices.length) {
-      return this.refresh()
-    } else {
-      this.setHasNewLatest(true)
-    }
-  }
-
-  /**
-   * Removes posts from the feed upon deletion.
-   */
-  onPostDeleted(uri: string) {
-    let i
-    do {
-      i = this.slices.findIndex(slice => slice.containsUri(uri))
-      if (i !== -1) {
-        this.slices.splice(i, 1)
-      }
-    } while (i !== -1)
-  }
-
-  // state transitions
-  // =
-
-  _xLoading(isRefreshing = false) {
-    this.isLoading = true
-    this.isRefreshing = isRefreshing
-    this.error = ''
-    this.knownError = undefined
-  }
-
-  _xIdle(error?: any, loadMoreError?: any) {
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = true
-    this.isBlocking = error instanceof GetAuthorFeed.BlockedActorError
-    this.isBlockedBy = error instanceof GetAuthorFeed.BlockedByActorError
-    this.error = cleanError(error)
-    this.knownError = detectKnownError(this.feedType, error)
-    this.loadMoreError = cleanError(loadMoreError)
-    if (error) {
-      logger.error('Posts feed request failed', {error})
-    }
-    if (loadMoreError) {
-      logger.error('Posts feed load-more request failed', {
-        error: loadMoreError,
-      })
-    }
-  }
-
-  // helper functions
-  // =
-
-  async _replaceAll(res: FeedAPIResponse) {
-    this.pollCursor = res.feed[0]?.post.uri
-    return this._appendAll(res, true)
-  }
-
-  async _appendAll(res: FeedAPIResponse, replace = false) {
-    this.hasMore = !!res.cursor && res.feed.length > 0
-    if (replace) {
-      this.emptyFetches = 0
-    }
-
-    this.rootStore.me.follows.hydrateMany(
-      res.feed.map(item => item.post.author),
-    )
-    for (const item of res.feed) {
-      this.rootStore.posts.fromFeedItem(item)
-    }
-
-    const slices = this.options.isSimpleFeed
-      ? res.feed.map(item => new FeedViewPostsSlice([item]))
-      : this.tuner.tune(
-          res.feed,
-          this.rootStore.preferences.getFeedTuners(this.feedType),
-        )
-
-    const toAppend: PostsFeedSliceModel[] = []
-    for (const slice of slices) {
-      const sliceModel = new PostsFeedSliceModel(this.rootStore, slice)
-      const dupTest = (item: PostsFeedSliceModel) =>
-        item._reactKey === sliceModel._reactKey
-      // sanity check
-      // if a duplicate _reactKey passes through, the UI breaks hard
-      if (!replace) {
-        if (this.slices.find(dupTest) || toAppend.find(dupTest)) {
-          continue
-        }
-      }
-      toAppend.push(sliceModel)
-    }
-    runInAction(() => {
-      if (replace) {
-        this.slices = toAppend
-      } else {
-        this.slices = this.slices.concat(toAppend)
-      }
-      if (toAppend.length === 0) {
-        this.emptyFetches++
-        if (this.emptyFetches >= 10) {
-          this.hasMore = false
-        }
-      }
-    })
-  }
-}
-
-function detectKnownError(
-  feedType: FeedType,
-  error: any,
-): KnownError | undefined {
-  if (!error) {
-    return undefined
-  }
-  if (typeof error !== 'string') {
-    error = error.toString()
-  }
-  if (feedType !== 'custom') {
-    return KnownError.Unknown
-  }
-  if (error.includes('could not find feed')) {
-    return KnownError.FeedgenDoesNotExist
-  }
-  if (error.includes('feed unavailable')) {
-    return KnownError.FeedgenOffline
-  }
-  if (error.includes('invalid did document')) {
-    return KnownError.FeedgenMisconfigured
-  }
-  if (error.includes('could not resolve did document')) {
-    return KnownError.FeedgenMisconfigured
-  }
-  if (
-    error.includes('invalid feed generator service details in did document')
-  ) {
-    return KnownError.FeedgenMisconfigured
-  }
-  if (error.includes('feed provided an invalid response')) {
-    return KnownError.FeedgenBadResponse
-  }
-  return KnownError.FeedgenUnknown
-}
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index d3061f166..4bbb5a04b 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -4,7 +4,6 @@ import {
   ComAtprotoServerListAppPasswords,
 } from '@atproto/api'
 import {RootStoreModel} from './root-store'
-import {PostsFeedModel} from './feeds/posts'
 import {NotificationsFeedModel} from './feeds/notifications'
 import {MyFeedsUIModel} from './ui/my-feeds'
 import {MyFollowsCache} from './cache/my-follows'
@@ -22,7 +21,6 @@ export class MeModel {
   avatar: string = ''
   followsCount: number | undefined
   followersCount: number | undefined
-  mainFeed: PostsFeedModel
   notifications: NotificationsFeedModel
   myFeeds: MyFeedsUIModel
   follows: MyFollowsCache
@@ -41,16 +39,12 @@ export class MeModel {
       {rootStore: false, serialize: false, hydrate: false},
       {autoBind: true},
     )
-    this.mainFeed = new PostsFeedModel(this.rootStore, 'home', {
-      algorithm: 'reverse-chronological',
-    })
     this.notifications = new NotificationsFeedModel(this.rootStore)
     this.myFeeds = new MyFeedsUIModel(this.rootStore)
     this.follows = new MyFollowsCache(this.rootStore)
   }
 
   clear() {
-    this.mainFeed.clear()
     this.notifications.clear()
     this.myFeeds.clear()
     this.follows.clear()
@@ -109,10 +103,6 @@ export class MeModel {
     if (sess.hasSession) {
       this.did = sess.currentSession?.did || ''
       await this.fetchProfile()
-      this.mainFeed.clear()
-      /* dont await */ this.mainFeed.setup().catch(e => {
-        logger.error('Failed to setup main feed model', {error: e})
-      })
       /* dont await */ this.notifications.setup().catch(e => {
         logger.error('Failed to setup notifications model', {
           error: e,
diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts
index f96340c65..0ef592928 100644
--- a/src/state/models/ui/profile.ts
+++ b/src/state/models/ui/profile.ts
@@ -1,7 +1,6 @@
 import {makeAutoObservable, runInAction} from 'mobx'
 import {RootStoreModel} from '../root-store'
 import {ProfileModel} from '../content/profile'
-import {PostsFeedModel} from '../feeds/posts'
 import {ActorFeedsModel} from '../lists/actor-feeds'
 import {ListsListModel} from '../lists/lists-list'
 import {logger} from '#/logger'
diff --git a/src/state/preferences/feed-tuners.tsx b/src/state/preferences/feed-tuners.tsx
new file mode 100644
index 000000000..96770055c
--- /dev/null
+++ b/src/state/preferences/feed-tuners.tsx
@@ -0,0 +1,48 @@
+import {useMemo} from 'react'
+import {FeedTuner} from '#/lib/api/feed-manip'
+import {FeedDescriptor} from '../queries/post-feed'
+import {useLanguagePrefs} from './languages'
+
+export function useFeedTuners(feedDesc: FeedDescriptor) {
+  const langPrefs = useLanguagePrefs()
+
+  return useMemo(() => {
+    if (feedDesc.startsWith('feedgen')) {
+      return [
+        FeedTuner.dedupReposts,
+        FeedTuner.preferredLangOnly(langPrefs.contentLanguages),
+      ]
+    }
+    if (feedDesc.startsWith('list')) {
+      return [FeedTuner.dedupReposts]
+    }
+    if (feedDesc === 'home' || feedDesc === 'following') {
+      const feedTuners = []
+
+      if (false /*TODOthis.homeFeed.hideReposts*/) {
+        feedTuners.push(FeedTuner.removeReposts)
+      } else {
+        feedTuners.push(FeedTuner.dedupReposts)
+      }
+
+      if (true /*TODOthis.homeFeed.hideReplies*/) {
+        feedTuners.push(FeedTuner.removeReplies)
+      } /* TODO else {
+        feedTuners.push(
+          FeedTuner.thresholdRepliesOnly({
+            userDid: this.rootStore.session.data?.did || '',
+            minLikes: this.homeFeed.hideRepliesByLikeCount,
+            followedOnly: !!this.homeFeed.hideRepliesByUnfollowed,
+          }),
+        )
+      }*/
+
+      if (false /*TODOthis.homeFeed.hideQuotePosts*/) {
+        feedTuners.push(FeedTuner.removeQuotePosts)
+      }
+
+      return feedTuners
+    }
+    return []
+  }, [feedDesc, langPrefs])
+}
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
new file mode 100644
index 000000000..1a391d5c3
--- /dev/null
+++ b/src/state/queries/post-feed.ts
@@ -0,0 +1,176 @@
+import {useCallback, useMemo} from 'react'
+import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api'
+import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {useSession} from '../session'
+import {useFeedTuners} from '../preferences/feed-tuners'
+import {FeedTuner, NoopFeedTuner} from 'lib/api/feed-manip'
+import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
+import {FollowingFeedAPI} from 'lib/api/feed/following'
+import {AuthorFeedAPI} from 'lib/api/feed/author'
+import {LikesFeedAPI} from 'lib/api/feed/likes'
+import {CustomFeedAPI} from 'lib/api/feed/custom'
+import {ListFeedAPI} from 'lib/api/feed/list'
+import {MergeFeedAPI} from 'lib/api/feed/merge'
+import {useStores} from '../models/root-store'
+
+type ActorDid = string
+type AuthorFilter =
+  | 'posts_with_replies'
+  | 'posts_no_replies'
+  | 'posts_with_media'
+type FeedUri = string
+type ListUri = string
+export type FeedDescriptor =
+  | 'home'
+  | 'following'
+  | `author|${ActorDid}|${AuthorFilter}`
+  | `feedgen|${FeedUri}`
+  | `likes|${ActorDid}`
+  | `list|${ListUri}`
+export interface FeedParams {
+  disableTuner?: boolean
+  mergeFeedEnabled?: boolean
+  mergeFeedSources?: string[]
+}
+
+type RQPageParam = string | undefined
+
+export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
+  return ['post-feed', feedDesc, params || {}]
+}
+
+export interface FeedPostSliceItem {
+  _reactKey: string
+  uri: string
+  post: AppBskyFeedDefs.PostView
+  record: AppBskyFeedPost.Record
+  reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
+}
+
+export interface FeedPostSlice {
+  _reactKey: string
+  rootUri: string
+  isThread: boolean
+  items: FeedPostSliceItem[]
+}
+
+export interface FeedPage {
+  cursor: string | undefined
+  slices: FeedPostSlice[]
+}
+
+export function usePostFeedQuery(
+  feedDesc: FeedDescriptor,
+  params?: FeedParams,
+  opts?: {enabled?: boolean},
+) {
+  const {agent} = useSession()
+  const feedTuners = useFeedTuners(feedDesc)
+  const store = useStores()
+  const enabled = opts?.enabled !== false
+
+  const api: FeedAPI = useMemo(() => {
+    if (feedDesc === 'home') {
+      return new MergeFeedAPI(agent, params || {}, feedTuners)
+    } else if (feedDesc === 'following') {
+      return new FollowingFeedAPI(agent)
+    } else if (feedDesc.startsWith('author')) {
+      const [_, actor, filter] = feedDesc.split('|')
+      return new AuthorFeedAPI(agent, {actor, filter})
+    } else if (feedDesc.startsWith('likes')) {
+      const [_, actor] = feedDesc.split('|')
+      return new LikesFeedAPI(agent, {actor})
+    } else if (feedDesc.startsWith('feedgen')) {
+      const [_, feed] = feedDesc.split('|')
+      return new CustomFeedAPI(agent, {feed})
+    } else if (feedDesc.startsWith('list')) {
+      const [_, list] = feedDesc.split('|')
+      return new ListFeedAPI(agent, {list})
+    } else {
+      // shouldnt happen
+      return new FollowingFeedAPI(agent)
+    }
+  }, [feedDesc, params, feedTuners, agent])
+  const tuner = useMemo(
+    () => (params?.disableTuner ? new NoopFeedTuner() : new FeedTuner()),
+    [params],
+  )
+
+  const pollLatest = useCallback(async () => {
+    if (!enabled) {
+      return false
+    }
+    console.log('poll')
+    const post = await api.peekLatest()
+    if (post) {
+      const slices = tuner.tune([post], feedTuners, {
+        dryRun: true,
+        maintainOrder: true,
+      })
+      if (slices[0]) {
+        if (
+          !moderatePost(
+            slices[0].items[0].post,
+            store.preferences.moderationOpts,
+          ).content.filter
+        ) {
+          return true
+        }
+      }
+    }
+    return false
+  }, [api, tuner, feedTuners, store.preferences.moderationOpts, enabled])
+
+  const out = useInfiniteQuery<
+    FeedPage,
+    Error,
+    InfiniteData<FeedPage>,
+    QueryKey,
+    RQPageParam
+  >({
+    queryKey: RQKEY(feedDesc, params),
+    async queryFn({pageParam}: {pageParam: RQPageParam}) {
+      console.log('fetch', feedDesc, pageParam)
+      if (!pageParam) {
+        tuner.reset()
+      }
+      const res = await api.fetch({cursor: pageParam, limit: 30})
+      const slices = tuner.tune(res.feed, feedTuners)
+      return {
+        cursor: res.cursor,
+        slices: slices.map(slice => ({
+          _reactKey: slice._reactKey,
+          rootUri: slice.rootItem.post.uri,
+          isThread:
+            slice.items.length > 1 &&
+            slice.items.every(
+              item => item.post.author.did === slice.items[0].post.author.did,
+            ),
+          source: undefined, // TODO
+          items: slice.items
+            .map((item, i) => {
+              if (
+                AppBskyFeedPost.isRecord(item.post.record) &&
+                AppBskyFeedPost.validateRecord(item.post.record).success
+              ) {
+                return {
+                  _reactKey: `${slice._reactKey}-${i}`,
+                  uri: item.post.uri,
+                  post: item.post,
+                  record: item.post.record,
+                  reason: i === 0 && slice.source ? slice.source : item.reason,
+                }
+              }
+              return undefined
+            })
+            .filter(Boolean) as FeedPostSliceItem[],
+        })),
+      }
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+    enabled,
+  })
+
+  return {...out, pollLatest}
+}
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index 4dea8aaf1..386c70483 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -57,17 +57,17 @@ export type ThreadNode =
 
 export function usePostThreadQuery(uri: string | undefined) {
   const {agent} = useSession()
-  return useQuery<ThreadNode, Error>(
-    RQKEY(uri || ''),
-    async () => {
+  return useQuery<ThreadNode, Error>({
+    queryKey: RQKEY(uri || ''),
+    async queryFn() {
       const res = await agent.getPostThread({uri: uri!})
       if (res.success) {
         return responseToThreadNodes(res.data.thread)
       }
       return {type: 'unknown', uri: uri!}
     },
-    {enabled: !!uri},
-  )
+    enabled: !!uri,
+  })
 }
 
 export function sortThread(
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index f62190c67..ffff7f967 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -7,9 +7,9 @@ export const RQKEY = (postUri: string) => ['post', postUri]
 
 export function usePostQuery(uri: string | undefined) {
   const {agent} = useSession()
-  return useQuery<AppBskyFeedDefs.PostView>(
-    RQKEY(uri || ''),
-    async () => {
+  return useQuery<AppBskyFeedDefs.PostView>({
+    queryKey: RQKEY(uri || ''),
+    async queryFn() {
       const res = await agent.getPosts({uris: [uri!]})
       if (res.success && res.data.posts[0]) {
         return res.data.posts[0]
@@ -17,10 +17,8 @@ export function usePostQuery(uri: string | undefined) {
 
       throw new Error('No data')
     },
-    {
-      enabled: !!uri,
-    },
-  )
+    enabled: !!uri,
+  })
 }
 
 export function usePostLikeMutation() {
@@ -29,7 +27,8 @@ export function usePostLikeMutation() {
     {uri: string}, // responds with the uri of the like
     Error,
     {uri: string; cid: string; likeCount: number} // the post's uri, cid, and likes
-  >(post => agent.like(post.uri, post.cid), {
+  >({
+    mutationFn: post => agent.like(post.uri, post.cid),
     onMutate(variables) {
       // optimistically update the post-shadow
       updatePostShadow(variables.uri, {
@@ -59,27 +58,25 @@ export function usePostUnlikeMutation() {
     void,
     Error,
     {postUri: string; likeUri: string; likeCount: number}
-  >(
-    async ({likeUri}) => {
+  >({
+    mutationFn: async ({likeUri}) => {
       await agent.deleteLike(likeUri)
     },
-    {
-      onMutate(variables) {
-        // optimistically update the post-shadow
-        updatePostShadow(variables.postUri, {
-          likeCount: variables.likeCount - 1,
-          likeUri: undefined,
-        })
-      },
-      onError(error, variables) {
-        // revert the optimistic update
-        updatePostShadow(variables.postUri, {
-          likeCount: variables.likeCount,
-          likeUri: variables.likeUri,
-        })
-      },
+    onMutate(variables) {
+      // optimistically update the post-shadow
+      updatePostShadow(variables.postUri, {
+        likeCount: variables.likeCount - 1,
+        likeUri: undefined,
+      })
+    },
+    onError(error, variables) {
+      // revert the optimistic update
+      updatePostShadow(variables.postUri, {
+        likeCount: variables.likeCount,
+        likeUri: variables.likeUri,
+      })
     },
-  )
+  })
 }
 
 export function usePostRepostMutation() {
@@ -88,7 +85,8 @@ export function usePostRepostMutation() {
     {uri: string}, // responds with the uri of the repost
     Error,
     {uri: string; cid: string; repostCount: number} // the post's uri, cid, and reposts
-  >(post => agent.repost(post.uri, post.cid), {
+  >({
+    mutationFn: post => agent.repost(post.uri, post.cid),
     onMutate(variables) {
       // optimistically update the post-shadow
       updatePostShadow(variables.uri, {
@@ -118,39 +116,35 @@ export function usePostUnrepostMutation() {
     void,
     Error,
     {postUri: string; repostUri: string; repostCount: number}
-  >(
-    async ({repostUri}) => {
+  >({
+    mutationFn: async ({repostUri}) => {
       await agent.deleteRepost(repostUri)
     },
-    {
-      onMutate(variables) {
-        // optimistically update the post-shadow
-        updatePostShadow(variables.postUri, {
-          repostCount: variables.repostCount - 1,
-          repostUri: undefined,
-        })
-      },
-      onError(error, variables) {
-        // revert the optimistic update
-        updatePostShadow(variables.postUri, {
-          repostCount: variables.repostCount,
-          repostUri: variables.repostUri,
-        })
-      },
+    onMutate(variables) {
+      // optimistically update the post-shadow
+      updatePostShadow(variables.postUri, {
+        repostCount: variables.repostCount - 1,
+        repostUri: undefined,
+      })
+    },
+    onError(error, variables) {
+      // revert the optimistic update
+      updatePostShadow(variables.postUri, {
+        repostCount: variables.repostCount,
+        repostUri: variables.repostUri,
+      })
     },
-  )
+  })
 }
 
 export function usePostDeleteMutation() {
   const {agent} = useSession()
-  return useMutation<void, Error, {uri: string}>(
-    async ({uri}) => {
+  return useMutation<void, Error, {uri: string}>({
+    mutationFn: async ({uri}) => {
       await agent.deletePost(uri)
     },
-    {
-      onSuccess(data, variables) {
-        updatePostShadow(variables.uri, {isDeleted: true})
-      },
+    onSuccess(data, variables) {
+      updatePostShadow(variables.uri, {isDeleted: true})
     },
-  )
+  })
 }
diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts
index 770be5cf8..26e0a475b 100644
--- a/src/state/queries/resolve-uri.ts
+++ b/src/state/queries/resolve-uri.ts
@@ -6,12 +6,15 @@ export const RQKEY = (uri: string) => ['resolved-uri', uri]
 
 export function useResolveUriQuery(uri: string) {
   const {agent} = useSession()
-  return useQuery<string | undefined, Error>(RQKEY(uri), async () => {
-    const urip = new AtUri(uri)
-    if (!urip.host.startsWith('did:')) {
-      const res = await agent.resolveHandle({handle: urip.host})
-      urip.host = res.data.did
-    }
-    return urip.toString()
+  return useQuery<string | undefined, Error>({
+    queryKey: RQKEY(uri),
+    async queryFn() {
+      const urip = new AtUri(uri)
+      if (!urip.host.startsWith('did:')) {
+        const res = await agent.resolveHandle({handle: urip.host})
+        urip.host = res.data.did
+      }
+      return urip.toString()
+    },
   })
 }
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 8f8c2eea3..65c485a29 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -245,7 +245,7 @@ export const ComposePost = observer(function ComposePost({
       if (replyTo && replyTo.uri) track('Post:Reply')
     }
     if (!replyTo) {
-      store.me.mainFeed.onPostCreated()
+      // TODO onPostCreated
     }
     setLangPrefs.savePostLanguageToHistory()
     onPost?.()
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index 6a846f677..8d6a4a3d0 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -4,36 +4,38 @@ import {
 } from '@fortawesome/react-native-fontawesome'
 import {useIsFocused} from '@react-navigation/native'
 import {useAnalytics} from '@segment/analytics-react-native'
+import {useQueryClient} from '@tanstack/react-query'
+import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
 import {ComposeIcon2} from 'lib/icons'
 import {colors, s} from 'lib/styles'
-import {observer} from 'mobx-react-lite'
 import React from 'react'
-import {FlatList, View} from 'react-native'
+import {FlatList, View, useWindowDimensions} from 'react-native'
 import {useStores} from 'state/index'
-import {PostsFeedModel} from 'state/models/feeds/posts'
-import {useHeaderOffset, POLL_FREQ} from 'view/screens/Home'
 import {Feed} from '../posts/Feed'
 import {TextLink} from '../util/Link'
 import {FAB} from '../util/fab/FAB'
 import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
-import useAppState from 'react-native-appstate-hook'
-import {logger} from '#/logger'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useSession} from '#/state/session'
 
-export const FeedPage = observer(function FeedPageImpl({
+const POLL_FREQ = 30e3 // 30sec
+
+export function FeedPage({
   testID,
   isPageFocused,
   feed,
+  feedParams,
   renderEmptyState,
   renderEndOfFeed,
 }: {
   testID?: string
-  feed: PostsFeedModel
+  feed: FeedDescriptor
+  feedParams?: FeedParams
   isPageFocused: boolean
   renderEmptyState: () => JSX.Element
   renderEndOfFeed?: () => JSX.Element
@@ -43,40 +45,13 @@ export const FeedPage = observer(function FeedPageImpl({
   const pal = usePalette('default')
   const {_} = useLingui()
   const {isDesktop} = useWebMediaQueries()
+  const queryClient = useQueryClient()
   const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
   const {screen, track} = useAnalytics()
   const headerOffset = useHeaderOffset()
   const scrollElRef = React.useRef<FlatList>(null)
-  const {appState} = useAppState({
-    onForeground: () => doPoll(true),
-  })
   const isScreenFocused = useIsFocused()
-  const hasNew = feed.hasNewLatest && !feed.isRefreshing
-
-  React.useEffect(() => {
-    // called on first load
-    if (!feed.hasLoaded && isPageFocused) {
-      feed.setup()
-    }
-  }, [isPageFocused, feed])
-
-  const doPoll = React.useCallback(
-    (knownActive = false) => {
-      if (
-        (!knownActive && appState !== 'active') ||
-        !isScreenFocused ||
-        !isPageFocused
-      ) {
-        return
-      }
-      if (feed.isLoading) {
-        return
-      }
-      logger.debug('HomeScreen: Polling for new posts')
-      feed.checkForLatest()
-    },
-    [appState, isScreenFocused, isPageFocused, feed],
-  )
+  const [hasNew, setHasNew] = React.useState(false)
 
   const scrollToTop = React.useCallback(() => {
     scrollElRef.current?.scrollToOffset({offset: -headerOffset})
@@ -86,31 +61,22 @@ export const FeedPage = observer(function FeedPageImpl({
   const onSoftReset = React.useCallback(() => {
     if (isPageFocused) {
       scrollToTop()
-      feed.refresh()
+      queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
+      setHasNew(false)
     }
-  }, [isPageFocused, scrollToTop, feed])
+  }, [isPageFocused, scrollToTop, queryClient, feed, setHasNew])
 
   // fires when page within screen is activated/deactivated
-  // - check for latest
   React.useEffect(() => {
     if (!isPageFocused || !isScreenFocused) {
       return
     }
-
     const softResetSub = store.onScreenSoftReset(onSoftReset)
-    const feedCleanup = feed.registerListeners()
-    const pollInterval = setInterval(doPoll, POLL_FREQ)
-
     screen('Feed')
-    logger.debug('HomeScreen: Updating feed')
-    feed.checkForLatest()
-
     return () => {
-      clearInterval(pollInterval)
       softResetSub.remove()
-      feedCleanup()
     }
-  }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused])
+  }, [store, onSoftReset, screen, feed, isPageFocused, isScreenFocused])
 
   const onPressCompose = React.useCallback(() => {
     track('HomeScreen:PressCompose')
@@ -119,8 +85,9 @@ export const FeedPage = observer(function FeedPageImpl({
 
   const onPressLoadLatest = React.useCallback(() => {
     scrollToTop()
-    feed.refresh()
-  }, [feed, scrollToTop])
+    queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
+    setHasNew(false)
+  }, [scrollToTop, feed, queryClient, setHasNew])
 
   const ListHeaderComponent = React.useCallback(() => {
     if (isDesktop) {
@@ -191,8 +158,12 @@ export const FeedPage = observer(function FeedPageImpl({
       <Feed
         testID={testID ? `${testID}-feed` : undefined}
         feed={feed}
+        feedParams={feedParams}
+        enabled={isPageFocused}
+        pollInterval={POLL_FREQ}
         scrollElRef={scrollElRef}
         onScroll={onMainScroll}
+        onHasNew={setHasNew}
         scrollEventThrottle={1}
         renderEmptyState={renderEmptyState}
         renderEndOfFeed={renderEndOfFeed}
@@ -216,4 +187,18 @@ export const FeedPage = observer(function FeedPageImpl({
       />
     </View>
   )
-})
+}
+
+function useHeaderOffset() {
+  const {isDesktop, isTablet} = useWebMediaQueries()
+  const {fontScale} = useWindowDimensions()
+  if (isDesktop) {
+    return 0
+  }
+  if (isTablet) {
+    return 50
+  }
+  // default text takes 44px, plus 34px of pad
+  // scale the 44px by the font scale
+  return 34 + 44 * fontScale
+}
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index a8e0c0f93..0535cab53 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -69,15 +69,11 @@ export function PostThreadItem({
   const postShadowed = usePostShadow(post, dataUpdatedAt)
   const richText = useMemo(
     () =>
-      post &&
-      AppBskyFeedPost.isRecord(post?.record) &&
-      AppBskyFeedPost.validateRecord(post?.record).success
-        ? new RichTextAPI({
-            text: post.record.text,
-            facets: post.record.facets,
-          })
-        : undefined,
-    [post],
+      new RichTextAPI({
+        text: record.text,
+        facets: record.facets,
+      }),
+    [record],
   )
   const moderation = useMemo(
     () =>
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 7e28712ee..50afc1950 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -1,5 +1,4 @@
 import React, {MutableRefObject} from 'react'
-import {observer} from 'mobx-react-lite'
 import {
   ActivityIndicator,
   Dimensions,
@@ -12,7 +11,6 @@ import {
 import {FlatList} from '../util/Views'
 import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {FeedErrorMessage} from './FeedErrorMessage'
-import {PostsFeedModel} from 'state/models/feeds/posts'
 import {FeedSlice} from './FeedSlice'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
@@ -21,17 +19,26 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {useTheme} from 'lib/ThemeContext'
 import {logger} from '#/logger'
+import {
+  FeedDescriptor,
+  FeedParams,
+  usePostFeedQuery,
+} from '#/state/queries/post-feed'
 
 const LOADING_ITEM = {_reactKey: '__loading__'}
 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
 const ERROR_ITEM = {_reactKey: '__error__'}
 const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
 
-export const Feed = observer(function Feed({
+export function Feed({
   feed,
+  feedParams,
   style,
+  enabled,
+  pollInterval,
   scrollElRef,
   onScroll,
+  onHasNew,
   scrollEventThrottle,
   renderEmptyState,
   renderEndOfFeed,
@@ -41,9 +48,13 @@ export const Feed = observer(function Feed({
   ListHeaderComponent,
   extraData,
 }: {
-  feed: PostsFeedModel
+  feed: FeedDescriptor
+  feedParams?: FeedParams
   style?: StyleProp<ViewStyle>
+  enabled?: boolean
+  pollInterval?: number
   scrollElRef?: MutableRefObject<FlatList<any> | null>
+  onHasNew?: (v: boolean) => void
   onScroll?: OnScrollHandler
   scrollEventThrottle?: number
   renderEmptyState: () => JSX.Element
@@ -58,32 +69,68 @@ export const Feed = observer(function Feed({
   const theme = useTheme()
   const {track} = useAnalytics()
   const [isRefreshing, setIsRefreshing] = React.useState(false)
+  const checkForNewRef = React.useRef<(() => void) | null>(null)
+
+  const opts = React.useMemo(() => ({enabled}), [enabled])
+  const {
+    data,
+    dataUpdatedAt,
+    isFetching,
+    isFetched,
+    isError,
+    error,
+    refetch,
+    hasNextPage,
+    isFetchingNextPage,
+    fetchNextPage,
+    pollLatest,
+  } = usePostFeedQuery(feed, feedParams, opts)
+  const isEmpty = !isFetching && !data?.pages[0]?.slices.length
 
-  const data = React.useMemo(() => {
-    let feedItems: any[] = []
-    if (feed.hasLoaded) {
-      if (feed.hasError) {
-        feedItems = feedItems.concat([ERROR_ITEM])
+  const checkForNew = React.useCallback(async () => {
+    if (!isFetched || isFetching || !onHasNew) {
+      return
+    }
+    try {
+      if (await pollLatest()) {
+        onHasNew(true)
       }
-      if (feed.isEmpty) {
-        feedItems = feedItems.concat([EMPTY_FEED_ITEM])
-      } else {
-        feedItems = feedItems.concat(feed.slices)
+    } catch (e) {
+      logger.error('Poll latest failed', {feed, error: String(e)})
+    }
+  }, [feed, isFetched, isFetching, pollLatest, onHasNew])
+
+  React.useEffect(() => {
+    // we store the interval handler in a ref to avoid needless
+    // reassignments of the interval
+    checkForNewRef.current = checkForNew
+  }, [checkForNew])
+  React.useEffect(() => {
+    const i = setInterval(() => checkForNewRef.current?.(), pollInterval)
+    return () => clearInterval(i)
+  }, [pollInterval])
+
+  const feedItems = React.useMemo(() => {
+    let arr: any[] = []
+    if (isFetched) {
+      if (isError && isEmpty) {
+        arr = arr.concat([ERROR_ITEM])
+      }
+      if (isEmpty) {
+        arr = arr.concat([EMPTY_FEED_ITEM])
+      } else if (data) {
+        for (const page of data?.pages) {
+          arr = arr.concat(page.slices)
+        }
       }
-      if (feed.loadMoreError) {
-        feedItems = feedItems.concat([LOAD_MORE_ERROR_ITEM])
+      if (isError && !isEmpty) {
+        arr = arr.concat([LOAD_MORE_ERROR_ITEM])
       }
     } else {
-      feedItems.push(LOADING_ITEM)
+      arr.push(LOADING_ITEM)
     }
-    return feedItems
-  }, [
-    feed.hasError,
-    feed.hasLoaded,
-    feed.isEmpty,
-    feed.slices,
-    feed.loadMoreError,
-  ])
+    return arr
+  }, [isFetched, isError, isEmpty, data])
 
   // events
   // =
@@ -92,31 +139,33 @@ export const Feed = observer(function Feed({
     track('Feed:onRefresh')
     setIsRefreshing(true)
     try {
-      await feed.refresh()
+      await refetch()
+      onHasNew?.(false)
     } catch (err) {
       logger.error('Failed to refresh posts feed', {error: err})
     }
     setIsRefreshing(false)
-  }, [feed, track, setIsRefreshing])
+  }, [refetch, track, setIsRefreshing, onHasNew])
 
   const onEndReached = React.useCallback(async () => {
-    if (!feed.hasLoaded || !feed.hasMore) return
+    if (isFetching || !hasNextPage || isError) return
 
     track('Feed:onEndReached')
     try {
-      await feed.loadMore()
+      await fetchNextPage()
     } catch (err) {
       logger.error('Failed to load more posts', {error: err})
     }
-  }, [feed, track])
+  }, [isFetching, hasNextPage, isError, fetchNextPage, track])
 
   const onPressTryAgain = React.useCallback(() => {
-    feed.refresh()
-  }, [feed])
+    refetch()
+    onHasNew?.(false)
+  }, [refetch, onHasNew])
 
   const onPressRetryLoadMore = React.useCallback(() => {
-    feed.retryLoadMore()
-  }, [feed])
+    fetchNextPage()
+  }, [fetchNextPage])
 
   // rendering
   // =
@@ -127,7 +176,11 @@ export const Feed = observer(function Feed({
         return renderEmptyState()
       } else if (item === ERROR_ITEM) {
         return (
-          <FeedErrorMessage feed={feed} onPressTryAgain={onPressTryAgain} />
+          <FeedErrorMessage
+            feedDesc={feed}
+            error={error}
+            onPressTryAgain={onPressTryAgain}
+          />
         )
       } else if (item === LOAD_MORE_ERROR_ITEM) {
         return (
@@ -139,23 +192,32 @@ export const Feed = observer(function Feed({
       } else if (item === LOADING_ITEM) {
         return <PostFeedLoadingPlaceholder />
       }
-      return <FeedSlice slice={item} />
+      return <FeedSlice slice={item} dataUpdatedAt={dataUpdatedAt} />
     },
-    [feed, onPressTryAgain, onPressRetryLoadMore, renderEmptyState],
+    [
+      feed,
+      dataUpdatedAt,
+      error,
+      onPressTryAgain,
+      onPressRetryLoadMore,
+      renderEmptyState,
+    ],
   )
 
+  const shouldRenderEndOfFeed =
+    !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed
   const FeedFooter = React.useCallback(
     () =>
-      feed.isLoadingMore ? (
+      isFetchingNextPage ? (
         <View style={styles.feedFooter}>
           <ActivityIndicator />
         </View>
-      ) : !feed.hasMore && !feed.isEmpty && renderEndOfFeed ? (
+      ) : shouldRenderEndOfFeed ? (
         renderEndOfFeed()
       ) : (
         <View />
       ),
-    [feed.isLoadingMore, feed.hasMore, feed.isEmpty, renderEndOfFeed],
+    [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed],
   )
 
   const scrollHandler = useAnimatedScrollHandler(onScroll || {})
@@ -164,7 +226,7 @@ export const Feed = observer(function Feed({
       <FlatList
         testID={testID ? `${testID}-flatlist` : undefined}
         ref={scrollElRef}
-        data={data}
+        data={feedItems}
         keyExtractor={item => item._reactKey}
         renderItem={renderItem}
         ListFooterComponent={FeedFooter}
@@ -197,7 +259,7 @@ export const Feed = observer(function Feed({
       />
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   feedFooter: {paddingTop: 20},
diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx
index 84e438fcd..feb4b1c99 100644
--- a/src/view/com/posts/FeedErrorMessage.tsx
+++ b/src/view/com/posts/FeedErrorMessage.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {View} from 'react-native'
-import {AtUri, AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api'
-import {PostsFeedModel, KnownError} from 'state/models/feeds/posts'
+import {AppBskyFeedGetAuthorFeed, AtUri} from '@atproto/api'
 import {Text} from '../util/text/Text'
 import {Button} from '../util/forms/Button'
 import * as Toast from '../util/Toast'
@@ -12,9 +11,22 @@ 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'
+
+enum KnownError {
+  Block,
+  FeedgenDoesNotExist,
+  FeedgenMisconfigured,
+  FeedgenBadResponse,
+  FeedgenOffline,
+  FeedgenUnknown,
+  Unknown,
+}
 
 const MESSAGES = {
   [KnownError.Unknown]: '',
+  [KnownError.Block]: '',
   [KnownError.FeedgenDoesNotExist]: `Hmmm, we're having trouble finding this feed. It may have been deleted.`,
   [KnownError.FeedgenMisconfigured]:
     'Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue.',
@@ -27,36 +39,51 @@ const MESSAGES = {
 }
 
 export function FeedErrorMessage({
-  feed,
+  feedDesc,
+  error,
   onPressTryAgain,
 }: {
-  feed: PostsFeedModel
+  feedDesc: FeedDescriptor
+  error: any
   onPressTryAgain: () => void
 }) {
+  const knownError = React.useMemo(
+    () => detectKnownError(feedDesc, error),
+    [feedDesc, error],
+  )
   if (
-    typeof feed.knownError === 'undefined' ||
-    feed.knownError === KnownError.Unknown
+    typeof knownError !== 'undefined' &&
+    knownError !== KnownError.Unknown &&
+    feedDesc.startsWith('feedgen')
   ) {
+    return <FeedgenErrorMessage feedDesc={feedDesc} knownError={knownError} />
+  }
+
+  if (knownError === KnownError.Block) {
     return (
-      <ErrorMessage message={feed.error} onPressTryAgain={onPressTryAgain} />
+      <EmptyState
+        icon="ban"
+        message="Posts hidden"
+        style={{paddingVertical: 40}}
+      />
     )
   }
 
-  return <FeedgenErrorMessage feed={feed} knownError={feed.knownError} />
+  return <ErrorMessage message={error} onPressTryAgain={onPressTryAgain} />
 }
 
 function FeedgenErrorMessage({
-  feed,
+  feedDesc,
   knownError,
 }: {
-  feed: PostsFeedModel
+  feedDesc: FeedDescriptor
   knownError: KnownError
 }) {
   const pal = usePalette('default')
   const store = useStores()
   const navigation = useNavigation<NavigationProp>()
   const msg = MESSAGES[knownError]
-  const uri = (feed.params as GetCustomFeed.QueryParams).feed
+  const [_, uri] = feedDesc.split('|')
   const [ownerDid] = safeParseFeedgenUri(uri)
   const {openModal, closeModal} = useModalControls()
 
@@ -120,3 +147,45 @@ function safeParseFeedgenUri(uri: string): [string, string] {
     return ['', '']
   }
 }
+
+function detectKnownError(
+  feedDesc: FeedDescriptor,
+  error: any,
+): KnownError | undefined {
+  if (!error) {
+    return undefined
+  }
+  if (
+    error instanceof AppBskyFeedGetAuthorFeed.BlockedActorError ||
+    error instanceof AppBskyFeedGetAuthorFeed.BlockedByActorError
+  ) {
+    return KnownError.Block
+  }
+  if (typeof error !== 'string') {
+    error = error.toString()
+  }
+  if (!feedDesc.startsWith('feedgen')) {
+    return KnownError.Unknown
+  }
+  if (error.includes('could not find feed')) {
+    return KnownError.FeedgenDoesNotExist
+  }
+  if (error.includes('feed unavailable')) {
+    return KnownError.FeedgenOffline
+  }
+  if (error.includes('invalid did document')) {
+    return KnownError.FeedgenMisconfigured
+  }
+  if (error.includes('could not resolve did document')) {
+    return KnownError.FeedgenMisconfigured
+  }
+  if (
+    error.includes('invalid feed generator service details in did document')
+  ) {
+    return KnownError.FeedgenMisconfigured
+  }
+  if (error.includes('feed provided an invalid response')) {
+    return KnownError.FeedgenBadResponse
+  }
+  return KnownError.FeedgenUnknown
+}
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 527cbb76f..c5a841e31 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -1,25 +1,27 @@
 import React, {useMemo, useState} from 'react'
-import {observer} from 'mobx-react-lite'
-import {Linking, StyleSheet, View} from 'react-native'
-import Clipboard from '@react-native-clipboard/clipboard'
-import {AtUri} from '@atproto/api'
+import {StyleSheet, View} from 'react-native'
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AtUri,
+  PostModeration,
+  RichText as RichTextAPI,
+} from '@atproto/api'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
-import {PostsFeedItemModel} from 'state/models/feeds/post'
-import {FeedSourceInfo} from 'lib/api/feed/types'
+import {ReasonFeedSource, isReasonFeedSource} from 'lib/api/feed/types'
 import {Link, TextLinkOnWebOnly, TextLink} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
-import {PostCtrls} from '../util/post-ctrls/PostCtrls'
+import {PostCtrls} from '../util/post-ctrls/PostCtrls2'
 import {PostEmbeds} from '../util/post-embeds'
 import {ContentHider} from '../util/moderation/ContentHider'
 import {PostAlerts} from '../util/moderation/PostAlerts'
 import {RichText} from '../util/text/RichText'
 import {PostSandboxWarning} from '../util/PostSandboxWarning'
-import * as Toast from '../util/Toast'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
 import {s} from 'lib/styles'
 import {useStores} from 'state/index'
@@ -27,47 +29,91 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
-import {getTranslatorLink} from '../../../locale/helpers'
 import {makeProfileLink} from 'lib/routes/links'
 import {isEmbedByEmbedder} from 'lib/embeds'
 import {MAX_POST_LINES} from 'lib/constants'
 import {countLines} from 'lib/strings/helpers'
-import {logger} from '#/logger'
-import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
-import {useLanguagePrefs} from '#/state/preferences'
+import {usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
 
-export const FeedItem = observer(function FeedItemImpl({
-  item,
-  source,
+export function FeedItem({
+  post,
+  record,
+  reason,
+  moderation,
+  dataUpdatedAt,
   isThreadChild,
   isThreadLastChild,
   isThreadParent,
 }: {
-  item: PostsFeedItemModel
-  source?: FeedSourceInfo
+  post: AppBskyFeedDefs.PostView
+  record: AppBskyFeedPost.Record
+  reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
+  moderation: PostModeration
+  dataUpdatedAt: number
+  isThreadChild?: boolean
+  isThreadLastChild?: boolean
+  isThreadParent?: boolean
+}) {
+  const postShadowed = usePostShadow(post, dataUpdatedAt)
+  const richText = useMemo(
+    () =>
+      new RichTextAPI({
+        text: record.text,
+        facets: record.facets,
+      }),
+    [record],
+  )
+  if (postShadowed === POST_TOMBSTONE) {
+    return null
+  }
+  if (richText && moderation) {
+    return (
+      <FeedItemInner
+        post={postShadowed}
+        record={record}
+        reason={reason}
+        richText={richText}
+        moderation={moderation}
+        isThreadChild={isThreadChild}
+        isThreadLastChild={isThreadLastChild}
+        isThreadParent={isThreadParent}
+      />
+    )
+  }
+  return null
+}
+
+function FeedItemInner({
+  post,
+  record,
+  reason,
+  richText,
+  moderation,
+  isThreadChild,
+  isThreadLastChild,
+  isThreadParent,
+}: {
+  post: AppBskyFeedDefs.PostView
+  record: AppBskyFeedPost.Record
+  reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
+  richText: RichTextAPI
+  moderation: PostModeration
   isThreadChild?: boolean
   isThreadLastChild?: boolean
   isThreadParent?: boolean
-  showReplyLine?: boolean
 }) {
   const store = useStores()
-  const langPrefs = useLanguagePrefs()
   const pal = usePalette('default')
-  const mutedThreads = useMutedThreads()
-  const toggleThreadMute = useToggleThreadMute()
   const {track} = useAnalytics()
-  const [deleted, setDeleted] = useState(false)
   const [limitLines, setLimitLines] = useState(
-    countLines(item.richText?.text) >= MAX_POST_LINES,
+    countLines(richText.text) >= MAX_POST_LINES,
   )
-  const record = item.postRecord
-  const itemUri = item.post.uri
-  const itemCid = item.post.cid
-  const itemHref = useMemo(() => {
-    const urip = new AtUri(item.post.uri)
-    return makeProfileLink(item.post.author, 'post', urip.rkey)
-  }, [item.post.uri, item.post.author])
-  const itemTitle = `Post by ${item.post.author.handle}`
+
+  const href = useMemo(() => {
+    const urip = new AtUri(post.uri)
+    return makeProfileLink(post.author, 'post', urip.rkey)
+  }, [post.uri, post.author])
+
   const replyAuthorDid = useMemo(() => {
     if (!record?.reply) {
       return ''
@@ -75,77 +121,22 @@ export const FeedItem = observer(function FeedItemImpl({
     const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
     return urip.hostname
   }, [record?.reply])
-  const translatorUrl = getTranslatorLink(
-    record?.text || '',
-    langPrefs.primaryLanguage,
-  )
 
   const onPressReply = React.useCallback(() => {
     track('FeedItem:PostReply')
     store.shell.openComposer({
       replyTo: {
-        uri: item.post.uri,
-        cid: item.post.cid,
-        text: record?.text || '',
+        uri: post.uri,
+        cid: post.cid,
+        text: record.text || '',
         author: {
-          handle: item.post.author.handle,
-          displayName: item.post.author.displayName,
-          avatar: item.post.author.avatar,
+          handle: post.author.handle,
+          displayName: post.author.displayName,
+          avatar: post.author.avatar,
         },
       },
     })
-  }, [item, track, record, store])
-
-  const onPressToggleRepost = React.useCallback(() => {
-    track('FeedItem:PostRepost')
-    return item
-      .toggleRepost()
-      .catch(e => logger.error('Failed to toggle repost', {error: e}))
-  }, [track, item])
-
-  const onPressToggleLike = React.useCallback(() => {
-    track('FeedItem:PostLike')
-    return item
-      .toggleLike()
-      .catch(e => logger.error('Failed to toggle like', {error: e}))
-  }, [track, item])
-
-  const onCopyPostText = React.useCallback(() => {
-    Clipboard.setString(record?.text || '')
-    Toast.show('Copied to clipboard')
-  }, [record])
-
-  const onOpenTranslate = React.useCallback(() => {
-    Linking.openURL(translatorUrl)
-  }, [translatorUrl])
-
-  const onToggleThreadMute = React.useCallback(() => {
-    track('FeedItem:ThreadMute')
-    try {
-      const muted = toggleThreadMute(item.rootUri)
-      if (muted) {
-        Toast.show('You will no longer receive notifications for this thread')
-      } else {
-        Toast.show('You will now receive notifications for this thread')
-      }
-    } catch (e) {
-      logger.error('Failed to toggle thread mute', {error: e})
-    }
-  }, [track, toggleThreadMute, item])
-
-  const onDeletePost = React.useCallback(() => {
-    track('FeedItem:PostDelete')
-    item.delete().then(
-      () => {
-        setDeleted(true)
-        Toast.show('Post deleted')
-      },
-      e => {
-        logger.error('Failed to delete post', {error: e})
-        Toast.show('Failed to delete post, please try again')
-      },
-    )
-  }, [track, item, setDeleted])
+  }, [post, record, track, store])
 
   const onPressShowMore = React.useCallback(() => {
     setLimitLines(false)
@@ -164,15 +155,11 @@ export const FeedItem = observer(function FeedItemImpl({
     isThreadChild ? styles.outerSmallTop : undefined,
   ]
 
-  if (!record || deleted) {
-    return <View />
-  }
-
   return (
     <Link
-      testID={`feedItem-by-${item.post.author.handle}`}
+      testID={`feedItem-by-${post.author.handle}`}
       style={outerStyles}
-      href={itemHref}
+      href={href}
       noFeedback
       accessible={false}>
       <PostSandboxWarning />
@@ -194,10 +181,10 @@ export const FeedItem = observer(function FeedItemImpl({
         </View>
 
         <View style={{paddingTop: 12, flexShrink: 1}}>
-          {source ? (
+          {isReasonFeedSource(reason) ? (
             <Link
-              title={sanitizeDisplayName(source.displayName)}
-              href={source.uri}>
+              title={sanitizeDisplayName(reason.displayName)}
+              href={reason.uri}>
               <Text
                 type="sm-bold"
                 style={pal.textLight}
@@ -209,17 +196,17 @@ export const FeedItem = observer(function FeedItemImpl({
                   style={pal.textLight}
                   lineHeight={1.2}
                   numberOfLines={1}
-                  text={sanitizeDisplayName(source.displayName)}
-                  href={source.uri}
+                  text={sanitizeDisplayName(reason.displayName)}
+                  href={reason.uri}
                 />
               </Text>
             </Link>
-          ) : item.reasonRepost ? (
+          ) : AppBskyFeedDefs.isReasonRepost(reason) ? (
             <Link
               style={styles.includeReason}
-              href={makeProfileLink(item.reasonRepost.by)}
+              href={makeProfileLink(reason.by)}
               title={`Reposted by ${sanitizeDisplayName(
-                item.reasonRepost.by.displayName || item.reasonRepost.by.handle,
+                reason.by.displayName || reason.by.handle,
               )}`}>
               <FontAwesomeIcon
                 icon="retweet"
@@ -241,10 +228,9 @@ export const FeedItem = observer(function FeedItemImpl({
                   lineHeight={1.2}
                   numberOfLines={1}
                   text={sanitizeDisplayName(
-                    item.reasonRepost.by.displayName ||
-                      sanitizeHandle(item.reasonRepost.by.handle),
+                    reason.by.displayName || sanitizeHandle(reason.by.handle),
                   )}
-                  href={makeProfileLink(item.reasonRepost.by)}
+                  href={makeProfileLink(reason.by)}
                 />
               </Text>
             </Link>
@@ -256,10 +242,10 @@ export const FeedItem = observer(function FeedItemImpl({
         <View style={styles.layoutAvi}>
           <PreviewableUserAvatar
             size={52}
-            did={item.post.author.did}
-            handle={item.post.author.handle}
-            avatar={item.post.author.avatar}
-            moderation={item.moderation.avatar}
+            did={post.author.did}
+            handle={post.author.handle}
+            avatar={post.author.avatar}
+            moderation={moderation.avatar}
           />
           {isThreadParent && (
             <View
@@ -276,10 +262,10 @@ export const FeedItem = observer(function FeedItemImpl({
         </View>
         <View style={styles.layoutContent}>
           <PostMeta
-            author={item.post.author}
-            authorHasWarning={!!item.post.author.labels?.length}
-            timestamp={item.post.indexedAt}
-            postHref={itemHref}
+            author={post.author}
+            authorHasWarning={!!post.author.labels?.length}
+            timestamp={post.indexedAt}
+            postHref={href}
           />
           {!isThreadChild && replyAuthorDid !== '' && (
             <View style={[s.flexRow, s.mb2, s.alignCenter]}>
@@ -308,19 +294,16 @@ export const FeedItem = observer(function FeedItemImpl({
           )}
           <ContentHider
             testID="contentHider-post"
-            moderation={item.moderation.content}
+            moderation={moderation.content}
             ignoreMute
             childContainerStyle={styles.contentHiderChild}>
-            <PostAlerts
-              moderation={item.moderation.content}
-              style={styles.alert}
-            />
-            {item.richText?.text ? (
+            <PostAlerts moderation={moderation.content} style={styles.alert} />
+            {richText.text ? (
               <View style={styles.postTextContainer}>
                 <RichText
                   testID="postText"
                   type="post-text"
-                  richText={item.richText}
+                  richText={richText}
                   lineHeight={1.3}
                   numberOfLines={limitLines ? MAX_POST_LINES : undefined}
                   style={s.flex1}
@@ -335,50 +318,22 @@ export const FeedItem = observer(function FeedItemImpl({
                 href="#"
               />
             ) : undefined}
-            {item.post.embed ? (
+            {post.embed ? (
               <ContentHider
                 testID="contentHider-embed"
-                moderation={item.moderation.embed}
-                ignoreMute={isEmbedByEmbedder(
-                  item.post.embed,
-                  item.post.author.did,
-                )}
+                moderation={moderation.embed}
+                ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)}
                 style={styles.embed}>
-                <PostEmbeds
-                  embed={item.post.embed}
-                  moderation={item.moderation.embed}
-                />
+                <PostEmbeds embed={post.embed} moderation={moderation.embed} />
               </ContentHider>
             ) : null}
           </ContentHider>
-          <PostCtrls
-            itemUri={itemUri}
-            itemCid={itemCid}
-            itemHref={itemHref}
-            itemTitle={itemTitle}
-            author={item.post.author}
-            text={item.richText?.text || record.text}
-            indexedAt={item.post.indexedAt}
-            isAuthor={item.post.author.did === store.me.did}
-            replyCount={item.post.replyCount}
-            repostCount={item.post.repostCount}
-            likeCount={item.post.likeCount}
-            isReposted={!!item.post.viewer?.repost}
-            isLiked={!!item.post.viewer?.like}
-            isThreadMuted={mutedThreads.includes(item.rootUri)}
-            onPressReply={onPressReply}
-            onPressToggleRepost={onPressToggleRepost}
-            onPressToggleLike={onPressToggleLike}
-            onCopyPostText={onCopyPostText}
-            onOpenTranslate={onOpenTranslate}
-            onToggleThreadMute={onToggleThreadMute}
-            onDeletePost={onDeletePost}
-          />
+          <PostCtrls post={post} record={record} onPressReply={onPressReply} />
         </View>
       </View>
     </Link>
   )
-})
+}
 
 const styles = StyleSheet.create({
   outer: {
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index 1d26f6cbd..c33c6028d 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -1,24 +1,40 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
-import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice'
-import {AtUri} from '@atproto/api'
+import {FeedPostSlice} from '#/state/queries/post-feed'
+import {AtUri, moderatePost} from '@atproto/api'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
 import Svg, {Circle, Line} from 'react-native-svg'
 import {FeedItem} from './FeedItem'
 import {usePalette} from 'lib/hooks/usePalette'
 import {makeProfileLink} from 'lib/routes/links'
+import {useStores} from '#/state'
 
 export const FeedSlice = observer(function FeedSliceImpl({
   slice,
+  dataUpdatedAt,
   ignoreFilterFor,
 }: {
-  slice: PostsFeedSliceModel
+  slice: FeedPostSlice
+  dataUpdatedAt: number
   ignoreFilterFor?: string
 }) {
-  if (slice.shouldFilter(ignoreFilterFor)) {
-    return null
+  const store = useStores()
+  const moderations = React.useMemo(() => {
+    return slice.items.map(item =>
+      moderatePost(item.post, store.preferences.moderationOpts),
+    )
+  }, [slice, store.preferences.moderationOpts])
+
+  // apply moderation filter
+  for (let i = 0; i < slice.items.length; i++) {
+    if (
+      moderations[i]?.content.filter &&
+      slice.items[i].post.author.did !== ignoreFilterFor
+    ) {
+      return null
+    }
   }
 
   if (slice.isThread && slice.items.length > 3) {
@@ -27,23 +43,34 @@ export const FeedSlice = observer(function FeedSliceImpl({
       <>
         <FeedItem
           key={slice.items[0]._reactKey}
-          item={slice.items[0]}
-          source={slice.source}
-          isThreadParent={slice.isThreadParentAt(0)}
-          isThreadChild={slice.isThreadChildAt(0)}
+          post={slice.items[0].post}
+          record={slice.items[0].record}
+          reason={slice.items[0].reason}
+          moderation={moderations[0]}
+          dataUpdatedAt={dataUpdatedAt}
+          isThreadParent={isThreadParentAt(slice.items, 0)}
+          isThreadChild={isThreadChildAt(slice.items, 0)}
         />
         <FeedItem
           key={slice.items[1]._reactKey}
-          item={slice.items[1]}
-          isThreadParent={slice.isThreadParentAt(1)}
-          isThreadChild={slice.isThreadChildAt(1)}
+          post={slice.items[1].post}
+          record={slice.items[1].record}
+          reason={slice.items[1].reason}
+          moderation={moderations[1]}
+          dataUpdatedAt={dataUpdatedAt}
+          isThreadParent={isThreadParentAt(slice.items, 1)}
+          isThreadChild={isThreadChildAt(slice.items, 1)}
         />
         <ViewFullThread slice={slice} />
         <FeedItem
           key={slice.items[last]._reactKey}
-          item={slice.items[last]}
-          isThreadParent={slice.isThreadParentAt(last)}
-          isThreadChild={slice.isThreadChildAt(last)}
+          post={slice.items[last].post}
+          record={slice.items[last].record}
+          reason={slice.items[last].reason}
+          moderation={moderations[last]}
+          dataUpdatedAt={dataUpdatedAt}
+          isThreadParent={isThreadParentAt(slice.items, last)}
+          isThreadChild={isThreadChildAt(slice.items, last)}
           isThreadLastChild
         />
       </>
@@ -55,12 +82,15 @@ export const FeedSlice = observer(function FeedSliceImpl({
       {slice.items.map((item, i) => (
         <FeedItem
           key={item._reactKey}
-          item={item}
-          source={i === 0 ? slice.source : undefined}
-          isThreadParent={slice.isThreadParentAt(i)}
-          isThreadChild={slice.isThreadChildAt(i)}
+          post={slice.items[i].post}
+          record={slice.items[i].record}
+          reason={slice.items[i].reason}
+          moderation={moderations[i]}
+          dataUpdatedAt={dataUpdatedAt}
+          isThreadParent={isThreadParentAt(slice.items, i)}
+          isThreadChild={isThreadChildAt(slice.items, i)}
           isThreadLastChild={
-            slice.isThreadChildAt(i) && slice.items.length === i + 1
+            isThreadChildAt(slice.items, i) && slice.items.length === i + 1
           }
         />
       ))}
@@ -68,12 +98,12 @@ export const FeedSlice = observer(function FeedSliceImpl({
   )
 })
 
-function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) {
+function ViewFullThread({slice}: {slice: FeedPostSlice}) {
   const pal = usePalette('default')
   const itemHref = React.useMemo(() => {
-    const urip = new AtUri(slice.rootItem.post.uri)
-    return makeProfileLink(slice.rootItem.post.author, 'post', urip.rkey)
-  }, [slice.rootItem.post.uri, slice.rootItem.post.author])
+    const urip = new AtUri(slice.rootUri)
+    return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey)
+  }, [slice.rootUri])
 
   return (
     <Link
@@ -115,3 +145,17 @@ const styles = StyleSheet.create({
     alignItems: 'center',
   },
 })
+
+function isThreadParentAt<T>(arr: Array<T>, i: number) {
+  if (arr.length === 1) {
+    return false
+  }
+  return i < arr.length - 1
+}
+
+function isThreadChildAt<T>(arr: Array<T>, i: number) {
+  if (arr.length === 1) {
+    return false
+  }
+  return i > 0
+}
diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx
index 489705d10..5e9d816ac 100644
--- a/src/view/com/testing/TestCtrls.e2e.tsx
+++ b/src/view/com/testing/TestCtrls.e2e.tsx
@@ -3,6 +3,7 @@ import {Pressable, View} from 'react-native'
 import {useStores} from 'state/index'
 import {navigate} from '../../../Navigation'
 import {useModalControls} from '#/state/modals'
+import {useQueryClient} from '@tanstack/react-query'
 import {useSessionApi} from '#/state/session'
 
 /**
@@ -15,6 +16,7 @@ const BTN = {height: 1, width: 1, backgroundColor: 'red'}
 
 export function TestCtrls() {
   const store = useStores()
+  const queryClient = useQueryClient()
   const {logout, login} = useSessionApi()
   const {openModal} = useModalControls()
   const onPressSignInAlice = async () => {
@@ -83,7 +85,7 @@ export function TestCtrls() {
       />
       <Pressable
         testID="e2eRefreshHome"
-        onPress={() => store.me.mainFeed.refresh()}
+        onPress={() => queryClient.invalidateQueries({queryKey: ['post-feed']})}
         accessibilityRole="button"
         style={BTN}
       />
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index d6603a936..53813f822 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,10 +1,9 @@
 import React from 'react'
-import {useWindowDimensions} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
 import {observer} from 'mobx-react-lite'
 import isEqual from 'lodash.isequal'
 import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
-import {PostsFeedModel} from 'state/models/feeds/posts'
+import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
 import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
@@ -12,12 +11,9 @@ import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
 import {FeedsTabBar} from '../com/pager/FeedsTabBar'
 import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
 import {useStores} from 'state/index'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {FeedPage} from 'view/com/feeds/FeedPage'
 import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
 
-export const POLL_FREQ = 30e3 // 30sec
-
 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
 export const HomeScreen = withAuthRequired(
   observer(function HomeScreenImpl({}: Props) {
@@ -26,7 +22,7 @@ export const HomeScreen = withAuthRequired(
     const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
     const pagerRef = React.useRef<PagerRef>(null)
     const [selectedPage, setSelectedPage] = React.useState(0)
-    const [customFeeds, setCustomFeeds] = React.useState<PostsFeedModel[]>([])
+    const [customFeeds, setCustomFeeds] = React.useState<FeedDescriptor[]>([])
     const [requestedCustomFeeds, setRequestedCustomFeeds] = React.useState<
       string[]
     >([])
@@ -39,14 +35,12 @@ export const HomeScreen = withAuthRequired(
         return
       }
 
-      const feeds = []
+      const feeds: FeedDescriptor[] = []
       for (const uri of pinned) {
         if (uri.includes('app.bsky.feed.generator')) {
-          const model = new PostsFeedModel(store, 'custom', {feed: uri})
-          feeds.push(model)
+          feeds.push(`feedgen|${uri}`)
         } else if (uri.includes('app.bsky.graph.list')) {
-          const model = new PostsFeedModel(store, 'list', {list: uri})
-          feeds.push(model)
+          feeds.push(`list|${uri}`)
         }
       }
       pagerRef.current?.setPage(0)
@@ -62,6 +56,19 @@ export const HomeScreen = withAuthRequired(
       setRequestedCustomFeeds,
     ])
 
+    const homeFeedParams = React.useMemo<FeedParams>(() => {
+      if (!store.preferences.homeFeed.lab_mergeFeedEnabled) {
+        return {}
+      }
+      return {
+        mergeFeedEnabled: true,
+        mergeFeedSources: store.preferences.savedFeeds,
+      }
+    }, [
+      store.preferences.homeFeed.lab_mergeFeedEnabled,
+      store.preferences.savedFeeds,
+    ])
+
     useFocusEffect(
       React.useCallback(() => {
         setMinimalShellMode(false)
@@ -129,14 +136,15 @@ export const HomeScreen = withAuthRequired(
           key="1"
           testID="followingFeedPage"
           isPageFocused={selectedPage === 0}
-          feed={store.me.mainFeed}
+          feed="home"
+          feedParams={homeFeedParams}
           renderEmptyState={renderFollowingEmptyState}
           renderEndOfFeed={FollowingEndOfFeed}
         />
         {customFeeds.map((f, index) => {
           return (
             <FeedPage
-              key={f.reactKey}
+              key={f}
               testID="customFeedPage"
               isPageFocused={selectedPage === 1 + index}
               feed={f}
@@ -148,17 +156,3 @@ export const HomeScreen = withAuthRequired(
     )
   }),
 )
-
-export function useHeaderOffset() {
-  const {isDesktop, isTablet} = useWebMediaQueries()
-  const {fontScale} = useWindowDimensions()
-  if (isDesktop) {
-    return 0
-  }
-  if (isTablet) {
-    return 50
-  }
-  // default text takes 44px, plus 34px of pad
-  // scale the 44px by the font scale
-  return 34 + 44 * fontScale
-}
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index f2aa7f05d..945a8cc20 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -9,7 +9,6 @@ import {CenteredView} from '../com/util/Views'
 import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
 import {ProfileUiModel, Sections} from 'state/models/ui/profile'
 import {useStores} from 'state/index'
-import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice'
 import {ProfileHeader} from '../com/profile/ProfileHeader'
 import {FeedSlice} from '../com/posts/FeedSlice'
 import {ListCard} from 'view/com/lists/ListCard'
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
index 116979568..4c56b6674 100644
--- a/src/view/screens/ProfileFeed.tsx
+++ b/src/view/screens/ProfileFeed.tsx
@@ -8,6 +8,7 @@ import {
 } from 'react-native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
 import {useNavigation} from '@react-navigation/native'
+import {useQueryClient} from '@tanstack/react-query'
 import {usePalette} from 'lib/hooks/usePalette'
 import {HeartIcon, HeartIconSolid} from 'lib/icons'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@@ -17,7 +18,7 @@ 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 {PostsFeedModel} from 'state/models/feeds/posts'
+import {FeedDescriptor} from '#/state/queries/post-feed'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
 import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
@@ -32,6 +33,7 @@ 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'
 import {toShareUrl} from 'lib/strings/url-helpers'
@@ -154,13 +156,6 @@ export const ProfileFeedScreenInner = observer(
       [rkey, feedOwnerDid],
     )
     const feedInfo = useCustomFeed(uri)
-    const feed: PostsFeedModel = useMemo(() => {
-      const model = new PostsFeedModel(store, 'custom', {
-        feed: uri,
-      })
-      model.setup()
-      return model
-    }, [store, uri])
     const isPinned = store.preferences.isPinnedFeed(uri)
     useSetTitle(feedInfo?.displayName)
 
@@ -352,7 +347,7 @@ export const ProfileFeedScreenInner = observer(
           {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
             <FeedSection
               ref={feedSectionRef}
-              feed={feed}
+              feed={`feedgen|${uri}`}
               onScroll={onScroll}
               headerHeight={headerHeight}
               isScrolledDown={isScrolledDown}
@@ -395,7 +390,7 @@ export const ProfileFeedScreenInner = observer(
 )
 
 interface FeedSectionProps {
-  feed: PostsFeedModel
+  feed: FeedDescriptor
   onScroll: OnScrollHandler
   headerHeight: number
   isScrolledDown: boolean
@@ -406,12 +401,14 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
     {feed, onScroll, headerHeight, isScrolledDown, scrollElRef},
     ref,
   ) {
-    const hasNew = feed.hasNewLatest && !feed.isRefreshing
+    const [hasNew, setHasNew] = React.useState(false)
+    const queryClient = useQueryClient()
 
     const onScrollToTop = useCallback(() => {
       scrollElRef.current?.scrollToOffset({offset: -headerHeight})
-      feed.refresh()
-    }, [feed, scrollElRef, headerHeight])
+      queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
+      setHasNew(false)
+    }, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
 
     React.useImperativeHandle(ref, () => ({
       scrollToTop: onScrollToTop,
@@ -425,7 +422,9 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
       <View>
         <Feed
           feed={feed}
+          pollInterval={30e3}
           scrollElRef={scrollElRef}
+          onHasNew={setHasNew}
           onScroll={onScroll}
           scrollEventThrottle={5}
           renderEmptyState={renderPostsEmpty}
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 9180d21d5..03c1703c1 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -12,6 +12,7 @@ import {useNavigation} from '@react-navigation/native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {observer} from 'mobx-react-lite'
 import {RichText as RichTextAPI} from '@atproto/api'
+import {useQueryClient} from '@tanstack/react-query'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
 import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
@@ -28,11 +29,12 @@ import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
 import {FAB} from 'view/com/util/fab/FAB'
 import {Haptics} from 'lib/haptics'
 import {ListModel} from 'state/models/content/list'
-import {PostsFeedModel} from 'state/models/feeds/posts'
+import {FeedDescriptor} from '#/state/queries/post-feed'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
 import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
 import {NavigationProp} from 'lib/routes/types'
 import {toShareUrl} from 'lib/strings/url-helpers'
@@ -109,34 +111,25 @@ export const ProfileListScreenInner = observer(
   }: Props & {listOwnerDid: string}) {
     const store = useStores()
     const {_} = useLingui()
+    const queryClient = useQueryClient()
     const setMinimalShellMode = useSetMinimalShellMode()
     const {rkey} = route.params
+    const listUri = `at://${listOwnerDid}/app.bsky.graph.list/${rkey}`
     const feedSectionRef = React.useRef<SectionRef>(null)
     const aboutSectionRef = React.useRef<SectionRef>(null)
     const {openModal} = useModalControls()
 
     const list: ListModel = useMemo(() => {
-      const model = new ListModel(
-        store,
-        `at://${listOwnerDid}/app.bsky.graph.list/${rkey}`,
-      )
+      const model = new ListModel(store, listUri)
       return model
-    }, [store, listOwnerDid, rkey])
-    const feed = useMemo(
-      () => new PostsFeedModel(store, 'list', {list: list.uri}),
-      [store, list],
-    )
+    }, [store, listUri])
     useSetTitle(list.data?.name)
 
     useFocusEffect(
       useCallback(() => {
         setMinimalShellMode(false)
-        list.loadMore(true).then(() => {
-          if (list.isCuratelist) {
-            feed.setup()
-          }
-        })
-      }, [setMinimalShellMode, list, feed]),
+        list.loadMore(true)
+      }, [setMinimalShellMode, list]),
     )
 
     const onPressAddUser = useCallback(() => {
@@ -145,11 +138,13 @@ export const ProfileListScreenInner = observer(
         list,
         onAdd() {
           if (list.isCuratelist) {
-            feed.refresh()
+            queryClient.invalidateQueries({
+              queryKey: FEED_RQKEY(`list|${listUri}`),
+            })
           }
         },
       })
-    }, [openModal, list, feed])
+    }, [openModal, list, queryClient, listUri])
 
     const onCurrentPageSelected = React.useCallback(
       (index: number) => {
@@ -178,10 +173,10 @@ export const ProfileListScreenInner = observer(
             {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
               <FeedSection
                 ref={feedSectionRef}
+                feed={`list|${listUri}`}
                 scrollElRef={
                   scrollElRef as React.MutableRefObject<FlatList<any> | null>
                 }
-                feed={feed}
                 onScroll={onScroll}
                 headerHeight={headerHeight}
                 isScrolledDown={isScrolledDown}
@@ -562,7 +557,7 @@ const Header = observer(function HeaderImpl({
 })
 
 interface FeedSectionProps {
-  feed: PostsFeedModel
+  feed: FeedDescriptor
   onScroll: OnScrollHandler
   headerHeight: number
   isScrolledDown: boolean
@@ -573,12 +568,14 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
     {feed, scrollElRef, onScroll, headerHeight, isScrolledDown},
     ref,
   ) {
-    const hasNew = feed.hasNewLatest && !feed.isRefreshing
+    const queryClient = useQueryClient()
+    const [hasNew, setHasNew] = React.useState(false)
 
     const onScrollToTop = useCallback(() => {
       scrollElRef.current?.scrollToOffset({offset: -headerHeight})
-      feed.refresh()
-    }, [feed, scrollElRef, headerHeight])
+      queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
+      setHasNew(false)
+    }, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
     React.useImperativeHandle(ref, () => ({
       scrollToTop: onScrollToTop,
     }))
@@ -592,7 +589,9 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
         <Feed
           testID="listFeed"
           feed={feed}
+          pollInterval={30e3}
           scrollElRef={scrollElRef}
+          onHasNew={setHasNew}
           onScroll={onScroll}
           scrollEventThrottle={1}
           renderEmptyState={renderPostsEmpty}
diff --git a/yarn.lock b/yarn.lock
index fd7cd19be..9e75b3e4b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5402,18 +5402,17 @@
     "@svgr/plugin-svgo" "^5.5.0"
     loader-utils "^2.0.0"
 
-"@tanstack/query-core@4.33.0":
-  version "4.33.0"
-  resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.33.0.tgz#7756da9a75a424e521622b1d84eb55b7a2b33715"
-  integrity sha512-qYu73ptvnzRh6se2nyBIDHGBQvPY1XXl3yR769B7B6mIDD7s+EZhdlWHQ67JI6UOTFRaI7wupnTnwJ3gE0Mr/g==
+"@tanstack/query-core@5.8.1":
+  version "5.8.1"
+  resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.8.1.tgz#5215a028370d9b2f32e83787a0ea119e2f977996"
+  integrity sha512-Y0enatz2zQXBAsd7XmajlCs+WaitdR7dIFkqz9Xd7HL4KV04JOigWVreYseTmNH7YFSBSC/BJ9uuNp1MAf+GfA==
 
-"@tanstack/react-query@^4.33.0":
-  version "4.33.0"
-  resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.33.0.tgz#e927b0343a6ecaa948fee59e9ca98fe561062638"
-  integrity sha512-97nGbmDK0/m0B86BdiXzx3EW9RcDYKpnyL2+WwyuLHEgpfThYAnXFaMMmnTDuAO4bQJXEhflumIEUfKmP7ESGA==
+"@tanstack/react-query@^5.8.1":
+  version "5.8.1"
+  resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.8.1.tgz#22a122016e23a39acd90341954a895980ec21ade"
+  integrity sha512-YMagxS8iNPOLg0pK6WOjdSDlAvWKOf69udLOwQrBVmkC2SRLNLko7elo5Ro3ptlJkXvTVHidxC/h5KGi5bH1XQ==
   dependencies:
-    "@tanstack/query-core" "4.33.0"
-    use-sync-external-store "^1.2.0"
+    "@tanstack/query-core" "5.8.1"
 
 "@testing-library/jest-native@^5.4.1":
   version "5.4.2"
@@ -18944,7 +18943,7 @@ use-sidecar@^1.1.2:
     detect-node-es "^1.1.0"
     tslib "^2.0.0"
 
-use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0:
+use-sync-external-store@^1.0.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
   integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==