about summary refs log tree commit diff
path: root/src/state/models/feeds
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/models/feeds')
-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
5 files changed, 649 insertions, 325 deletions
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),
-  }
 }