about summary refs log tree commit diff
path: root/src/state/models/feeds/posts.ts
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-11-10 15:34:25 -0800
committerGitHub <noreply@github.com>2023-11-10 15:34:25 -0800
commitc8c308e31e63607280648e3e9f1f56a371adcd05 (patch)
tree09cea4c603968a1a0b4cab299af9a417880c8115 /src/state/models/feeds/posts.ts
parent51f04b96200e38d95e486628d3cbc43398c47980 (diff)
downloadvoidsky-c8c308e31e63607280648e3e9f1f56a371adcd05.tar.zst
Refactor feeds to use react-query (#1862)
* Update to react-query v5

* Introduce post-feed react query

* Add feed refresh behaviors

* Only fetch feeds of visible pages

* Implement polling for latest on feeds

* Add moderation filtering to slices

* Handle block errors

* Update feed error messages

* Remove old models

* Replace simple-feed option with disable-tuner option

* Add missing useMemo

* Implement the mergefeed and fixes to polling

* Correctly handle failed load more state

* Improve error and empty state behaviors

* Clearer naming
Diffstat (limited to 'src/state/models/feeds/posts.ts')
-rw-r--r--src/state/models/feeds/posts.ts429
1 files changed, 0 insertions, 429 deletions
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
-}