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/feed-source.ts223
-rw-r--r--src/state/models/content/list-membership.ts9
-rw-r--r--src/state/models/content/list.ts230
-rw-r--r--src/state/models/content/profile.ts3
-rw-r--r--src/state/models/discovery/feeds.ts8
-rw-r--r--src/state/models/feeds/custom-feed.ts151
-rw-r--r--src/state/models/feeds/posts.ts28
-rw-r--r--src/state/models/lists/actor-feeds.ts8
-rw-r--r--src/state/models/lists/lists-list.ts205
-rw-r--r--src/state/models/me.ts3
-rw-r--r--src/state/models/ui/my-feeds.ts30
-rw-r--r--src/state/models/ui/preferences.ts25
-rw-r--r--src/state/models/ui/saved-feeds.ts152
-rw-r--r--src/state/models/ui/shell.ts27
14 files changed, 684 insertions, 418 deletions
diff --git a/src/state/models/content/feed-source.ts b/src/state/models/content/feed-source.ts
new file mode 100644
index 000000000..8dac9b56f
--- /dev/null
+++ b/src/state/models/content/feed-source.ts
@@ -0,0 +1,223 @@
+import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api'
+import {makeAutoObservable, runInAction} from 'mobx'
+import {RootStoreModel} from 'state/models/root-store'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {bundleAsync} from 'lib/async/bundle'
+import {cleanError} from 'lib/strings/errors'
+import {track} from 'lib/analytics/analytics'
+
+export class FeedSourceModel {
+  // state
+  _reactKey: string
+  hasLoaded = false
+  error: string | undefined
+
+  // data
+  uri: string
+  cid: string = ''
+  type: 'feed-generator' | 'list' | 'unsupported' = 'unsupported'
+  avatar: string | undefined = ''
+  displayName: string = ''
+  descriptionRT: RichText | null = null
+  creatorDid: string = ''
+  creatorHandle: string = ''
+  likeCount: number | undefined = 0
+  likeUri: string | undefined = ''
+
+  constructor(public rootStore: RootStoreModel, uri: string) {
+    this._reactKey = uri
+    this.uri = uri
+
+    try {
+      const urip = new AtUri(uri)
+      if (urip.collection === 'app.bsky.feed.generator') {
+        this.type = 'feed-generator'
+      } else if (urip.collection === 'app.bsky.graph.list') {
+        this.type = 'list'
+      }
+    } catch {}
+    this.displayName = uri.split('/').pop() || ''
+
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get href() {
+    const urip = new AtUri(this.uri)
+    const collection =
+      urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists'
+    return `/profile/${urip.hostname}/${collection}/${urip.rkey}`
+  }
+
+  get isSaved() {
+    return this.rootStore.preferences.savedFeeds.includes(this.uri)
+  }
+
+  get isPinned() {
+    return this.rootStore.preferences.isPinnedFeed(this.uri)
+  }
+
+  get isLiked() {
+    return !!this.likeUri
+  }
+
+  get isOwner() {
+    return this.creatorDid === this.rootStore.me.did
+  }
+
+  setup = bundleAsync(async () => {
+    try {
+      if (this.type === 'feed-generator') {
+        const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
+          feed: this.uri,
+        })
+        this.hydrateFeedGenerator(res.data.view)
+      } else if (this.type === 'list') {
+        const res = await this.rootStore.agent.app.bsky.graph.getList({
+          list: this.uri,
+          limit: 1,
+        })
+        this.hydrateList(res.data.list)
+      }
+    } catch (e) {
+      runInAction(() => {
+        this.error = cleanError(e)
+      })
+    }
+  })
+
+  hydrateFeedGenerator(view: AppBskyFeedDefs.GeneratorView) {
+    this.uri = view.uri
+    this.cid = view.cid
+    this.avatar = view.avatar
+    this.displayName = view.displayName
+      ? sanitizeDisplayName(view.displayName)
+      : `Feed by ${sanitizeHandle(view.creator.handle, '@')}`
+    this.descriptionRT = new RichText({
+      text: view.description || '',
+      facets: (view.descriptionFacets || [])?.slice(),
+    })
+    this.creatorDid = view.creator.did
+    this.creatorHandle = view.creator.handle
+    this.likeCount = view.likeCount
+    this.likeUri = view.viewer?.like
+    this.hasLoaded = true
+  }
+
+  hydrateList(view: AppBskyGraphDefs.ListView) {
+    this.uri = view.uri
+    this.cid = view.cid
+    this.avatar = view.avatar
+    this.displayName = view.name
+      ? sanitizeDisplayName(view.name)
+      : `User List by ${sanitizeHandle(view.creator.handle, '@')}`
+    this.descriptionRT = new RichText({
+      text: view.description || '',
+      facets: (view.descriptionFacets || [])?.slice(),
+    })
+    this.creatorDid = view.creator.did
+    this.creatorHandle = view.creator.handle
+    this.likeCount = undefined
+    this.hasLoaded = true
+  }
+
+  async save() {
+    if (this.type !== 'feed-generator') {
+      return
+    }
+    try {
+      await this.rootStore.preferences.addSavedFeed(this.uri)
+    } catch (error) {
+      this.rootStore.log.error('Failed to save feed', error)
+    } finally {
+      track('CustomFeed:Save')
+    }
+  }
+
+  async unsave() {
+    if (this.type !== 'feed-generator') {
+      return
+    }
+    try {
+      await this.rootStore.preferences.removeSavedFeed(this.uri)
+    } catch (error) {
+      this.rootStore.log.error('Failed to unsave feed', error)
+    } finally {
+      track('CustomFeed:Unsave')
+    }
+  }
+
+  async pin() {
+    try {
+      await this.rootStore.preferences.addPinnedFeed(this.uri)
+    } catch (error) {
+      this.rootStore.log.error('Failed to pin feed', error)
+    } finally {
+      track('CustomFeed:Pin', {
+        name: this.displayName,
+        uri: this.uri,
+      })
+    }
+  }
+
+  async togglePin() {
+    if (!this.isPinned) {
+      track('CustomFeed:Pin', {
+        name: this.displayName,
+        uri: this.uri,
+      })
+      return this.rootStore.preferences.addPinnedFeed(this.uri)
+    } else {
+      track('CustomFeed:Unpin', {
+        name: this.displayName,
+        uri: this.uri,
+      })
+      return this.rootStore.preferences.removePinnedFeed(this.uri)
+    }
+  }
+
+  async like() {
+    if (this.type !== 'feed-generator') {
+      return
+    }
+    try {
+      this.likeUri = 'pending'
+      this.likeCount = (this.likeCount || 0) + 1
+      const res = await this.rootStore.agent.like(this.uri, this.cid)
+      this.likeUri = res.uri
+    } catch (e: any) {
+      this.likeUri = undefined
+      this.likeCount = (this.likeCount || 1) - 1
+      this.rootStore.log.error('Failed to like feed', e)
+    } finally {
+      track('CustomFeed:Like')
+    }
+  }
+
+  async unlike() {
+    if (this.type !== 'feed-generator') {
+      return
+    }
+    if (!this.likeUri) {
+      return
+    }
+    const uri = this.likeUri
+    try {
+      this.likeUri = undefined
+      this.likeCount = (this.likeCount || 1) - 1
+      await this.rootStore.agent.deleteLike(uri!)
+    } catch (e: any) {
+      this.likeUri = uri
+      this.likeCount = (this.likeCount || 0) + 1
+      this.rootStore.log.error('Failed to unlike feed', e)
+    } finally {
+      track('CustomFeed:Unlike')
+    }
+  }
+}
diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts
index 20d9b60af..135d34dd5 100644
--- a/src/state/models/content/list-membership.ts
+++ b/src/state/models/content/list-membership.ts
@@ -110,14 +110,21 @@ export class ListMembershipModel {
     })
   }
 
-  async updateTo(uris: string[]) {
+  async updateTo(
+    uris: string[],
+  ): Promise<{added: string[]; removed: string[]}> {
+    const added = []
+    const removed = []
     for (const uri of uris) {
       await this.add(uri)
+      added.push(uri)
     }
     for (const membership of this.memberships) {
       if (!uris.includes(membership.value.list)) {
         await this.remove(membership.value.list)
+        removed.push(membership.value.list)
       }
     }
+    return {added, removed}
   }
 }
diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts
index fd5074d8c..0331f58bd 100644
--- a/src/state/models/content/list.ts
+++ b/src/state/models/content/list.ts
@@ -1,10 +1,12 @@
 import {makeAutoObservable, runInAction} from 'mobx'
 import {
   AtUri,
+  AppBskyActorDefs,
   AppBskyGraphGetList as GetList,
   AppBskyGraphDefs as GraphDefs,
   AppBskyGraphList,
   AppBskyGraphListitem,
+  RichText,
 } from '@atproto/api'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import chunk from 'lodash.chunk'
@@ -13,6 +15,7 @@ import * as apilib from 'lib/api/index'
 import {cleanError} from 'lib/strings/errors'
 import {bundleAsync} from 'lib/async/bundle'
 import {track} from 'lib/analytics/analytics'
+import {until} from 'lib/async/until'
 
 const PAGE_SIZE = 30
 
@@ -37,19 +40,32 @@ export class ListModel {
   loadMoreCursor?: string
 
   // data
-  list: GraphDefs.ListView | null = null
+  data: GraphDefs.ListView | null = null
   items: GraphDefs.ListItemView[] = []
+  descriptionRT: RichText | null = null
 
-  static async createModList(
+  static async createList(
     rootStore: RootStoreModel,
     {
+      purpose,
       name,
       description,
       avatar,
-    }: {name: string; description: string; avatar: RNImage | null | undefined},
+    }: {
+      purpose: string
+      name: string
+      description: string
+      avatar: RNImage | null | undefined
+    },
   ) {
+    if (
+      purpose !== 'app.bsky.graph.defs#curatelist' &&
+      purpose !== 'app.bsky.graph.defs#modlist'
+    ) {
+      throw new Error('Invalid list purpose: must be curatelist or modlist')
+    }
     const record: AppBskyGraphList.Record = {
-      purpose: 'app.bsky.graph.defs#modlist',
+      purpose,
       name,
       description,
       avatar: undefined,
@@ -69,7 +85,20 @@ export class ListModel {
       },
       record,
     )
-    await rootStore.agent.app.bsky.graph.muteActorList({list: res.uri})
+
+    // wait for the appview to update
+    await until(
+      5, // 5 tries
+      1e3, // 1s delay between tries
+      (v: GetList.Response, _e: any) => {
+        return typeof v?.data?.list.uri === 'string'
+      },
+      () =>
+        rootStore.agent.app.bsky.graph.getList({
+          list: res.uri,
+          limit: 1,
+        }),
+    )
     return res
   }
 
@@ -95,16 +124,40 @@ export class ListModel {
     return this.hasLoaded && !this.hasContent
   }
 
+  get isCuratelist() {
+    return this.data?.purpose === 'app.bsky.graph.defs#curatelist'
+  }
+
+  get isModlist() {
+    return this.data?.purpose === 'app.bsky.graph.defs#modlist'
+  }
+
   get isOwner() {
-    return this.list?.creator.did === this.rootStore.me.did
+    return this.data?.creator.did === this.rootStore.me.did
+  }
+
+  get isBlocking() {
+    return !!this.data?.viewer?.blocked
   }
 
-  get isSubscribed() {
-    return this.list?.viewer?.muted
+  get isMuting() {
+    return !!this.data?.viewer?.muted
+  }
+
+  get isPinned() {
+    return this.rootStore.preferences.isPinnedFeed(this.uri)
   }
 
   get creatorDid() {
-    return this.list?.creator.did
+    return this.data?.creator.did
+  }
+
+  getMembership(did: string) {
+    return this.items.find(item => item.subject.did === did)
+  }
+
+  isMember(did: string) {
+    return !!this.getMembership(did)
   }
 
   // public api
@@ -137,6 +190,15 @@ export class ListModel {
     }
   })
 
+  async loadAll() {
+    for (let i = 0; i < 1000; i++) {
+      if (!this.hasMore) {
+        break
+      }
+      await this.loadMore()
+    }
+  }
+
   async updateMetadata({
     name,
     description,
@@ -146,7 +208,7 @@ export class ListModel {
     description: string
     avatar: RNImage | null | undefined
   }) {
-    if (!this.list) {
+    if (!this.data) {
       return
     }
     if (!this.isOwner) {
@@ -183,7 +245,7 @@ export class ListModel {
   }
 
   async delete() {
-    if (!this.list) {
+    if (!this.data) {
       return
     }
     await this._resolveUri()
@@ -231,28 +293,140 @@ export class ListModel {
     this.rootStore.emitListDeleted(this.uri)
   }
 
-  async subscribe() {
-    if (!this.list) {
+  async addMember(profile: AppBskyActorDefs.ProfileViewBasic) {
+    if (this.isMember(profile.did)) {
+      return
+    }
+    await this.rootStore.agent.app.bsky.graph.listitem.create(
+      {
+        repo: this.rootStore.me.did,
+      },
+      {
+        subject: profile.did,
+        list: this.uri,
+        createdAt: new Date().toISOString(),
+      },
+    )
+    runInAction(() => {
+      this.items = this.items.concat([
+        {_reactKey: profile.did, subject: profile},
+      ])
+    })
+  }
+
+  /**
+   * Just adds to local cache; used to reflect changes affected elsewhere
+   */
+  cacheAddMember(profile: AppBskyActorDefs.ProfileViewBasic) {
+    if (!this.isMember(profile.did)) {
+      this.items = this.items.concat([
+        {_reactKey: profile.did, subject: profile},
+      ])
+    }
+  }
+
+  /**
+   * Just removes from local cache; used to reflect changes affected elsewhere
+   */
+  cacheRemoveMember(profile: AppBskyActorDefs.ProfileViewBasic) {
+    if (this.isMember(profile.did)) {
+      this.items = this.items.filter(item => item.subject.did !== profile.did)
+    }
+  }
+
+  async pin() {
+    try {
+      await this.rootStore.preferences.addPinnedFeed(this.uri)
+    } catch (error) {
+      this.rootStore.log.error('Failed to pin feed', error)
+    } finally {
+      track('CustomFeed:Pin', {
+        name: this.data?.name || '',
+        uri: this.uri,
+      })
+    }
+  }
+
+  async togglePin() {
+    if (!this.isPinned) {
+      track('CustomFeed:Pin', {
+        name: this.data?.name || '',
+        uri: this.uri,
+      })
+      return this.rootStore.preferences.addPinnedFeed(this.uri)
+    } else {
+      track('CustomFeed:Unpin', {
+        name: this.data?.name || '',
+        uri: this.uri,
+      })
+      // TEMPORARY
+      // lists are temporarily piggybacking on the saved/pinned feeds preferences
+      // we'll eventually replace saved feeds with the bookmarks API
+      // until then, we need to unsave lists instead of just unpin them
+      // -prf
+      // return this.rootStore.preferences.removePinnedFeed(this.uri)
+      return this.rootStore.preferences.removeSavedFeed(this.uri)
+    }
+  }
+
+  async mute() {
+    if (!this.data) {
+      return
+    }
+    await this._resolveUri()
+    await this.rootStore.agent.muteModList(this.data.uri)
+    track('Lists:Mute')
+    runInAction(() => {
+      if (this.data) {
+        const d = this.data
+        this.data = {...d, viewer: {...(d.viewer || {}), muted: true}}
+      }
+    })
+  }
+
+  async unmute() {
+    if (!this.data) {
       return
     }
     await this._resolveUri()
-    await this.rootStore.agent.app.bsky.graph.muteActorList({
-      list: this.list.uri,
+    await this.rootStore.agent.unmuteModList(this.data.uri)
+    track('Lists:Unmute')
+    runInAction(() => {
+      if (this.data) {
+        const d = this.data
+        this.data = {...d, viewer: {...(d.viewer || {}), muted: false}}
+      }
     })
-    track('Lists:Subscribe')
-    await this.refresh()
   }
 
-  async unsubscribe() {
-    if (!this.list) {
+  async block() {
+    if (!this.data) {
       return
     }
     await this._resolveUri()
-    await this.rootStore.agent.app.bsky.graph.unmuteActorList({
-      list: this.list.uri,
+    const res = await this.rootStore.agent.blockModList(this.data.uri)
+    track('Lists:Block')
+    runInAction(() => {
+      if (this.data) {
+        const d = this.data
+        this.data = {...d, viewer: {...(d.viewer || {}), blocked: res.uri}}
+      }
+    })
+  }
+
+  async unblock() {
+    if (!this.data || !this.data.viewer?.blocked) {
+      return
+    }
+    await this._resolveUri()
+    await this.rootStore.agent.unblockModList(this.data.uri)
+    track('Lists:Unblock')
+    runInAction(() => {
+      if (this.data) {
+        const d = this.data
+        this.data = {...d, viewer: {...(d.viewer || {}), blocked: undefined}}
+      }
     })
-    track('Lists:Unsubscribe')
-    await this.refresh()
   }
 
   /**
@@ -314,9 +488,17 @@ export class ListModel {
   _appendAll(res: GetList.Response) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
-    this.list = res.data.list
+    this.data = res.data.list
     this.items = this.items.concat(
       res.data.items.map(item => ({...item, _reactKey: item.subject.did})),
     )
+    if (this.data.description) {
+      this.descriptionRT = new RichText({
+        text: this.data.description,
+        facets: (this.data.descriptionFacets || [])?.slice(),
+      })
+    } else {
+      this.descriptionRT = null
+    }
   }
 }
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index 906f84c28..5333e7116 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -22,7 +22,8 @@ export class ProfileViewerModel {
   following?: string
   followedBy?: string
   blockedBy?: boolean
-  blocking?: string;
+  blocking?: string
+  blockingByList?: AppBskyGraphDefs.ListViewBasic;
   [key: string]: unknown
 
   constructor() {
diff --git a/src/state/models/discovery/feeds.ts b/src/state/models/discovery/feeds.ts
index fa4054ff0..1a00f802c 100644
--- a/src/state/models/discovery/feeds.ts
+++ b/src/state/models/discovery/feeds.ts
@@ -3,7 +3,7 @@ import {AppBskyUnspeccedGetPopularFeedGenerators} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {bundleAsync} from 'lib/async/bundle'
 import {cleanError} from 'lib/strings/errors'
-import {CustomFeedModel} from '../feeds/custom-feed'
+import {FeedSourceModel} from '../content/feed-source'
 
 const DEFAULT_LIMIT = 50
 
@@ -16,7 +16,7 @@ export class FeedsDiscoveryModel {
   loadMoreCursor: string | undefined = undefined
 
   // data
-  feeds: CustomFeedModel[] = []
+  feeds: FeedSourceModel[] = []
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(
@@ -137,7 +137,9 @@ export class FeedsDiscoveryModel {
   _append(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
     // 1. push data into feeds array
     for (const f of res.data.feeds) {
-      this.feeds.push(new CustomFeedModel(this.rootStore, f))
+      const model = new FeedSourceModel(this.rootStore, f.uri)
+      model.hydrateFeedGenerator(f)
+      this.feeds.push(model)
     }
     // 2. set loadMoreCursor
     this.loadMoreCursor = res.data.cursor
diff --git a/src/state/models/feeds/custom-feed.ts b/src/state/models/feeds/custom-feed.ts
deleted file mode 100644
index 2de4534e7..000000000
--- a/src/state/models/feeds/custom-feed.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import {AppBskyFeedDefs} 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 {updateDataOptimistically} from 'lib/async/revertible'
-import {track} from 'lib/analytics/analytics'
-
-export class CustomFeedModel {
-  // data
-  _reactKey: string
-  data: AppBskyFeedDefs.GeneratorView
-  isOnline: boolean
-  isValid: boolean
-
-  constructor(
-    public rootStore: RootStoreModel,
-    view: AppBskyFeedDefs.GeneratorView,
-    isOnline?: boolean,
-    isValid?: boolean,
-  ) {
-    this._reactKey = view.uri
-    this.data = view
-    this.isOnline = isOnline ?? true
-    this.isValid = isValid ?? true
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-      },
-      {autoBind: true},
-    )
-  }
-
-  // local actions
-  // =
-
-  get uri() {
-    return this.data.uri
-  }
-
-  get displayName() {
-    if (this.data.displayName) {
-      return sanitizeDisplayName(this.data.displayName)
-    }
-    return `Feed by ${sanitizeHandle(this.data.creator.handle, '@')}`
-  }
-
-  get isSaved() {
-    return this.rootStore.preferences.savedFeeds.includes(this.uri)
-  }
-
-  get isLiked() {
-    return this.data.viewer?.like
-  }
-
-  // public apis
-  // =
-
-  async save() {
-    try {
-      await this.rootStore.preferences.addSavedFeed(this.uri)
-    } catch (error) {
-      this.rootStore.log.error('Failed to save feed', error)
-    } finally {
-      track('CustomFeed:Save')
-    }
-  }
-
-  async pin() {
-    try {
-      await this.rootStore.preferences.addPinnedFeed(this.uri)
-    } catch (error) {
-      this.rootStore.log.error('Failed to pin feed', error)
-    } finally {
-      track('CustomFeed:Pin', {
-        name: this.data.displayName,
-        uri: this.uri,
-      })
-    }
-  }
-
-  async unsave() {
-    try {
-      await this.rootStore.preferences.removeSavedFeed(this.uri)
-    } catch (error) {
-      this.rootStore.log.error('Failed to unsave feed', error)
-    } finally {
-      track('CustomFeed:Unsave')
-    }
-  }
-
-  async like() {
-    try {
-      await updateDataOptimistically(
-        this.data,
-        () => {
-          this.data.viewer = this.data.viewer || {}
-          this.data.viewer.like = 'pending'
-          this.data.likeCount = (this.data.likeCount || 0) + 1
-        },
-        () => this.rootStore.agent.like(this.data.uri, this.data.cid),
-        res => {
-          this.data.viewer = this.data.viewer || {}
-          this.data.viewer.like = res.uri
-        },
-      )
-    } catch (e: any) {
-      this.rootStore.log.error('Failed to like feed', e)
-    } finally {
-      track('CustomFeed:Like')
-    }
-  }
-
-  async unlike() {
-    if (!this.data.viewer?.like) {
-      return
-    }
-    try {
-      const likeUri = this.data.viewer.like
-      await updateDataOptimistically(
-        this.data,
-        () => {
-          this.data.viewer = this.data.viewer || {}
-          this.data.viewer.like = undefined
-          this.data.likeCount = (this.data.likeCount || 1) - 1
-        },
-        () => this.rootStore.agent.deleteLike(likeUri),
-      )
-    } catch (e: any) {
-      this.rootStore.log.error('Failed to unlike feed', e)
-    } finally {
-      track('CustomFeed:Unlike')
-    }
-  }
-
-  async reload() {
-    const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
-      feed: this.data.uri,
-    })
-    runInAction(() => {
-      this.data = res.data.view
-      this.isOnline = res.data.isOnline
-      this.isValid = res.data.isValid
-    })
-  }
-
-  serialize() {
-    return JSON.stringify(this.data)
-  }
-}
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index 2462689b1..169eedac8 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -4,6 +4,7 @@ import {
   AppBskyFeedGetAuthorFeed as GetAuthorFeed,
   AppBskyFeedGetFeed as GetCustomFeed,
   AppBskyFeedGetActorLikes as GetActorLikes,
+  AppBskyFeedGetListFeed as GetListFeed,
 } from '@atproto/api'
 import AwaitLock from 'await-lock'
 import {bundleAsync} from 'lib/async/bundle'
@@ -19,6 +20,7 @@ import {FollowingFeedAPI} from 'lib/api/feed/following'
 import {AuthorFeedAPI} from 'lib/api/feed/author'
 import {LikesFeedAPI} from 'lib/api/feed/likes'
 import {CustomFeedAPI} from 'lib/api/feed/custom'
+import {ListFeedAPI} from 'lib/api/feed/list'
 import {MergeFeedAPI} from 'lib/api/feed/merge'
 
 const PAGE_SIZE = 30
@@ -36,6 +38,7 @@ type QueryParams =
   | GetAuthorFeed.QueryParams
   | GetActorLikes.QueryParams
   | GetCustomFeed.QueryParams
+  | GetListFeed.QueryParams
 
 export class PostsFeedModel {
   // state
@@ -66,7 +69,13 @@ export class PostsFeedModel {
 
   constructor(
     public rootStore: RootStoreModel,
-    public feedType: 'home' | 'following' | 'author' | 'custom' | 'likes',
+    public feedType:
+      | 'home'
+      | 'following'
+      | 'author'
+      | 'custom'
+      | 'likes'
+      | 'list',
     params: QueryParams,
     options?: Options,
   ) {
@@ -99,11 +108,26 @@ export class PostsFeedModel {
         rootStore,
         params as GetCustomFeed.QueryParams,
       )
+    } else if (feedType === 'list') {
+      this.api = new ListFeedAPI(rootStore, params as GetListFeed.QueryParams)
     } else {
       this.api = new FollowingFeedAPI(rootStore)
     }
   }
 
+  get reactKey() {
+    if (this.feedType === 'author') {
+      return (this.params as GetAuthorFeed.QueryParams).actor
+    }
+    if (this.feedType === 'custom') {
+      return (this.params as GetCustomFeed.QueryParams).feed
+    }
+    if (this.feedType === 'list') {
+      return (this.params as GetListFeed.QueryParams).list
+    }
+    return this.feedType
+  }
+
   get hasContent() {
     return this.slices.length !== 0
   }
@@ -117,7 +141,7 @@ export class PostsFeedModel {
   }
 
   get isLoadingMore() {
-    return this.isLoading && !this.isRefreshing
+    return this.isLoading && !this.isRefreshing && this.hasContent
   }
 
   setHasNewLatest(v: boolean) {
diff --git a/src/state/models/lists/actor-feeds.ts b/src/state/models/lists/actor-feeds.ts
index 0f2060581..d2bd7680b 100644
--- a/src/state/models/lists/actor-feeds.ts
+++ b/src/state/models/lists/actor-feeds.ts
@@ -3,7 +3,7 @@ import {AppBskyFeedGetActorFeeds as GetActorFeeds} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {bundleAsync} from 'lib/async/bundle'
 import {cleanError} from 'lib/strings/errors'
-import {CustomFeedModel} from '../feeds/custom-feed'
+import {FeedSourceModel} from '../content/feed-source'
 
 const PAGE_SIZE = 30
 
@@ -17,7 +17,7 @@ export class ActorFeedsModel {
   loadMoreCursor?: string
 
   // data
-  feeds: CustomFeedModel[] = []
+  feeds: FeedSourceModel[] = []
 
   constructor(
     public rootStore: RootStoreModel,
@@ -114,7 +114,9 @@ export class ActorFeedsModel {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
     for (const f of res.data.feeds) {
-      this.feeds.push(new CustomFeedModel(this.rootStore, f))
+      const model = new FeedSourceModel(this.rootStore, f.uri)
+      model.hydrateFeedGenerator(f)
+      this.feeds.push(model)
     }
   }
 }
diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts
index 54e2f5fde..42638757a 100644
--- a/src/state/models/lists/lists-list.ts
+++ b/src/state/models/lists/lists-list.ts
@@ -1,12 +1,9 @@
 import {makeAutoObservable} from 'mobx'
-import {
-  AppBskyGraphGetLists as GetLists,
-  AppBskyGraphGetListMutes as GetListMutes,
-  AppBskyGraphDefs as GraphDefs,
-} from '@atproto/api'
+import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {cleanError} from 'lib/strings/errors'
 import {bundleAsync} from 'lib/async/bundle'
+import {accumulate} from 'lib/async/accumulate'
 
 const PAGE_SIZE = 30
 
@@ -25,7 +22,7 @@ export class ListsListModel {
 
   constructor(
     public rootStore: RootStoreModel,
-    public source: 'my-modlists' | string,
+    public source: 'mine' | 'my-curatelists' | 'my-modlists' | string,
   ) {
     makeAutoObservable(
       this,
@@ -48,6 +45,26 @@ export class ListsListModel {
     return this.hasLoaded && !this.hasContent
   }
 
+  get curatelists() {
+    return this.lists.filter(
+      list => list.purpose === 'app.bsky.graph.defs#curatelist',
+    )
+  }
+
+  get isCuratelistsEmpty() {
+    return this.hasLoaded && this.curatelists.length === 0
+  }
+
+  get modlists() {
+    return this.lists.filter(
+      list => list.purpose === 'app.bsky.graph.defs#modlist',
+    )
+  }
+
+  get isModlistsEmpty() {
+    return this.hasLoaded && this.modlists.length === 0
+  }
+
   /**
    * Removes posts from the feed upon deletion.
    */
@@ -76,44 +93,85 @@ export class ListsListModel {
     }
     this._xLoading(replace)
     try {
-      let res: GetLists.Response
-      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)
-          }
+      let cursor: string | undefined
+      let lists: GraphDefs.ListView[] = []
+      if (
+        this.source === 'mine' ||
+        this.source === 'my-curatelists' ||
+        this.source === 'my-modlists'
+      ) {
+        const promises = [
+          accumulate(cursor =>
+            this.rootStore.agent.app.bsky.graph
+              .getLists({
+                actor: this.rootStore.me.did,
+                cursor,
+                limit: 50,
+              })
+              .then(res => ({cursor: res.data.cursor, items: res.data.lists})),
+          ),
+        ]
+        if (this.source === 'my-modlists') {
+          promises.push(
+            accumulate(cursor =>
+              this.rootStore.agent.app.bsky.graph
+                .getListMutes({
+                  cursor,
+                  limit: 50,
+                })
+                .then(res => ({
+                  cursor: res.data.cursor,
+                  items: res.data.lists,
+                })),
+            ),
+          )
+          promises.push(
+            accumulate(cursor =>
+              this.rootStore.agent.app.bsky.graph
+                .getListBlocks({
+                  cursor,
+                  limit: 50,
+                })
+                .then(res => ({
+                  cursor: res.data.cursor,
+                  items: res.data.lists,
+                })),
+            ),
+          )
         }
-        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)
+        const resultset = await Promise.all(promises)
+        for (const res of resultset) {
+          for (let list of res) {
+            if (
+              this.source === 'my-curatelists' &&
+              list.purpose !== 'app.bsky.graph.defs#curatelist'
+            ) {
+              continue
+            }
+            if (
+              this.source === 'my-modlists' &&
+              list.purpose !== 'app.bsky.graph.defs#modlist'
+            ) {
+              continue
+            }
+            if (!lists.find(l => l.uri === list.uri)) {
+              lists.push(list)
+            }
           }
         }
       } else {
-        res = await this.rootStore.agent.app.bsky.graph.getLists({
+        const res = await this.rootStore.agent.app.bsky.graph.getLists({
           actor: this.source,
           limit: PAGE_SIZE,
           cursor: replace ? undefined : this.loadMoreCursor,
         })
+        lists = res.data.lists
+        cursor = res.data.cursor
       }
       if (replace) {
-        this._replaceAll(res)
+        this._replaceAll({lists, cursor})
       } else {
-        this._appendAll(res)
+        this._appendAll({lists, cursor})
       }
       this._xIdle()
     } catch (e: any) {
@@ -156,75 +214,28 @@ export class ListsListModel {
   // helper functions
   // =
 
-  _replaceAll(res: GetLists.Response | GetListMutes.Response) {
+  _replaceAll({
+    lists,
+    cursor,
+  }: {
+    lists: GraphDefs.ListView[]
+    cursor: string | undefined
+  }) {
     this.lists = []
-    this._appendAll(res)
+    this._appendAll({lists, cursor})
   }
 
-  _appendAll(res: GetLists.Response | GetListMutes.Response) {
-    this.loadMoreCursor = res.data.cursor
+  _appendAll({
+    lists,
+    cursor,
+  }: {
+    lists: GraphDefs.ListView[]
+    cursor: string | undefined
+  }) {
+    this.loadMoreCursor = cursor
     this.hasMore = !!this.loadMoreCursor
     this.lists = this.lists.concat(
-      res.data.lists.map(list => ({...list, _reactKey: list.uri})),
+      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: GetLists.Response = 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: GetListMutes.Response =
-      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/me.ts b/src/state/models/me.ts
index 186e61cf6..75c87d765 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -8,7 +8,6 @@ import {PostsFeedModel} from './feeds/posts'
 import {NotificationsFeedModel} from './feeds/notifications'
 import {MyFollowsCache} from './cache/my-follows'
 import {isObj, hasProp} from 'lib/type-guards'
-import {SavedFeedsModel} from './ui/saved-feeds'
 
 const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min
 const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec
@@ -22,7 +21,6 @@ export class MeModel {
   followsCount: number | undefined
   followersCount: number | undefined
   mainFeed: PostsFeedModel
-  savedFeeds: SavedFeedsModel
   notifications: NotificationsFeedModel
   follows: MyFollowsCache
   invites: ComAtprotoServerDefs.InviteCode[] = []
@@ -45,7 +43,6 @@ export class MeModel {
     })
     this.notifications = new NotificationsFeedModel(this.rootStore)
     this.follows = new MyFollowsCache(this.rootStore)
-    this.savedFeeds = new SavedFeedsModel(this.rootStore)
   }
 
   clear() {
diff --git a/src/state/models/ui/my-feeds.ts b/src/state/models/ui/my-feeds.ts
index 6b017709e..58f2e7f65 100644
--- a/src/state/models/ui/my-feeds.ts
+++ b/src/state/models/ui/my-feeds.ts
@@ -1,6 +1,7 @@
-import {makeAutoObservable} from 'mobx'
+import {makeAutoObservable, reaction} from 'mobx'
+import {SavedFeedsModel} from './saved-feeds'
 import {FeedsDiscoveryModel} from '../discovery/feeds'
-import {CustomFeedModel} from '../feeds/custom-feed'
+import {FeedSourceModel} from '../content/feed-source'
 import {RootStoreModel} from '../root-store'
 
 export type MyFeedsItem =
@@ -29,7 +30,7 @@ export type MyFeedsItem =
   | {
       _reactKey: string
       type: 'saved-feed'
-      feed: CustomFeedModel
+      feed: FeedSourceModel
     }
   | {
       _reactKey: string
@@ -46,21 +47,19 @@ export type MyFeedsItem =
   | {
       _reactKey: string
       type: 'discover-feed'
-      feed: CustomFeedModel
+      feed: FeedSourceModel
     }
 
 export class MyFeedsUIModel {
+  saved: SavedFeedsModel
   discovery: FeedsDiscoveryModel
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(this)
+    this.saved = new SavedFeedsModel(this.rootStore)
     this.discovery = new FeedsDiscoveryModel(this.rootStore)
   }
 
-  get saved() {
-    return this.rootStore.me.savedFeeds
-  }
-
   get isRefreshing() {
     return !this.saved.isLoading && this.saved.isRefreshing
   }
@@ -78,6 +77,21 @@ export class MyFeedsUIModel {
     }
   }
 
+  registerListeners() {
+    const dispose1 = reaction(
+      () => this.rootStore.preferences.savedFeeds,
+      () => this.saved.refresh(),
+    )
+    const dispose2 = reaction(
+      () => this.rootStore.preferences.pinnedFeeds,
+      () => this.saved.refresh(),
+    )
+    return () => {
+      dispose1()
+      dispose2()
+    }
+  }
+
   async refresh() {
     return Promise.all([this.saved.refresh(), this.discovery.refresh()])
   }
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index 6ca19b4b7..7714d65df 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -194,7 +194,7 @@ export class PreferencesModel {
   /**
    * This function fetches preferences and sets defaults for missing items.
    */
-  async sync({clearCache}: {clearCache?: boolean} = {}) {
+  async sync() {
     await this.lock.acquireAsync()
     try {
       // fetch preferences
@@ -252,8 +252,6 @@ export class PreferencesModel {
     } finally {
       this.lock.release()
     }
-
-    await this.rootStore.me.savedFeeds.updateCache(clearCache)
   }
 
   async syncLegacyPreferences() {
@@ -286,6 +284,9 @@ export class PreferencesModel {
     }
   }
 
+  // languages
+  // =
+
   hasContentLanguage(code2: string) {
     return this.contentLanguages.includes(code2)
   }
@@ -358,6 +359,9 @@ export class PreferencesModel {
     return all.join(', ')
   }
 
+  // moderation
+  // =
+
   async setContentLabelPref(
     key: keyof LabelPreferencesModel,
     value: LabelPreference,
@@ -409,6 +413,13 @@ export class PreferencesModel {
     }
   }
 
+  // feeds
+  // =
+
+  isPinnedFeed(uri: string) {
+    return this.pinnedFeeds.includes(uri)
+  }
+
   async _optimisticUpdateSavedFeeds(
     saved: string[],
     pinned: string[],
@@ -474,6 +485,9 @@ export class PreferencesModel {
     )
   }
 
+  // other
+  // =
+
   async setBirthDate(birthDate: Date) {
     this.birthDate = birthDate
     await this.lock.acquireAsync()
@@ -602,7 +616,7 @@ export class PreferencesModel {
   }
 
   getFeedTuners(
-    feedType: 'home' | 'following' | 'author' | 'custom' | 'likes',
+    feedType: 'home' | 'following' | 'author' | 'custom' | 'list' | 'likes',
   ) {
     if (feedType === 'custom') {
       return [
@@ -610,6 +624,9 @@ export class PreferencesModel {
         FeedTuner.preferredLangOnly(this.contentLanguages),
       ]
     }
+    if (feedType === 'list') {
+      return [FeedTuner.dedupReposts]
+    }
     if (feedType === 'home' || feedType === 'following') {
       const feedTuners = []
 
diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts
index 2dd72980d..4156f792a 100644
--- a/src/state/models/ui/saved-feeds.ts
+++ b/src/state/models/ui/saved-feeds.ts
@@ -2,7 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
 import {RootStoreModel} from '../root-store'
 import {bundleAsync} from 'lib/async/bundle'
 import {cleanError} from 'lib/strings/errors'
-import {CustomFeedModel} from '../feeds/custom-feed'
+import {FeedSourceModel} from '../content/feed-source'
 import {track} from 'lib/analytics/analytics'
 
 export class SavedFeedsModel {
@@ -13,7 +13,7 @@ export class SavedFeedsModel {
   error = ''
 
   // data
-  _feedModelCache: Record<string, CustomFeedModel> = {}
+  all: FeedSourceModel[] = []
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(
@@ -38,20 +38,11 @@ export class SavedFeedsModel {
   }
 
   get pinned() {
-    return this.rootStore.preferences.pinnedFeeds
-      .map(uri => this._feedModelCache[uri] as CustomFeedModel)
-      .filter(Boolean)
+    return this.all.filter(feed => feed.isPinned)
   }
 
   get unpinned() {
-    return this.rootStore.preferences.savedFeeds
-      .filter(uri => !this.isPinned(uri))
-      .map(uri => this._feedModelCache[uri] as CustomFeedModel)
-      .filter(Boolean)
-  }
-
-  get all() {
-    return [...this.pinned, ...this.unpinned]
+    return this.all.filter(feed => !feed.isPinned)
   }
 
   get pinnedFeedNames() {
@@ -62,120 +53,38 @@ export class SavedFeedsModel {
   // =
 
   /**
-   * Syncs the cached models against the current state
-   * - Should only be called by the preferences model after syncing state
-   */
-  updateCache = bundleAsync(async (clearCache?: boolean) => {
-    let newFeedModels: Record<string, CustomFeedModel> = {}
-    if (!clearCache) {
-      newFeedModels = {...this._feedModelCache}
-    }
-
-    // collect the feed URIs that havent been synced yet
-    const neededFeedUris = []
-    for (const feedUri of this.rootStore.preferences.savedFeeds) {
-      if (!(feedUri in newFeedModels)) {
-        neededFeedUris.push(feedUri)
-      }
-    }
-
-    // early exit if no feeds need to be fetched
-    if (!neededFeedUris.length || neededFeedUris.length === 0) {
-      return
-    }
-
-    // fetch the missing models
-    try {
-      for (let i = 0; i < neededFeedUris.length; i += 25) {
-        const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({
-          feeds: neededFeedUris.slice(i, 25),
-        })
-        for (const feedInfo of res.data.feeds) {
-          newFeedModels[feedInfo.uri] = new CustomFeedModel(
-            this.rootStore,
-            feedInfo,
-          )
-        }
-      }
-    } catch (error) {
-      console.error('Failed to fetch feed models', error)
-      this.rootStore.log.error('Failed to fetch feed models', error)
-    }
-
-    // merge into the cache
-    runInAction(() => {
-      this._feedModelCache = newFeedModels
-    })
-  })
-
-  /**
    * Refresh the preferences then reload all feed infos
    */
   refresh = bundleAsync(async () => {
     this._xLoading(true)
     try {
-      await this.rootStore.preferences.sync({clearCache: true})
+      await this.rootStore.preferences.sync()
+      const uris = dedup(
+        this.rootStore.preferences.pinnedFeeds.concat(
+          this.rootStore.preferences.savedFeeds,
+        ),
+      )
+      const feeds = uris.map(uri => new FeedSourceModel(this.rootStore, uri))
+      await Promise.all(feeds.map(f => f.setup()))
+      runInAction(() => {
+        this.all = feeds
+        this._updatePinSortOrder()
+      })
       this._xIdle()
     } catch (e: any) {
       this._xIdle(e)
     }
   })
 
-  async save(feed: CustomFeedModel) {
-    try {
-      await feed.save()
-      await this.updateCache()
-    } catch (e: any) {
-      this.rootStore.log.error('Failed to save feed', e)
-    }
-  }
-
-  async unsave(feed: CustomFeedModel) {
-    const uri = feed.uri
-    try {
-      if (this.isPinned(feed)) {
-        await this.rootStore.preferences.removePinnedFeed(uri)
-      }
-      await feed.unsave()
-    } catch (e: any) {
-      this.rootStore.log.error('Failed to unsave feed', e)
-    }
-  }
-
-  async togglePinnedFeed(feed: CustomFeedModel) {
-    if (!this.isPinned(feed)) {
-      track('CustomFeed:Pin', {
-        name: feed.data.displayName,
-        uri: feed.uri,
-      })
-      return this.rootStore.preferences.addPinnedFeed(feed.uri)
-    } else {
-      track('CustomFeed:Unpin', {
-        name: feed.data.displayName,
-        uri: feed.uri,
-      })
-      return this.rootStore.preferences.removePinnedFeed(feed.uri)
-    }
-  }
-
-  async reorderPinnedFeeds(feeds: CustomFeedModel[]) {
-    return this.rootStore.preferences.setSavedFeeds(
+  async reorderPinnedFeeds(feeds: FeedSourceModel[]) {
+    this._updatePinSortOrder(feeds.map(f => f.uri))
+    await this.rootStore.preferences.setSavedFeeds(
       this.rootStore.preferences.savedFeeds,
-      feeds.filter(feed => this.isPinned(feed)).map(feed => feed.uri),
+      feeds.filter(feed => feed.isPinned).map(feed => feed.uri),
     )
   }
 
-  isPinned(feedOrUri: CustomFeedModel | string) {
-    let uri: string
-    if (typeof feedOrUri === 'string') {
-      uri = feedOrUri
-    } else {
-      uri = feedOrUri.uri
-    }
-    return this.rootStore.preferences.pinnedFeeds.includes(uri)
-  }
-
-  async movePinnedFeed(item: CustomFeedModel, direction: 'up' | 'down') {
+  async movePinnedFeed(item: FeedSourceModel, direction: 'up' | 'down') {
     const pinned = this.rootStore.preferences.pinnedFeeds.slice()
     const index = pinned.indexOf(item.uri)
     if (index === -1) {
@@ -194,8 +103,9 @@ export class SavedFeedsModel {
       this.rootStore.preferences.savedFeeds,
       pinned,
     )
+    this._updatePinSortOrder()
     track('CustomFeed:Reorder', {
-      name: item.data.displayName,
+      name: item.displayName,
       uri: item.uri,
       index: pinned.indexOf(item.uri),
     })
@@ -219,4 +129,20 @@ export class SavedFeedsModel {
       this.rootStore.log.error('Failed to fetch user feeds', err)
     }
   }
+
+  // helpers
+  // =
+
+  _updatePinSortOrder(order?: string[]) {
+    order ??= this.rootStore.preferences.pinnedFeeds.concat(
+      this.rootStore.preferences.savedFeeds,
+    )
+    this.all.sort((a, b) => {
+      return order!.indexOf(a.uri) - order!.indexOf(b.uri)
+    })
+  }
+}
+
+function dedup(strings: string[]): string[] {
+  return Array.from(new Set(strings))
 }
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index a8937b84c..9c0cc6e30 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -1,4 +1,4 @@
-import {AppBskyEmbedRecord, ModerationUI} from '@atproto/api'
+import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {makeAutoObservable, runInAction} from 'mobx'
 import {ProfileModel} from '../content/profile'
@@ -60,17 +60,25 @@ export type ReportModal = {
   | {did: string}
 )
 
-export interface CreateOrEditMuteListModal {
-  name: 'create-or-edit-mute-list'
+export interface CreateOrEditListModal {
+  name: 'create-or-edit-list'
+  purpose?: string
   list?: ListModel
   onSave?: (uri: string) => void
 }
 
-export interface ListAddRemoveUserModal {
-  name: 'list-add-remove-user'
+export interface UserAddRemoveListsModal {
+  name: 'user-add-remove-lists'
   subject: string
   displayName: string
-  onUpdate?: () => void
+  onAdd?: (listUri: string) => void
+  onRemove?: (listUri: string) => void
+}
+
+export interface ListAddUserModal {
+  name: 'list-add-user'
+  list: ListModel
+  onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void
 }
 
 export interface EditImageModal {
@@ -180,8 +188,11 @@ export type Modal =
   // Moderation
   | ModerationDetailsModal
   | ReportModal
-  | CreateOrEditMuteListModal
-  | ListAddRemoveUserModal
+
+  // Lists
+  | CreateOrEditListModal
+  | UserAddRemoveListsModal
+  | ListAddUserModal
 
   // Posts
   | AltTextImageModal