about summary refs log tree commit diff
path: root/src/state/models/content
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/models/content')
-rw-r--r--src/state/models/content/feed-source.ts223
-rw-r--r--src/state/models/content/list-membership.ts9
-rw-r--r--src/state/models/content/list.ts230
-rw-r--r--src/state/models/content/profile.ts3
4 files changed, 439 insertions, 26 deletions
diff --git a/src/state/models/content/feed-source.ts b/src/state/models/content/feed-source.ts
new file mode 100644
index 000000000..8dac9b56f
--- /dev/null
+++ b/src/state/models/content/feed-source.ts
@@ -0,0 +1,223 @@
+import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api'
+import {makeAutoObservable, runInAction} from 'mobx'
+import {RootStoreModel} from 'state/models/root-store'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {bundleAsync} from 'lib/async/bundle'
+import {cleanError} from 'lib/strings/errors'
+import {track} from 'lib/analytics/analytics'
+
+export class FeedSourceModel {
+  // state
+  _reactKey: string
+  hasLoaded = false
+  error: string | undefined
+
+  // data
+  uri: string
+  cid: string = ''
+  type: 'feed-generator' | 'list' | 'unsupported' = 'unsupported'
+  avatar: string | undefined = ''
+  displayName: string = ''
+  descriptionRT: RichText | null = null
+  creatorDid: string = ''
+  creatorHandle: string = ''
+  likeCount: number | undefined = 0
+  likeUri: string | undefined = ''
+
+  constructor(public rootStore: RootStoreModel, uri: string) {
+    this._reactKey = uri
+    this.uri = uri
+
+    try {
+      const urip = new AtUri(uri)
+      if (urip.collection === 'app.bsky.feed.generator') {
+        this.type = 'feed-generator'
+      } else if (urip.collection === 'app.bsky.graph.list') {
+        this.type = 'list'
+      }
+    } catch {}
+    this.displayName = uri.split('/').pop() || ''
+
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get href() {
+    const urip = new AtUri(this.uri)
+    const collection =
+      urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists'
+    return `/profile/${urip.hostname}/${collection}/${urip.rkey}`
+  }
+
+  get isSaved() {
+    return this.rootStore.preferences.savedFeeds.includes(this.uri)
+  }
+
+  get isPinned() {
+    return this.rootStore.preferences.isPinnedFeed(this.uri)
+  }
+
+  get isLiked() {
+    return !!this.likeUri
+  }
+
+  get isOwner() {
+    return this.creatorDid === this.rootStore.me.did
+  }
+
+  setup = bundleAsync(async () => {
+    try {
+      if (this.type === 'feed-generator') {
+        const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
+          feed: this.uri,
+        })
+        this.hydrateFeedGenerator(res.data.view)
+      } else if (this.type === 'list') {
+        const res = await this.rootStore.agent.app.bsky.graph.getList({
+          list: this.uri,
+          limit: 1,
+        })
+        this.hydrateList(res.data.list)
+      }
+    } catch (e) {
+      runInAction(() => {
+        this.error = cleanError(e)
+      })
+    }
+  })
+
+  hydrateFeedGenerator(view: AppBskyFeedDefs.GeneratorView) {
+    this.uri = view.uri
+    this.cid = view.cid
+    this.avatar = view.avatar
+    this.displayName = view.displayName
+      ? sanitizeDisplayName(view.displayName)
+      : `Feed by ${sanitizeHandle(view.creator.handle, '@')}`
+    this.descriptionRT = new RichText({
+      text: view.description || '',
+      facets: (view.descriptionFacets || [])?.slice(),
+    })
+    this.creatorDid = view.creator.did
+    this.creatorHandle = view.creator.handle
+    this.likeCount = view.likeCount
+    this.likeUri = view.viewer?.like
+    this.hasLoaded = true
+  }
+
+  hydrateList(view: AppBskyGraphDefs.ListView) {
+    this.uri = view.uri
+    this.cid = view.cid
+    this.avatar = view.avatar
+    this.displayName = view.name
+      ? sanitizeDisplayName(view.name)
+      : `User List by ${sanitizeHandle(view.creator.handle, '@')}`
+    this.descriptionRT = new RichText({
+      text: view.description || '',
+      facets: (view.descriptionFacets || [])?.slice(),
+    })
+    this.creatorDid = view.creator.did
+    this.creatorHandle = view.creator.handle
+    this.likeCount = undefined
+    this.hasLoaded = true
+  }
+
+  async save() {
+    if (this.type !== 'feed-generator') {
+      return
+    }
+    try {
+      await this.rootStore.preferences.addSavedFeed(this.uri)
+    } catch (error) {
+      this.rootStore.log.error('Failed to save feed', error)
+    } finally {
+      track('CustomFeed:Save')
+    }
+  }
+
+  async unsave() {
+    if (this.type !== 'feed-generator') {
+      return
+    }
+    try {
+      await this.rootStore.preferences.removeSavedFeed(this.uri)
+    } catch (error) {
+      this.rootStore.log.error('Failed to unsave feed', error)
+    } finally {
+      track('CustomFeed:Unsave')
+    }
+  }
+
+  async pin() {
+    try {
+      await this.rootStore.preferences.addPinnedFeed(this.uri)
+    } catch (error) {
+      this.rootStore.log.error('Failed to pin feed', error)
+    } finally {
+      track('CustomFeed:Pin', {
+        name: this.displayName,
+        uri: this.uri,
+      })
+    }
+  }
+
+  async togglePin() {
+    if (!this.isPinned) {
+      track('CustomFeed:Pin', {
+        name: this.displayName,
+        uri: this.uri,
+      })
+      return this.rootStore.preferences.addPinnedFeed(this.uri)
+    } else {
+      track('CustomFeed:Unpin', {
+        name: this.displayName,
+        uri: this.uri,
+      })
+      return this.rootStore.preferences.removePinnedFeed(this.uri)
+    }
+  }
+
+  async like() {
+    if (this.type !== 'feed-generator') {
+      return
+    }
+    try {
+      this.likeUri = 'pending'
+      this.likeCount = (this.likeCount || 0) + 1
+      const res = await this.rootStore.agent.like(this.uri, this.cid)
+      this.likeUri = res.uri
+    } catch (e: any) {
+      this.likeUri = undefined
+      this.likeCount = (this.likeCount || 1) - 1
+      this.rootStore.log.error('Failed to like feed', e)
+    } finally {
+      track('CustomFeed:Like')
+    }
+  }
+
+  async unlike() {
+    if (this.type !== 'feed-generator') {
+      return
+    }
+    if (!this.likeUri) {
+      return
+    }
+    const uri = this.likeUri
+    try {
+      this.likeUri = undefined
+      this.likeCount = (this.likeCount || 1) - 1
+      await this.rootStore.agent.deleteLike(uri!)
+    } catch (e: any) {
+      this.likeUri = uri
+      this.likeCount = (this.likeCount || 0) + 1
+      this.rootStore.log.error('Failed to unlike feed', e)
+    } finally {
+      track('CustomFeed:Unlike')
+    }
+  }
+}
diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts
index 20d9b60af..135d34dd5 100644
--- a/src/state/models/content/list-membership.ts
+++ b/src/state/models/content/list-membership.ts
@@ -110,14 +110,21 @@ export class ListMembershipModel {
     })
   }
 
-  async updateTo(uris: string[]) {
+  async updateTo(
+    uris: string[],
+  ): Promise<{added: string[]; removed: string[]}> {
+    const added = []
+    const removed = []
     for (const uri of uris) {
       await this.add(uri)
+      added.push(uri)
     }
     for (const membership of this.memberships) {
       if (!uris.includes(membership.value.list)) {
         await this.remove(membership.value.list)
+        removed.push(membership.value.list)
       }
     }
+    return {added, removed}
   }
 }
diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts
index fd5074d8c..0331f58bd 100644
--- a/src/state/models/content/list.ts
+++ b/src/state/models/content/list.ts
@@ -1,10 +1,12 @@
 import {makeAutoObservable, runInAction} from 'mobx'
 import {
   AtUri,
+  AppBskyActorDefs,
   AppBskyGraphGetList as GetList,
   AppBskyGraphDefs as GraphDefs,
   AppBskyGraphList,
   AppBskyGraphListitem,
+  RichText,
 } from '@atproto/api'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import chunk from 'lodash.chunk'
@@ -13,6 +15,7 @@ import * as apilib from 'lib/api/index'
 import {cleanError} from 'lib/strings/errors'
 import {bundleAsync} from 'lib/async/bundle'
 import {track} from 'lib/analytics/analytics'
+import {until} from 'lib/async/until'
 
 const PAGE_SIZE = 30
 
@@ -37,19 +40,32 @@ export class ListModel {
   loadMoreCursor?: string
 
   // data
-  list: GraphDefs.ListView | null = null
+  data: GraphDefs.ListView | null = null
   items: GraphDefs.ListItemView[] = []
+  descriptionRT: RichText | null = null
 
-  static async createModList(
+  static async createList(
     rootStore: RootStoreModel,
     {
+      purpose,
       name,
       description,
       avatar,
-    }: {name: string; description: string; avatar: RNImage | null | undefined},
+    }: {
+      purpose: string
+      name: string
+      description: string
+      avatar: RNImage | null | undefined
+    },
   ) {
+    if (
+      purpose !== 'app.bsky.graph.defs#curatelist' &&
+      purpose !== 'app.bsky.graph.defs#modlist'
+    ) {
+      throw new Error('Invalid list purpose: must be curatelist or modlist')
+    }
     const record: AppBskyGraphList.Record = {
-      purpose: 'app.bsky.graph.defs#modlist',
+      purpose,
       name,
       description,
       avatar: undefined,
@@ -69,7 +85,20 @@ export class ListModel {
       },
       record,
     )
-    await rootStore.agent.app.bsky.graph.muteActorList({list: res.uri})
+
+    // wait for the appview to update
+    await until(
+      5, // 5 tries
+      1e3, // 1s delay between tries
+      (v: GetList.Response, _e: any) => {
+        return typeof v?.data?.list.uri === 'string'
+      },
+      () =>
+        rootStore.agent.app.bsky.graph.getList({
+          list: res.uri,
+          limit: 1,
+        }),
+    )
     return res
   }
 
@@ -95,16 +124,40 @@ export class ListModel {
     return this.hasLoaded && !this.hasContent
   }
 
+  get isCuratelist() {
+    return this.data?.purpose === 'app.bsky.graph.defs#curatelist'
+  }
+
+  get isModlist() {
+    return this.data?.purpose === 'app.bsky.graph.defs#modlist'
+  }
+
   get isOwner() {
-    return this.list?.creator.did === this.rootStore.me.did
+    return this.data?.creator.did === this.rootStore.me.did
+  }
+
+  get isBlocking() {
+    return !!this.data?.viewer?.blocked
   }
 
-  get isSubscribed() {
-    return this.list?.viewer?.muted
+  get isMuting() {
+    return !!this.data?.viewer?.muted
+  }
+
+  get isPinned() {
+    return this.rootStore.preferences.isPinnedFeed(this.uri)
   }
 
   get creatorDid() {
-    return this.list?.creator.did
+    return this.data?.creator.did
+  }
+
+  getMembership(did: string) {
+    return this.items.find(item => item.subject.did === did)
+  }
+
+  isMember(did: string) {
+    return !!this.getMembership(did)
   }
 
   // public api
@@ -137,6 +190,15 @@ export class ListModel {
     }
   })
 
+  async loadAll() {
+    for (let i = 0; i < 1000; i++) {
+      if (!this.hasMore) {
+        break
+      }
+      await this.loadMore()
+    }
+  }
+
   async updateMetadata({
     name,
     description,
@@ -146,7 +208,7 @@ export class ListModel {
     description: string
     avatar: RNImage | null | undefined
   }) {
-    if (!this.list) {
+    if (!this.data) {
       return
     }
     if (!this.isOwner) {
@@ -183,7 +245,7 @@ export class ListModel {
   }
 
   async delete() {
-    if (!this.list) {
+    if (!this.data) {
       return
     }
     await this._resolveUri()
@@ -231,28 +293,140 @@ export class ListModel {
     this.rootStore.emitListDeleted(this.uri)
   }
 
-  async subscribe() {
-    if (!this.list) {
+  async addMember(profile: AppBskyActorDefs.ProfileViewBasic) {
+    if (this.isMember(profile.did)) {
+      return
+    }
+    await this.rootStore.agent.app.bsky.graph.listitem.create(
+      {
+        repo: this.rootStore.me.did,
+      },
+      {
+        subject: profile.did,
+        list: this.uri,
+        createdAt: new Date().toISOString(),
+      },
+    )
+    runInAction(() => {
+      this.items = this.items.concat([
+        {_reactKey: profile.did, subject: profile},
+      ])
+    })
+  }
+
+  /**
+   * Just adds to local cache; used to reflect changes affected elsewhere
+   */
+  cacheAddMember(profile: AppBskyActorDefs.ProfileViewBasic) {
+    if (!this.isMember(profile.did)) {
+      this.items = this.items.concat([
+        {_reactKey: profile.did, subject: profile},
+      ])
+    }
+  }
+
+  /**
+   * Just removes from local cache; used to reflect changes affected elsewhere
+   */
+  cacheRemoveMember(profile: AppBskyActorDefs.ProfileViewBasic) {
+    if (this.isMember(profile.did)) {
+      this.items = this.items.filter(item => item.subject.did !== profile.did)
+    }
+  }
+
+  async pin() {
+    try {
+      await this.rootStore.preferences.addPinnedFeed(this.uri)
+    } catch (error) {
+      this.rootStore.log.error('Failed to pin feed', error)
+    } finally {
+      track('CustomFeed:Pin', {
+        name: this.data?.name || '',
+        uri: this.uri,
+      })
+    }
+  }
+
+  async togglePin() {
+    if (!this.isPinned) {
+      track('CustomFeed:Pin', {
+        name: this.data?.name || '',
+        uri: this.uri,
+      })
+      return this.rootStore.preferences.addPinnedFeed(this.uri)
+    } else {
+      track('CustomFeed:Unpin', {
+        name: this.data?.name || '',
+        uri: this.uri,
+      })
+      // TEMPORARY
+      // lists are temporarily piggybacking on the saved/pinned feeds preferences
+      // we'll eventually replace saved feeds with the bookmarks API
+      // until then, we need to unsave lists instead of just unpin them
+      // -prf
+      // return this.rootStore.preferences.removePinnedFeed(this.uri)
+      return this.rootStore.preferences.removeSavedFeed(this.uri)
+    }
+  }
+
+  async mute() {
+    if (!this.data) {
+      return
+    }
+    await this._resolveUri()
+    await this.rootStore.agent.muteModList(this.data.uri)
+    track('Lists:Mute')
+    runInAction(() => {
+      if (this.data) {
+        const d = this.data
+        this.data = {...d, viewer: {...(d.viewer || {}), muted: true}}
+      }
+    })
+  }
+
+  async unmute() {
+    if (!this.data) {
       return
     }
     await this._resolveUri()
-    await this.rootStore.agent.app.bsky.graph.muteActorList({
-      list: this.list.uri,
+    await this.rootStore.agent.unmuteModList(this.data.uri)
+    track('Lists:Unmute')
+    runInAction(() => {
+      if (this.data) {
+        const d = this.data
+        this.data = {...d, viewer: {...(d.viewer || {}), muted: false}}
+      }
     })
-    track('Lists:Subscribe')
-    await this.refresh()
   }
 
-  async unsubscribe() {
-    if (!this.list) {
+  async block() {
+    if (!this.data) {
       return
     }
     await this._resolveUri()
-    await this.rootStore.agent.app.bsky.graph.unmuteActorList({
-      list: this.list.uri,
+    const res = await this.rootStore.agent.blockModList(this.data.uri)
+    track('Lists:Block')
+    runInAction(() => {
+      if (this.data) {
+        const d = this.data
+        this.data = {...d, viewer: {...(d.viewer || {}), blocked: res.uri}}
+      }
+    })
+  }
+
+  async unblock() {
+    if (!this.data || !this.data.viewer?.blocked) {
+      return
+    }
+    await this._resolveUri()
+    await this.rootStore.agent.unblockModList(this.data.uri)
+    track('Lists:Unblock')
+    runInAction(() => {
+      if (this.data) {
+        const d = this.data
+        this.data = {...d, viewer: {...(d.viewer || {}), blocked: undefined}}
+      }
     })
-    track('Lists:Unsubscribe')
-    await this.refresh()
   }
 
   /**
@@ -314,9 +488,17 @@ export class ListModel {
   _appendAll(res: GetList.Response) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
-    this.list = res.data.list
+    this.data = res.data.list
     this.items = this.items.concat(
       res.data.items.map(item => ({...item, _reactKey: item.subject.did})),
     )
+    if (this.data.description) {
+      this.descriptionRT = new RichText({
+        text: this.data.description,
+        facets: (this.data.descriptionFacets || [])?.slice(),
+      })
+    } else {
+      this.descriptionRT = null
+    }
   }
 }
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index 906f84c28..5333e7116 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -22,7 +22,8 @@ export class ProfileViewerModel {
   following?: string
   followedBy?: string
   blockedBy?: boolean
-  blocking?: string;
+  blocking?: string
+  blockingByList?: AppBskyGraphDefs.ListViewBasic;
   [key: string]: unknown
 
   constructor() {