about summary refs log tree commit diff
path: root/src/state/models/feeds/multi-feed.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/models/feeds/multi-feed.ts')
-rw-r--r--src/state/models/feeds/multi-feed.ts216
1 files changed, 216 insertions, 0 deletions
diff --git a/src/state/models/feeds/multi-feed.ts b/src/state/models/feeds/multi-feed.ts
new file mode 100644
index 000000000..3c13041c6
--- /dev/null
+++ b/src/state/models/feeds/multi-feed.ts
@@ -0,0 +1,216 @@
+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 './post'
+
+const FEED_PAGE_SIZE = 5
+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: `/profile/${feedInfo.data.creator.did}/feed/${urip.rkey}`,
+      })
+    }
+    if (!this.hasMore) {
+      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 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
+  // =
+}