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/cache/image-sizes.ts1
-rw-r--r--src/state/models/content/post-thread.ts13
-rw-r--r--src/state/models/discovery/feeds.ts97
-rw-r--r--src/state/models/feeds/custom-feed.ts120
-rw-r--r--src/state/models/feeds/multi-feed.ts216
-rw-r--r--src/state/models/feeds/notifications.ts10
-rw-r--r--src/state/models/feeds/post.ts265
-rw-r--r--src/state/models/feeds/posts.ts363
-rw-r--r--src/state/models/lists/actor-feeds.ts120
-rw-r--r--src/state/models/log.ts16
-rw-r--r--src/state/models/me.ts6
-rw-r--r--src/state/models/media/gallery.ts24
-rw-r--r--src/state/models/media/image.ts179
-rw-r--r--src/state/models/session.ts4
-rw-r--r--src/state/models/ui/preferences.ts157
-rw-r--r--src/state/models/ui/profile.ts36
-rw-r--r--src/state/models/ui/saved-feeds.ts185
-rw-r--r--src/state/models/ui/shell.ts2
18 files changed, 1379 insertions, 435 deletions
diff --git a/src/state/models/cache/image-sizes.ts b/src/state/models/cache/image-sizes.ts
index bbfb9612b..c30a68f4d 100644
--- a/src/state/models/cache/image-sizes.ts
+++ b/src/state/models/cache/image-sizes.ts
@@ -16,6 +16,7 @@ export class ImageSizesCache {
     if (Dimensions) {
       return Dimensions
     }
+
     const prom =
       this.activeRequests.get(uri) ||
       new Promise<Dimensions>(resolve => {
diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts
index 74a75d803..577b76e01 100644
--- a/src/state/models/content/post-thread.ts
+++ b/src/state/models/content/post-thread.ts
@@ -118,7 +118,7 @@ export class PostThreadItemModel {
 
   assignTreeModels(
     v: AppBskyFeedDefs.ThreadViewPost,
-    higlightedPostUri: string,
+    highlightedPostUri: string,
     includeParent = true,
     includeChildren = true,
   ) {
@@ -130,7 +130,12 @@ export class PostThreadItemModel {
         parentModel._showChildReplyLine = true
         if (v.parent.parent) {
           parentModel._showParentReplyLine = true
-          parentModel.assignTreeModels(v.parent, higlightedPostUri, true, false)
+          parentModel.assignTreeModels(
+            v.parent,
+            highlightedPostUri,
+            true,
+            false,
+          )
         }
         this.parent = parentModel
       } else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) {
@@ -147,10 +152,10 @@ export class PostThreadItemModel {
           const itemModel = new PostThreadItemModel(this.rootStore, item)
           itemModel._depth = this._depth + 1
           itemModel._showParentReplyLine =
-            itemModel.parentUri !== higlightedPostUri && replies.length === 0
+            itemModel.parentUri !== highlightedPostUri && replies.length === 0
           if (item.replies?.length) {
             itemModel._showChildReplyLine = true
-            itemModel.assignTreeModels(item, higlightedPostUri, false, true)
+            itemModel.assignTreeModels(item, highlightedPostUri, false, true)
           }
           replies.push(itemModel)
         } else if (AppBskyFeedDefs.isNotFoundPost(item)) {
diff --git a/src/state/models/discovery/feeds.ts b/src/state/models/discovery/feeds.ts
new file mode 100644
index 000000000..26a8d650c
--- /dev/null
+++ b/src/state/models/discovery/feeds.ts
@@ -0,0 +1,97 @@
+import {makeAutoObservable} from 'mobx'
+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'
+
+export class FeedsDiscoveryModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+
+  // data
+  feeds: CustomFeedModel[] = []
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get hasMore() {
+    return false
+  }
+
+  get hasContent() {
+    return this.feeds.length > 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  // public api
+  // =
+
+  refresh = bundleAsync(async () => {
+    this._xLoading()
+    try {
+      const res =
+        await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators(
+          {},
+        )
+      this._replaceAll(res)
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
+
+  clear() {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = false
+    this.error = ''
+    this.feeds = []
+  }
+
+  // state transitions
+  // =
+
+  _xLoading() {
+    this.isLoading = true
+    this.isRefreshing = true
+    this.error = ''
+  }
+
+  _xIdle(err?: any) {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = true
+    this.error = cleanError(err)
+    if (err) {
+      this.rootStore.log.error('Failed to fetch popular feeds', err)
+    }
+  }
+
+  // helper functions
+  // =
+
+  _replaceAll(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
+    this.feeds = []
+    for (const f of res.data.feeds) {
+      this.feeds.push(new CustomFeedModel(this.rootStore, f))
+    }
+  }
+}
diff --git a/src/state/models/feeds/custom-feed.ts b/src/state/models/feeds/custom-feed.ts
new file mode 100644
index 000000000..8fc1eb1ec
--- /dev/null
+++ b/src/state/models/feeds/custom-feed.ts
@@ -0,0 +1,120 @@
+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 {updateDataOptimistically} from 'lib/async/revertible'
+
+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 @${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() {
+    await this.rootStore.preferences.addSavedFeed(this.uri)
+  }
+
+  async unsave() {
+    await this.rootStore.preferences.removeSavedFeed(this.uri)
+  }
+
+  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)
+    }
+  }
+
+  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)
+    }
+  }
+
+  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/multi-feed.ts b/src/state/models/feeds/multi-feed.ts
new file mode 100644
index 000000000..3c13041c6
--- /dev/null
+++ b/src/state/models/feeds/multi-feed.ts
@@ -0,0 +1,216 @@
+import {makeAutoObservable, runInAction} from 'mobx'
+import {AtUri} from '@atproto/api'
+import {bundleAsync} from 'lib/async/bundle'
+import {RootStoreModel} from '../root-store'
+import {CustomFeedModel} from './custom-feed'
+import {PostsFeedModel} from './posts'
+import {PostsFeedSliceModel} from './post'
+
+const FEED_PAGE_SIZE = 5
+const FEEDS_PAGE_SIZE = 3
+
+export type MultiFeedItem =
+  | {
+      _reactKey: string
+      type: 'header'
+    }
+  | {
+      _reactKey: string
+      type: 'feed-header'
+      avatar: string | undefined
+      title: string
+    }
+  | {
+      _reactKey: string
+      type: 'feed-slice'
+      slice: PostsFeedSliceModel
+    }
+  | {
+      _reactKey: string
+      type: 'feed-loading'
+    }
+  | {
+      _reactKey: string
+      type: 'feed-error'
+      error: string
+    }
+  | {
+      _reactKey: string
+      type: 'feed-footer'
+      title: string
+      uri: string
+    }
+  | {
+      _reactKey: string
+      type: 'footer'
+    }
+
+export class PostsMultiFeedModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  hasMore = true
+
+  // data
+  feedInfos: CustomFeedModel[] = []
+  feeds: PostsFeedModel[] = []
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(this, {rootStore: false}, {autoBind: true})
+  }
+
+  get hasContent() {
+    return this.feeds.length !== 0
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  get items() {
+    const items: MultiFeedItem[] = [{_reactKey: '__header__', type: 'header'}]
+    for (let i = 0; i < this.feedInfos.length; i++) {
+      if (!this.feeds[i]) {
+        break
+      }
+      const feed = this.feeds[i]
+      const feedInfo = this.feedInfos[i]
+      const urip = new AtUri(feedInfo.uri)
+      items.push({
+        _reactKey: `__feed_header_${i}__`,
+        type: 'feed-header',
+        avatar: feedInfo.data.avatar,
+        title: feedInfo.displayName,
+      })
+      if (feed.isLoading) {
+        items.push({
+          _reactKey: `__feed_loading_${i}__`,
+          type: 'feed-loading',
+        })
+      } else if (feed.hasError) {
+        items.push({
+          _reactKey: `__feed_error_${i}__`,
+          type: 'feed-error',
+          error: feed.error,
+        })
+      } else {
+        for (let j = 0; j < feed.slices.length; j++) {
+          items.push({
+            _reactKey: `__feed_slice_${i}_${j}__`,
+            type: 'feed-slice',
+            slice: feed.slices[j],
+          })
+        }
+      }
+      items.push({
+        _reactKey: `__feed_footer_${i}__`,
+        type: 'feed-footer',
+        title: feedInfo.displayName,
+        uri: `/profile/${feedInfo.data.creator.did}/feed/${urip.rkey}`,
+      })
+    }
+    if (!this.hasMore) {
+      items.push({_reactKey: '__footer__', type: 'footer'})
+    }
+    return items
+  }
+
+  // public api
+  // =
+
+  /**
+   * Nuke all data
+   */
+  clear() {
+    this.rootStore.log.debug('MultiFeedModel:clear')
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = false
+    this.hasMore = true
+    this.feeds = []
+  }
+
+  /**
+   * 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() {
+    this.feedInfos = this.rootStore.me.savedFeeds.all.slice() // capture current feeds
+    await this.loadMore(true)
+  }
+
+  /**
+   * Load more posts to the end of the feed
+   */
+  loadMore = bundleAsync(async (isRefreshing: boolean = false) => {
+    if (!isRefreshing && !this.hasMore) {
+      return
+    }
+    if (isRefreshing) {
+      this.isRefreshing = true // set optimistically for UI
+      this.feeds = []
+    }
+    this._xLoading(isRefreshing)
+    const start = this.feeds.length
+    const newFeeds: PostsFeedModel[] = []
+    for (
+      let i = start;
+      i < start + FEEDS_PAGE_SIZE && i < this.feedInfos.length;
+      i++
+    ) {
+      const feed = new PostsFeedModel(this.rootStore, 'custom', {
+        feed: this.feedInfos[i].uri,
+      })
+      feed.pageSize = FEED_PAGE_SIZE
+      await feed.setup()
+      newFeeds.push(feed)
+    }
+    runInAction(() => {
+      this.feeds = this.feeds.concat(newFeeds)
+      this.hasMore = this.feeds.length < this.feedInfos.length
+    })
+    this._xIdle()
+  })
+
+  /**
+   * Attempt to load more again after a failure
+   */
+  async retryLoadMore() {
+    this.hasMore = true
+    return this.loadMore()
+  }
+
+  /**
+   * Removes posts from the feed upon deletion.
+   */
+  onPostDeleted(uri: string) {
+    for (const f of this.feeds) {
+      f.onPostDeleted(uri)
+    }
+  }
+
+  // state transitions
+  // =
+
+  _xLoading(isRefreshing = false) {
+    this.isLoading = true
+    this.isRefreshing = isRefreshing
+  }
+
+  _xIdle() {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = true
+  }
+
+  // helper functions
+  // =
+}
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts
index 73424f03e..5005f1d91 100644
--- a/src/state/models/feeds/notifications.ts
+++ b/src/state/models/feeds/notifications.ts
@@ -181,7 +181,7 @@ export class NotificationsFeedItemModel {
     return false
   }
 
-  get additionaDataUri(): string | undefined {
+  get additionalDataUri(): string | undefined {
     if (this.isReply || this.isQuote || this.isMention) {
       return this.uri
     } else if (this.isLike || this.isRepost) {
@@ -290,7 +290,9 @@ export class NotificationsFeedModel {
   }
 
   get hasNewLatest() {
-    return this.queuedNotifications && this.queuedNotifications?.length > 0
+    return Boolean(
+      this.queuedNotifications && this.queuedNotifications?.length > 0,
+    )
   }
 
   get unreadCountLabel(): string {
@@ -490,7 +492,7 @@ export class NotificationsFeedModel {
       'mostRecent',
       res.data.notifications[0],
     )
-    const addedUri = notif.additionaDataUri
+    const addedUri = notif.additionalDataUri
     if (addedUri) {
       const postsRes = await this.rootStore.agent.app.bsky.feed.getPosts({
         uris: [addedUri],
@@ -583,7 +585,7 @@ export class NotificationsFeedModel {
         `item-${_idCounter++}`,
         item,
       )
-      const uri = itemModel.additionaDataUri
+      const uri = itemModel.additionalDataUri
       if (uri) {
         const models = addedPostMap.get(uri) || []
         models.push(itemModel)
diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts
new file mode 100644
index 000000000..0c411d448
--- /dev/null
+++ b/src/state/models/feeds/post.ts
@@ -0,0 +1,265 @@
+import {makeAutoObservable} from 'mobx'
+import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api'
+import {RootStoreModel} from '../root-store'
+import {updateDataOptimistically} from 'lib/async/revertible'
+import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
+import {FeedViewPostsSlice} from 'lib/api/feed-manip'
+import {
+  getEmbedLabels,
+  getEmbedMuted,
+  getEmbedMutedByList,
+  getEmbedBlocking,
+  getEmbedBlockedBy,
+  getPostModeration,
+  filterAccountLabels,
+  filterProfileLabels,
+  mergePostModerations,
+} from 'lib/labeling/helpers'
+
+type FeedViewPost = AppBskyFeedDefs.FeedViewPost
+type ReasonRepost = AppBskyFeedDefs.ReasonRepost
+type PostView = AppBskyFeedDefs.PostView
+
+let _idCounter = 0
+
+export class PostsFeedItemModel {
+  // ui state
+  _reactKey: string = ''
+
+  // data
+  post: PostView
+  postRecord?: AppBskyFeedPost.Record
+  reply?: FeedViewPost['reply']
+  reason?: FeedViewPost['reason']
+  richText?: RichText
+
+  constructor(
+    public rootStore: RootStoreModel,
+    reactKey: string,
+    v: FeedViewPost,
+  ) {
+    this._reactKey = reactKey
+    this.post = v.post
+    if (AppBskyFeedPost.isRecord(this.post.record)) {
+      const valid = AppBskyFeedPost.validateRecord(this.post.record)
+      if (valid.success) {
+        this.postRecord = this.post.record
+        this.richText = new RichText(this.postRecord, {cleanNewlines: true})
+      } else {
+        this.postRecord = undefined
+        this.richText = undefined
+        rootStore.log.warn(
+          'Received an invalid app.bsky.feed.post record',
+          valid.error,
+        )
+      }
+    } else {
+      this.postRecord = undefined
+      this.richText = undefined
+      rootStore.log.warn(
+        'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type',
+        this.post.record,
+      )
+    }
+    this.reply = v.reply
+    this.reason = v.reason
+    makeAutoObservable(this, {rootStore: false})
+  }
+
+  get rootUri(): string {
+    if (this.reply?.root.uri) {
+      return this.reply.root.uri
+    }
+    return this.post.uri
+  }
+
+  get isThreadMuted() {
+    return this.rootStore.mutedThreads.uris.has(this.rootUri)
+  }
+
+  get labelInfo(): PostLabelInfo {
+    return {
+      postLabels: (this.post.labels || []).concat(
+        getEmbedLabels(this.post.embed),
+      ),
+      accountLabels: filterAccountLabels(this.post.author.labels),
+      profileLabels: filterProfileLabels(this.post.author.labels),
+      isMuted:
+        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) ||
+        false,
+      isBlockedBy:
+        !!this.post.author.viewer?.blockedBy ||
+        getEmbedBlockedBy(this.post.embed) ||
+        false,
+    }
+  }
+
+  get moderation(): PostModeration {
+    return getPostModeration(this.rootStore, this.labelInfo)
+  }
+
+  copy(v: FeedViewPost) {
+    this.post = v.post
+    this.reply = v.reply
+    this.reason = v.reason
+  }
+
+  copyMetrics(v: FeedViewPost) {
+    this.post.replyCount = v.post.replyCount
+    this.post.repostCount = v.post.repostCount
+    this.post.likeCount = v.post.likeCount
+    this.post.viewer = v.post.viewer
+  }
+
+  get reasonRepost(): ReasonRepost | undefined {
+    if (this.reason?.$type === 'app.bsky.feed.defs#reasonRepost') {
+      return this.reason as ReasonRepost
+    }
+  }
+
+  async toggleLike() {
+    this.post.viewer = this.post.viewer || {}
+    if (this.post.viewer.like) {
+      const url = this.post.viewer.like
+      await updateDataOptimistically(
+        this.post,
+        () => {
+          this.post.likeCount = (this.post.likeCount || 0) - 1
+          this.post.viewer!.like = undefined
+        },
+        () => this.rootStore.agent.deleteLike(url),
+      )
+    } else {
+      await updateDataOptimistically(
+        this.post,
+        () => {
+          this.post.likeCount = (this.post.likeCount || 0) + 1
+          this.post.viewer!.like = 'pending'
+        },
+        () => this.rootStore.agent.like(this.post.uri, this.post.cid),
+        res => {
+          this.post.viewer!.like = res.uri
+        },
+      )
+    }
+  }
+
+  async toggleRepost() {
+    this.post.viewer = this.post.viewer || {}
+    if (this.post.viewer?.repost) {
+      const url = this.post.viewer.repost
+      await updateDataOptimistically(
+        this.post,
+        () => {
+          this.post.repostCount = (this.post.repostCount || 0) - 1
+          this.post.viewer!.repost = undefined
+        },
+        () => this.rootStore.agent.deleteRepost(url),
+      )
+    } else {
+      await updateDataOptimistically(
+        this.post,
+        () => {
+          this.post.repostCount = (this.post.repostCount || 0) + 1
+          this.post.viewer!.repost = 'pending'
+        },
+        () => this.rootStore.agent.repost(this.post.uri, this.post.cid),
+        res => {
+          this.post.viewer!.repost = res.uri
+        },
+      )
+    }
+  }
+
+  async toggleThreadMute() {
+    if (this.isThreadMuted) {
+      this.rootStore.mutedThreads.uris.delete(this.rootUri)
+    } else {
+      this.rootStore.mutedThreads.uris.add(this.rootUri)
+    }
+  }
+
+  async delete() {
+    await this.rootStore.agent.deletePost(this.post.uri)
+    this.rootStore.emitPostDeleted(this.post.uri)
+  }
+}
+
+export class PostsFeedSliceModel {
+  // ui state
+  _reactKey: string = ''
+
+  // data
+  items: PostsFeedItemModel[] = []
+
+  constructor(
+    public rootStore: RootStoreModel,
+    reactKey: string,
+    slice: FeedViewPostsSlice,
+  ) {
+    this._reactKey = reactKey
+    for (const item of slice.items) {
+      this.items.push(
+        new PostsFeedItemModel(rootStore, `slice-${_idCounter++}`, item),
+      )
+    }
+    makeAutoObservable(this, {rootStore: false})
+  }
+
+  get uri() {
+    if (this.isReply) {
+      return this.items[1].post.uri
+    }
+    return this.items[0].post.uri
+  }
+
+  get isThread() {
+    return (
+      this.items.length > 1 &&
+      this.items.every(
+        item => item.post.author.did === this.items[0].post.author.did,
+      )
+    )
+  }
+
+  get isReply() {
+    return this.items.length > 1 && !this.isThread
+  }
+
+  get rootItem() {
+    if (this.isReply) {
+      return this.items[1]
+    }
+    return this.items[0]
+  }
+
+  get moderation() {
+    return mergePostModerations(this.items.map(item => item.moderation))
+  }
+
+  containsUri(uri: string) {
+    return !!this.items.find(item => item.post.uri === uri)
+  }
+
+  isThreadParentAt(i: number) {
+    if (this.items.length === 1) {
+      return false
+    }
+    return i < this.items.length - 1
+  }
+
+  isThreadChildAt(i: number) {
+    if (this.items.length === 1) {
+      return false
+    }
+    return i > 0
+  }
+}
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index b2dffdc69..911cc6309 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -1,11 +1,8 @@
 import {makeAutoObservable, runInAction} from 'mobx'
 import {
   AppBskyFeedGetTimeline as GetTimeline,
-  AppBskyFeedDefs,
-  AppBskyFeedPost,
   AppBskyFeedGetAuthorFeed as GetAuthorFeed,
-  RichText,
-  jsonToLex,
+  AppBskyFeedGetFeed as GetCustomFeed,
 } from '@atproto/api'
 import AwaitLock from 'await-lock'
 import {bundleAsync} from 'lib/async/bundle'
@@ -19,269 +16,11 @@ import {
   mergePosts,
 } from 'lib/api/build-suggested-posts'
 import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
-import {updateDataOptimistically} from 'lib/async/revertible'
-import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
-import {
-  getEmbedLabels,
-  getEmbedMuted,
-  getEmbedMutedByList,
-  getEmbedBlocking,
-  getEmbedBlockedBy,
-  getPostModeration,
-  mergePostModerations,
-  filterAccountLabels,
-  filterProfileLabels,
-} from 'lib/labeling/helpers'
-
-type FeedViewPost = AppBskyFeedDefs.FeedViewPost
-type ReasonRepost = AppBskyFeedDefs.ReasonRepost
-type PostView = AppBskyFeedDefs.PostView
+import {PostsFeedSliceModel} from './post'
 
 const PAGE_SIZE = 30
 let _idCounter = 0
 
-export class PostsFeedItemModel {
-  // ui state
-  _reactKey: string = ''
-
-  // data
-  post: PostView
-  postRecord?: AppBskyFeedPost.Record
-  reply?: FeedViewPost['reply']
-  reason?: FeedViewPost['reason']
-  richText?: RichText
-
-  constructor(
-    public rootStore: RootStoreModel,
-    reactKey: string,
-    v: FeedViewPost,
-  ) {
-    this._reactKey = reactKey
-    this.post = v.post
-    if (AppBskyFeedPost.isRecord(this.post.record)) {
-      const valid = AppBskyFeedPost.validateRecord(this.post.record)
-      if (valid.success) {
-        this.postRecord = this.post.record
-        this.richText = new RichText(this.postRecord, {cleanNewlines: true})
-      } else {
-        this.postRecord = undefined
-        this.richText = undefined
-        rootStore.log.warn(
-          'Received an invalid app.bsky.feed.post record',
-          valid.error,
-        )
-      }
-    } else {
-      this.postRecord = undefined
-      this.richText = undefined
-      rootStore.log.warn(
-        'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type',
-        this.post.record,
-      )
-    }
-    this.reply = v.reply
-    this.reason = v.reason
-    makeAutoObservable(this, {rootStore: false})
-  }
-
-  get rootUri(): string {
-    if (this.reply?.root.uri) {
-      return this.reply.root.uri
-    }
-    return this.post.uri
-  }
-
-  get isThreadMuted() {
-    return this.rootStore.mutedThreads.uris.has(this.rootUri)
-  }
-
-  get labelInfo(): PostLabelInfo {
-    return {
-      postLabels: (this.post.labels || []).concat(
-        getEmbedLabels(this.post.embed),
-      ),
-      accountLabels: filterAccountLabels(this.post.author.labels),
-      profileLabels: filterProfileLabels(this.post.author.labels),
-      isMuted:
-        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) ||
-        false,
-      isBlockedBy:
-        !!this.post.author.viewer?.blockedBy ||
-        getEmbedBlockedBy(this.post.embed) ||
-        false,
-    }
-  }
-
-  get moderation(): PostModeration {
-    return getPostModeration(this.rootStore, this.labelInfo)
-  }
-
-  copy(v: FeedViewPost) {
-    this.post = v.post
-    this.reply = v.reply
-    this.reason = v.reason
-  }
-
-  copyMetrics(v: FeedViewPost) {
-    this.post.replyCount = v.post.replyCount
-    this.post.repostCount = v.post.repostCount
-    this.post.likeCount = v.post.likeCount
-    this.post.viewer = v.post.viewer
-  }
-
-  get reasonRepost(): ReasonRepost | undefined {
-    if (this.reason?.$type === 'app.bsky.feed.defs#reasonRepost') {
-      return this.reason as ReasonRepost
-    }
-  }
-
-  async toggleLike() {
-    this.post.viewer = this.post.viewer || {}
-    if (this.post.viewer.like) {
-      const url = this.post.viewer.like
-      await updateDataOptimistically(
-        this.post,
-        () => {
-          this.post.likeCount = (this.post.likeCount || 0) - 1
-          this.post.viewer!.like = undefined
-        },
-        () => this.rootStore.agent.deleteLike(url),
-      )
-    } else {
-      await updateDataOptimistically(
-        this.post,
-        () => {
-          this.post.likeCount = (this.post.likeCount || 0) + 1
-          this.post.viewer!.like = 'pending'
-        },
-        () => this.rootStore.agent.like(this.post.uri, this.post.cid),
-        res => {
-          this.post.viewer!.like = res.uri
-        },
-      )
-    }
-  }
-
-  async toggleRepost() {
-    this.post.viewer = this.post.viewer || {}
-    if (this.post.viewer?.repost) {
-      const url = this.post.viewer.repost
-      await updateDataOptimistically(
-        this.post,
-        () => {
-          this.post.repostCount = (this.post.repostCount || 0) - 1
-          this.post.viewer!.repost = undefined
-        },
-        () => this.rootStore.agent.deleteRepost(url),
-      )
-    } else {
-      await updateDataOptimistically(
-        this.post,
-        () => {
-          this.post.repostCount = (this.post.repostCount || 0) + 1
-          this.post.viewer!.repost = 'pending'
-        },
-        () => this.rootStore.agent.repost(this.post.uri, this.post.cid),
-        res => {
-          this.post.viewer!.repost = res.uri
-        },
-      )
-    }
-  }
-
-  async toggleThreadMute() {
-    if (this.isThreadMuted) {
-      this.rootStore.mutedThreads.uris.delete(this.rootUri)
-    } else {
-      this.rootStore.mutedThreads.uris.add(this.rootUri)
-    }
-  }
-
-  async delete() {
-    await this.rootStore.agent.deletePost(this.post.uri)
-    this.rootStore.emitPostDeleted(this.post.uri)
-  }
-}
-
-export class PostsFeedSliceModel {
-  // ui state
-  _reactKey: string = ''
-
-  // data
-  items: PostsFeedItemModel[] = []
-
-  constructor(
-    public rootStore: RootStoreModel,
-    reactKey: string,
-    slice: FeedViewPostsSlice,
-  ) {
-    this._reactKey = reactKey
-    for (const item of slice.items) {
-      this.items.push(
-        new PostsFeedItemModel(rootStore, `item-${_idCounter++}`, item),
-      )
-    }
-    makeAutoObservable(this, {rootStore: false})
-  }
-
-  get uri() {
-    if (this.isReply) {
-      return this.items[1].post.uri
-    }
-    return this.items[0].post.uri
-  }
-
-  get isThread() {
-    return (
-      this.items.length > 1 &&
-      this.items.every(
-        item => item.post.author.did === this.items[0].post.author.did,
-      )
-    )
-  }
-
-  get isReply() {
-    return this.items.length > 1 && !this.isThread
-  }
-
-  get rootItem() {
-    if (this.isReply) {
-      return this.items[1]
-    }
-    return this.items[0]
-  }
-
-  get moderation() {
-    return mergePostModerations(this.items.map(item => item.moderation))
-  }
-
-  containsUri(uri: string) {
-    return !!this.items.find(item => item.post.uri === uri)
-  }
-
-  isThreadParentAt(i: number) {
-    if (this.items.length === 1) {
-      return false
-    }
-    return i < this.items.length - 1
-  }
-
-  isThreadChildAt(i: number) {
-    if (this.items.length === 1) {
-      return false
-    }
-    return i > 0
-  }
-}
-
 export class PostsFeedModel {
   // state
   isLoading = false
@@ -297,6 +36,7 @@ export class PostsFeedModel {
   loadMoreCursor: string | undefined
   pollCursor: string | undefined
   tuner = new FeedTuner()
+  pageSize = PAGE_SIZE
 
   // used to linearize async modifications to state
   lock = new AwaitLock()
@@ -309,8 +49,11 @@ export class PostsFeedModel {
 
   constructor(
     public rootStore: RootStoreModel,
-    public feedType: 'home' | 'author' | 'suggested' | 'goodstuff',
-    params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams,
+    public feedType: 'home' | 'author' | 'suggested' | 'custom',
+    params:
+      | GetTimeline.QueryParams
+      | GetAuthorFeed.QueryParams
+      | GetCustomFeed.QueryParams,
   ) {
     makeAutoObservable(
       this,
@@ -387,10 +130,9 @@ export class PostsFeedModel {
   }
 
   get feedTuners() {
-    if (this.feedType === 'goodstuff') {
+    if (this.feedType === 'custom') {
       return [
         FeedTuner.dedupReposts,
-        FeedTuner.likedRepliesOnly,
         FeedTuner.preferredLangOnly(
           this.rootStore.preferences.contentLanguages,
         ),
@@ -416,7 +158,7 @@ export class PostsFeedModel {
       this.tuner.reset()
       this._xLoading(isRefreshing)
       try {
-        const res = await this._getFeed({limit: PAGE_SIZE})
+        const res = await this._getFeed({limit: this.pageSize})
         await this._replaceAll(res)
         this._xIdle()
       } catch (e: any) {
@@ -455,7 +197,7 @@ export class PostsFeedModel {
       try {
         const res = await this._getFeed({
           cursor: this.loadMoreCursor,
-          limit: PAGE_SIZE,
+          limit: this.pageSize,
         })
         await this._appendAll(res)
         this._xIdle()
@@ -524,7 +266,7 @@ export class PostsFeedModel {
     if (this.hasNewLatest || this.feedType === 'suggested') {
       return
     }
-    const res = await this._getFeed({limit: PAGE_SIZE})
+    const res = await this._getFeed({limit: this.pageSize})
     const tuner = new FeedTuner()
     const slices = tuner.tune(res.data.feed, this.feedTuners)
     this.setHasNewLatest(slices[0]?.uri !== this.slices[0]?.uri)
@@ -599,13 +341,15 @@ export class PostsFeedModel {
   // helper functions
   // =
 
-  async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
+  async _replaceAll(
+    res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
+  ) {
     this.pollCursor = res.data.feed[0]?.post.uri
     return this._appendAll(res, true)
   }
 
   async _appendAll(
-    res: GetTimeline.Response | GetAuthorFeed.Response,
+    res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
     replace = false,
   ) {
     this.loadMoreCursor = res.data.cursor
@@ -644,7 +388,9 @@ export class PostsFeedModel {
     })
   }
 
-  _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
+  _updateAll(
+    res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
+  ) {
     for (const item of res.data.feed) {
       const existingSlice = this.slices.find(slice =>
         slice.containsUri(item.post.uri),
@@ -661,8 +407,13 @@ export class PostsFeedModel {
   }
 
   protected async _getFeed(
-    params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {},
-  ): Promise<GetTimeline.Response | GetAuthorFeed.Response> {
+    params:
+      | GetTimeline.QueryParams
+      | GetAuthorFeed.QueryParams
+      | GetCustomFeed.QueryParams,
+  ): Promise<
+    GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response
+  > {
     params = Object.assign({}, this.params, params)
     if (this.feedType === 'suggested') {
       const responses = await getMultipleAuthorsPosts(
@@ -684,61 +435,31 @@ export class PostsFeedModel {
       }
     } else if (this.feedType === 'home') {
       return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams)
-    } else if (this.feedType === 'goodstuff') {
-      const res = await getGoodStuff(
-        this.rootStore.session.currentSession?.accessJwt || '',
-        params as GetTimeline.QueryParams,
+    } else if (this.feedType === 'custom') {
+      this.checkIfCustomFeedIsOnlineAndValid(
+        params as GetCustomFeed.QueryParams,
       )
-      res.data.feed = (res.data.feed || []).filter(
-        item => !item.post.author.viewer?.muted,
+      return this.rootStore.agent.app.bsky.feed.getFeed(
+        params as GetCustomFeed.QueryParams,
       )
-      return res
     } else {
       return this.rootStore.agent.getAuthorFeed(
         params as GetAuthorFeed.QueryParams,
       )
     }
   }
-}
 
-// HACK
-// temporary off-spec route to get the good stuff
-// -prf
-async function getGoodStuff(
-  accessJwt: string,
-  params: GetTimeline.QueryParams,
-): Promise<GetTimeline.Response> {
-  const controller = new AbortController()
-  const to = setTimeout(() => controller.abort(), 15e3)
-
-  const uri = new URL('https://bsky.social/xrpc/app.bsky.unspecced.getPopular')
-  let k: keyof GetTimeline.QueryParams
-  for (k in params) {
-    if (typeof params[k] !== 'undefined') {
-      uri.searchParams.set(k, String(params[k]))
+  private async checkIfCustomFeedIsOnlineAndValid(
+    params: GetCustomFeed.QueryParams,
+  ) {
+    const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
+      feed: params.feed,
+    })
+    if (!res.data.isOnline || !res.data.isValid) {
+      runInAction(() => {
+        this.error =
+          'This custom feed is not online or may be experiencing issues.'
+      })
     }
   }
-
-  const res = await fetch(String(uri), {
-    method: 'get',
-    headers: {
-      accept: 'application/json',
-      authorization: `Bearer ${accessJwt}`,
-    },
-    signal: controller.signal,
-  })
-
-  const resHeaders: Record<string, string> = {}
-  res.headers.forEach((value: string, key: string) => {
-    resHeaders[key] = value
-  })
-  let resBody = await res.json()
-
-  clearTimeout(to)
-
-  return {
-    success: res.status === 200,
-    headers: resHeaders,
-    data: jsonToLex(resBody),
-  }
 }
diff --git a/src/state/models/lists/actor-feeds.ts b/src/state/models/lists/actor-feeds.ts
new file mode 100644
index 000000000..0f2060581
--- /dev/null
+++ b/src/state/models/lists/actor-feeds.ts
@@ -0,0 +1,120 @@
+import {makeAutoObservable} from 'mobx'
+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'
+
+const PAGE_SIZE = 30
+
+export class ActorFeedsModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+  hasMore = true
+  loadMoreCursor?: string
+
+  // data
+  feeds: CustomFeedModel[] = []
+
+  constructor(
+    public rootStore: RootStoreModel,
+    public params: GetActorFeeds.QueryParams,
+  ) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get hasContent() {
+    return this.feeds.length > 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  // public api
+  // =
+
+  async refresh() {
+    return this.loadMore(true)
+  }
+
+  clear() {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = false
+    this.error = ''
+    this.hasMore = true
+    this.loadMoreCursor = undefined
+    this.feeds = []
+  }
+
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    if (!replace && !this.hasMore) {
+      return
+    }
+    this._xLoading(replace)
+    try {
+      const res = await this.rootStore.agent.app.bsky.feed.getActorFeeds({
+        actor: this.params.actor,
+        limit: PAGE_SIZE,
+        cursor: replace ? undefined : this.loadMoreCursor,
+      })
+      if (replace) {
+        this._replaceAll(res)
+      } else {
+        this._appendAll(res)
+      }
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
+
+  // 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) {
+      this.rootStore.log.error('Failed to fetch user followers', err)
+    }
+  }
+
+  // helper functions
+  // =
+
+  _replaceAll(res: GetActorFeeds.Response) {
+    this.feeds = []
+    this._appendAll(res)
+  }
+
+  _appendAll(res: GetActorFeeds.Response) {
+    this.loadMoreCursor = res.data.cursor
+    this.hasMore = !!this.loadMoreCursor
+    for (const f of res.data.feeds) {
+      this.feeds.push(new CustomFeedModel(this.rootStore, f))
+    }
+  }
+}
diff --git a/src/state/models/log.ts b/src/state/models/log.ts
index d80617139..7c9c37c0d 100644
--- a/src/state/models/log.ts
+++ b/src/state/models/log.ts
@@ -27,6 +27,7 @@ function genId(): string {
 
 export class LogModel {
   entries: LogEntry[] = []
+  timers = new Map<string, number>()
 
   constructor() {
     makeAutoObservable(this)
@@ -74,6 +75,21 @@ export class LogModel {
       ts: Date.now(),
     })
   }
+
+  time = (label = 'default') => {
+    this.timers.set(label, performance.now())
+  }
+
+  timeEnd = (label = 'default', warn = false) => {
+    const endTime = performance.now()
+    if (this.timers.has(label)) {
+      const elapsedTime = endTime - this.timers.get(label)!
+      console.log(`${label}: ${elapsedTime.toFixed(3)}ms`)
+      this.timers.delete(label)
+    } else {
+      warn && console.warn(`Timer with label '${label}' does not exist.`)
+    }
+  }
 }
 
 function detailsToStr(details?: any) {
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index ba2dc6f32..815044857 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -8,6 +8,7 @@ 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
@@ -21,6 +22,7 @@ export class MeModel {
   followsCount: number | undefined
   followersCount: number | undefined
   mainFeed: PostsFeedModel
+  savedFeeds: SavedFeedsModel
   notifications: NotificationsFeedModel
   follows: MyFollowsCache
   invites: ComAtprotoServerDefs.InviteCode[] = []
@@ -43,12 +45,14 @@ export class MeModel {
     })
     this.notifications = new NotificationsFeedModel(this.rootStore)
     this.follows = new MyFollowsCache(this.rootStore)
+    this.savedFeeds = new SavedFeedsModel(this.rootStore)
   }
 
   clear() {
     this.mainFeed.clear()
     this.notifications.clear()
     this.follows.clear()
+    this.savedFeeds.clear()
     this.did = ''
     this.handle = ''
     this.displayName = ''
@@ -110,6 +114,7 @@ export class MeModel {
       /* dont await */ this.notifications.setup().catch(e => {
         this.rootStore.log.error('Failed to setup notifications model', e)
       })
+      /* dont await */ this.savedFeeds.refresh(true)
       this.rootStore.emitSessionLoaded()
       await this.fetchInviteCodes()
       await this.fetchAppPasswords()
@@ -119,6 +124,7 @@ export class MeModel {
   }
 
   async updateIfNeeded() {
+    /* dont await */ this.savedFeeds.refresh(true)
     if (Date.now() - this.lastProfileStateUpdate > PROFILE_UPDATE_INTERVAL) {
       this.rootStore.log.debug('Updating me profile information')
       this.lastProfileStateUpdate = Date.now()
diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts
index 67f8d2ea1..52ef8f375 100644
--- a/src/state/models/media/gallery.ts
+++ b/src/state/models/media/gallery.ts
@@ -4,7 +4,6 @@ import {ImageModel} from './image'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {openPicker} from 'lib/media/picker'
 import {getImageDim} from 'lib/media/manip'
-import {getDataUriSize} from 'lib/media/util'
 import {isNative} from 'platform/detection'
 
 export class GalleryModel {
@@ -24,13 +23,7 @@ export class GalleryModel {
     return this.images.length
   }
 
-  get paths() {
-    return this.images.map(image =>
-      image.compressed === undefined ? image.path : image.compressed.path,
-    )
-  }
-
-  async add(image_: RNImage) {
+  async add(image_: Omit<RNImage, 'size'>) {
     if (this.size >= 4) {
       return
     }
@@ -39,15 +32,9 @@ export class GalleryModel {
     if (!this.images.some(i => i.path === image_.path)) {
       const image = new ImageModel(this.rootStore, image_)
 
-      if (!isNative) {
-        await image.manipulate({})
-      } else {
-        await image.compress()
-      }
-
-      runInAction(() => {
-        this.images.push(image)
-      })
+      // Initial resize
+      image.manipulate({})
+      this.images.push(image)
     }
   }
 
@@ -70,11 +57,10 @@ export class GalleryModel {
 
     const {width, height} = await getImageDim(uri)
 
-    const image: RNImage = {
+    const image = {
       path: uri,
       height,
       width,
-      size: getDataUriSize(uri),
       mime: 'image/jpeg',
     }
 
diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts
index ec93bf5b6..e524c49de 100644
--- a/src/state/models/media/image.ts
+++ b/src/state/models/media/image.ts
@@ -3,14 +3,11 @@ import {RootStoreModel} from 'state/index'
 import {makeAutoObservable, runInAction} from 'mobx'
 import {POST_IMG_MAX} from 'lib/constants'
 import * as ImageManipulator from 'expo-image-manipulator'
-import {getDataUriSize, scaleDownDimensions} from 'lib/media/util'
+import {getDataUriSize} from 'lib/media/util'
 import {openCropper} from 'lib/media/picker'
 import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator'
 import {Position} from 'react-avatar-editor'
-import {compressAndResizeImageForPost} from 'lib/media/manip'
-
-// TODO: EXIF embed
-// Cases to consider: ExternalEmbed
+import {Dimensions} from 'lib/media/types'
 
 export interface ImageManipulationAttributes {
   aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
@@ -21,17 +18,16 @@ export interface ImageManipulationAttributes {
   flipVertical?: boolean
 }
 
-export class ImageModel implements RNImage {
+const MAX_IMAGE_SIZE_IN_BYTES = 976560
+
+export class ImageModel implements Omit<RNImage, 'size'> {
   path: string
   mime = 'image/jpeg'
   width: number
   height: number
-  size: number
   altText = ''
   cropped?: RNImage = undefined
   compressed?: RNImage = undefined
-  scaledWidth: number = POST_IMG_MAX.width
-  scaledHeight: number = POST_IMG_MAX.height
 
   // Web manipulation
   prev?: RNImage
@@ -44,7 +40,7 @@ export class ImageModel implements RNImage {
   }
   prevAttributes: ImageManipulationAttributes = {}
 
-  constructor(public rootStore: RootStoreModel, image: RNImage) {
+  constructor(public rootStore: RootStoreModel, image: Omit<RNImage, 'size'>) {
     makeAutoObservable(this, {
       rootStore: false,
     })
@@ -52,19 +48,8 @@ export class ImageModel implements RNImage {
     this.path = image.path
     this.width = image.width
     this.height = image.height
-    this.size = image.size
-    this.calcScaledDimensions()
   }
 
-  // TODO: Revisit compression factor due to updated sizing with zoom
-  // get compressionFactor() {
-  //   const MAX_IMAGE_SIZE_IN_BYTES = 976560
-
-  //   return this.size < MAX_IMAGE_SIZE_IN_BYTES
-  //     ? 1
-  //     : MAX_IMAGE_SIZE_IN_BYTES / this.size
-  // }
-
   setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
     this.attributes.aspectRatio = aspectRatio
   }
@@ -93,8 +78,24 @@ export class ImageModel implements RNImage {
     }
   }
 
-  getDisplayDimensions(
-    as: ImageManipulationAttributes['aspectRatio'] = '1:1',
+  getUploadDimensions(
+    dimensions: Dimensions,
+    maxDimensions: Dimensions = POST_IMG_MAX,
+    as: ImageManipulationAttributes['aspectRatio'] = 'None',
+  ) {
+    const {width, height} = dimensions
+    const {width: maxWidth, height: maxHeight} = maxDimensions
+
+    return width < maxWidth && height < maxHeight
+      ? {
+          width,
+          height,
+        }
+      : this.getResizedDimensions(as, POST_IMG_MAX.width)
+  }
+
+  getResizedDimensions(
+    as: ImageManipulationAttributes['aspectRatio'] = 'None',
     maxSide: number,
   ) {
     const ratioMultiplier = this.ratioMultipliers[as]
@@ -119,59 +120,70 @@ export class ImageModel implements RNImage {
     }
   }
 
-  calcScaledDimensions() {
-    const {width, height} = scaleDownDimensions(
-      {width: this.width, height: this.height},
-      POST_IMG_MAX,
-    )
-    this.scaledWidth = width
-    this.scaledHeight = height
-  }
-
   async setAltText(altText: string) {
     this.altText = altText
   }
 
-  // Only for mobile
-  async crop() {
-    try {
-      const cropped = await openCropper({
-        mediaType: 'photo',
-        path: this.path,
-        freeStyleCropEnabled: true,
-        width: this.scaledWidth,
-        height: this.scaledHeight,
-      })
-      runInAction(() => {
-        this.cropped = cropped
-        this.compress()
-      })
-    } catch (err) {
-      this.rootStore.log.error('Failed to crop photo', err)
+  // Only compress prior to upload
+  async compress() {
+    for (let i = 10; i > 0; i--) {
+      // Float precision
+      const factor = Math.round(i) / 10
+      const compressed = await ImageManipulator.manipulateAsync(
+        this.cropped?.path ?? this.path,
+        undefined,
+        {
+          compress: factor,
+          base64: true,
+          format: SaveFormat.JPEG,
+        },
+      )
+
+      if (compressed.base64 !== undefined) {
+        const size = getDataUriSize(compressed.base64)
+
+        if (size < MAX_IMAGE_SIZE_IN_BYTES) {
+          runInAction(() => {
+            this.compressed = {
+              mime: 'image/jpeg',
+              path: compressed.uri,
+              size,
+              ...compressed,
+            }
+          })
+          return
+        }
+      }
     }
+
+    // Compression fails when removing redundant information is not possible.
+    // This can be tested with images that have high variance in noise.
+    throw new Error('Failed to compress image')
   }
 
-  async compress() {
+  // Mobile
+  async crop() {
     try {
-      const {width, height} = scaleDownDimensions(
-        this.cropped
-          ? {width: this.cropped.width, height: this.cropped.height}
-          : {width: this.width, height: this.height},
-        POST_IMG_MAX,
-      )
+      // openCropper requires an output width and height hence
+      // getting upload dimensions before cropping is necessary.
+      const {width, height} = this.getUploadDimensions({
+        width: this.width,
+        height: this.height,
+      })
 
-      // TODO: Revisit this - currently iOS uses this as well
-      const compressed = await compressAndResizeImageForPost({
-        ...(this.cropped === undefined ? this : this.cropped),
+      const cropped = await openCropper(this.rootStore, {
+        mediaType: 'photo',
+        path: this.path,
+        freeStyleCropEnabled: true,
         width,
         height,
       })
 
       runInAction(() => {
-        this.compressed = compressed
+        this.cropped = cropped
       })
     } catch (err) {
-      this.rootStore.log.error('Failed to compress photo', err)
+      this.rootStore.log.error('Failed to crop photo', err)
     }
   }
 
@@ -181,6 +193,9 @@ export class ImageModel implements RNImage {
       crop?: ActionCrop['crop']
     } & ImageManipulationAttributes,
   ) {
+    let uploadWidth: number | undefined
+    let uploadHeight: number | undefined
+
     const {aspectRatio, crop, position, scale} = attributes
     const modifiers = []
 
@@ -197,14 +212,34 @@ export class ImageModel implements RNImage {
     }
 
     if (crop !== undefined) {
+      const croppedHeight = crop.height * this.height
+      const croppedWidth = crop.width * this.width
       modifiers.push({
         crop: {
           originX: crop.originX * this.width,
           originY: crop.originY * this.height,
-          height: crop.height * this.height,
-          width: crop.width * this.width,
+          height: croppedHeight,
+          width: croppedWidth,
         },
       })
+
+      const uploadDimensions = this.getUploadDimensions(
+        {width: croppedWidth, height: croppedHeight},
+        POST_IMG_MAX,
+        aspectRatio,
+      )
+
+      uploadWidth = uploadDimensions.width
+      uploadHeight = uploadDimensions.height
+    } else {
+      const uploadDimensions = this.getUploadDimensions(
+        {width: this.width, height: this.height},
+        POST_IMG_MAX,
+        aspectRatio,
+      )
+
+      uploadWidth = uploadDimensions.width
+      uploadHeight = uploadDimensions.height
     }
 
     if (scale !== undefined) {
@@ -222,36 +257,40 @@ export class ImageModel implements RNImage {
     const ratioMultiplier =
       this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1']
 
-    const MAX_SIDE = 2000
-
     const result = await ImageManipulator.manipulateAsync(
       this.path,
       [
         ...modifiers,
-        {resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}},
+        {
+          resize:
+            ratioMultiplier > 1 ? {width: uploadWidth} : {height: uploadHeight},
+        },
       ],
       {
-        compress: 0.9,
+        base64: true,
         format: SaveFormat.JPEG,
       },
     )
 
     runInAction(() => {
-      this.compressed = {
+      this.cropped = {
         mime: 'image/jpeg',
         path: result.uri,
-        size: getDataUriSize(result.uri),
+        size:
+          result.base64 !== undefined
+            ? getDataUriSize(result.base64)
+            : MAX_IMAGE_SIZE_IN_BYTES + 999, // shouldn't hit this unless manipulation fails
         ...result,
       }
     })
   }
 
-  resetCompressed() {
+  resetCropped() {
     this.manipulate({})
   }
 
   previous() {
-    this.compressed = this.prev
+    this.cropped = this.prev
     this.attributes = this.prevAttributes
   }
 }
diff --git a/src/state/models/session.ts b/src/state/models/session.ts
index c36537601..aa9c97750 100644
--- a/src/state/models/session.ts
+++ b/src/state/models/session.ts
@@ -187,7 +187,7 @@ export class SessionModel {
       account => account.service === service && account.did === did,
     )
 
-    // fall back to any pre-existing access tokens
+    // fall back to any preexisting access tokens
     let refreshJwt = session?.refreshJwt || existingAccount?.refreshJwt
     let accessJwt = session?.accessJwt || existingAccount?.accessJwt
     if (event === 'expired') {
@@ -247,7 +247,7 @@ export class SessionModel {
     const res = await agent.getProfile({actor: did}).catch(_e => undefined)
     if (res) {
       return {
-        dispayName: res.data.displayName,
+        displayName: res.data.displayName,
         aviUrl: res.data.avatar,
       }
     }
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index 1471420fc..dcf6b9a7a 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -11,6 +11,7 @@ import {
   ALWAYS_FILTER_LABEL_GROUP,
   ALWAYS_WARN_LABEL_GROUP,
 } from 'lib/labeling/const'
+import {DEFAULT_FEEDS} from 'lib/constants'
 import {isIOS} from 'platform/detection'
 
 const deviceLocales = getLocales()
@@ -25,6 +26,7 @@ const LABEL_GROUPS = [
   'spam',
   'impersonation',
 ]
+const VISIBILITY_VALUES = ['show', 'warn', 'hide']
 
 export class LabelPreferencesModel {
   nsfw: LabelPreference = 'hide'
@@ -45,6 +47,8 @@ export class PreferencesModel {
   contentLanguages: string[] =
     deviceLocales?.map?.(locale => locale.languageCode) || []
   contentLabels = new LabelPreferencesModel()
+  savedFeeds: string[] = []
+  pinnedFeeds: string[] = []
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(this, {}, {autoBind: true})
@@ -54,9 +58,16 @@ export class PreferencesModel {
     return {
       contentLanguages: this.contentLanguages,
       contentLabels: this.contentLabels,
+      savedFeeds: this.savedFeeds,
+      pinnedFeeds: this.pinnedFeeds,
     }
   }
 
+  /**
+   * The function hydrates an object with properties related to content languages, labels, saved feeds,
+   * and pinned feeds that it gets from the parameter `v` (probably local storage)
+   * @param {unknown} v - the data object to hydrate from
+   */
   hydrate(v: unknown) {
     if (isObj(v)) {
       if (
@@ -72,10 +83,29 @@ export class PreferencesModel {
         // default to the device languages
         this.contentLanguages = deviceLocales.map(locale => locale.languageCode)
       }
+      if (
+        hasProp(v, 'savedFeeds') &&
+        Array.isArray(v.savedFeeds) &&
+        typeof v.savedFeeds.every(item => typeof item === 'string')
+      ) {
+        this.savedFeeds = v.savedFeeds
+      }
+      if (
+        hasProp(v, 'pinnedFeeds') &&
+        Array.isArray(v.pinnedFeeds) &&
+        typeof v.pinnedFeeds.every(item => typeof item === 'string')
+      ) {
+        this.pinnedFeeds = v.pinnedFeeds
+      }
     }
   }
 
+  /**
+   * This function fetches preferences and sets defaults for missing items.
+   */
   async sync() {
+    // fetch preferences
+    let hasSavedFeedsPref = false
     const res = await this.rootStore.agent.app.bsky.actor.getPreferences({})
     runInAction(() => {
       for (const pref of res.data.preferences) {
@@ -88,22 +118,83 @@ export class PreferencesModel {
           AppBskyActorDefs.isContentLabelPref(pref) &&
           AppBskyActorDefs.validateAdultContentPref(pref).success
         ) {
-          if (LABEL_GROUPS.includes(pref.label)) {
-            this.contentLabels[pref.label] = pref.visibility
+          if (
+            LABEL_GROUPS.includes(pref.label) &&
+            VISIBILITY_VALUES.includes(pref.visibility)
+          ) {
+            this.contentLabels[pref.label as keyof LabelPreferencesModel] =
+              pref.visibility as LabelPreference
           }
+        } else if (
+          AppBskyActorDefs.isSavedFeedsPref(pref) &&
+          AppBskyActorDefs.validateSavedFeedsPref(pref).success
+        ) {
+          this.savedFeeds = pref.saved
+          this.pinnedFeeds = pref.pinned
+          hasSavedFeedsPref = true
         }
       }
     })
+
+    // set defaults on missing items
+    if (!hasSavedFeedsPref) {
+      const {saved, pinned} = await DEFAULT_FEEDS(
+        this.rootStore.agent.service.toString(),
+        (handle: string) =>
+          this.rootStore.agent
+            .resolveHandle({handle})
+            .then(({data}) => data.did),
+      )
+      runInAction(() => {
+        this.savedFeeds = saved
+        this.pinnedFeeds = pinned
+      })
+      res.data.preferences.push({
+        $type: 'app.bsky.actor.defs#savedFeedsPref',
+        saved,
+        pinned,
+      })
+      await this.rootStore.agent.app.bsky.actor.putPreferences({
+        preferences: res.data.preferences,
+      })
+      /* dont await */ this.rootStore.me.savedFeeds.refresh()
+    }
   }
 
-  async update(cb: (prefs: AppBskyActorDefs.Preferences) => void) {
+  /**
+   * This function updates the preferences of a user and allows for a callback function to be executed
+   * before the update.
+   * @param cb - cb is a callback function that takes in a single parameter of type
+   * AppBskyActorDefs.Preferences and returns either a boolean or void. This callback function is used to
+   * update the preferences of the user. The function is called with the current preferences as an
+   * argument and if the callback returns false, the preferences are not updated.
+   * @returns void
+   */
+  async update(cb: (prefs: AppBskyActorDefs.Preferences) => boolean | void) {
     const res = await this.rootStore.agent.app.bsky.actor.getPreferences({})
-    cb(res.data.preferences)
+    if (cb(res.data.preferences) === false) {
+      return
+    }
     await this.rootStore.agent.app.bsky.actor.putPreferences({
       preferences: res.data.preferences,
     })
   }
 
+  /**
+   * This function resets the preferences to an empty array of no preferences.
+   */
+  async reset() {
+    runInAction(() => {
+      this.contentLabels = new LabelPreferencesModel()
+      this.contentLanguages = deviceLocales.map(locale => locale.languageCode)
+      this.savedFeeds = []
+      this.pinnedFeeds = []
+    })
+    await this.rootStore.agent.app.bsky.actor.putPreferences({
+      preferences: [],
+    })
+  }
+
   hasContentLanguage(code2: string) {
     return this.contentLanguages.includes(code2)
   }
@@ -200,4 +291,62 @@ export class PreferencesModel {
     }
     return res
   }
+
+  setFeeds(saved: string[], pinned: string[]) {
+    this.savedFeeds = saved
+    this.pinnedFeeds = pinned
+  }
+
+  async setSavedFeeds(saved: string[], pinned: string[]) {
+    const oldSaved = this.savedFeeds
+    const oldPinned = this.pinnedFeeds
+    this.setFeeds(saved, pinned)
+    try {
+      await this.update((prefs: AppBskyActorDefs.Preferences) => {
+        const existing = prefs.find(
+          pref =>
+            AppBskyActorDefs.isSavedFeedsPref(pref) &&
+            AppBskyActorDefs.validateSavedFeedsPref(pref).success,
+        )
+        if (existing) {
+          existing.saved = saved
+          existing.pinned = pinned
+        } else {
+          prefs.push({
+            $type: 'app.bsky.actor.defs#savedFeedsPref',
+            saved,
+            pinned,
+          })
+        }
+      })
+    } catch (e) {
+      runInAction(() => {
+        this.savedFeeds = oldSaved
+        this.pinnedFeeds = oldPinned
+      })
+      throw e
+    }
+  }
+
+  async addSavedFeed(v: string) {
+    return this.setSavedFeeds([...this.savedFeeds, v], this.pinnedFeeds)
+  }
+
+  async removeSavedFeed(v: string) {
+    return this.setSavedFeeds(
+      this.savedFeeds.filter(uri => uri !== v),
+      this.pinnedFeeds.filter(uri => uri !== v),
+    )
+  }
+
+  async addPinnedFeed(v: string) {
+    return this.setSavedFeeds(this.savedFeeds, [...this.pinnedFeeds, v])
+  }
+
+  async removePinnedFeed(v: string) {
+    return this.setSavedFeeds(
+      this.savedFeeds,
+      this.pinnedFeeds.filter(uri => uri !== v),
+    )
+  }
 }
diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts
index 861b3df0e..81daf797f 100644
--- a/src/state/models/ui/profile.ts
+++ b/src/state/models/ui/profile.ts
@@ -2,20 +2,16 @@ import {makeAutoObservable} from 'mobx'
 import {RootStoreModel} from '../root-store'
 import {ProfileModel} from '../content/profile'
 import {PostsFeedModel} from '../feeds/posts'
+import {ActorFeedsModel} from '../lists/actor-feeds'
 import {ListsListModel} from '../lists/lists-list'
 
 export enum Sections {
   Posts = 'Posts',
   PostsWithReplies = 'Posts & replies',
+  CustomAlgorithms = 'Feeds',
   Lists = 'Lists',
 }
 
-const USER_SELECTOR_ITEMS = [
-  Sections.Posts,
-  Sections.PostsWithReplies,
-  Sections.Lists,
-]
-
 export interface ProfileUiParams {
   user: string
 }
@@ -28,6 +24,7 @@ export class ProfileUiModel {
   // data
   profile: ProfileModel
   feed: PostsFeedModel
+  algos: ActorFeedsModel
   lists: ListsListModel
 
   // ui state
@@ -50,10 +47,11 @@ export class ProfileUiModel {
       actor: params.user,
       limit: 10,
     })
+    this.algos = new ActorFeedsModel(rootStore, {actor: params.user})
     this.lists = new ListsListModel(rootStore, params.user)
   }
 
-  get currentView(): PostsFeedModel | ListsListModel {
+  get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel {
     if (
       this.selectedView === Sections.Posts ||
       this.selectedView === Sections.PostsWithReplies
@@ -62,6 +60,9 @@ export class ProfileUiModel {
     } else if (this.selectedView === Sections.Lists) {
       return this.lists
     }
+    if (this.selectedView === Sections.CustomAlgorithms) {
+      return this.algos
+    }
     throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
   }
 
@@ -75,7 +76,14 @@ export class ProfileUiModel {
   }
 
   get selectorItems() {
-    return USER_SELECTOR_ITEMS
+    const items = [Sections.Posts, Sections.PostsWithReplies]
+    if (this.algos.hasLoaded && !this.algos.isEmpty) {
+      items.push(Sections.CustomAlgorithms)
+    }
+    if (this.lists.hasLoaded && !this.lists.isEmpty) {
+      items.push(Sections.Lists)
+    }
+    return items
   }
 
   get selectedView() {
@@ -84,9 +92,11 @@ export class ProfileUiModel {
 
   get uiItems() {
     let arr: any[] = []
+    // if loading, return loading item to show loading spinner
     if (this.isInitialLoading) {
       arr = arr.concat([ProfileUiModel.LOADING_ITEM])
     } else if (this.currentView.hasError) {
+      // if error, return error item to show error message
       arr = arr.concat([
         {
           _reactKey: '__error__',
@@ -94,12 +104,16 @@ export class ProfileUiModel {
         },
       ])
     } else {
+      // not loading, no error, show content
       if (
         this.selectedView === Sections.Posts ||
-        this.selectedView === Sections.PostsWithReplies
+        this.selectedView === Sections.PostsWithReplies ||
+        this.selectedView === Sections.CustomAlgorithms
       ) {
         if (this.feed.hasContent) {
-          if (this.selectedView === Sections.Posts) {
+          if (this.selectedView === Sections.CustomAlgorithms) {
+            arr = this.algos.feeds
+          } else if (this.selectedView === Sections.Posts) {
             arr = this.feed.nonReplyFeed
           } else {
             arr = this.feed.slices.slice()
@@ -117,6 +131,7 @@ export class ProfileUiModel {
           arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
         }
       } else {
+        // fallback, add empty item, to show empty message
         arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
       }
     }
@@ -151,6 +166,7 @@ export class ProfileUiModel {
         .setup()
         .catch(err => this.rootStore.log.error('Failed to fetch feed', err)),
     ])
+    this.algos.refresh()
     // HACK: need to use the DID as a param, not the username -prf
     this.lists.source = this.profile.did
     this.lists
diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts
new file mode 100644
index 000000000..979fddf49
--- /dev/null
+++ b/src/state/models/ui/saved-feeds.ts
@@ -0,0 +1,185 @@
+import {makeAutoObservable, runInAction} from 'mobx'
+import {AppBskyFeedDefs} 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'
+
+export class SavedFeedsModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+
+  // data
+  feeds: CustomFeedModel[] = []
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get hasContent() {
+    return this.feeds.length > 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  get pinned() {
+    return this.rootStore.preferences.pinnedFeeds
+      .map(uri => this.feeds.find(f => f.uri === uri) as CustomFeedModel)
+      .filter(Boolean)
+  }
+
+  get unpinned() {
+    return this.feeds.filter(f => !this.isPinned(f))
+  }
+
+  get all() {
+    return this.pinned.concat(this.unpinned)
+  }
+
+  get pinnedFeedNames() {
+    return this.pinned.map(f => f.displayName)
+  }
+
+  // public api
+  // =
+
+  clear() {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = false
+    this.error = ''
+    this.feeds = []
+  }
+
+  refresh = bundleAsync(async (quietRefresh = false) => {
+    this._xLoading(!quietRefresh)
+    try {
+      let feeds: AppBskyFeedDefs.GeneratorView[] = []
+      for (
+        let i = 0;
+        i < this.rootStore.preferences.savedFeeds.length;
+        i += 25
+      ) {
+        const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({
+          feeds: this.rootStore.preferences.savedFeeds.slice(i, 25),
+        })
+        feeds = feeds.concat(res.data.feeds)
+      }
+      runInAction(() => {
+        this.feeds = feeds.map(f => new CustomFeedModel(this.rootStore, f))
+      })
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
+
+  async save(feed: CustomFeedModel) {
+    try {
+      await feed.save()
+      runInAction(() => {
+        this.feeds = [
+          ...this.feeds,
+          new CustomFeedModel(this.rootStore, feed.data),
+        ]
+      })
+    } 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()
+      runInAction(() => {
+        this.feeds = this.feeds.filter(f => f.data.uri !== uri)
+      })
+    } catch (e: any) {
+      this.rootStore.log.error('Failed to unsave feed', e)
+    }
+  }
+
+  async togglePinnedFeed(feed: CustomFeedModel) {
+    if (!this.isPinned(feed)) {
+      return this.rootStore.preferences.addPinnedFeed(feed.uri)
+    } else {
+      return this.rootStore.preferences.removePinnedFeed(feed.uri)
+    }
+  }
+
+  async reorderPinnedFeeds(feeds: CustomFeedModel[]) {
+    return this.rootStore.preferences.setSavedFeeds(
+      this.rootStore.preferences.savedFeeds,
+      feeds.filter(feed => this.isPinned(feed)).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') {
+    const pinned = this.rootStore.preferences.pinnedFeeds.slice()
+    const index = pinned.indexOf(item.uri)
+    if (index === -1) {
+      return
+    }
+    if (direction === 'up' && index !== 0) {
+      const temp = pinned[index]
+      pinned[index] = pinned[index - 1]
+      pinned[index - 1] = temp
+    } else if (direction === 'down' && index < pinned.length - 1) {
+      const temp = pinned[index]
+      pinned[index] = pinned[index + 1]
+      pinned[index + 1] = temp
+    }
+    await this.rootStore.preferences.setSavedFeeds(
+      this.rootStore.preferences.savedFeeds,
+      pinned,
+    )
+  }
+
+  // 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) {
+      this.rootStore.log.error('Failed to fetch user feeds', err)
+    }
+  }
+}
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index 187342ec3..a77ffbdfb 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -119,7 +119,7 @@ export type Modal =
   // Moderation
   | ReportAccountModal
   | ReportPostModal
-  | CreateMuteListModal
+  | CreateOrEditMuteListModal
   | ListAddRemoveUserModal
 
   // Posts