about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/state')
-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/media/gallery.ts18
-rw-r--r--src/state/models/media/image.ts94
-rw-r--r--src/state/models/root-store.ts4
-rw-r--r--src/state/models/ui/preferences.ts84
-rw-r--r--src/state/models/ui/profile.ts24
-rw-r--r--src/state/models/ui/shell.ts18
13 files changed, 775 insertions, 63 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 7adc1cb1c..dfd92b35c 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -25,6 +25,7 @@ import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
 import {
   getEmbedLabels,
   getEmbedMuted,
+  getEmbedMutedByList,
   getEmbedBlocking,
   getEmbedBlockedBy,
   getPostModeration,
@@ -106,6 +107,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/media/gallery.ts b/src/state/models/media/gallery.ts
index 86bf8a314..67f8d2ea1 100644
--- a/src/state/models/media/gallery.ts
+++ b/src/state/models/media/gallery.ts
@@ -52,16 +52,14 @@ export class GalleryModel {
   }
 
   async edit(image: ImageModel) {
-    if (!isNative) {
+    if (isNative) {
+      this.crop(image)
+    } else {
       this.rootStore.shell.openModal({
         name: 'edit-image',
         image,
         gallery: this,
       })
-
-      return
-    } else {
-      this.crop(image)
     }
   }
 
@@ -104,10 +102,14 @@ export class GalleryModel {
 
   async pick() {
     const images = await openPicker(this.rootStore, {
-      multiple: true,
-      maxFiles: 4 - this.images.length,
+      selectionLimit: 4 - this.size,
+      allowsMultipleSelection: true,
     })
 
-    await Promise.all(images.map(image => this.add(image)))
+    return await Promise.all(
+      images.map(image => {
+        this.add(image)
+      }),
+    )
   }
 }
diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts
index ff464a5a9..ec93bf5b6 100644
--- a/src/state/models/media/image.ts
+++ b/src/state/models/media/image.ts
@@ -13,12 +13,12 @@ import {compressAndResizeImageForPost} from 'lib/media/manip'
 // Cases to consider: ExternalEmbed
 
 export interface ImageManipulationAttributes {
+  aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
   rotate?: number
   scale?: number
   position?: Position
   flipHorizontal?: boolean
   flipVertical?: boolean
-  aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
 }
 
 export class ImageModel implements RNImage {
@@ -34,14 +34,14 @@ export class ImageModel implements RNImage {
   scaledHeight: number = POST_IMG_MAX.height
 
   // Web manipulation
-  aspectRatio?: ImageManipulationAttributes['aspectRatio']
-  position?: Position = undefined
-  prev?: RNImage = undefined
-  rotation?: number = 0
-  scale?: number = 1
-  flipHorizontal?: boolean = false
-  flipVertical?: boolean = false
-
+  prev?: RNImage
+  attributes: ImageManipulationAttributes = {
+    aspectRatio: '1:1',
+    scale: 1,
+    flipHorizontal: false,
+    flipVertical: false,
+    rotate: 0,
+  }
   prevAttributes: ImageManipulationAttributes = {}
 
   constructor(public rootStore: RootStoreModel, image: RNImage) {
@@ -65,6 +65,25 @@ export class ImageModel implements RNImage {
   //     : MAX_IMAGE_SIZE_IN_BYTES / this.size
   // }
 
+  setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
+    this.attributes.aspectRatio = aspectRatio
+  }
+
+  setRotate(degrees: number) {
+    this.attributes.rotate = degrees
+    this.manipulate({})
+  }
+
+  flipVertical() {
+    this.attributes.flipVertical = !this.attributes.flipVertical
+    this.manipulate({})
+  }
+
+  flipHorizontal() {
+    this.attributes.flipHorizontal = !this.attributes.flipHorizontal
+    this.manipulate({})
+  }
+
   get ratioMultipliers() {
     return {
       '4:3': 4 / 3,
@@ -116,7 +135,7 @@ export class ImageModel implements RNImage {
   // Only for mobile
   async crop() {
     try {
-      const cropped = await openCropper(this.rootStore, {
+      const cropped = await openCropper({
         mediaType: 'photo',
         path: this.path,
         freeStyleCropEnabled: true,
@@ -162,33 +181,19 @@ export class ImageModel implements RNImage {
       crop?: ActionCrop['crop']
     } & ImageManipulationAttributes,
   ) {
-    const {aspectRatio, crop, flipHorizontal, flipVertical, rotate, scale} =
-      attributes
+    const {aspectRatio, crop, position, scale} = attributes
     const modifiers = []
 
-    if (flipHorizontal !== undefined) {
-      this.flipHorizontal = flipHorizontal
-    }
-
-    if (flipVertical !== undefined) {
-      this.flipVertical = flipVertical
-    }
-
-    if (this.flipHorizontal) {
+    if (this.attributes.flipHorizontal) {
       modifiers.push({flip: FlipType.Horizontal})
     }
 
-    if (this.flipVertical) {
+    if (this.attributes.flipVertical) {
       modifiers.push({flip: FlipType.Vertical})
     }
 
-    // TODO: Fix rotation -- currently not functional
-    if (rotate !== undefined) {
-      this.rotation = rotate
-    }
-
-    if (this.rotation !== undefined) {
-      modifiers.push({rotate: this.rotation})
+    if (this.attributes.rotate !== undefined) {
+      modifiers.push({rotate: this.attributes.rotate})
     }
 
     if (crop !== undefined) {
@@ -203,18 +208,21 @@ export class ImageModel implements RNImage {
     }
 
     if (scale !== undefined) {
-      this.scale = scale
+      this.attributes.scale = scale
+    }
+
+    if (position !== undefined) {
+      this.attributes.position = position
     }
 
     if (aspectRatio !== undefined) {
-      this.aspectRatio = aspectRatio
+      this.attributes.aspectRatio = aspectRatio
     }
 
-    const ratioMultiplier = this.ratioMultipliers[this.aspectRatio ?? '1:1']
+    const ratioMultiplier =
+      this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1']
 
-    // TODO: Ollie - should support up to 2000 but smaller images that scale
-    // up need an updated compression factor calculation. Use 1000 for now.
-    const MAX_SIDE = 1000
+    const MAX_SIDE = 2000
 
     const result = await ImageManipulator.manipulateAsync(
       this.path,
@@ -223,7 +231,7 @@ export class ImageModel implements RNImage {
         {resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}},
       ],
       {
-        compress: 0.7, // TODO: revisit compression calculation
+        compress: 0.9,
         format: SaveFormat.JPEG,
       },
     )
@@ -238,16 +246,12 @@ export class ImageModel implements RNImage {
     })
   }
 
+  resetCompressed() {
+    this.manipulate({})
+  }
+
   previous() {
     this.compressed = this.prev
-
-    const {flipHorizontal, flipVertical, rotate, position, scale} =
-      this.prevAttributes
-
-    this.scale = scale
-    this.rotation = rotate
-    this.flipHorizontal = flipHorizontal
-    this.flipVertical = flipVertical
-    this.position = position
+    this.attributes = this.prevAttributes
   }
 }
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index 8cd23efcd..f2a352a79 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -37,7 +37,7 @@ export class RootStoreModel {
   log = new LogModel()
   session = new SessionModel(this)
   shell = new ShellUiModel(this)
-  preferences = new PreferencesModel()
+  preferences = new PreferencesModel(this)
   me = new MeModel(this)
   invitedUsers = new InvitedUsers(this)
   profiles = new ProfilesCache(this)
@@ -126,6 +126,7 @@ export class RootStoreModel {
     this.log.debug('RootStoreModel:handleSessionChange')
     this.agent = agent
     this.me.clear()
+    /* dont await */ this.preferences.sync()
     await this.me.load()
     if (!hadSession) {
       resetNavigation()
@@ -161,6 +162,7 @@ export class RootStoreModel {
     }
     try {
       await this.me.updateIfNeeded()
+      await this.preferences.sync()
     } catch (e: any) {
       this.log.error('Failed to fetch latest state', e)
     }
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index fcd33af8e..1471420fc 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -1,7 +1,8 @@
-import {makeAutoObservable} from 'mobx'
+import {makeAutoObservable, runInAction} from 'mobx'
 import {getLocales} from 'expo-localization'
 import {isObj, hasProp} from 'lib/type-guards'
-import {ComAtprotoLabelDefs} from '@atproto/api'
+import {RootStoreModel} from '../root-store'
+import {ComAtprotoLabelDefs, AppBskyActorDefs} from '@atproto/api'
 import {LabelValGroup} from 'lib/labeling/types'
 import {getLabelValueGroup} from 'lib/labeling/helpers'
 import {
@@ -15,6 +16,15 @@ import {isIOS} from 'platform/detection'
 const deviceLocales = getLocales()
 
 export type LabelPreference = 'show' | 'warn' | 'hide'
+const LABEL_GROUPS = [
+  'nsfw',
+  'nudity',
+  'suggestive',
+  'gore',
+  'hate',
+  'spam',
+  'impersonation',
+]
 
 export class LabelPreferencesModel {
   nsfw: LabelPreference = 'hide'
@@ -36,7 +46,7 @@ export class PreferencesModel {
     deviceLocales?.map?.(locale => locale.languageCode) || []
   contentLabels = new LabelPreferencesModel()
 
-  constructor() {
+  constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(this, {}, {autoBind: true})
   }
 
@@ -65,6 +75,35 @@ export class PreferencesModel {
     }
   }
 
+  async sync() {
+    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
+        ) {
+          if (LABEL_GROUPS.includes(pref.label)) {
+            this.contentLabels[pref.label] = pref.visibility
+          }
+        }
+      }
+    })
+  }
+
+  async update(cb: (prefs: AppBskyActorDefs.Preferences) => void) {
+    const res = await this.rootStore.agent.app.bsky.actor.getPreferences({})
+    cb(res.data.preferences)
+    await this.rootStore.agent.app.bsky.actor.putPreferences({
+      preferences: res.data.preferences,
+    })
+  }
+
   hasContentLanguage(code2: string) {
     return this.contentLanguages.includes(code2)
   }
@@ -79,11 +118,48 @@ export class PreferencesModel {
     }
   }
 
-  setContentLabelPref(
+  async setContentLabelPref(
     key: keyof LabelPreferencesModel,
     value: LabelPreference,
   ) {
     this.contentLabels[key] = value
+
+    await this.update((prefs: AppBskyActorDefs.Preferences) => {
+      const existing = prefs.find(
+        pref =>
+          AppBskyActorDefs.isContentLabelPref(pref) &&
+          AppBskyActorDefs.validateAdultContentPref(pref).success &&
+          pref.label === key,
+      )
+      if (existing) {
+        existing.visibility = value
+      } else {
+        prefs.push({
+          $type: 'app.bsky.actor.defs#contentLabelPref',
+          label: key,
+          visibility: value,
+        })
+      }
+    })
+  }
+
+  async setAdultContentEnabled(v: boolean) {
+    this.adultContentEnabled = v
+    await this.update((prefs: AppBskyActorDefs.Preferences) => {
+      const existing = prefs.find(
+        pref =>
+          AppBskyActorDefs.isAdultContentPref(pref) &&
+          AppBskyActorDefs.validateAdultContentPref(pref).success,
+      )
+      if (existing) {
+        existing.enabled = v
+      } else {
+        prefs.push({
+          $type: 'app.bsky.actor.defs#adultContentPref',
+          enabled: v,
+        })
+      }
+    })
   }
 
   getLabelPreference(labels: ComAtprotoLabelDefs.Label[] | undefined): {
diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts
index 855955d12..4f604bfc0 100644
--- a/src/state/models/ui/profile.ts
+++ b/src/state/models/ui/profile.ts
@@ -1,20 +1,23 @@
 import {makeAutoObservable} from 'mobx'
+import {AppBskyFeedDefs} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {ProfileModel} from '../content/profile'
 import {PostsFeedModel} from '../feeds/posts'
 import {ActorFeedsModel} from '../feeds/algo/actor'
-import {AppBskyFeedDefs} from '@atproto/api'
+import {ListsListModel} from '../lists/lists-list'
 
 export enum Sections {
   Posts = 'Posts',
   PostsWithReplies = 'Posts & replies',
   CustomAlgorithms = 'Algos',
+  Lists = 'Lists',
 }
 
 const USER_SELECTOR_ITEMS = [
   Sections.Posts,
   Sections.PostsWithReplies,
   Sections.CustomAlgorithms,
+  Sections.Lists,
 ]
 
 export interface ProfileUiParams {
@@ -30,6 +33,7 @@ export class ProfileUiModel {
   profile: ProfileModel
   feed: PostsFeedModel
   algos: ActorFeedsModel
+  lists: ListsListModel
 
   // ui state
   selectedViewIndex = 0
@@ -52,14 +56,17 @@ export class ProfileUiModel {
       limit: 10,
     })
     this.algos = new ActorFeedsModel(rootStore, {actor: params.user})
+    this.lists = new ListsListModel(rootStore, params.user)
   }
 
-  get currentView(): PostsFeedModel | ActorFeedsModel {
+  get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel {
     if (
       this.selectedView === Sections.Posts ||
       this.selectedView === Sections.PostsWithReplies
     ) {
       return this.feed
+    } else if (this.selectedView === Sections.Lists) {
+      return this.lists
     }
     if (this.selectedView === Sections.CustomAlgorithms) {
       return this.algos
@@ -121,6 +128,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 {
         // fallback, add empty item, to show empty message
         arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
@@ -135,6 +148,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
   }
@@ -155,6 +170,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