about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/state')
-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
9 files changed, 285 insertions, 595 deletions
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()
+    },
   })
 }