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/notifications.ts574
-rw-r--r--src/state/models/feeds/posts.ts648
2 files changed, 1222 insertions, 0 deletions
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts
new file mode 100644
index 000000000..ea3538438
--- /dev/null
+++ b/src/state/models/feeds/notifications.ts
@@ -0,0 +1,574 @@
+import {makeAutoObservable, runInAction} from 'mobx'
+import {
+  AppBskyNotificationListNotifications as ListNotifications,
+  AppBskyActorDefs,
+  AppBskyFeedPost,
+  AppBskyFeedRepost,
+  AppBskyFeedLike,
+  AppBskyGraphFollow,
+} from '@atproto/api'
+import AwaitLock from 'await-lock'
+import {bundleAsync} from 'lib/async/bundle'
+import {RootStoreModel} from '../root-store'
+import {PostThreadModel} from '../content/post-thread'
+import {cleanError} from 'lib/strings/errors'
+
+const GROUPABLE_REASONS = ['like', 'repost', 'follow']
+const PAGE_SIZE = 30
+const MS_1HR = 1e3 * 60 * 60
+const MS_2DAY = MS_1HR * 48
+
+let _idCounter = 0
+
+export interface GroupedNotification extends ListNotifications.Notification {
+  additional?: ListNotifications.Notification[]
+}
+
+type SupportedRecord =
+  | AppBskyFeedPost.Record
+  | AppBskyFeedRepost.Record
+  | AppBskyFeedLike.Record
+  | AppBskyGraphFollow.Record
+
+export class NotificationsFeedItemModel {
+  // ui state
+  _reactKey: string = ''
+
+  // data
+  uri: string = ''
+  cid: string = ''
+  author: AppBskyActorDefs.ProfileViewBasic = {
+    did: '',
+    handle: '',
+    avatar: '',
+  }
+  reason: string = ''
+  reasonSubject?: string
+  record?: SupportedRecord
+  isRead: boolean = false
+  indexedAt: string = ''
+  additional?: NotificationsFeedItemModel[]
+
+  // additional data
+  additionalPost?: PostThreadModel
+
+  constructor(
+    public rootStore: RootStoreModel,
+    reactKey: string,
+    v: GroupedNotification,
+  ) {
+    makeAutoObservable(this, {rootStore: false})
+    this._reactKey = reactKey
+    this.copy(v)
+  }
+
+  copy(v: GroupedNotification, preserve = false) {
+    this.uri = v.uri
+    this.cid = v.cid
+    this.author = v.author
+    this.reason = v.reason
+    this.reasonSubject = v.reasonSubject
+    this.record = this.toSupportedRecord(v.record)
+    this.isRead = v.isRead
+    this.indexedAt = v.indexedAt
+    if (v.additional?.length) {
+      this.additional = []
+      for (const add of v.additional) {
+        this.additional.push(
+          new NotificationsFeedItemModel(this.rootStore, '', add),
+        )
+      }
+    } else if (!preserve) {
+      this.additional = undefined
+    }
+  }
+
+  get isLike() {
+    return this.reason === 'like'
+  }
+
+  get isRepost() {
+    return this.reason === 'repost'
+  }
+
+  get isMention() {
+    return this.reason === 'mention'
+  }
+
+  get isReply() {
+    return this.reason === 'reply'
+  }
+
+  get isQuote() {
+    return this.reason === 'quote'
+  }
+
+  get isFollow() {
+    return this.reason === 'follow'
+  }
+
+  get needsAdditionalData() {
+    if (
+      this.isLike ||
+      this.isRepost ||
+      this.isReply ||
+      this.isQuote ||
+      this.isMention
+    ) {
+      return !this.additionalPost
+    }
+    return false
+  }
+
+  get subjectUri(): string {
+    if (this.reasonSubject) {
+      return this.reasonSubject
+    }
+    const record = this.record
+    if (
+      AppBskyFeedRepost.isRecord(record) ||
+      AppBskyFeedLike.isRecord(record)
+    ) {
+      return record.subject.uri
+    }
+    return ''
+  }
+
+  toSupportedRecord(v: unknown): SupportedRecord | undefined {
+    for (const ns of [
+      AppBskyFeedPost,
+      AppBskyFeedRepost,
+      AppBskyFeedLike,
+      AppBskyGraphFollow,
+    ]) {
+      if (ns.isRecord(v)) {
+        const valid = ns.validateRecord(v)
+        if (valid.success) {
+          return v
+        } else {
+          this.rootStore.log.warn('Received an invalid record', {
+            record: v,
+            error: valid.error,
+          })
+          return
+        }
+      }
+    }
+    this.rootStore.log.warn(
+      'app.bsky.notifications.list served an unsupported record type',
+      v,
+    )
+  }
+
+  async fetchAdditionalData() {
+    if (!this.needsAdditionalData) {
+      return
+    }
+    let postUri
+    if (this.isReply || this.isQuote || this.isMention) {
+      postUri = this.uri
+    } else if (this.isLike || this.isRepost) {
+      postUri = this.subjectUri
+    }
+    if (postUri) {
+      this.additionalPost = new PostThreadModel(this.rootStore, {
+        uri: postUri,
+        depth: 0,
+      })
+      await this.additionalPost.setup().catch(e => {
+        this.rootStore.log.error(
+          'Failed to load post needed by notification',
+          e,
+        )
+      })
+    }
+  }
+}
+
+export class NotificationsFeedModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+  params: ListNotifications.QueryParams
+  hasMore = true
+  loadMoreCursor?: string
+
+  // used to linearize async modifications to state
+  lock = new AwaitLock()
+
+  // data
+  notifications: NotificationsFeedItemModel[] = []
+  unreadCount = 0
+
+  // this is used to help trigger push notifications
+  mostRecentNotificationUri: string | undefined
+
+  constructor(
+    public rootStore: RootStoreModel,
+    params: ListNotifications.QueryParams,
+  ) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+        params: false,
+        mostRecentNotificationUri: false,
+      },
+      {autoBind: true},
+    )
+    this.params = params
+  }
+
+  get hasContent() {
+    return this.notifications.length !== 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  // public api
+  // =
+
+  /**
+   * Nuke all data
+   */
+  clear() {
+    this.rootStore.log.debug('NotificationsModel:clear')
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = false
+    this.error = ''
+    this.hasMore = true
+    this.loadMoreCursor = undefined
+    this.notifications = []
+    this.unreadCount = 0
+    this.rootStore.emitUnreadNotifications(0)
+    this.mostRecentNotificationUri = undefined
+  }
+
+  /**
+   * Load for first render
+   */
+  setup = bundleAsync(async (isRefreshing: boolean = false) => {
+    this.rootStore.log.debug('NotificationsModel:setup', {isRefreshing})
+    if (isRefreshing) {
+      this.isRefreshing = true // set optimistically for UI
+    }
+    await this.lock.acquireAsync()
+    try {
+      this._xLoading(isRefreshing)
+      try {
+        const params = Object.assign({}, this.params, {
+          limit: PAGE_SIZE,
+        })
+        const res = await this.rootStore.agent.listNotifications(params)
+        await this._replaceAll(res)
+        this._xIdle()
+      } catch (e: any) {
+        this._xIdle(e)
+      }
+    } finally {
+      this.lock.release()
+    }
+  })
+
+  /**
+   * Reset and load
+   */
+  async refresh() {
+    return this.setup(true)
+  }
+
+  /**
+   * Load more posts to the end of the notifications
+   */
+  loadMore = bundleAsync(async () => {
+    if (!this.hasMore) {
+      return
+    }
+    this.lock.acquireAsync()
+    try {
+      this._xLoading()
+      try {
+        const params = Object.assign({}, this.params, {
+          limit: PAGE_SIZE,
+          cursor: this.loadMoreCursor,
+        })
+        const res = await this.rootStore.agent.listNotifications(params)
+        await this._appendAll(res)
+        this._xIdle()
+      } catch (e: any) {
+        this._xIdle() // don't bubble the error to the user
+        this.rootStore.log.error('NotificationsView: Failed to load more', {
+          params: this.params,
+          e,
+        })
+      }
+    } finally {
+      this.lock.release()
+    }
+  })
+
+  /**
+   * Load more posts at the start of the notifications
+   */
+  loadLatest = bundleAsync(async () => {
+    if (this.notifications.length === 0 || this.unreadCount > PAGE_SIZE) {
+      return this.refresh()
+    }
+    this.lock.acquireAsync()
+    try {
+      this._xLoading()
+      try {
+        const res = await this.rootStore.agent.listNotifications({
+          limit: PAGE_SIZE,
+        })
+        await this._prependAll(res)
+        this._xIdle()
+      } catch (e: any) {
+        this._xIdle() // don't bubble the error to the user
+        this.rootStore.log.error('NotificationsView: Failed to load latest', {
+          params: this.params,
+          e,
+        })
+      }
+    } finally {
+      this.lock.release()
+    }
+  })
+
+  /**
+   * Update content in-place
+   */
+  update = bundleAsync(async () => {
+    await this.lock.acquireAsync()
+    try {
+      if (!this.notifications.length) {
+        return
+      }
+      this._xLoading()
+      let numToFetch = this.notifications.length
+      let cursor
+      try {
+        do {
+          const res: ListNotifications.Response =
+            await this.rootStore.agent.listNotifications({
+              cursor,
+              limit: Math.min(numToFetch, 100),
+            })
+          if (res.data.notifications.length === 0) {
+            break // sanity check
+          }
+          this._updateAll(res)
+          numToFetch -= res.data.notifications.length
+          cursor = res.data.cursor
+        } while (cursor && numToFetch > 0)
+        this._xIdle()
+      } catch (e: any) {
+        this._xIdle() // don't bubble the error to the user
+        this.rootStore.log.error('NotificationsView: Failed to update', {
+          params: this.params,
+          e,
+        })
+      }
+    } finally {
+      this.lock.release()
+    }
+  })
+
+  // unread notification apis
+  // =
+
+  /**
+   * Get the current number of unread notifications
+   * returns true if the number changed
+   */
+  loadUnreadCount = bundleAsync(async () => {
+    const old = this.unreadCount
+    const res = await this.rootStore.agent.countUnreadNotifications()
+    runInAction(() => {
+      this.unreadCount = res.data.count
+    })
+    this.rootStore.emitUnreadNotifications(this.unreadCount)
+    return this.unreadCount !== old
+  })
+
+  /**
+   * Update read/unread state
+   */
+  async markAllRead() {
+    try {
+      this.unreadCount = 0
+      this.rootStore.emitUnreadNotifications(0)
+      for (const notif of this.notifications) {
+        notif.isRead = true
+      }
+      await this.rootStore.agent.updateSeenNotifications()
+    } catch (e: any) {
+      this.rootStore.log.warn('Failed to update notifications read state', e)
+    }
+  }
+
+  async getNewMostRecent(): Promise<NotificationsFeedItemModel | undefined> {
+    let old = this.mostRecentNotificationUri
+    const res = await this.rootStore.agent.listNotifications({
+      limit: 1,
+    })
+    if (!res.data.notifications[0] || old === res.data.notifications[0].uri) {
+      return
+    }
+    this.mostRecentNotificationUri = res.data.notifications[0].uri
+    const notif = new NotificationsFeedItemModel(
+      this.rootStore,
+      'mostRecent',
+      res.data.notifications[0],
+    )
+    await notif.fetchAdditionalData()
+    return notif
+  }
+
+  // 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 notifications', err)
+    }
+  }
+
+  // helper functions
+  // =
+
+  async _replaceAll(res: ListNotifications.Response) {
+    if (res.data.notifications[0]) {
+      this.mostRecentNotificationUri = res.data.notifications[0].uri
+    }
+    return this._appendAll(res, true)
+  }
+
+  async _appendAll(res: ListNotifications.Response, replace = false) {
+    this.loadMoreCursor = res.data.cursor
+    this.hasMore = !!this.loadMoreCursor
+    const promises = []
+    const itemModels: NotificationsFeedItemModel[] = []
+    for (const item of groupNotifications(res.data.notifications)) {
+      const itemModel = new NotificationsFeedItemModel(
+        this.rootStore,
+        `item-${_idCounter++}`,
+        item,
+      )
+      if (itemModel.needsAdditionalData) {
+        promises.push(itemModel.fetchAdditionalData())
+      }
+      itemModels.push(itemModel)
+    }
+    await Promise.all(promises).catch(e => {
+      this.rootStore.log.error(
+        'Uncaught failure during notifications-view _appendAll()',
+        e,
+      )
+    })
+    runInAction(() => {
+      if (replace) {
+        this.notifications = itemModels
+      } else {
+        this.notifications = this.notifications.concat(itemModels)
+      }
+    })
+  }
+
+  async _prependAll(res: ListNotifications.Response) {
+    const promises = []
+    const itemModels: NotificationsFeedItemModel[] = []
+    const dedupedNotifs = res.data.notifications.filter(
+      n1 =>
+        !this.notifications.find(
+          n2 => isEq(n1, n2) || n2.additional?.find(n3 => isEq(n1, n3)),
+        ),
+    )
+    for (const item of groupNotifications(dedupedNotifs)) {
+      const itemModel = new NotificationsFeedItemModel(
+        this.rootStore,
+        `item-${_idCounter++}`,
+        item,
+      )
+      if (itemModel.needsAdditionalData) {
+        promises.push(itemModel.fetchAdditionalData())
+      }
+      itemModels.push(itemModel)
+    }
+    await Promise.all(promises).catch(e => {
+      this.rootStore.log.error(
+        'Uncaught failure during notifications-view _prependAll()',
+        e,
+      )
+    })
+    runInAction(() => {
+      this.notifications = itemModels.concat(this.notifications)
+    })
+  }
+
+  _updateAll(res: ListNotifications.Response) {
+    for (const item of res.data.notifications) {
+      const existingItem = this.notifications.find(item2 => isEq(item, item2))
+      if (existingItem) {
+        existingItem.copy(item, true)
+      }
+    }
+  }
+}
+
+function groupNotifications(
+  items: ListNotifications.Notification[],
+): GroupedNotification[] {
+  const items2: GroupedNotification[] = []
+  for (const item of items) {
+    const ts = +new Date(item.indexedAt)
+    let grouped = false
+    if (GROUPABLE_REASONS.includes(item.reason)) {
+      for (const item2 of items2) {
+        const ts2 = +new Date(item2.indexedAt)
+        if (
+          Math.abs(ts2 - ts) < MS_2DAY &&
+          item.reason === item2.reason &&
+          item.reasonSubject === item2.reasonSubject &&
+          item.author.did !== item2.author.did
+        ) {
+          item2.additional = item2.additional || []
+          item2.additional.push(item)
+          grouped = true
+          break
+        }
+      }
+    }
+    if (!grouped) {
+      items2.push(item)
+    }
+  }
+  return items2
+}
+
+type N = ListNotifications.Notification | NotificationsFeedItemModel
+function isEq(a: N, b: N) {
+  // this function has a key subtlety- the indexedAt comparison
+  // the reason for this is reposts: they set the URI of the original post, not of the repost record
+  // the indexedAt time will be for the repost however, so we use that to help us
+  return a.uri === b.uri && a.indexedAt === b.indexedAt
+}
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
new file mode 100644
index 000000000..9e593f313
--- /dev/null
+++ b/src/state/models/feeds/posts.ts
@@ -0,0 +1,648 @@
+import {makeAutoObservable, runInAction} from 'mobx'
+import {
+  AppBskyFeedGetTimeline as GetTimeline,
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AppBskyFeedGetAuthorFeed as GetAuthorFeed,
+  RichText,
+  jsonToLex,
+} from '@atproto/api'
+import AwaitLock from 'await-lock'
+import {bundleAsync} from 'lib/async/bundle'
+import sampleSize from 'lodash.samplesize'
+import {RootStoreModel} from '../root-store'
+import {cleanError} from 'lib/strings/errors'
+import {SUGGESTED_FOLLOWS} from 'lib/constants'
+import {
+  getCombinedCursors,
+  getMultipleAuthorsPosts,
+  mergePosts,
+} from 'lib/api/build-suggested-posts'
+import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
+
+type FeedViewPost = AppBskyFeedDefs.FeedViewPost
+type ReasonRepost = AppBskyFeedDefs.ReasonRepost
+type PostView = AppBskyFeedDefs.PostView
+
+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})
+  }
+
+  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.feedViewPost#reasonRepost') {
+      return this.reason as ReasonRepost
+    }
+  }
+
+  async toggleLike() {
+    if (this.post.viewer?.like) {
+      await this.rootStore.agent.deleteLike(this.post.viewer.like)
+      runInAction(() => {
+        this.post.likeCount = this.post.likeCount || 0
+        this.post.viewer = this.post.viewer || {}
+        this.post.likeCount--
+        this.post.viewer.like = undefined
+      })
+    } else {
+      const res = await this.rootStore.agent.like(this.post.uri, this.post.cid)
+      runInAction(() => {
+        this.post.likeCount = this.post.likeCount || 0
+        this.post.viewer = this.post.viewer || {}
+        this.post.likeCount++
+        this.post.viewer.like = res.uri
+      })
+    }
+  }
+
+  async toggleRepost() {
+    if (this.post.viewer?.repost) {
+      await this.rootStore.agent.deleteRepost(this.post.viewer.repost)
+      runInAction(() => {
+        this.post.repostCount = this.post.repostCount || 0
+        this.post.viewer = this.post.viewer || {}
+        this.post.repostCount--
+        this.post.viewer.repost = undefined
+      })
+    } else {
+      const res = await this.rootStore.agent.repost(
+        this.post.uri,
+        this.post.cid,
+      )
+      runInAction(() => {
+        this.post.repostCount = this.post.repostCount || 0
+        this.post.viewer = this.post.viewer || {}
+        this.post.repostCount++
+        this.post.viewer.repost = res.uri
+      })
+    }
+  }
+
+  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]
+  }
+
+  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
+  isRefreshing = false
+  hasNewLatest = false
+  hasLoaded = false
+  error = ''
+  params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams
+  hasMore = true
+  loadMoreCursor: string | undefined
+  pollCursor: string | undefined
+  tuner = new FeedTuner()
+
+  // used to linearize async modifications to state
+  lock = new AwaitLock()
+
+  // data
+  slices: PostsFeedSliceModel[] = []
+  nextSlices: PostsFeedSliceModel[] = []
+
+  constructor(
+    public rootStore: RootStoreModel,
+    public feedType: 'home' | 'author' | 'suggested' | 'goodstuff',
+    params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams,
+  ) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+        params: false,
+        loadMoreCursor: false,
+      },
+      {autoBind: true},
+    )
+    this.params = params
+  }
+
+  get hasContent() {
+    return this.slices.length !== 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  get nonReplyFeed() {
+    if (this.feedType === 'author') {
+      return this.slices.filter(slice => {
+        const params = this.params as GetAuthorFeed.QueryParams
+        const item = slice.rootItem
+        const isRepost =
+          item?.reasonRepost?.by?.handle === params.actor ||
+          item?.reasonRepost?.by?.did === params.actor
+        return (
+          !item.reply || // not a reply
+          isRepost || // but allow if it's a repost
+          (slice.isThread && // or a thread by the user
+            item.reply?.root.author.did === item.post.author.did)
+        )
+      })
+    } else {
+      return this.slices
+    }
+  }
+
+  setHasNewLatest(v: boolean) {
+    this.hasNewLatest = v
+  }
+
+  // public api
+  // =
+
+  /**
+   * Nuke all data
+   */
+  clear() {
+    this.rootStore.log.debug('FeedModel:clear')
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasNewLatest = false
+    this.hasLoaded = false
+    this.error = ''
+    this.hasMore = true
+    this.loadMoreCursor = undefined
+    this.pollCursor = undefined
+    this.slices = []
+    this.nextSlices = []
+    this.tuner.reset()
+  }
+
+  switchFeedType(feedType: 'home' | 'suggested') {
+    if (this.feedType === feedType) {
+      return
+    }
+    this.feedType = feedType
+    return this.setup()
+  }
+
+  get feedTuners() {
+    if (this.feedType === 'goodstuff') {
+      return [
+        FeedTuner.dedupReposts,
+        FeedTuner.likedRepliesOnly,
+        FeedTuner.preferredLangOnly(
+          this.rootStore.preferences.contentLanguages,
+        ),
+      ]
+    }
+    if (this.feedType === 'home') {
+      return [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly]
+    }
+    return []
+  }
+
+  /**
+   * Load for first render
+   */
+  setup = bundleAsync(async (isRefreshing: boolean = false) => {
+    this.rootStore.log.debug('FeedModel:setup', {isRefreshing})
+    if (isRefreshing) {
+      this.isRefreshing = true // set optimistically for UI
+    }
+    await this.lock.acquireAsync()
+    try {
+      this.setHasNewLatest(false)
+      this.tuner.reset()
+      this._xLoading(isRefreshing)
+      try {
+        const res = await this._getFeed({limit: PAGE_SIZE})
+        await this._replaceAll(res)
+        this._xIdle()
+      } catch (e: any) {
+        this._xIdle(e)
+      }
+    } finally {
+      this.lock.release()
+    }
+  })
+
+  /**
+   * Register any event listeners. Returns a cleanup function.
+   */
+  registerListeners() {
+    const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this))
+    return () => sub.remove()
+  }
+
+  /**
+   * Reset and load
+   */
+  async refresh() {
+    await this.setup(true)
+  }
+
+  /**
+   * Load more posts to the end of the feed
+   */
+  loadMore = bundleAsync(async () => {
+    await this.lock.acquireAsync()
+    try {
+      if (!this.hasMore || this.hasError) {
+        return
+      }
+      this._xLoading()
+      try {
+        const res = await this._getFeed({
+          cursor: this.loadMoreCursor,
+          limit: PAGE_SIZE,
+        })
+        await this._appendAll(res)
+        this._xIdle()
+      } catch (e: any) {
+        this._xIdle() // don't bubble the error to the user
+        this.rootStore.log.error('FeedView: Failed to load more', {
+          params: this.params,
+          e,
+        })
+        this.hasMore = false
+      }
+    } finally {
+      this.lock.release()
+    }
+  })
+
+  /**
+   * Update content in-place
+   */
+  update = bundleAsync(async () => {
+    await this.lock.acquireAsync()
+    try {
+      if (!this.slices.length) {
+        return
+      }
+      this._xLoading()
+      let numToFetch = this.slices.length
+      let cursor
+      try {
+        do {
+          const res: GetTimeline.Response = await this._getFeed({
+            cursor,
+            limit: Math.min(numToFetch, 100),
+          })
+          if (res.data.feed.length === 0) {
+            break // sanity check
+          }
+          this._updateAll(res)
+          numToFetch -= res.data.feed.length
+          cursor = res.data.cursor
+        } while (cursor && numToFetch > 0)
+        this._xIdle()
+      } catch (e: any) {
+        this._xIdle() // don't bubble the error to the user
+        this.rootStore.log.error('FeedView: Failed to update', {
+          params: this.params,
+          e,
+        })
+      }
+    } finally {
+      this.lock.release()
+    }
+  })
+
+  /**
+   * Check if new posts are available
+   */
+  async checkForLatest({autoPrepend}: {autoPrepend?: boolean} = {}) {
+    if (this.hasNewLatest || this.feedType === 'suggested') {
+      return
+    }
+    const res = await this._getFeed({limit: PAGE_SIZE})
+    const tuner = new FeedTuner()
+    const nextSlices = tuner.tune(res.data.feed, this.feedTuners)
+    if (nextSlices[0]?.uri !== this.slices[0]?.uri) {
+      const nextSlicesModels = nextSlices.map(
+        slice =>
+          new PostsFeedSliceModel(
+            this.rootStore,
+            `item-${_idCounter++}`,
+            slice,
+          ),
+      )
+      if (autoPrepend) {
+        runInAction(() => {
+          this.slices = nextSlicesModels.concat(
+            this.slices.filter(slice1 =>
+              nextSlicesModels.find(slice2 => slice1.uri === slice2.uri),
+            ),
+          )
+          this.setHasNewLatest(false)
+        })
+      } else {
+        runInAction(() => {
+          this.nextSlices = nextSlicesModels
+        })
+        this.setHasNewLatest(true)
+      }
+    } else {
+      this.setHasNewLatest(false)
+    }
+  }
+
+  /**
+   * Sets the current slices to the "next slices" loaded by checkForLatest
+   */
+  resetToLatest() {
+    if (this.nextSlices.length) {
+      this.slices = this.nextSlices
+    }
+    this.setHasNewLatest(false)
+  }
+
+  /**
+   * Removes posts from the feed upon deletion.
+   */
+  onPostDeleted(uri: string) {
+    let i
+    do {
+      i = this.slices.findIndex(slice => slice.containsUri(uri))
+      if (i !== -1) {
+        this.slices.splice(i, 1)
+      }
+    } while (i !== -1)
+  }
+
+  // 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('Posts feed request failed', err)
+    }
+  }
+
+  // helper functions
+  // =
+
+  async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
+    this.pollCursor = res.data.feed[0]?.post.uri
+    return this._appendAll(res, true)
+  }
+
+  async _appendAll(
+    res: GetTimeline.Response | GetAuthorFeed.Response,
+    replace = false,
+  ) {
+    this.loadMoreCursor = res.data.cursor
+    this.hasMore = !!this.loadMoreCursor
+
+    const slices = this.tuner.tune(res.data.feed, this.feedTuners)
+
+    const toAppend: PostsFeedSliceModel[] = []
+    for (const slice of slices) {
+      const sliceModel = new PostsFeedSliceModel(
+        this.rootStore,
+        `item-${_idCounter++}`,
+        slice,
+      )
+      toAppend.push(sliceModel)
+    }
+    runInAction(() => {
+      if (replace) {
+        this.slices = toAppend
+      } else {
+        this.slices = this.slices.concat(toAppend)
+      }
+    })
+  }
+
+  _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
+    for (const item of res.data.feed) {
+      const existingSlice = this.slices.find(slice =>
+        slice.containsUri(item.post.uri),
+      )
+      if (existingSlice) {
+        const existingItem = existingSlice.items.find(
+          item2 => item2.post.uri === item.post.uri,
+        )
+        if (existingItem) {
+          existingItem.copyMetrics(item)
+        }
+      }
+    }
+  }
+
+  protected async _getFeed(
+    params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {},
+  ): Promise<GetTimeline.Response | GetAuthorFeed.Response> {
+    params = Object.assign({}, this.params, params)
+    if (this.feedType === 'suggested') {
+      const responses = await getMultipleAuthorsPosts(
+        this.rootStore,
+        sampleSize(SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)), 20),
+        params.cursor,
+        20,
+      )
+      const combinedCursor = getCombinedCursors(responses)
+      const finalData = mergePosts(responses, {bestOfOnly: true})
+      const lastHeaders = responses[responses.length - 1].headers
+      return {
+        success: true,
+        data: {
+          feed: finalData,
+          cursor: combinedCursor,
+        },
+        headers: lastHeaders,
+      }
+    } 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,
+      )
+      res.data.feed = (res.data.feed || []).filter(
+        item => !item.post.author.viewer?.muted,
+      )
+      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]))
+    }
+  }
+
+  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),
+  }
+}