about summary refs log tree commit diff
path: root/src/state/models/feeds
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/models/feeds')
-rw-r--r--src/state/models/feeds/multi-feed.ts227
-rw-r--r--src/state/models/feeds/posts-slice.ts3
-rw-r--r--src/state/models/feeds/posts.ts172
3 files changed, 60 insertions, 342 deletions
diff --git a/src/state/models/feeds/multi-feed.ts b/src/state/models/feeds/multi-feed.ts
deleted file mode 100644
index 95574fb56..000000000
--- a/src/state/models/feeds/multi-feed.ts
+++ /dev/null
@@ -1,227 +0,0 @@
-import {makeAutoObservable, runInAction} from 'mobx'
-import {AtUri} from '@atproto/api'
-import {bundleAsync} from 'lib/async/bundle'
-import {RootStoreModel} from '../root-store'
-import {CustomFeedModel} from './custom-feed'
-import {PostsFeedModel} from './posts'
-import {PostsFeedSliceModel} from './posts-slice'
-import {makeProfileLink} from 'lib/routes/links'
-
-const FEED_PAGE_SIZE = 10
-const FEEDS_PAGE_SIZE = 3
-
-export type MultiFeedItem =
-  | {
-      _reactKey: string
-      type: 'header'
-    }
-  | {
-      _reactKey: string
-      type: 'feed-header'
-      avatar: string | undefined
-      title: string
-    }
-  | {
-      _reactKey: string
-      type: 'feed-slice'
-      slice: PostsFeedSliceModel
-    }
-  | {
-      _reactKey: string
-      type: 'feed-loading'
-    }
-  | {
-      _reactKey: string
-      type: 'feed-error'
-      error: string
-    }
-  | {
-      _reactKey: string
-      type: 'feed-footer'
-      title: string
-      uri: string
-    }
-  | {
-      _reactKey: string
-      type: 'footer'
-    }
-
-export class PostsMultiFeedModel {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasLoaded = false
-  hasMore = true
-
-  // data
-  feedInfos: CustomFeedModel[] = []
-  feeds: PostsFeedModel[] = []
-
-  constructor(public rootStore: RootStoreModel) {
-    makeAutoObservable(this, {rootStore: false}, {autoBind: true})
-  }
-
-  get hasContent() {
-    return this.feeds.length !== 0
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  get items() {
-    const items: MultiFeedItem[] = [{_reactKey: '__header__', type: 'header'}]
-    for (let i = 0; i < this.feedInfos.length; i++) {
-      if (!this.feeds[i]) {
-        break
-      }
-      const feed = this.feeds[i]
-      const feedInfo = this.feedInfos[i]
-      const urip = new AtUri(feedInfo.uri)
-      items.push({
-        _reactKey: `__feed_header_${i}__`,
-        type: 'feed-header',
-        avatar: feedInfo.data.avatar,
-        title: feedInfo.displayName,
-      })
-      if (feed.isLoading) {
-        items.push({
-          _reactKey: `__feed_loading_${i}__`,
-          type: 'feed-loading',
-        })
-      } else if (feed.hasError) {
-        items.push({
-          _reactKey: `__feed_error_${i}__`,
-          type: 'feed-error',
-          error: feed.error,
-        })
-      } else {
-        for (let j = 0; j < feed.slices.length; j++) {
-          items.push({
-            _reactKey: `__feed_slice_${i}_${j}__`,
-            type: 'feed-slice',
-            slice: feed.slices[j],
-          })
-        }
-      }
-      items.push({
-        _reactKey: `__feed_footer_${i}__`,
-        type: 'feed-footer',
-        title: feedInfo.displayName,
-        uri: makeProfileLink(feedInfo.data.creator, 'feed', urip.rkey),
-      })
-    }
-    if (!this.hasMore && this.hasContent) {
-      // only show if hasContent to avoid double discover-feed links
-      items.push({_reactKey: '__footer__', type: 'footer'})
-    }
-    return items
-  }
-
-  // public api
-  // =
-
-  /**
-   * Nuke all data
-   */
-  clear() {
-    this.rootStore.log.debug('MultiFeedModel:clear')
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = false
-    this.hasMore = true
-    this.feeds = []
-  }
-
-  /**
-   * 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() {
-    this.feedInfos = this.rootStore.me.savedFeeds.all.slice() // capture current feeds
-    await this.loadMore(true)
-  }
-
-  /**
-   * Load latest in the active feeds
-   */
-  loadLatest() {
-    for (const feed of this.feeds) {
-      /* dont await */ feed.refresh()
-    }
-  }
-
-  /**
-   * Load more posts to the end of the feed
-   */
-  loadMore = bundleAsync(async (isRefreshing: boolean = false) => {
-    if (!isRefreshing && !this.hasMore) {
-      return
-    }
-    if (isRefreshing) {
-      this.isRefreshing = true // set optimistically for UI
-      this.feeds = []
-    }
-    this._xLoading(isRefreshing)
-    const start = this.feeds.length
-    const newFeeds: PostsFeedModel[] = []
-    for (
-      let i = start;
-      i < start + FEEDS_PAGE_SIZE && i < this.feedInfos.length;
-      i++
-    ) {
-      const feed = new PostsFeedModel(this.rootStore, 'custom', {
-        feed: this.feedInfos[i].uri,
-      })
-      feed.pageSize = FEED_PAGE_SIZE
-      await feed.setup()
-      newFeeds.push(feed)
-    }
-    runInAction(() => {
-      this.feeds = this.feeds.concat(newFeeds)
-      this.hasMore = this.feeds.length < this.feedInfos.length
-    })
-    this._xIdle()
-  })
-
-  /**
-   * Attempt to load more again after a failure
-   */
-  async retryLoadMore() {
-    this.hasMore = true
-    return this.loadMore()
-  }
-
-  /**
-   * Removes posts from the feed upon deletion.
-   */
-  onPostDeleted(uri: string) {
-    for (const f of this.feeds) {
-      f.onPostDeleted(uri)
-    }
-  }
-
-  // state transitions
-  // =
-
-  _xLoading(isRefreshing = false) {
-    this.isLoading = true
-    this.isRefreshing = isRefreshing
-  }
-
-  _xIdle() {
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = true
-  }
-
-  // helper functions
-  // =
-}
diff --git a/src/state/models/feeds/posts-slice.ts b/src/state/models/feeds/posts-slice.ts
index 16e4eef15..2501cef6f 100644
--- a/src/state/models/feeds/posts-slice.ts
+++ b/src/state/models/feeds/posts-slice.ts
@@ -2,6 +2,7 @@ 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
@@ -9,9 +10,11 @@ export class PostsFeedSliceModel {
 
   // 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(
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index c88249c8f..d4e62533e 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -14,6 +14,13 @@ 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 {MergeFeedAPI} from 'lib/api/feed/merge'
+
 const PAGE_SIZE = 30
 
 type Options = {
@@ -27,6 +34,7 @@ type Options = {
 type QueryParams =
   | GetTimeline.QueryParams
   | GetAuthorFeed.QueryParams
+  | GetActorLikes.QueryParams
   | GetCustomFeed.QueryParams
 
 export class PostsFeedModel {
@@ -41,8 +49,8 @@ export class PostsFeedModel {
   loadMoreError = ''
   params: QueryParams
   hasMore = true
-  loadMoreCursor: string | undefined
   pollCursor: string | undefined
+  api: FeedAPI
   tuner = new FeedTuner()
   pageSize = PAGE_SIZE
   options: Options = {}
@@ -50,7 +58,7 @@ export class PostsFeedModel {
   // used to linearize async modifications to state
   lock = new AwaitLock()
 
-  // used to track if what's hot is coming up empty
+  // used to track if a feed is coming up empty
   emptyFetches = 0
 
   // data
@@ -58,7 +66,7 @@ export class PostsFeedModel {
 
   constructor(
     public rootStore: RootStoreModel,
-    public feedType: 'home' | 'author' | 'custom' | 'likes',
+    public feedType: 'home' | 'following' | 'author' | 'custom' | 'likes',
     params: QueryParams,
     options?: Options,
   ) {
@@ -67,12 +75,33 @@ export class PostsFeedModel {
       {
         rootStore: false,
         params: false,
-        loadMoreCursor: 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 {
+      this.api = new FollowingFeedAPI(rootStore)
+    }
   }
 
   get hasContent() {
@@ -105,7 +134,6 @@ export class PostsFeedModel {
     this.hasLoaded = false
     this.error = ''
     this.hasMore = true
-    this.loadMoreCursor = undefined
     this.pollCursor = undefined
     this.slices = []
     this.tuner.reset()
@@ -113,6 +141,8 @@ export class PostsFeedModel {
 
   get feedTuners() {
     const areRepliesEnabled = this.rootStore.preferences.homeFeedRepliesEnabled
+    const areRepliesByFollowedOnlyEnabled =
+      this.rootStore.preferences.homeFeedRepliesByFollowedOnlyEnabled
     const repliesThreshold = this.rootStore.preferences.homeFeedRepliesThreshold
     const areRepostsEnabled = this.rootStore.preferences.homeFeedRepostsEnabled
     const areQuotePostsEnabled =
@@ -126,7 +156,7 @@ export class PostsFeedModel {
         ),
       ]
     }
-    if (this.feedType === 'home') {
+    if (this.feedType === 'home' || this.feedType === 'following') {
       const feedTuners = []
 
       if (areRepostsEnabled) {
@@ -136,7 +166,13 @@ export class PostsFeedModel {
       }
 
       if (areRepliesEnabled) {
-        feedTuners.push(FeedTuner.likedRepliesOnly({repliesThreshold}))
+        feedTuners.push(
+          FeedTuner.thresholdRepliesOnly({
+            userDid: this.rootStore.session.data?.did || '',
+            minLikes: repliesThreshold,
+            followedOnly: areRepliesByFollowedOnlyEnabled,
+          }),
+        )
       } else {
         feedTuners.push(FeedTuner.removeReplies)
       }
@@ -161,10 +197,11 @@ export class PostsFeedModel {
     await this.lock.acquireAsync()
     try {
       this.setHasNewLatest(false)
+      this.api.reset()
       this.tuner.reset()
       this._xLoading(isRefreshing)
       try {
-        const res = await this._getFeed({limit: this.pageSize})
+        const res = await this.api.fetchNext({limit: this.pageSize})
         await this._replaceAll(res)
         this._xIdle()
       } catch (e: any) {
@@ -201,8 +238,7 @@ export class PostsFeedModel {
       }
       this._xLoading()
       try {
-        const res = await this._getFeed({
-          cursor: this.loadMoreCursor,
+        const res = await this.api.fetchNext({
           limit: this.pageSize,
         })
         await this._appendAll(res)
@@ -231,53 +267,15 @@ export class PostsFeedModel {
   }
 
   /**
-   * Update content in-place
-   */
-  update = bundleAsync(async () => {
-    await this.lock.acquireAsync()
-    try {
-      if (!this.slices.length) {
-        return
-      }
-      this._xLoading()
-      let numToFetch = this.slices.length
-      let cursor
-      try {
-        do {
-          const res: GetTimeline.Response = await this._getFeed({
-            cursor,
-            limit: Math.min(numToFetch, 100),
-          })
-          if (res.data.feed.length === 0) {
-            break // sanity check
-          }
-          this._updateAll(res)
-          numToFetch -= res.data.feed.length
-          cursor = res.data.cursor
-        } while (cursor && numToFetch > 0)
-        this._xIdle()
-      } catch (e: any) {
-        this._xIdle() // don't bubble the error to the user
-        this.rootStore.log.error('FeedView: Failed to update', {
-          params: this.params,
-          e,
-        })
-      }
-    } finally {
-      this.lock.release()
-    }
-  })
-
-  /**
    * Check if new posts are available
    */
   async checkForLatest() {
     if (!this.hasLoaded || this.hasNewLatest || this.isLoading) {
       return
     }
-    const res = await this._getFeed({limit: 1})
-    if (res.data.feed[0]) {
-      const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
+    const post = await this.api.peekLatest()
+    if (post) {
+      const slices = this.tuner.tune([post], this.feedTuners, {
         dryRun: true,
       })
       if (slices[0]) {
@@ -345,33 +343,27 @@ export class PostsFeedModel {
   // helper functions
   // =
 
-  async _replaceAll(
-    res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
-  ) {
-    this.pollCursor = res.data.feed[0]?.post.uri
+  async _replaceAll(res: FeedAPIResponse) {
+    this.pollCursor = res.feed[0]?.post.uri
     return this._appendAll(res, true)
   }
 
-  async _appendAll(
-    res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
-    replace = false,
-  ) {
-    this.loadMoreCursor = res.data.cursor
-    this.hasMore = !!this.loadMoreCursor
+  async _appendAll(res: FeedAPIResponse, replace = false) {
+    this.hasMore = !!res.cursor
     if (replace) {
       this.emptyFetches = 0
     }
 
     this.rootStore.me.follows.hydrateProfiles(
-      res.data.feed.map(item => item.post.author),
+      res.feed.map(item => item.post.author),
     )
-    for (const item of res.data.feed) {
+    for (const item of res.feed) {
       this.rootStore.posts.fromFeedItem(item)
     }
 
     const slices = this.options.isSimpleFeed
-      ? res.data.feed.map(item => new FeedViewPostsSlice([item]))
-      : this.tuner.tune(res.data.feed, this.feedTuners)
+      ? res.feed.map(item => new FeedViewPostsSlice([item]))
+      : this.tuner.tune(res.feed, this.feedTuners)
 
     const toAppend: PostsFeedSliceModel[] = []
     for (const slice of slices) {
@@ -401,54 +393,4 @@ export class PostsFeedModel {
       }
     })
   }
-
-  _updateAll(
-    res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
-  ) {
-    for (const item of res.data.feed) {
-      this.rootStore.posts.fromFeedItem(item)
-      const existingSlice = this.slices.find(slice =>
-        slice.containsUri(item.post.uri),
-      )
-      if (existingSlice) {
-        const existingItem = existingSlice.items.find(
-          item2 => item2.post.uri === item.post.uri,
-        )
-        if (existingItem) {
-          existingItem.copyMetrics(item)
-        }
-      }
-    }
-  }
-
-  protected async _getFeed(
-    params: QueryParams,
-  ): Promise<
-    GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response
-  > {
-    params = Object.assign({}, this.params, params)
-    if (this.feedType === 'home') {
-      return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams)
-    } else if (this.feedType === 'custom') {
-      const res = await this.rootStore.agent.app.bsky.feed.getFeed(
-        params as GetCustomFeed.QueryParams,
-      )
-      // NOTE
-      // some custom feeds fail to enforce the pagination limit
-      // so we manually truncate here
-      // -prf
-      if (params.limit && res.data.feed.length > params.limit) {
-        res.data.feed = res.data.feed.slice(0, params.limit)
-      }
-      return res
-    } else if (this.feedType === 'author') {
-      return this.rootStore.agent.getAuthorFeed(
-        params as GetAuthorFeed.QueryParams,
-      )
-    } else {
-      return this.rootStore.agent.getActorLikes(
-        params as GetActorLikes.QueryParams,
-      )
-    }
-  }
 }