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.ts157
-rw-r--r--src/state/models/ui/profile.ts36
-rw-r--r--src/state/models/ui/saved-feeds.ts185
-rw-r--r--src/state/models/ui/shell.ts2
4 files changed, 365 insertions, 15 deletions
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index 1471420fc..dcf6b9a7a 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -11,6 +11,7 @@ import {
   ALWAYS_FILTER_LABEL_GROUP,
   ALWAYS_WARN_LABEL_GROUP,
 } from 'lib/labeling/const'
+import {DEFAULT_FEEDS} from 'lib/constants'
 import {isIOS} from 'platform/detection'
 
 const deviceLocales = getLocales()
@@ -25,6 +26,7 @@ const LABEL_GROUPS = [
   'spam',
   'impersonation',
 ]
+const VISIBILITY_VALUES = ['show', 'warn', 'hide']
 
 export class LabelPreferencesModel {
   nsfw: LabelPreference = 'hide'
@@ -45,6 +47,8 @@ export class PreferencesModel {
   contentLanguages: string[] =
     deviceLocales?.map?.(locale => locale.languageCode) || []
   contentLabels = new LabelPreferencesModel()
+  savedFeeds: string[] = []
+  pinnedFeeds: string[] = []
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(this, {}, {autoBind: true})
@@ -54,9 +58,16 @@ export class PreferencesModel {
     return {
       contentLanguages: this.contentLanguages,
       contentLabels: this.contentLabels,
+      savedFeeds: this.savedFeeds,
+      pinnedFeeds: this.pinnedFeeds,
     }
   }
 
+  /**
+   * The function hydrates an object with properties related to content languages, labels, saved feeds,
+   * and pinned feeds that it gets from the parameter `v` (probably local storage)
+   * @param {unknown} v - the data object to hydrate from
+   */
   hydrate(v: unknown) {
     if (isObj(v)) {
       if (
@@ -72,10 +83,29 @@ export class PreferencesModel {
         // default to the device languages
         this.contentLanguages = deviceLocales.map(locale => locale.languageCode)
       }
+      if (
+        hasProp(v, 'savedFeeds') &&
+        Array.isArray(v.savedFeeds) &&
+        typeof v.savedFeeds.every(item => typeof item === 'string')
+      ) {
+        this.savedFeeds = v.savedFeeds
+      }
+      if (
+        hasProp(v, 'pinnedFeeds') &&
+        Array.isArray(v.pinnedFeeds) &&
+        typeof v.pinnedFeeds.every(item => typeof item === 'string')
+      ) {
+        this.pinnedFeeds = v.pinnedFeeds
+      }
     }
   }
 
+  /**
+   * 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) {
@@ -88,22 +118,83 @@ export class PreferencesModel {
           AppBskyActorDefs.isContentLabelPref(pref) &&
           AppBskyActorDefs.validateAdultContentPref(pref).success
         ) {
-          if (LABEL_GROUPS.includes(pref.label)) {
-            this.contentLabels[pref.label] = pref.visibility
+          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
+        ) {
+          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()
+    }
   }
 
-  async update(cb: (prefs: AppBskyActorDefs.Preferences) => void) {
+  /**
+   * This function updates the preferences of a user and allows for a callback function to be executed
+   * before the update.
+   * @param cb - cb is a callback function that takes in a single parameter of type
+   * AppBskyActorDefs.Preferences and returns either a boolean or void. This callback function is used to
+   * update the preferences of the user. The function is called with the current preferences as an
+   * 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({})
-    cb(res.data.preferences)
+    if (cb(res.data.preferences) === false) {
+      return
+    }
     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: [],
+    })
+  }
+
   hasContentLanguage(code2: string) {
     return this.contentLanguages.includes(code2)
   }
@@ -200,4 +291,62 @@ 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)
+    try {
+      await this.update((prefs: AppBskyActorDefs.Preferences) => {
+        const existing = prefs.find(
+          pref =>
+            AppBskyActorDefs.isSavedFeedsPref(pref) &&
+            AppBskyActorDefs.validateSavedFeedsPref(pref).success,
+        )
+        if (existing) {
+          existing.saved = saved
+          existing.pinned = pinned
+        } else {
+          prefs.push({
+            $type: 'app.bsky.actor.defs#savedFeedsPref',
+            saved,
+            pinned,
+          })
+        }
+      })
+    } catch (e) {
+      runInAction(() => {
+        this.savedFeeds = oldSaved
+        this.pinnedFeeds = oldPinned
+      })
+      throw e
+    }
+  }
+
+  async addSavedFeed(v: string) {
+    return this.setSavedFeeds([...this.savedFeeds, v], this.pinnedFeeds)
+  }
+
+  async removeSavedFeed(v: string) {
+    return this.setSavedFeeds(
+      this.savedFeeds.filter(uri => uri !== v),
+      this.pinnedFeeds.filter(uri => uri !== v),
+    )
+  }
+
+  async addPinnedFeed(v: string) {
+    return this.setSavedFeeds(this.savedFeeds, [...this.pinnedFeeds, v])
+  }
+
+  async removePinnedFeed(v: string) {
+    return this.setSavedFeeds(
+      this.savedFeeds,
+      this.pinnedFeeds.filter(uri => uri !== v),
+    )
+  }
 }
diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts
index 861b3df0e..81daf797f 100644
--- a/src/state/models/ui/profile.ts
+++ b/src/state/models/ui/profile.ts
@@ -2,20 +2,16 @@ import {makeAutoObservable} from 'mobx'
 import {RootStoreModel} from '../root-store'
 import {ProfileModel} from '../content/profile'
 import {PostsFeedModel} from '../feeds/posts'
+import {ActorFeedsModel} from '../lists/actor-feeds'
 import {ListsListModel} from '../lists/lists-list'
 
 export enum Sections {
   Posts = 'Posts',
   PostsWithReplies = 'Posts & replies',
+  CustomAlgorithms = 'Feeds',
   Lists = 'Lists',
 }
 
-const USER_SELECTOR_ITEMS = [
-  Sections.Posts,
-  Sections.PostsWithReplies,
-  Sections.Lists,
-]
-
 export interface ProfileUiParams {
   user: string
 }
@@ -28,6 +24,7 @@ export class ProfileUiModel {
   // data
   profile: ProfileModel
   feed: PostsFeedModel
+  algos: ActorFeedsModel
   lists: ListsListModel
 
   // ui state
@@ -50,10 +47,11 @@ export class ProfileUiModel {
       actor: params.user,
       limit: 10,
     })
+    this.algos = new ActorFeedsModel(rootStore, {actor: params.user})
     this.lists = new ListsListModel(rootStore, params.user)
   }
 
-  get currentView(): PostsFeedModel | ListsListModel {
+  get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel {
     if (
       this.selectedView === Sections.Posts ||
       this.selectedView === Sections.PostsWithReplies
@@ -62,6 +60,9 @@ export class ProfileUiModel {
     } else if (this.selectedView === Sections.Lists) {
       return this.lists
     }
+    if (this.selectedView === Sections.CustomAlgorithms) {
+      return this.algos
+    }
     throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
   }
 
@@ -75,7 +76,14 @@ export class ProfileUiModel {
   }
 
   get selectorItems() {
-    return USER_SELECTOR_ITEMS
+    const items = [Sections.Posts, Sections.PostsWithReplies]
+    if (this.algos.hasLoaded && !this.algos.isEmpty) {
+      items.push(Sections.CustomAlgorithms)
+    }
+    if (this.lists.hasLoaded && !this.lists.isEmpty) {
+      items.push(Sections.Lists)
+    }
+    return items
   }
 
   get selectedView() {
@@ -84,9 +92,11 @@ export class ProfileUiModel {
 
   get uiItems() {
     let arr: any[] = []
+    // if loading, return loading item to show loading spinner
     if (this.isInitialLoading) {
       arr = arr.concat([ProfileUiModel.LOADING_ITEM])
     } else if (this.currentView.hasError) {
+      // if error, return error item to show error message
       arr = arr.concat([
         {
           _reactKey: '__error__',
@@ -94,12 +104,16 @@ export class ProfileUiModel {
         },
       ])
     } else {
+      // not loading, no error, show content
       if (
         this.selectedView === Sections.Posts ||
-        this.selectedView === Sections.PostsWithReplies
+        this.selectedView === Sections.PostsWithReplies ||
+        this.selectedView === Sections.CustomAlgorithms
       ) {
         if (this.feed.hasContent) {
-          if (this.selectedView === Sections.Posts) {
+          if (this.selectedView === Sections.CustomAlgorithms) {
+            arr = this.algos.feeds
+          } else if (this.selectedView === Sections.Posts) {
             arr = this.feed.nonReplyFeed
           } else {
             arr = this.feed.slices.slice()
@@ -117,6 +131,7 @@ export class ProfileUiModel {
           arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
         }
       } else {
+        // fallback, add empty item, to show empty message
         arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
       }
     }
@@ -151,6 +166,7 @@ export class ProfileUiModel {
         .setup()
         .catch(err => this.rootStore.log.error('Failed to fetch feed', err)),
     ])
+    this.algos.refresh()
     // HACK: need to use the DID as a param, not the username -prf
     this.lists.source = this.profile.did
     this.lists
diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts
new file mode 100644
index 000000000..979fddf49
--- /dev/null
+++ b/src/state/models/ui/saved-feeds.ts
@@ -0,0 +1,185 @@
+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'
+import {CustomFeedModel} from '../feeds/custom-feed'
+
+export class SavedFeedsModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+
+  // data
+  feeds: CustomFeedModel[] = []
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get hasContent() {
+    return this.feeds.length > 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  get pinned() {
+    return this.rootStore.preferences.pinnedFeeds
+      .map(uri => this.feeds.find(f => f.uri === uri) as CustomFeedModel)
+      .filter(Boolean)
+  }
+
+  get unpinned() {
+    return this.feeds.filter(f => !this.isPinned(f))
+  }
+
+  get all() {
+    return this.pinned.concat(this.unpinned)
+  }
+
+  get pinnedFeedNames() {
+    return this.pinned.map(f => f.displayName)
+  }
+
+  // public api
+  // =
+
+  clear() {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = false
+    this.error = ''
+    this.feeds = []
+  }
+
+  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)
+      }
+      runInAction(() => {
+        this.feeds = feeds.map(f => new CustomFeedModel(this.rootStore, f))
+      })
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
+
+  async save(feed: CustomFeedModel) {
+    try {
+      await feed.save()
+      runInAction(() => {
+        this.feeds = [
+          ...this.feeds,
+          new CustomFeedModel(this.rootStore, feed.data),
+        ]
+      })
+    } catch (e: any) {
+      this.rootStore.log.error('Failed to save feed', e)
+    }
+  }
+
+  async unsave(feed: CustomFeedModel) {
+    const uri = feed.uri
+    try {
+      if (this.isPinned(feed)) {
+        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)
+    }
+  }
+
+  async togglePinnedFeed(feed: CustomFeedModel) {
+    if (!this.isPinned(feed)) {
+      return this.rootStore.preferences.addPinnedFeed(feed.uri)
+    } else {
+      return this.rootStore.preferences.removePinnedFeed(feed.uri)
+    }
+  }
+
+  async reorderPinnedFeeds(feeds: CustomFeedModel[]) {
+    return this.rootStore.preferences.setSavedFeeds(
+      this.rootStore.preferences.savedFeeds,
+      feeds.filter(feed => this.isPinned(feed)).map(feed => feed.uri),
+    )
+  }
+
+  isPinned(feedOrUri: CustomFeedModel | string) {
+    let uri: string
+    if (typeof feedOrUri === 'string') {
+      uri = feedOrUri
+    } else {
+      uri = feedOrUri.uri
+    }
+    return this.rootStore.preferences.pinnedFeeds.includes(uri)
+  }
+
+  async movePinnedFeed(item: CustomFeedModel, direction: 'up' | 'down') {
+    const pinned = this.rootStore.preferences.pinnedFeeds.slice()
+    const index = pinned.indexOf(item.uri)
+    if (index === -1) {
+      return
+    }
+    if (direction === 'up' && index !== 0) {
+      const temp = pinned[index]
+      pinned[index] = pinned[index - 1]
+      pinned[index - 1] = temp
+    } else if (direction === 'down' && index < pinned.length - 1) {
+      const temp = pinned[index]
+      pinned[index] = pinned[index + 1]
+      pinned[index + 1] = temp
+    }
+    await this.rootStore.preferences.setSavedFeeds(
+      this.rootStore.preferences.savedFeeds,
+      pinned,
+    )
+  }
+
+  // state transitions
+  // =
+
+  _xLoading(isRefreshing = false) {
+    this.isLoading = true
+    this.isRefreshing = isRefreshing
+    this.error = ''
+  }
+
+  _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 user feeds', err)
+    }
+  }
+}
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index 187342ec3..a77ffbdfb 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -119,7 +119,7 @@ export type Modal =
   // Moderation
   | ReportAccountModal
   | ReportPostModal
-  | CreateMuteListModal
+  | CreateOrEditMuteListModal
   | ListAddRemoveUserModal
 
   // Posts