about summary refs log tree commit diff
path: root/src/state/models
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/models')
-rw-r--r--src/state/models/content/list-membership.ts112
-rw-r--r--src/state/models/content/list.ts257
-rw-r--r--src/state/models/content/post-thread.ts4
-rw-r--r--src/state/models/content/profile.ts4
-rw-r--r--src/state/models/feeds/notifications.ts1
-rw-r--r--src/state/models/feeds/posts.ts4
-rw-r--r--src/state/models/lists/lists-list.ts214
-rw-r--r--src/state/models/ui/profile.ts27
-rw-r--r--src/state/models/ui/shell.ts18
9 files changed, 636 insertions, 5 deletions
diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts
new file mode 100644
index 000000000..b4af4472b
--- /dev/null
+++ b/src/state/models/content/list-membership.ts
@@ -0,0 +1,112 @@
+import {makeAutoObservable} from 'mobx'
+import {AtUri, AppBskyGraphListitem} from '@atproto/api'
+import {runInAction} from 'mobx'
+import {RootStoreModel} from '../root-store'
+
+const PAGE_SIZE = 100
+interface Membership {
+  uri: string
+  value: AppBskyGraphListitem.Record
+}
+
+export class ListMembershipModel {
+  // data
+  memberships: Membership[] = []
+
+  constructor(public rootStore: RootStoreModel, public subject: string) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  // public api
+  // =
+
+  async fetch() {
+    // NOTE
+    // this approach to determining list membership is too inefficient to work at any scale
+    // it needs to be replaced with server side list membership queries
+    // -prf
+    let cursor
+    let records = []
+    for (let i = 0; i < 100; i++) {
+      const res = await this.rootStore.agent.app.bsky.graph.listitem.list({
+        repo: this.rootStore.me.did,
+        cursor,
+        limit: PAGE_SIZE,
+      })
+      records = records.concat(
+        res.records.filter(record => record.value.subject === this.subject),
+      )
+      cursor = res.cursor
+      if (!cursor) {
+        break
+      }
+    }
+    runInAction(() => {
+      this.memberships = records
+    })
+  }
+
+  getMembership(listUri: string) {
+    return this.memberships.find(m => m.value.list === listUri)
+  }
+
+  isMember(listUri: string) {
+    return !!this.getMembership(listUri)
+  }
+
+  async add(listUri: string) {
+    if (this.isMember(listUri)) {
+      return
+    }
+    const res = await this.rootStore.agent.app.bsky.graph.listitem.create(
+      {
+        repo: this.rootStore.me.did,
+      },
+      {
+        subject: this.subject,
+        list: listUri,
+        createdAt: new Date().toISOString(),
+      },
+    )
+    const {rkey} = new AtUri(res.uri)
+    const record = await this.rootStore.agent.app.bsky.graph.listitem.get({
+      repo: this.rootStore.me.did,
+      rkey,
+    })
+    runInAction(() => {
+      this.memberships = this.memberships.concat([record])
+    })
+  }
+
+  async remove(listUri: string) {
+    const membership = this.getMembership(listUri)
+    if (!membership) {
+      return
+    }
+    const {rkey} = new AtUri(membership.uri)
+    await this.rootStore.agent.app.bsky.graph.listitem.delete({
+      repo: this.rootStore.me.did,
+      rkey,
+    })
+    runInAction(() => {
+      this.memberships = this.memberships.filter(m => m.value.list !== listUri)
+    })
+  }
+
+  async updateTo(uris: string) {
+    for (const uri of uris) {
+      await this.add(uri)
+    }
+    for (const membership of this.memberships) {
+      if (!uris.includes(membership.value.list)) {
+        await this.remove(membership.value.list)
+      }
+    }
+  }
+}
diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts
new file mode 100644
index 000000000..673ee9430
--- /dev/null
+++ b/src/state/models/content/list.ts
@@ -0,0 +1,257 @@
+import {makeAutoObservable} from 'mobx'
+import {
+  AtUri,
+  AppBskyGraphGetList as GetList,
+  AppBskyGraphDefs as GraphDefs,
+  AppBskyGraphList,
+} from '@atproto/api'
+import {Image as RNImage} from 'react-native-image-crop-picker'
+import {RootStoreModel} from '../root-store'
+import * as apilib from 'lib/api/index'
+import {cleanError} from 'lib/strings/errors'
+import {bundleAsync} from 'lib/async/bundle'
+
+const PAGE_SIZE = 30
+
+export class ListModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+  loadMoreError = ''
+  hasMore = true
+  loadMoreCursor?: string
+
+  // data
+  list: GraphDefs.ListView | null = null
+  items: GraphDefs.ListItemView[] = []
+
+  static async createModList(
+    rootStore: RootStoreModel,
+    {
+      name,
+      description,
+      avatar,
+    }: {name: string; description: string; avatar: RNImage | undefined},
+  ) {
+    const record: AppBskyGraphList.Record = {
+      purpose: 'app.bsky.graph.defs#modlist',
+      name,
+      description,
+      avatar: undefined,
+      createdAt: new Date().toISOString(),
+    }
+    if (avatar) {
+      const blobRes = await apilib.uploadBlob(
+        rootStore,
+        avatar.path,
+        avatar.mime,
+      )
+      record.avatar = blobRes.data.blob
+    }
+    const res = await rootStore.agent.app.bsky.graph.list.create(
+      {
+        repo: rootStore.me.did,
+      },
+      record,
+    )
+    await rootStore.agent.app.bsky.graph.muteActorList({list: res.uri})
+    return res
+  }
+
+  constructor(public rootStore: RootStoreModel, public uri: string) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get hasContent() {
+    return this.items.length > 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  get isOwner() {
+    return this.list?.creator.did === this.rootStore.me.did
+  }
+
+  // public api
+  // =
+
+  async refresh() {
+    return this.loadMore(true)
+  }
+
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    if (!replace && !this.hasMore) {
+      return
+    }
+    this._xLoading(replace)
+    try {
+      const res = await this.rootStore.agent.app.bsky.graph.getList({
+        list: this.uri,
+        limit: PAGE_SIZE,
+        cursor: replace ? undefined : this.loadMoreCursor,
+      })
+      if (replace) {
+        this._replaceAll(res)
+      } else {
+        this._appendAll(res)
+      }
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(replace ? e : undefined, !replace ? e : undefined)
+    }
+  })
+
+  async updateMetadata({
+    name,
+    description,
+    avatar,
+  }: {
+    name: string
+    description: string
+    avatar: RNImage | null | undefined
+  }) {
+    if (!this.isOwner) {
+      throw new Error('Cannot edit this list')
+    }
+
+    // get the current record
+    const {rkey} = new AtUri(this.uri)
+    const {value: record} = await this.rootStore.agent.app.bsky.graph.list.get({
+      repo: this.rootStore.me.did,
+      rkey,
+    })
+
+    // update the fields
+    record.name = name
+    record.description = description
+    if (avatar) {
+      const blobRes = await apilib.uploadBlob(
+        this.rootStore,
+        avatar.path,
+        avatar.mime,
+      )
+      record.avatar = blobRes.data.blob
+    } else if (avatar === null) {
+      record.avatar = undefined
+    }
+    return await this.rootStore.agent.com.atproto.repo.putRecord({
+      repo: this.rootStore.me.did,
+      collection: 'app.bsky.graph.list',
+      rkey,
+      record,
+    })
+  }
+
+  async delete() {
+    // fetch all the listitem records that belong to this list
+    let cursor
+    let records = []
+    for (let i = 0; i < 100; i++) {
+      const res = await this.rootStore.agent.app.bsky.graph.listitem.list({
+        repo: this.rootStore.me.did,
+        cursor,
+        limit: PAGE_SIZE,
+      })
+      records = records.concat(
+        res.records.filter(record => record.value.list === this.uri),
+      )
+      cursor = res.cursor
+      if (!cursor) {
+        break
+      }
+    }
+
+    // batch delete the list and listitem records
+    const createDel = (uri: string) => {
+      const urip = new AtUri(uri)
+      return {
+        $type: 'com.atproto.repo.applyWrites#delete',
+        collection: urip.collection,
+        rkey: urip.rkey,
+      }
+    }
+    await this.rootStore.agent.com.atproto.repo.applyWrites({
+      repo: this.rootStore.me.did,
+      writes: [createDel(this.uri)].concat(
+        records.map(record => createDel(record.uri)),
+      ),
+    })
+  }
+
+  async subscribe() {
+    await this.rootStore.agent.app.bsky.graph.muteActorList({
+      list: this.list.uri,
+    })
+    await this.refresh()
+  }
+
+  async unsubscribe() {
+    await this.rootStore.agent.app.bsky.graph.unmuteActorList({
+      list: this.list.uri,
+    })
+    await this.refresh()
+  }
+
+  /**
+   * Attempt to load more again after a failure
+   */
+  async retryLoadMore() {
+    this.loadMoreError = ''
+    this.hasMore = true
+    return this.loadMore()
+  }
+
+  // state transitions
+  // =
+
+  _xLoading(isRefreshing = false) {
+    this.isLoading = true
+    this.isRefreshing = isRefreshing
+    this.error = ''
+  }
+
+  _xIdle(err?: any, loadMoreErr?: any) {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = true
+    this.error = cleanError(err)
+    this.loadMoreError = cleanError(loadMoreErr)
+    if (err) {
+      this.rootStore.log.error('Failed to fetch user items', err)
+    }
+    if (loadMoreErr) {
+      this.rootStore.log.error('Failed to fetch user items', loadMoreErr)
+    }
+  }
+
+  // helper functions
+  // =
+
+  _replaceAll(res: GetList.Response) {
+    this.items = []
+    this._appendAll(res)
+  }
+
+  _appendAll(res: GetList.Response) {
+    this.loadMoreCursor = res.data.cursor
+    this.hasMore = !!this.loadMoreCursor
+    this.list = res.data.list
+    this.items = this.items.concat(
+      res.data.items.map(item => ({...item, _reactKey: item.subject})),
+    )
+  }
+}
diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts
index a0f75493a..74a75d803 100644
--- a/src/state/models/content/post-thread.ts
+++ b/src/state/models/content/post-thread.ts
@@ -14,6 +14,7 @@ import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
 import {
   getEmbedLabels,
   getEmbedMuted,
+  getEmbedMutedByList,
   getEmbedBlocking,
   getEmbedBlockedBy,
   filterAccountLabels,
@@ -70,6 +71,9 @@ export class PostThreadItemModel {
         this.post.author.viewer?.muted ||
         getEmbedMuted(this.post.embed) ||
         false,
+      mutedByList:
+        this.post.author.viewer?.mutedByList ||
+        getEmbedMutedByList(this.post.embed),
       isBlocking:
         !!this.post.author.viewer?.blocking ||
         getEmbedBlocking(this.post.embed) ||
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index dddf488a3..9d8378f79 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
 import {
   AtUri,
   ComAtprotoLabelDefs,
+  AppBskyGraphDefs,
   AppBskyActorGetProfile as GetProfile,
   AppBskyActorProfile,
   RichText,
@@ -18,10 +19,9 @@ import {
   filterProfileLabels,
 } from 'lib/labeling/helpers'
 
-export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
-
 export class ProfileViewerModel {
   muted?: boolean
+  mutedByList?: AppBskyGraphDefs.ListViewBasic
   following?: string
   followedBy?: string
   blockedBy?: boolean
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts
index 3ffd10b99..73424f03e 100644
--- a/src/state/models/feeds/notifications.ts
+++ b/src/state/models/feeds/notifications.ts
@@ -111,6 +111,7 @@ export class NotificationsFeedItemModel {
         addedInfo?.profileLabels || [],
       ),
       isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false,
+      mutedByList: this.author.viewer?.mutedByList || addedInfo?.mutedByList,
       isBlocking:
         !!this.author.viewer?.blocking || addedInfo?.isBlocking || false,
       isBlockedBy:
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index 44cec3af7..b2dffdc69 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -24,6 +24,7 @@ import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
 import {
   getEmbedLabels,
   getEmbedMuted,
+  getEmbedMutedByList,
   getEmbedBlocking,
   getEmbedBlockedBy,
   getPostModeration,
@@ -105,6 +106,9 @@ export class PostsFeedItemModel {
         this.post.author.viewer?.muted ||
         getEmbedMuted(this.post.embed) ||
         false,
+      mutedByList:
+        this.post.author.viewer?.mutedByList ||
+        getEmbedMutedByList(this.post.embed),
       isBlocking:
         !!this.post.author.viewer?.blocking ||
         getEmbedBlocking(this.post.embed) ||
diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts
new file mode 100644
index 000000000..309ab0e03
--- /dev/null
+++ b/src/state/models/lists/lists-list.ts
@@ -0,0 +1,214 @@
+import {makeAutoObservable} from 'mobx'
+import {
+  AppBskyGraphGetLists as GetLists,
+  AppBskyGraphGetListMutes as GetListMutes,
+  AppBskyGraphDefs as GraphDefs,
+} from '@atproto/api'
+import {RootStoreModel} from '../root-store'
+import {cleanError} from 'lib/strings/errors'
+import {bundleAsync} from 'lib/async/bundle'
+
+const PAGE_SIZE = 30
+
+export class ListsListModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+  loadMoreError = ''
+  hasMore = true
+  loadMoreCursor?: string
+
+  // data
+  lists: GraphDefs.ListView[] = []
+
+  constructor(
+    public rootStore: RootStoreModel,
+    public source: 'my-modlists' | string,
+  ) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get hasContent() {
+    return this.lists.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
+    }
+    this._xLoading(replace)
+    try {
+      let res
+      if (this.source === 'my-modlists') {
+        res = {
+          success: true,
+          headers: {},
+          data: {
+            subject: undefined,
+            lists: [],
+          },
+        }
+        const [res1, res2] = await Promise.all([
+          fetchAllUserLists(this.rootStore, this.rootStore.me.did),
+          fetchAllMyMuteLists(this.rootStore),
+        ])
+        for (let list of res1.data.lists) {
+          if (list.purpose === 'app.bsky.graph.defs#modlist') {
+            res.data.lists.push(list)
+          }
+        }
+        for (let list of res2.data.lists) {
+          if (
+            list.purpose === 'app.bsky.graph.defs#modlist' &&
+            !res.data.lists.find(l => l.uri === list.uri)
+          ) {
+            res.data.lists.push(list)
+          }
+        }
+      } else {
+        res = await this.rootStore.agent.app.bsky.graph.getLists({
+          actor: this.source,
+          limit: PAGE_SIZE,
+          cursor: replace ? undefined : this.loadMoreCursor,
+        })
+      }
+      if (replace) {
+        this._replaceAll(res)
+      } else {
+        this._appendAll(res)
+      }
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(replace ? e : undefined, !replace ? e : undefined)
+    }
+  })
+
+  /**
+   * Attempt to load more again after a failure
+   */
+  async retryLoadMore() {
+    this.loadMoreError = ''
+    this.hasMore = true
+    return this.loadMore()
+  }
+
+  // state transitions
+  // =
+
+  _xLoading(isRefreshing = false) {
+    this.isLoading = true
+    this.isRefreshing = isRefreshing
+    this.error = ''
+  }
+
+  _xIdle(err?: any, loadMoreErr?: any) {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = true
+    this.error = cleanError(err)
+    this.loadMoreError = cleanError(loadMoreErr)
+    if (err) {
+      this.rootStore.log.error('Failed to fetch user lists', err)
+    }
+    if (loadMoreErr) {
+      this.rootStore.log.error('Failed to fetch user lists', loadMoreErr)
+    }
+  }
+
+  // helper functions
+  // =
+
+  _replaceAll(res: GetLists.Response | GetListMutes.Response) {
+    this.lists = []
+    this._appendAll(res)
+  }
+
+  _appendAll(res: GetLists.Response | GetListMutes.Response) {
+    this.loadMoreCursor = res.data.cursor
+    this.hasMore = !!this.loadMoreCursor
+    this.lists = this.lists.concat(
+      res.data.lists.map(list => ({...list, _reactKey: list.uri})),
+    )
+  }
+}
+
+async function fetchAllUserLists(
+  store: RootStoreModel,
+  did: string,
+): Promise<GetLists.Response> {
+  let acc: GetLists.Response = {
+    success: true,
+    headers: {},
+    data: {
+      subject: undefined,
+      lists: [],
+    },
+  }
+
+  let cursor
+  for (let i = 0; i < 100; i++) {
+    const res = await store.agent.app.bsky.graph.getLists({
+      actor: did,
+      cursor,
+      limit: 50,
+    })
+    cursor = res.data.cursor
+    acc.data.lists = acc.data.lists.concat(res.data.lists)
+    if (!cursor) {
+      break
+    }
+  }
+
+  return acc
+}
+
+async function fetchAllMyMuteLists(
+  store: RootStoreModel,
+): Promise<GetListMutes.Response> {
+  let acc: GetListMutes.Response = {
+    success: true,
+    headers: {},
+    data: {
+      subject: undefined,
+      lists: [],
+    },
+  }
+
+  let cursor
+  for (let i = 0; i < 100; i++) {
+    const res = await store.agent.app.bsky.graph.getListMutes({
+      cursor,
+      limit: 50,
+    })
+    cursor = res.data.cursor
+    acc.data.lists = acc.data.lists.concat(res.data.lists)
+    if (!cursor) {
+      break
+    }
+  }
+
+  return acc
+}
diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts
index d06a196f3..861b3df0e 100644
--- a/src/state/models/ui/profile.ts
+++ b/src/state/models/ui/profile.ts
@@ -2,13 +2,19 @@ import {makeAutoObservable} from 'mobx'
 import {RootStoreModel} from '../root-store'
 import {ProfileModel} from '../content/profile'
 import {PostsFeedModel} from '../feeds/posts'
+import {ListsListModel} from '../lists/lists-list'
 
 export enum Sections {
   Posts = 'Posts',
   PostsWithReplies = 'Posts & replies',
+  Lists = 'Lists',
 }
 
-const USER_SELECTOR_ITEMS = [Sections.Posts, Sections.PostsWithReplies]
+const USER_SELECTOR_ITEMS = [
+  Sections.Posts,
+  Sections.PostsWithReplies,
+  Sections.Lists,
+]
 
 export interface ProfileUiParams {
   user: string
@@ -22,6 +28,7 @@ export class ProfileUiModel {
   // data
   profile: ProfileModel
   feed: PostsFeedModel
+  lists: ListsListModel
 
   // ui state
   selectedViewIndex = 0
@@ -43,14 +50,17 @@ export class ProfileUiModel {
       actor: params.user,
       limit: 10,
     })
+    this.lists = new ListsListModel(rootStore, params.user)
   }
 
-  get currentView(): PostsFeedModel {
+  get currentView(): PostsFeedModel | ListsListModel {
     if (
       this.selectedView === Sections.Posts ||
       this.selectedView === Sections.PostsWithReplies
     ) {
       return this.feed
+    } else if (this.selectedView === Sections.Lists) {
+      return this.lists
     }
     throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
   }
@@ -100,6 +110,12 @@ export class ProfileUiModel {
         } else if (this.feed.isEmpty) {
           arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
         }
+      } else if (this.selectedView === Sections.Lists) {
+        if (this.lists.hasContent) {
+          arr = this.lists.lists
+        } else if (this.lists.isEmpty) {
+          arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
+        }
       } else {
         arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
       }
@@ -113,6 +129,8 @@ export class ProfileUiModel {
       this.selectedView === Sections.PostsWithReplies
     ) {
       return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading
+    } else if (this.selectedView === Sections.Lists) {
+      return this.lists.hasContent && this.lists.hasMore && this.lists.isLoading
     }
     return false
   }
@@ -133,6 +151,11 @@ export class ProfileUiModel {
         .setup()
         .catch(err => this.rootStore.log.error('Failed to fetch feed', err)),
     ])
+    // HACK: need to use the DID as a param, not the username -prf
+    this.lists.source = this.profile.did
+    this.lists
+      .loadMore()
+      .catch(err => this.rootStore.log.error('Failed to fetch lists', err))
   }
 
   async update() {
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index 67f8e16d4..9b9a176be 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -5,6 +5,7 @@ import {ProfileModel} from '../content/profile'
 import {isObj, hasProp} from 'lib/type-guards'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {ImageModel} from '../media/image'
+import {ListModel} from '../content/list'
 import {GalleryModel} from '../media/gallery'
 
 export interface ConfirmModal {
@@ -38,6 +39,19 @@ export interface ReportAccountModal {
   did: string
 }
 
+export interface CreateOrEditMuteListModal {
+  name: 'create-or-edit-mute-list'
+  list?: ListModel
+  onSave?: (uri: string) => void
+}
+
+export interface ListAddRemoveUserModal {
+  name: 'list-add-remove-user'
+  subject: string
+  displayName: string
+  onUpdate?: () => void
+}
+
 export interface EditImageModal {
   name: 'edit-image'
   image: ImageModel
@@ -102,9 +116,11 @@ export type Modal =
   | ContentFilteringSettingsModal
   | ContentLanguagesSettingsModal
 
-  // Reporting
+  // Moderation
   | ReportAccountModal
   | ReportPostModal
+  | CreateMuteListModal
+  | ListAddRemoveUserModal
 
   // Posts
   | AltTextImageModal