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.ts231
-rw-r--r--src/state/models/content/list-membership.ts130
-rw-r--r--src/state/models/content/list.ts508
-rw-r--r--src/state/models/content/post-thread-item.ts139
-rw-r--r--src/state/models/content/post-thread.ts354
-rw-r--r--src/state/models/content/profile.ts306
6 files changed, 0 insertions, 1668 deletions
diff --git a/src/state/models/content/feed-source.ts b/src/state/models/content/feed-source.ts
deleted file mode 100644
index 156e3be3b..000000000
--- a/src/state/models/content/feed-source.ts
+++ /dev/null
@@ -1,231 +0,0 @@
-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'
-import {logger} from '#/logger'
-
-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) {
-      logger.error('Failed to save feed', {error})
-    } finally {
-      track('CustomFeed:Save')
-    }
-  }
-
-  async unsave() {
-    // TODO TEMPORARY — see PRF's comment in content/list.ts togglePin
-    if (this.type !== 'feed-generator' && this.type !== 'list') {
-      return
-    }
-    try {
-      await this.rootStore.preferences.removeSavedFeed(this.uri)
-    } catch (error) {
-      logger.error('Failed to unsave feed', {error})
-    } finally {
-      track('CustomFeed:Unsave')
-    }
-  }
-
-  async pin() {
-    try {
-      await this.rootStore.preferences.addPinnedFeed(this.uri)
-    } catch (error) {
-      logger.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,
-      })
-
-      if (this.type === 'list') {
-        // TODO TEMPORARY — see PRF's comment in content/list.ts togglePin
-        return this.unsave()
-      } else {
-        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
-      logger.error('Failed to like feed', {error: 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
-      logger.error('Failed to unlike feed', {error: e})
-    } finally {
-      track('CustomFeed:Unlike')
-    }
-  }
-}
diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts
deleted file mode 100644
index 135d34dd5..000000000
--- a/src/state/models/content/list-membership.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-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
-}
-
-interface ListitemRecord {
-  uri: string
-  value: AppBskyGraphListitem.Record
-}
-
-interface ListitemListResponse {
-  cursor?: string
-  records: ListitemRecord[]
-}
-
-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: ListitemRecord[] = []
-    for (let i = 0; i < 100; i++) {
-      const res: ListitemListResponse =
-        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[],
-  ): 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
deleted file mode 100644
index fc09eeb9f..000000000
--- a/src/state/models/content/list.ts
+++ /dev/null
@@ -1,508 +0,0 @@
-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'
-import {RootStoreModel} from '../root-store'
-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'
-import {logger} from '#/logger'
-
-const PAGE_SIZE = 30
-
-interface ListitemRecord {
-  uri: string
-  value: AppBskyGraphListitem.Record
-}
-
-interface ListitemListResponse {
-  cursor?: string
-  records: ListitemRecord[]
-}
-
-export class ListModel {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasLoaded = false
-  error = ''
-  loadMoreError = ''
-  hasMore = true
-  loadMoreCursor?: string
-
-  // data
-  data: GraphDefs.ListView | null = null
-  items: GraphDefs.ListItemView[] = []
-  descriptionRT: RichText | null = null
-
-  static async createList(
-    rootStore: RootStoreModel,
-    {
-      purpose,
-      name,
-      description,
-      avatar,
-    }: {
-      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,
-      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,
-    )
-
-    // 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
-  }
-
-  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 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.data?.creator.did === this.rootStore.me.did
-  }
-
-  get isBlocking() {
-    return !!this.data?.viewer?.blocked
-  }
-
-  get isMuting() {
-    return !!this.data?.viewer?.muted
-  }
-
-  get isPinned() {
-    return this.rootStore.preferences.isPinnedFeed(this.uri)
-  }
-
-  get creatorDid() {
-    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
-  // =
-
-  async refresh() {
-    return this.loadMore(true)
-  }
-
-  loadMore = bundleAsync(async (replace: boolean = false) => {
-    if (!replace && !this.hasMore) {
-      return
-    }
-    this._xLoading(replace)
-    try {
-      await this._resolveUri()
-      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 loadAll() {
-    for (let i = 0; i < 1000; i++) {
-      if (!this.hasMore) {
-        break
-      }
-      await this.loadMore()
-    }
-  }
-
-  async updateMetadata({
-    name,
-    description,
-    avatar,
-  }: {
-    name: string
-    description: string
-    avatar: RNImage | null | undefined
-  }) {
-    if (!this.data) {
-      return
-    }
-    if (!this.isOwner) {
-      throw new Error('Cannot edit this list')
-    }
-    await this._resolveUri()
-
-    // 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() {
-    if (!this.data) {
-      return
-    }
-    await this._resolveUri()
-
-    // fetch all the listitem records that belong to this list
-    let cursor
-    let records: ListitemRecord[] = []
-    for (let i = 0; i < 100; i++) {
-      const res: ListitemListResponse =
-        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,
-      }
-    }
-    const writes = records
-      .map(record => createDel(record.uri))
-      .concat([createDel(this.uri)])
-
-    // apply in chunks
-    for (const writesChunk of chunk(writes, 10)) {
-      await this.rootStore.agent.com.atproto.repo.applyWrites({
-        repo: this.rootStore.me.did,
-        writes: writesChunk,
-      })
-    }
-
-    /* dont await */ this.rootStore.preferences.removeSavedFeed(this.uri)
-    this.rootStore.emitListDeleted(this.uri)
-  }
-
-  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) {
-      logger.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,
-      })
-      // TODO 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.unmuteModList(this.data.uri)
-    track('Lists:Unmute')
-    runInAction(() => {
-      if (this.data) {
-        const d = this.data
-        this.data = {...d, viewer: {...(d.viewer || {}), muted: false}}
-      }
-    })
-  }
-
-  async block() {
-    if (!this.data) {
-      return
-    }
-    await this._resolveUri()
-    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}}
-      }
-    })
-  }
-
-  /**
-   * 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) {
-      logger.error('Failed to fetch user items', {error: err})
-    }
-    if (loadMoreErr) {
-      logger.error('Failed to fetch user items', {
-        error: loadMoreErr,
-      })
-    }
-  }
-
-  // helper functions
-  // =
-
-  async _resolveUri() {
-    const urip = new AtUri(this.uri)
-    if (!urip.host.startsWith('did:')) {
-      try {
-        urip.host = await apilib.resolveName(this.rootStore, urip.host)
-      } catch (e: any) {
-        runInAction(() => {
-          this.error = e.toString()
-        })
-      }
-    }
-    runInAction(() => {
-      this.uri = urip.toString()
-    })
-  }
-
-  _replaceAll(res: GetList.Response) {
-    this.items = []
-    this._appendAll(res)
-  }
-
-  _appendAll(res: GetList.Response) {
-    this.loadMoreCursor = res.data.cursor
-    this.hasMore = !!this.loadMoreCursor
-    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/post-thread-item.ts b/src/state/models/content/post-thread-item.ts
deleted file mode 100644
index 942f3acc8..000000000
--- a/src/state/models/content/post-thread-item.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-import {makeAutoObservable} from 'mobx'
-import {
-  AppBskyFeedPost as FeedPost,
-  AppBskyFeedDefs,
-  RichText,
-  PostModeration,
-} from '@atproto/api'
-import {RootStoreModel} from '../root-store'
-import {PostsFeedItemModel} from '../feeds/post'
-
-type PostView = AppBskyFeedDefs.PostView
-
-// NOTE: this model uses the same data as PostsFeedItemModel, but is used for
-// rendering a single post in a thread view, and has additional state
-// for rendering the thread view, but calls the same data methods
-// as PostsFeedItemModel
-// TODO: refactor as an extension or subclass of PostsFeedItemModel
-export class PostThreadItemModel {
-  // ui state
-  _reactKey: string = ''
-  _depth = 0
-  _isHighlightedPost = false
-  _showParentReplyLine = false
-  _showChildReplyLine = false
-  _hasMore = false
-
-  // data
-  data: PostsFeedItemModel
-  post: PostView
-  postRecord?: FeedPost.Record
-  richText?: RichText
-  parent?:
-    | PostThreadItemModel
-    | AppBskyFeedDefs.NotFoundPost
-    | AppBskyFeedDefs.BlockedPost
-  replies?: (PostThreadItemModel | AppBskyFeedDefs.NotFoundPost)[]
-
-  constructor(
-    public rootStore: RootStoreModel,
-    v: AppBskyFeedDefs.ThreadViewPost,
-  ) {
-    this._reactKey = `thread-${v.post.uri}`
-    this.data = new PostsFeedItemModel(rootStore, this._reactKey, v)
-    this.post = this.data.post
-    this.postRecord = this.data.postRecord
-    this.richText = this.data.richText
-    // replies and parent are handled via assignTreeModels
-    makeAutoObservable(this, {rootStore: false})
-  }
-
-  get uri() {
-    return this.post.uri
-  }
-
-  get parentUri() {
-    return this.postRecord?.reply?.parent.uri
-  }
-
-  get rootUri(): string {
-    if (this.postRecord?.reply?.root.uri) {
-      return this.postRecord.reply.root.uri
-    }
-    return this.post.uri
-  }
-
-  get isThreadMuted() {
-    return this.data.isThreadMuted
-  }
-
-  get moderation(): PostModeration {
-    return this.data.moderation
-  }
-
-  assignTreeModels(
-    v: AppBskyFeedDefs.ThreadViewPost,
-    highlightedPostUri: string,
-    includeParent = true,
-    includeChildren = true,
-  ) {
-    // parents
-    if (includeParent && v.parent) {
-      if (AppBskyFeedDefs.isThreadViewPost(v.parent)) {
-        const parentModel = new PostThreadItemModel(this.rootStore, v.parent)
-        parentModel._depth = this._depth - 1
-        parentModel._showChildReplyLine = true
-        if (v.parent.parent) {
-          parentModel._showParentReplyLine = true
-          parentModel.assignTreeModels(
-            v.parent,
-            highlightedPostUri,
-            true,
-            false,
-          )
-        }
-        this.parent = parentModel
-      } else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) {
-        this.parent = v.parent
-      } else if (AppBskyFeedDefs.isBlockedPost(v.parent)) {
-        this.parent = v.parent
-      }
-    }
-    // replies
-    if (includeChildren && v.replies) {
-      const replies = []
-      for (const item of v.replies) {
-        if (AppBskyFeedDefs.isThreadViewPost(item)) {
-          const itemModel = new PostThreadItemModel(this.rootStore, item)
-          itemModel._depth = this._depth + 1
-          itemModel._showParentReplyLine =
-            itemModel.parentUri !== highlightedPostUri
-          if (item.replies?.length) {
-            itemModel._showChildReplyLine = true
-            itemModel.assignTreeModels(item, highlightedPostUri, false, true)
-          }
-          replies.push(itemModel)
-        } else if (AppBskyFeedDefs.isNotFoundPost(item)) {
-          replies.push(item)
-        }
-      }
-      this.replies = replies
-    }
-  }
-
-  async toggleLike() {
-    this.data.toggleLike()
-  }
-
-  async toggleRepost() {
-    this.data.toggleRepost()
-  }
-
-  async toggleThreadMute() {
-    this.data.toggleThreadMute()
-  }
-
-  async delete() {
-    this.data.delete()
-  }
-}
diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts
deleted file mode 100644
index fd194056a..000000000
--- a/src/state/models/content/post-thread.ts
+++ /dev/null
@@ -1,354 +0,0 @@
-import {makeAutoObservable, runInAction} from 'mobx'
-import {
-  AppBskyFeedGetPostThread as GetPostThread,
-  AppBskyFeedDefs,
-  AppBskyFeedPost,
-  PostModeration,
-} from '@atproto/api'
-import {AtUri} from '@atproto/api'
-import {RootStoreModel} from '../root-store'
-import * as apilib from 'lib/api/index'
-import {cleanError} from 'lib/strings/errors'
-import {ThreadViewPreference} from '../ui/preferences'
-import {PostThreadItemModel} from './post-thread-item'
-import {logger} from '#/logger'
-
-export class PostThreadModel {
-  // state
-  isLoading = false
-  isLoadingFromCache = false
-  isFromCache = false
-  isRefreshing = false
-  hasLoaded = false
-  error = ''
-  notFound = false
-  resolvedUri = ''
-  params: GetPostThread.QueryParams
-
-  // data
-  thread?: PostThreadItemModel | null = null
-  isBlocked = false
-
-  constructor(
-    public rootStore: RootStoreModel,
-    params: GetPostThread.QueryParams,
-  ) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-        params: false,
-      },
-      {autoBind: true},
-    )
-    this.params = params
-  }
-
-  static fromPostView(
-    rootStore: RootStoreModel,
-    postView: AppBskyFeedDefs.PostView,
-  ) {
-    const model = new PostThreadModel(rootStore, {uri: postView.uri})
-    model.resolvedUri = postView.uri
-    model.hasLoaded = true
-    model.thread = new PostThreadItemModel(rootStore, {
-      post: postView,
-    })
-    return model
-  }
-
-  get hasContent() {
-    return !!this.thread
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get rootUri(): string {
-    if (this.thread) {
-      if (this.thread.postRecord?.reply?.root.uri) {
-        return this.thread.postRecord.reply.root.uri
-      }
-    }
-    return this.resolvedUri
-  }
-
-  get isThreadMuted() {
-    return this.rootStore.mutedThreads.uris.has(this.rootUri)
-  }
-
-  get isCachedPostAReply() {
-    if (AppBskyFeedPost.isRecord(this.thread?.post.record)) {
-      return !!this.thread?.post.record.reply
-    }
-    return false
-  }
-
-  // public api
-  // =
-
-  /**
-   * Load for first render
-   */
-  async setup() {
-    if (!this.resolvedUri) {
-      await this._resolveUri()
-    }
-
-    if (this.hasContent) {
-      await this.update()
-    } else {
-      const precache = this.rootStore.posts.cache.get(this.resolvedUri)
-      if (precache) {
-        await this._loadPrecached(precache)
-      } else {
-        await this._load()
-      }
-    }
-  }
-
-  /**
-   * Register any event listeners. Returns a cleanup function.
-   */
-  registerListeners() {
-    const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this))
-    return () => sub.remove()
-  }
-
-  /**
-   * Reset and load
-   */
-  async refresh() {
-    await this._load(true)
-  }
-
-  /**
-   * Update content in-place
-   */
-  async update() {
-    // NOTE: it currently seems that a full load-and-replace works fine for this
-    //       if the UI loses its place or has jarring re-arrangements, replace this
-    //       with a more in-place update
-    this._load()
-  }
-
-  /**
-   * Refreshes when posts are deleted
-   */
-  onPostDeleted(_uri: string) {
-    this.refresh()
-  }
-
-  async toggleThreadMute() {
-    if (this.isThreadMuted) {
-      this.rootStore.mutedThreads.uris.delete(this.rootUri)
-    } else {
-      this.rootStore.mutedThreads.uris.add(this.rootUri)
-    }
-  }
-
-  // state transitions
-  // =
-
-  _xLoading(isRefreshing = false) {
-    this.isLoading = true
-    this.isRefreshing = isRefreshing
-    this.error = ''
-    this.notFound = false
-  }
-
-  _xIdle(err?: any) {
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = true
-    this.error = cleanError(err)
-    if (err) {
-      logger.error('Failed to fetch post thread', {error: err})
-    }
-    this.notFound = err instanceof GetPostThread.NotFoundError
-  }
-
-  // loader functions
-  // =
-
-  async _resolveUri() {
-    const urip = new AtUri(this.params.uri)
-    if (!urip.host.startsWith('did:')) {
-      try {
-        urip.host = await apilib.resolveName(this.rootStore, urip.host)
-      } catch (e: any) {
-        runInAction(() => {
-          this.error = e.toString()
-        })
-      }
-    }
-    runInAction(() => {
-      this.resolvedUri = urip.toString()
-    })
-  }
-
-  async _loadPrecached(precache: AppBskyFeedDefs.PostView) {
-    // start with the cached version
-    this.isLoadingFromCache = true
-    this.isFromCache = true
-    this._replaceAll({
-      success: true,
-      headers: {},
-      data: {
-        thread: {
-          post: precache,
-        },
-      },
-    })
-    this._xIdle()
-
-    // then update in the background
-    try {
-      const res = await this.rootStore.agent.getPostThread(
-        Object.assign({}, this.params, {uri: this.resolvedUri}),
-      )
-      this._replaceAll(res)
-    } catch (e: any) {
-      console.log(e)
-      this._xIdle(e)
-    } finally {
-      runInAction(() => {
-        this.isLoadingFromCache = false
-      })
-    }
-  }
-
-  async _load(isRefreshing = false) {
-    if (this.hasLoaded && !isRefreshing) {
-      return
-    }
-    this._xLoading(isRefreshing)
-    try {
-      const res = await this.rootStore.agent.getPostThread(
-        Object.assign({}, this.params, {uri: this.resolvedUri}),
-      )
-      this._replaceAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      console.log(e)
-      this._xIdle(e)
-    }
-  }
-
-  _replaceAll(res: GetPostThread.Response) {
-    this.isBlocked = AppBskyFeedDefs.isBlockedPost(res.data.thread)
-    if (this.isBlocked) {
-      return
-    }
-    pruneReplies(res.data.thread)
-    const thread = new PostThreadItemModel(
-      this.rootStore,
-      res.data.thread as AppBskyFeedDefs.ThreadViewPost,
-    )
-    thread._isHighlightedPost = true
-    thread.assignTreeModels(
-      res.data.thread as AppBskyFeedDefs.ThreadViewPost,
-      thread.uri,
-    )
-    sortThread(thread, this.rootStore.preferences.thread)
-    this.thread = thread
-  }
-}
-
-type MaybePost =
-  | AppBskyFeedDefs.ThreadViewPost
-  | AppBskyFeedDefs.NotFoundPost
-  | AppBskyFeedDefs.BlockedPost
-  | {[k: string]: unknown; $type: string}
-function pruneReplies(post: MaybePost) {
-  if (post.replies) {
-    post.replies = (post.replies as MaybePost[]).filter((reply: MaybePost) => {
-      if (reply.blocked) {
-        return false
-      }
-      pruneReplies(reply)
-      return true
-    })
-  }
-}
-
-type MaybeThreadItem =
-  | PostThreadItemModel
-  | AppBskyFeedDefs.NotFoundPost
-  | AppBskyFeedDefs.BlockedPost
-function sortThread(item: MaybeThreadItem, opts: ThreadViewPreference) {
-  if ('notFound' in item) {
-    return
-  }
-  item = item as PostThreadItemModel
-  if (item.replies) {
-    item.replies.sort((a: MaybeThreadItem, b: MaybeThreadItem) => {
-      if ('notFound' in a && a.notFound) {
-        return 1
-      }
-      if ('notFound' in b && b.notFound) {
-        return -1
-      }
-      item = item as PostThreadItemModel
-      a = a as PostThreadItemModel
-      b = b as PostThreadItemModel
-      const aIsByOp = a.post.author.did === item.post.author.did
-      const bIsByOp = b.post.author.did === item.post.author.did
-      if (aIsByOp && bIsByOp) {
-        return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
-      } else if (aIsByOp) {
-        return -1 // op's own reply
-      } else if (bIsByOp) {
-        return 1 // op's own reply
-      }
-      // put moderated content down at the bottom
-      if (modScore(a.moderation) !== modScore(b.moderation)) {
-        return modScore(a.moderation) - modScore(b.moderation)
-      }
-      if (opts.prioritizeFollowedUsers) {
-        const af = a.post.author.viewer?.following
-        const bf = b.post.author.viewer?.following
-        if (af && !bf) {
-          return -1
-        } else if (!af && bf) {
-          return 1
-        }
-      }
-      if (opts.sort === 'oldest') {
-        return a.post.indexedAt.localeCompare(b.post.indexedAt)
-      } else if (opts.sort === 'newest') {
-        return b.post.indexedAt.localeCompare(a.post.indexedAt)
-      } else if (opts.sort === 'most-likes') {
-        if (a.post.likeCount === b.post.likeCount) {
-          return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
-        } else {
-          return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes
-        }
-      } else if (opts.sort === 'random') {
-        return 0.5 - Math.random() // this is vaguely criminal but we can get away with it
-      }
-      return b.post.indexedAt.localeCompare(a.post.indexedAt)
-    })
-    item.replies.forEach(reply => sortThread(reply, opts))
-  }
-}
-
-function modScore(mod: PostModeration): number {
-  if (mod.content.blur && mod.content.noOverride) {
-    return 5
-  }
-  if (mod.content.blur) {
-    return 4
-  }
-  if (mod.content.alert) {
-    return 3
-  }
-  if (mod.embed.blur && mod.embed.noOverride) {
-    return 2
-  }
-  if (mod.embed.blur) {
-    return 1
-  }
-  return 0
-}
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
deleted file mode 100644
index 14362ceec..000000000
--- a/src/state/models/content/profile.ts
+++ /dev/null
@@ -1,306 +0,0 @@
-import {makeAutoObservable, runInAction} from 'mobx'
-import {
-  AtUri,
-  ComAtprotoLabelDefs,
-  AppBskyGraphDefs,
-  AppBskyActorGetProfile as GetProfile,
-  AppBskyActorProfile,
-  RichText,
-  moderateProfile,
-  ProfileModeration,
-} from '@atproto/api'
-import {RootStoreModel} from '../root-store'
-import * as apilib from 'lib/api/index'
-import {cleanError} from 'lib/strings/errors'
-import {FollowState} from '../cache/my-follows'
-import {Image as RNImage} from 'react-native-image-crop-picker'
-import {track} from 'lib/analytics/analytics'
-import {logger} from '#/logger'
-
-export class ProfileViewerModel {
-  muted?: boolean
-  mutedByList?: AppBskyGraphDefs.ListViewBasic
-  following?: string
-  followedBy?: string
-  blockedBy?: boolean
-  blocking?: string
-  blockingByList?: AppBskyGraphDefs.ListViewBasic;
-  [key: string]: unknown
-
-  constructor() {
-    makeAutoObservable(this)
-  }
-}
-
-export class ProfileModel {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasLoaded = false
-  error = ''
-  params: GetProfile.QueryParams
-
-  // data
-  did: string = ''
-  handle: string = ''
-  creator: string = ''
-  displayName?: string = ''
-  description?: string = ''
-  avatar?: string = ''
-  banner?: string = ''
-  followersCount: number = 0
-  followsCount: number = 0
-  postsCount: number = 0
-  labels?: ComAtprotoLabelDefs.Label[] = undefined
-  viewer = new ProfileViewerModel();
-  [key: string]: unknown
-
-  // added data
-  descriptionRichText?: RichText = new RichText({text: ''})
-
-  constructor(
-    public rootStore: RootStoreModel,
-    params: GetProfile.QueryParams,
-  ) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-        params: false,
-      },
-      {autoBind: true},
-    )
-    this.params = params
-  }
-
-  get hasContent() {
-    return this.did !== ''
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  get moderation(): ProfileModeration {
-    return moderateProfile(this, this.rootStore.preferences.moderationOpts)
-  }
-
-  // public api
-  // =
-
-  async setup() {
-    const precache = await this.rootStore.profiles.cache.get(this.params.actor)
-    if (precache) {
-      await this._loadWithCache(precache)
-    } else {
-      await this._load()
-    }
-  }
-
-  async refresh() {
-    await this._load(true)
-  }
-
-  async toggleFollowing() {
-    if (!this.rootStore.me.did) {
-      throw new Error('Not logged in')
-    }
-
-    const follows = this.rootStore.me.follows
-    const followUri =
-      (await follows.fetchFollowState(this.did)) === FollowState.Following
-        ? follows.getFollowUri(this.did)
-        : undefined
-
-    // guard against this view getting out of sync with the follows cache
-    if (followUri !== this.viewer.following) {
-      this.viewer.following = followUri
-      return
-    }
-
-    if (followUri) {
-      // unfollow
-      await this.rootStore.agent.deleteFollow(followUri)
-      runInAction(() => {
-        this.followersCount--
-        this.viewer.following = undefined
-        this.rootStore.me.follows.removeFollow(this.did)
-      })
-      track('Profile:Unfollow', {
-        username: this.handle,
-      })
-    } else {
-      // follow
-      const res = await this.rootStore.agent.follow(this.did)
-      runInAction(() => {
-        this.followersCount++
-        this.viewer.following = res.uri
-        this.rootStore.me.follows.hydrate(this.did, this)
-      })
-      track('Profile:Follow', {
-        username: this.handle,
-      })
-    }
-  }
-
-  async updateProfile(
-    updates: AppBskyActorProfile.Record,
-    newUserAvatar: RNImage | undefined | null,
-    newUserBanner: RNImage | undefined | null,
-  ) {
-    await this.rootStore.agent.upsertProfile(async existing => {
-      existing = existing || {}
-      existing.displayName = updates.displayName
-      existing.description = updates.description
-      if (newUserAvatar) {
-        const res = await apilib.uploadBlob(
-          this.rootStore,
-          newUserAvatar.path,
-          newUserAvatar.mime,
-        )
-        existing.avatar = res.data.blob
-      } else if (newUserAvatar === null) {
-        existing.avatar = undefined
-      }
-      if (newUserBanner) {
-        const res = await apilib.uploadBlob(
-          this.rootStore,
-          newUserBanner.path,
-          newUserBanner.mime,
-        )
-        existing.banner = res.data.blob
-      } else if (newUserBanner === null) {
-        existing.banner = undefined
-      }
-      return existing
-    })
-    await this.rootStore.me.load()
-    await this.refresh()
-  }
-
-  async muteAccount() {
-    await this.rootStore.agent.mute(this.did)
-    this.viewer.muted = true
-    await this.refresh()
-  }
-
-  async unmuteAccount() {
-    await this.rootStore.agent.unmute(this.did)
-    this.viewer.muted = false
-    await this.refresh()
-  }
-
-  async blockAccount() {
-    const res = await this.rootStore.agent.app.bsky.graph.block.create(
-      {
-        repo: this.rootStore.me.did,
-      },
-      {
-        subject: this.did,
-        createdAt: new Date().toISOString(),
-      },
-    )
-    this.viewer.blocking = res.uri
-    await this.refresh()
-  }
-
-  async unblockAccount() {
-    if (!this.viewer.blocking) {
-      return
-    }
-    const {rkey} = new AtUri(this.viewer.blocking)
-    await this.rootStore.agent.app.bsky.graph.block.delete({
-      repo: this.rootStore.me.did,
-      rkey,
-    })
-    this.viewer.blocking = undefined
-    await this.refresh()
-  }
-
-  // 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) {
-      logger.error('Failed to fetch profile', {error: err})
-    }
-  }
-
-  // loader functions
-  // =
-
-  async _load(isRefreshing = false) {
-    this._xLoading(isRefreshing)
-    try {
-      const res = await this.rootStore.agent.getProfile(this.params)
-      this.rootStore.profiles.overwrite(this.params.actor, res)
-      if (res.data.handle) {
-        this.rootStore.handleResolutions.cache.set(
-          res.data.handle,
-          res.data.did,
-        )
-      }
-      this._replaceAll(res)
-      await this._createRichText()
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  async _loadWithCache(precache: GetProfile.Response) {
-    // use cached value
-    this._replaceAll(precache)
-    await this._createRichText()
-    this._xIdle()
-
-    // fetch latest
-    try {
-      const res = await this.rootStore.agent.getProfile(this.params)
-      this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation
-      this._replaceAll(res)
-      await this._createRichText()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  _replaceAll(res: GetProfile.Response) {
-    this.did = res.data.did
-    this.handle = res.data.handle
-    this.displayName = res.data.displayName
-    this.description = res.data.description
-    this.avatar = res.data.avatar
-    this.banner = res.data.banner
-    this.followersCount = res.data.followersCount || 0
-    this.followsCount = res.data.followsCount || 0
-    this.postsCount = res.data.postsCount || 0
-    this.labels = res.data.labels
-    if (res.data.viewer) {
-      Object.assign(this.viewer, res.data.viewer)
-    }
-    this.rootStore.me.follows.hydrate(this.did, res.data)
-  }
-
-  async _createRichText() {
-    this.descriptionRichText = new RichText(
-      {text: this.description || ''},
-      {cleanNewlines: true},
-    )
-    await this.descriptionRichText.detectFacets(this.rootStore.agent)
-  }
-}