about summary refs log tree commit diff
path: root/src/state/models/discovery/suggested-actors.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/models/discovery/suggested-actors.ts')
-rw-r--r--src/state/models/discovery/suggested-actors.ts170
1 files changed, 170 insertions, 0 deletions
diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts
new file mode 100644
index 000000000..cf8e2dd7b
--- /dev/null
+++ b/src/state/models/discovery/suggested-actors.ts
@@ -0,0 +1,170 @@
+import {makeAutoObservable, runInAction} from 'mobx'
+import {AppBskyActorProfile as Profile} from '@atproto/api'
+import shuffle from 'lodash.shuffle'
+import {RootStoreModel} from '../root-store'
+import {cleanError} from 'lib/strings/errors'
+import {bundleAsync} from 'lib/async/bundle'
+import {SUGGESTED_FOLLOWS} from 'lib/constants'
+
+const PAGE_SIZE = 30
+
+export type SuggestedActor = Profile.ViewBasic | Profile.View
+
+export class SuggestedActorsModel {
+  // state
+  pageSize = PAGE_SIZE
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+  hasMore = true
+  loadMoreCursor?: string
+
+  private hardCodedSuggestions: SuggestedActor[] | undefined
+
+  // data
+  suggestions: SuggestedActor[] = []
+
+  constructor(public rootStore: RootStoreModel, opts?: {pageSize?: number}) {
+    if (opts?.pageSize) {
+      this.pageSize = opts.pageSize
+    }
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get hasContent() {
+    return this.suggestions.length > 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  // public api
+  // =
+
+  async refresh() {
+    return this.loadMore(true)
+  }
+
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    if (!replace && !this.hasMore) {
+      return
+    }
+    if (replace) {
+      this.hardCodedSuggestions = undefined
+    }
+    this._xLoading(replace)
+    try {
+      let items: SuggestedActor[] = this.suggestions
+      if (replace) {
+        items = []
+        this.loadMoreCursor = undefined
+      }
+      let res
+      do {
+        await this.fetchHardcodedSuggestions()
+        if (this.hardCodedSuggestions && this.hardCodedSuggestions.length > 0) {
+          // pull from the hard-coded suggestions
+          const newItems = this.hardCodedSuggestions.splice(0, this.pageSize)
+          items = items.concat(newItems)
+          this.hasMore = true
+          this.loadMoreCursor = undefined
+        } else {
+          // pull from the PDS' algo
+          res = await this.rootStore.api.app.bsky.actor.getSuggestions({
+            limit: this.pageSize,
+            cursor: this.loadMoreCursor,
+          })
+          this.loadMoreCursor = res.data.cursor
+          this.hasMore = !!this.loadMoreCursor
+          items = items.concat(
+            res.data.actors.filter(
+              actor => !items.find(i => i.did === actor.did),
+            ),
+          )
+        }
+      } while (items.length < this.pageSize && this.hasMore)
+      runInAction(() => {
+        this.suggestions = items
+      })
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
+
+  private async fetchHardcodedSuggestions() {
+    if (this.hardCodedSuggestions) {
+      return
+    }
+    await this.rootStore.me.follows.fetchIfNeeded()
+    try {
+      // clone the array so we can mutate it
+      const actors = [
+        ...SUGGESTED_FOLLOWS(
+          this.rootStore.session.currentSession?.service || '',
+        ),
+      ]
+
+      // fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`)
+      let profiles: Profile.View[] = []
+      do {
+        const res = await this.rootStore.api.app.bsky.actor.getProfiles({
+          actors: actors.splice(0, 25),
+        })
+        profiles = profiles.concat(res.data.profiles)
+      } while (actors.length)
+
+      runInAction(() => {
+        profiles = profiles.filter(profile => {
+          if (this.rootStore.me.follows.isFollowing(profile.did)) {
+            return false
+          }
+          if (profile.did === this.rootStore.me.did) {
+            return false
+          }
+          return true
+        })
+        this.hardCodedSuggestions = shuffle(profiles)
+      })
+    } catch (e) {
+      this.rootStore.log.error(
+        'Failed to getProfiles() for suggested follows',
+        {e},
+      )
+      runInAction(() => {
+        this.hardCodedSuggestions = []
+      })
+    }
+  }
+
+  // state transitions
+  // =
+
+  private _xLoading(isRefreshing = false) {
+    this.isLoading = true
+    this.isRefreshing = isRefreshing
+    this.error = ''
+  }
+
+  private _xIdle(err?: any) {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = true
+    this.error = cleanError(err)
+    if (err) {
+      this.rootStore.log.error('Failed to fetch suggested actors', err)
+    }
+  }
+}