about summary refs log tree commit diff
path: root/src/state/models/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/models/ui')
-rw-r--r--src/state/models/ui/preferences.ts189
-rw-r--r--src/state/models/ui/saved-feeds.ts87
2 files changed, 160 insertions, 116 deletions
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index dcf6b9a7a..a42f0a837 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -1,5 +1,7 @@
 import {makeAutoObservable, runInAction} from 'mobx'
 import {getLocales} from 'expo-localization'
+import AwaitLock from 'await-lock'
+import isEqual from 'lodash.isequal'
 import {isObj, hasProp} from 'lib/type-guards'
 import {RootStoreModel} from '../root-store'
 import {ComAtprotoLabelDefs, AppBskyActorDefs} from '@atproto/api'
@@ -50,8 +52,11 @@ export class PreferencesModel {
   savedFeeds: string[] = []
   pinnedFeeds: string[] = []
 
+  // used to linearize async modifications to state
+  lock = new AwaitLock()
+
   constructor(public rootStore: RootStoreModel) {
-    makeAutoObservable(this, {}, {autoBind: true})
+    makeAutoObservable(this, {lock: false}, {autoBind: true})
   }
 
   serialize() {
@@ -103,62 +108,72 @@ export class PreferencesModel {
   /**
    * This function fetches preferences and sets defaults for missing items.
    */
-  async sync() {
-    // fetch preferences
-    let hasSavedFeedsPref = false
-    const res = await this.rootStore.agent.app.bsky.actor.getPreferences({})
-    runInAction(() => {
-      for (const pref of res.data.preferences) {
-        if (
-          AppBskyActorDefs.isAdultContentPref(pref) &&
-          AppBskyActorDefs.validateAdultContentPref(pref).success
-        ) {
-          this.adultContentEnabled = pref.enabled
-        } else if (
-          AppBskyActorDefs.isContentLabelPref(pref) &&
-          AppBskyActorDefs.validateAdultContentPref(pref).success
-        ) {
+  async sync({clearCache}: {clearCache?: boolean} = {}) {
+    await this.lock.acquireAsync()
+    try {
+      // fetch preferences
+      let hasSavedFeedsPref = false
+      const res = await this.rootStore.agent.app.bsky.actor.getPreferences({})
+      runInAction(() => {
+        for (const pref of res.data.preferences) {
           if (
-            LABEL_GROUPS.includes(pref.label) &&
-            VISIBILITY_VALUES.includes(pref.visibility)
+            AppBskyActorDefs.isAdultContentPref(pref) &&
+            AppBskyActorDefs.validateAdultContentPref(pref).success
           ) {
-            this.contentLabels[pref.label as keyof LabelPreferencesModel] =
-              pref.visibility as LabelPreference
+            this.adultContentEnabled = pref.enabled
+          } else if (
+            AppBskyActorDefs.isContentLabelPref(pref) &&
+            AppBskyActorDefs.validateAdultContentPref(pref).success
+          ) {
+            if (
+              LABEL_GROUPS.includes(pref.label) &&
+              VISIBILITY_VALUES.includes(pref.visibility)
+            ) {
+              this.contentLabels[pref.label as keyof LabelPreferencesModel] =
+                pref.visibility as LabelPreference
+            }
+          } else if (
+            AppBskyActorDefs.isSavedFeedsPref(pref) &&
+            AppBskyActorDefs.validateSavedFeedsPref(pref).success
+          ) {
+            if (!isEqual(this.savedFeeds, pref.saved)) {
+              this.savedFeeds = pref.saved
+            }
+            if (!isEqual(this.pinnedFeeds, pref.pinned)) {
+              this.pinnedFeeds = pref.pinned
+            }
+            hasSavedFeedsPref = true
           }
-        } else if (
-          AppBskyActorDefs.isSavedFeedsPref(pref) &&
-          AppBskyActorDefs.validateSavedFeedsPref(pref).success
-        ) {
-          this.savedFeeds = pref.saved
-          this.pinnedFeeds = pref.pinned
-          hasSavedFeedsPref = true
         }
-      }
-    })
-
-    // set defaults on missing items
-    if (!hasSavedFeedsPref) {
-      const {saved, pinned} = await DEFAULT_FEEDS(
-        this.rootStore.agent.service.toString(),
-        (handle: string) =>
-          this.rootStore.agent
-            .resolveHandle({handle})
-            .then(({data}) => data.did),
-      )
-      runInAction(() => {
-        this.savedFeeds = saved
-        this.pinnedFeeds = pinned
-      })
-      res.data.preferences.push({
-        $type: 'app.bsky.actor.defs#savedFeedsPref',
-        saved,
-        pinned,
       })
-      await this.rootStore.agent.app.bsky.actor.putPreferences({
-        preferences: res.data.preferences,
-      })
-      /* dont await */ this.rootStore.me.savedFeeds.refresh()
+
+      // set defaults on missing items
+      if (!hasSavedFeedsPref) {
+        const {saved, pinned} = await DEFAULT_FEEDS(
+          this.rootStore.agent.service.toString(),
+          (handle: string) =>
+            this.rootStore.agent
+              .resolveHandle({handle})
+              .then(({data}) => data.did),
+        )
+        runInAction(() => {
+          this.savedFeeds = saved
+          this.pinnedFeeds = pinned
+        })
+        res.data.preferences.push({
+          $type: 'app.bsky.actor.defs#savedFeedsPref',
+          saved,
+          pinned,
+        })
+        await this.rootStore.agent.app.bsky.actor.putPreferences({
+          preferences: res.data.preferences,
+        })
+      }
+    } finally {
+      this.lock.release()
     }
+
+    await this.rootStore.me.savedFeeds.updateCache(clearCache)
   }
 
   /**
@@ -170,29 +185,44 @@ export class PreferencesModel {
    * argument and if the callback returns false, the preferences are not updated.
    * @returns void
    */
-  async update(cb: (prefs: AppBskyActorDefs.Preferences) => boolean | void) {
-    const res = await this.rootStore.agent.app.bsky.actor.getPreferences({})
-    if (cb(res.data.preferences) === false) {
-      return
+  async update(
+    cb: (
+      prefs: AppBskyActorDefs.Preferences,
+    ) => AppBskyActorDefs.Preferences | false,
+  ) {
+    await this.lock.acquireAsync()
+    try {
+      const res = await this.rootStore.agent.app.bsky.actor.getPreferences({})
+      const newPrefs = cb(res.data.preferences)
+      if (newPrefs === false) {
+        return
+      }
+      await this.rootStore.agent.app.bsky.actor.putPreferences({
+        preferences: newPrefs,
+      })
+    } finally {
+      this.lock.release()
     }
-    await this.rootStore.agent.app.bsky.actor.putPreferences({
-      preferences: res.data.preferences,
-    })
   }
 
   /**
    * This function resets the preferences to an empty array of no preferences.
    */
   async reset() {
-    runInAction(() => {
-      this.contentLabels = new LabelPreferencesModel()
-      this.contentLanguages = deviceLocales.map(locale => locale.languageCode)
-      this.savedFeeds = []
-      this.pinnedFeeds = []
-    })
-    await this.rootStore.agent.app.bsky.actor.putPreferences({
-      preferences: [],
-    })
+    await this.lock.acquireAsync()
+    try {
+      runInAction(() => {
+        this.contentLabels = new LabelPreferencesModel()
+        this.contentLanguages = deviceLocales.map(locale => locale.languageCode)
+        this.savedFeeds = []
+        this.pinnedFeeds = []
+      })
+      await this.rootStore.agent.app.bsky.actor.putPreferences({
+        preferences: [],
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   hasContentLanguage(code2: string) {
@@ -231,6 +261,7 @@ export class PreferencesModel {
           visibility: value,
         })
       }
+      return prefs
     })
   }
 
@@ -250,6 +281,7 @@ export class PreferencesModel {
           enabled: v,
         })
       }
+      return prefs
     })
   }
 
@@ -292,32 +324,31 @@ export class PreferencesModel {
     return res
   }
 
-  setFeeds(saved: string[], pinned: string[]) {
-    this.savedFeeds = saved
-    this.pinnedFeeds = pinned
-  }
-
   async setSavedFeeds(saved: string[], pinned: string[]) {
     const oldSaved = this.savedFeeds
     const oldPinned = this.pinnedFeeds
-    this.setFeeds(saved, pinned)
+    this.savedFeeds = saved
+    this.pinnedFeeds = pinned
     try {
       await this.update((prefs: AppBskyActorDefs.Preferences) => {
-        const existing = prefs.find(
+        let feedsPref = prefs.find(
           pref =>
             AppBskyActorDefs.isSavedFeedsPref(pref) &&
             AppBskyActorDefs.validateSavedFeedsPref(pref).success,
         )
-        if (existing) {
-          existing.saved = saved
-          existing.pinned = pinned
+        if (feedsPref) {
+          feedsPref.saved = saved
+          feedsPref.pinned = pinned
         } else {
-          prefs.push({
+          feedsPref = {
             $type: 'app.bsky.actor.defs#savedFeedsPref',
             saved,
             pinned,
-          })
+          }
         }
+        return prefs
+          .filter(pref => !AppBskyActorDefs.isSavedFeedsPref(pref))
+          .concat([feedsPref])
       })
     } catch (e) {
       runInAction(() => {
diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts
index 979fddf49..f82666517 100644
--- a/src/state/models/ui/saved-feeds.ts
+++ b/src/state/models/ui/saved-feeds.ts
@@ -1,5 +1,4 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import {AppBskyFeedDefs} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {bundleAsync} from 'lib/async/bundle'
 import {cleanError} from 'lib/strings/errors'
@@ -13,7 +12,7 @@ export class SavedFeedsModel {
   error = ''
 
   // data
-  feeds: CustomFeedModel[] = []
+  _feedModelCache: Record<string, CustomFeedModel> = {}
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(
@@ -26,7 +25,7 @@ export class SavedFeedsModel {
   }
 
   get hasContent() {
-    return this.feeds.length > 0
+    return this.all.length > 0
   }
 
   get hasError() {
@@ -39,16 +38,19 @@ export class SavedFeedsModel {
 
   get pinned() {
     return this.rootStore.preferences.pinnedFeeds
-      .map(uri => this.feeds.find(f => f.uri === uri) as CustomFeedModel)
+      .map(uri => this._feedModelCache[uri] as CustomFeedModel)
       .filter(Boolean)
   }
 
   get unpinned() {
-    return this.feeds.filter(f => !this.isPinned(f))
+    return this.rootStore.preferences.savedFeeds
+      .filter(uri => !this.isPinned(uri))
+      .map(uri => this._feedModelCache[uri] as CustomFeedModel)
+      .filter(Boolean)
   }
 
   get all() {
-    return this.pinned.concat(this.unpinned)
+    return [...this.pinned, ...this.unpinned]
   }
 
   get pinnedFeedNames() {
@@ -58,31 +60,50 @@ export class SavedFeedsModel {
   // public api
   // =
 
-  clear() {
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = false
-    this.error = ''
-    this.feeds = []
-  }
+  /**
+   * Syncs the cached models against the current state
+   * - Should only be called by the preferences model after syncing state
+   */
+  updateCache = bundleAsync(async (clearCache?: boolean) => {
+    let newFeedModels: Record<string, CustomFeedModel> = {}
+    if (!clearCache) {
+      newFeedModels = {...this._feedModelCache}
+    }
 
-  refresh = bundleAsync(async (quietRefresh = false) => {
-    this._xLoading(!quietRefresh)
-    try {
-      let feeds: AppBskyFeedDefs.GeneratorView[] = []
-      for (
-        let i = 0;
-        i < this.rootStore.preferences.savedFeeds.length;
-        i += 25
-      ) {
-        const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({
-          feeds: this.rootStore.preferences.savedFeeds.slice(i, 25),
-        })
-        feeds = feeds.concat(res.data.feeds)
+    // collect the feed URIs that havent been synced yet
+    const neededFeedUris = []
+    for (const feedUri of this.rootStore.preferences.savedFeeds) {
+      if (!(feedUri in newFeedModels)) {
+        neededFeedUris.push(feedUri)
       }
-      runInAction(() => {
-        this.feeds = feeds.map(f => new CustomFeedModel(this.rootStore, f))
+    }
+
+    // fetch the missing models
+    for (let i = 0; i < neededFeedUris.length; i += 25) {
+      const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({
+        feeds: neededFeedUris.slice(i, 25),
       })
+      for (const feedInfo of res.data.feeds) {
+        newFeedModels[feedInfo.uri] = new CustomFeedModel(
+          this.rootStore,
+          feedInfo,
+        )
+      }
+    }
+
+    // merge into the cache
+    runInAction(() => {
+      this._feedModelCache = newFeedModels
+    })
+  })
+
+  /**
+   * Refresh the preferences then reload all feed infos
+   */
+  refresh = bundleAsync(async () => {
+    this._xLoading(true)
+    try {
+      await this.rootStore.preferences.sync({clearCache: true})
       this._xIdle()
     } catch (e: any) {
       this._xIdle(e)
@@ -92,12 +113,7 @@ export class SavedFeedsModel {
   async save(feed: CustomFeedModel) {
     try {
       await feed.save()
-      runInAction(() => {
-        this.feeds = [
-          ...this.feeds,
-          new CustomFeedModel(this.rootStore, feed.data),
-        ]
-      })
+      await this.updateCache()
     } catch (e: any) {
       this.rootStore.log.error('Failed to save feed', e)
     }
@@ -110,9 +126,6 @@ export class SavedFeedsModel {
         await this.rootStore.preferences.removePinnedFeed(uri)
       }
       await feed.unsave()
-      runInAction(() => {
-        this.feeds = this.feeds.filter(f => f.data.uri !== uri)
-      })
     } catch (e: any) {
       this.rootStore.log.error('Failed to unsave feed', e)
     }