about summary refs log tree commit diff
path: root/src/state/models
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-02-22 14:23:57 -0600
committerGitHub <noreply@github.com>2023-02-22 14:23:57 -0600
commitf28334739b107f3e9f7b6ca2670778dba280600d (patch)
tree4e1563242e1a041c5d5483ab018123170dcb3fc8 /src/state/models
parent7916b26aadb7e003728d9dc653ab8b8deabf4076 (diff)
downloadvoidsky-f28334739b107f3e9f7b6ca2670778dba280600d.tar.zst
Merge main into the Web PR (#230)
* Update to RN 71.1.0 (#100)

* Update to RN 71

* Adds missing lint plugin

* Add missing native changes

* Bump @atproto/api@0.0.7 (#112)

* Image not loading on swipe (#114)

* Adds prefetching to images

* Adds image prefetch

* bugfix for images not showing on swipe

* Fixes prefetch bug

* Update src/view/com/util/PostEmbeds.tsx

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* Fixes to session management (#117)

* Update session-management to solve incorrectly dropped sessions

* Reset the nav on account switch

* Reset the feed on me.load()

* Update tests to reflect new account-switching behavior

* Increase max image resolutions and sizes (#118)

* Slightly increase the hitslop for post controls

* Fix character counter color in dark mode

* Update login to use new session.create api, which enables email login (close #93) (#119)

* Replaces the alert with dropdown for profile image and banner (#123)

* replaces the alert with dropdown for profile image and banner

* lint

* Fix to ordering of images in the embed grid (#121)

* Add explicit link-embed controls to the composer (#120)

* Add explicit link-embed controls

* Update the target rez/size of link embed thumbs

* Remove the alert before publishing without a link card

* [Draft] Fixes image failing on reupload issue (#128)

* Fixes image failing on reupload issue

* Use tmp folder instead of documents

* lint

* Image performance improvements (#126)

* Switch out most images for FastImage

* Add image loading placeholders

* Fix tests

* Collection of fixes to list rendering (#127)

* Fix bug that caused endless spinners in profile feeds

* Bundle fetches of suggested actors into one update

* Fixes to suggested follow rendering

* Fix missing replacement of flex:1 to height:100

* Fixes to navigation swipes (#129)

* Nav swipe: increase the distance traveled in response to gesture movement.

This causes swipes to feel faster and more responsive.

* Fix: fully clamp the swipe against the edge

* Improve the performance of swipes by skipping the interaction manager

* Adds dark mode to the edit screen (#130)

* Adds dark mode to edit screen

* lint

* lint

* lint

* Reduce render cost of post controls and improve perceived responsiveness (#132)

* Move post control animations into conditional render and increase perceived responsiveness

* Remove log

* Adds dark mode to the dropdown (#131)

* Adds dark mode to the bottom sheet

* Make background button lighter (like before)

* lint

* Fix bug in lightbox rendering (#133)

* Fix layout in onboarding to not overflow the footer

* Configure feed FlatList (removeClippedSubviews=true) to improve scroll performance (#136)

* Disable like/repost animations to see if theyre causing #135 (#137)

* Composer: mention tagging now works in middle of text (close #105) (#139)

* Implement account deletion (#141)

* Fix photo & camera permission management (#140)

* Check photo & camera perms and alert the user if not available (close #64)

- Adds perms checks with a prompt to update settings if needed
- Moves initial access of photos in the composer so that the initial prompt
  occurs at an intuitive time.

* Add react-native-permissions test mock

* Fix issue causing multiple access requests

* Use longer var names

* Update podfile.lock

* Lint fix

* Move photo perm request in composer to the gallery btn instead of when the carousel is opened

* Adds more tracking all around the app (#142)

* Adds more tracking all around the app

* more events

* lint

* using better analytics naming

* missed file

* more fixes

* Calculate image aspect ratio on load (#146)

* Calculate image aspect ratio on load

* Move aspect ratio bounds to constants

* Adds detox testing and instructions (#147)

* Adds detox testing and instructions

* lint

* lint

* Error cleanup (close #79) (#148)

* Avoid surfacing errors to the user when it's not critical

* Remove now-unused GetAssertionsView

* Apply cleanError() consistently

* Give a better error message for Upstream Failures (http status 502)

* Hide errors in notifications because they're not useful

* More e2e tests (create account) (#150)

* Adds respots under the 'post' tab under profile (#158)

* Adds dark mode to delete account screen (#159)

* 87 dark mode edit profile (#162)

* Adds dark mode to delete account screen

* Adds one more missed darkmode

* more fixes

* Remove fallback gradient on external links without thumbs (#164)

* Remove fallback gradient on external links without thumbs

* Remove fallback gradient on external links without thumbs in the composer preview

* Fix refresh behavior around a series of models (repost, graph, vote) (#163)

* Fix refresh behavior around a series of models (repost, graph, vote)

* Fix cursor behavior in reposted-by view

* Fixes issue where retrying on image upload fails (#166)

* Fixes issue where retrying on image upload fails

* Lint, longer test time

* Longer waitfor time in tests

* even longer timeout

* longer timeout

* missed file

* Update src/view/com/composer/ComposePost.tsx

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* Update src/view/com/composer/ComposePost.tsx

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* 154 cached image profile (#167)

* Fixes issue where retrying on image upload fails

* Lint, longer test time

* Longer waitfor time in tests

* even longer timeout

* longer timeout

* missed file

* Fixes image cache error on second try for profile screen

* lint

* lint

* lint

* Refactor session management to use a new "Agent" API (#165)

* Add the atp-agent implementation (temporarily in this repo)

* Rewrite all session & API management to use the new atp-agent

* Update tests for the atp-agent refactor

* Refactor management of session-related state. Includes:
- More careful management of when state is cleared or fetched
- Debug logging to help trace future issues
- Clearer APIs overall

* Bubble session-expiration events to the user and display a toast to explain

* Switch to the new @atproto/api@0.1.0

* Minor aesthetic cleanup in SessionModel

* Wire up ReportAccount and ReportPost (#168)

* Fixes embeds for youtube channels (#169)

* Bump app ios version to 1.1 (needed after app store submission)

* Fix potential issues with promise guards when an error occurs (#170)

* Refactor models to use bundleAsync and lock regions (#171)

* Fix to an edge case with feed re-ordering for threads (#172)

* 151 fix youtube channel embed (#173)

* Fixes embeds for youtube channels

* Tests for youtube extract meta

* lint

* Add 'doesnt use non-exempt encryption' to ios config

* Rework the search UI and add  (#174)

* Add search tab and move icon to footer

* Remove subtitles from view header

* Remove unused code

* Clean up UI of search screen

* Search: give better user feedback to UI state and add a cancel button

* Add WhoToFollow section to search

* Add a temporary SuggestedPosts solution using the patented 'bsky team algo'

* Trigger reload of suggested content in search on open

* Wait five min between reloading discovery content

* Reduce weight of solid search icon in footer

* Fix lint

* Fix tests

* 151 feat youtube embed iframe (#176)

* youtube embed iframe temp commit

* Fixes styling and code cleanup

* lint

* Now clicking between the pause and settings button doesn't trigger the parent

* use modest branding (less yt logos)

* Stop playing the video once there's a navigation event

* Make sure the iframe is unmounted on any navigation event

* fixes tests

* lint

* Add scroll-to-top for all screens (#177)

* Adds hardcoded suggested list (#178)

* Adds hardcoded suggested list

* Update suggested-actors-view to support page sizes smaller than the hardcoded list

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* more robust centering of the play button (#181)

Co-authored-by: Aryan Goharzad <arrygoo@gmail.com>

* Bundle of UI modifications (#175)

* Adjust visual balance of SuggestedPosts and WhoToFollow

* Fix bug in the discovery load trigger

* Adjust search header aesthetic and have it scroll away

* More visual balance tweaks on the search page

* Even more visual balance tweaks on the search page

* Hide the footer on scroll in search

* Ditch the composer prompt buttons in the home feed

* Center the view header title

* Hide header on scroll on the home feed

* Fix e2e tests

* Fix home feed positioning (closes #189) (#195)

* Fix home feed positioning for floating header

* Fix positioning of errors in home feed

* Fix lint

* Don't show new-content notification for reposts (close #179) (#197)

* Show the splash screen during session resumption (close #186) (#199)

* Fix to suggested follows: chunk the hardcoded fetches to 25 at a time (close #196) (#198)

* UI updates to the floating action button (#201)

* Update FAB to use a plus icon and not drop shadow

* Update FAB positioning to be more consistent in different shell modes

* Animate the FAB's repositioning

* Remove the 'loading' placeholder from images as it degraded feed perf (#202)

* Remove the 'loading' placeholder from images as it degraded feed perf

* Remove references

* Fix RN bug that causes home feed not to load more; also fix home feed load view. (#208)

RN has a bug where rendering a flatlist with an empty array appears to break its
virtual list windowing behaviors. See https://stackoverflow.com/a/67873596

* Only give the loading spinner on the home feed during PTR (#207)

(cherry picked from commit b7a5da12fdfacef74873b5cf6d75f20d259bde0e)

* Implement our own lifecycle tracking to ensure it never fires while the app is backgrounded (close #193) (#211)

* Push notification fixes (#210)

* Fix to when screen analytics events are firing

* Fix: dont trigger update state when backgrounded

* Small fix to notifee API usage

* Fix: properly load notification info for push card

* Add feedback link to main menu (close #191) (#212)

* Add "follows you" information and sync follow state between views (#215)

* Bump @atproto/api@0.1.2 and update API usage

* Add 'follows you' pill to profile header (close #110)

* Add 'follows you' to followers and follows (close #103)

* Update reposted-by and liked-by views to use the same components as followers and following

* Create a local follows cache MyFollowsModel to keep views in sync (close #205)

* Add incremental hydration to the MyFollows model

* Fix tests

* Update deps

* Fix lint

* Fix to paginated fetches

* Fix reference

* Fix potential state-desync issue

* Fixes to notifications (#216)

* Improve push-notification for follows

* Refresh notifications on screen open (close #214)

* Avoid showing loader more than needed in post threads

* Refactor notification polling to handle view-state more effectively

* Delete a bunch of tests taht werent adding value

* Remove the accounts integration test; we'll use the e2e test instead

* Load latest in notifications when the screen is open rather than full refresh

* Randomize hard-coded suggested follows (#226)

* Ensure follows are loaded before filtering hardcoded suggestions

* Randomize hard-coded suggested profiles (close #219)

* Sanitizes posts on publish and render (#217)

* Sanatizes posts on publish and render

* lint

* lint and added sanitize to thread view as well

* adjusts indices based on replaced text

* Woops, fixes a bug

* bugfix + cleanup

* comment

* lint

* move sanitize text to later in the flow

* undo changes to compose post

* Add RichText library building upon the sanitizePost library method

* Add lodash.clonedeep dep

* Switch to RichText processing on record load & render

* Fix lint

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* A group of notifications fixes (#227)

* Fix: don't group together notifications that can't visually be grouped (close #221)

* Mark all notifications read on PTR

* Small optimization: useCallback and useMemo in posts feed

* Add loading spinner to footer of notifications (close #222)

* Fix to scrolling to posts within a thread (#228)

* Fix: render the entire thread at start so that scrollToIndex works always (close #270)

* Visual fixes to thread 'load more'

* A few small perf improvements to thread rendering

* Fix lint

* 1.2

* Remove unused logger lib

* Remove state-mock

* Type fixes

* Reorganize the folder structure for lib and switch to typescript path aliases

* Move build-flags into lib

* Move to the state path alias

* Add view path alias

* Fix lint

* iOS build fixes

* Wrap analytics in native/web splitter and re-enable in all view code

* Add web version of react-native-webview

* Add web split for version number

* Fix BlurView import for web

* Add web split for fastimage

* Create web split for permissions lib

* Fix for web high priority images

---------

Co-authored-by: Aryan Goharzad <arrygoo@gmail.com>
Diffstat (limited to 'src/state/models')
-rw-r--r--src/state/models/feed-view.ts343
-rw-r--r--src/state/models/get-assertions-view.ts123
-rw-r--r--src/state/models/link-metas-view.ts2
-rw-r--r--src/state/models/log.ts2
-rw-r--r--src/state/models/me.ts68
-rw-r--r--src/state/models/my-follows.ts109
-rw-r--r--src/state/models/navigation.ts55
-rw-r--r--src/state/models/notifications-view.ts334
-rw-r--r--src/state/models/onboard.ts2
-rw-r--r--src/state/models/post-thread-view.ts39
-rw-r--r--src/state/models/post.ts3
-rw-r--r--src/state/models/profile-view.ts62
-rw-r--r--src/state/models/profiles-view.ts4
-rw-r--r--src/state/models/reposted-by-view.ts69
-rw-r--r--src/state/models/root-store.ts198
-rw-r--r--src/state/models/session.ts414
-rw-r--r--src/state/models/shell-ui.ts30
-rw-r--r--src/state/models/suggested-actors-view.ts173
-rw-r--r--src/state/models/suggested-posts-view.ts148
-rw-r--r--src/state/models/user-autocomplete-view.ts34
-rw-r--r--src/state/models/user-followers-view.ts61
-rw-r--r--src/state/models/user-follows-view.ts61
-rw-r--r--src/state/models/votes-view.ts62
23 files changed, 1413 insertions, 983 deletions
diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts
index 621059822..f80c5f2c0 100644
--- a/src/state/models/feed-view.ts
+++ b/src/state/models/feed-view.ts
@@ -5,13 +5,16 @@ import {
   AppBskyFeedPost,
   AppBskyFeedGetAuthorFeed as GetAuthorFeed,
 } from '@atproto/api'
+import AwaitLock from 'await-lock'
+import {bundleAsync} from 'lib/async/bundle'
 type FeedViewPost = AppBskyFeedFeedViewPost.Main
 type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
 type PostView = AppBskyFeedPost.View
 import {AtUri} from '../../third-party/uri'
 import {RootStoreModel} from './root-store'
-import * as apilib from '../lib/api'
-import {cleanError} from '../../lib/strings'
+import * as apilib from 'lib/api/index'
+import {cleanError} from 'lib/strings/errors'
+import {RichText} from 'lib/strings/rich-text'
 
 const PAGE_SIZE = 30
 
@@ -37,6 +40,7 @@ export class FeedItemModel {
   reply?: FeedViewPost['reply']
   replyParent?: FeedItemModel
   reason?: FeedViewPost['reason']
+  richText?: RichText
 
   constructor(
     public rootStore: RootStoreModel,
@@ -49,6 +53,11 @@ export class FeedItemModel {
       const valid = AppBskyFeedPost.validateRecord(this.post.record)
       if (valid.success) {
         this.postRecord = this.post.record
+        this.richText = new RichText(
+          this.postRecord.text,
+          this.postRecord.entities,
+          {cleanNewlines: true},
+        )
       } else {
         rootStore.log.warn(
           'Received an invalid app.bsky.feed.post record',
@@ -187,10 +196,9 @@ export class FeedModel {
   hasMore = true
   loadMoreCursor: string | undefined
   pollCursor: string | undefined
-  _loadPromise: Promise<void> | undefined
-  _loadMorePromise: Promise<void> | undefined
-  _loadLatestPromise: Promise<void> | undefined
-  _updatePromise: Promise<void> | undefined
+
+  // used to linearize async modifications to state
+  private lock = new AwaitLock()
 
   // data
   feed: FeedItemModel[] = []
@@ -206,10 +214,6 @@ export class FeedModel {
         rootStore: false,
         params: false,
         loadMoreCursor: false,
-        _loadPromise: false,
-        _loadMorePromise: false,
-        _loadLatestPromise: false,
-        _updatePromise: false,
       },
       {autoBind: true},
     )
@@ -229,13 +233,22 @@ export class FeedModel {
   }
 
   get nonReplyFeed() {
-    return this.feed.filter(
-      item =>
+    const nonReplyFeed = this.feed.filter(item => {
+      const params = this.params as GetAuthorFeed.QueryParams
+      const isRepost =
+        item.reply &&
+        (item?.reasonRepost?.by?.handle === params.author ||
+          item?.reasonRepost?.by?.did === params.author)
+
+      return (
         !item.reply || // not a reply
+        isRepost ||
         ((item._isThreadParent || // but allow if it's a thread by the user
           item._isThreadChild) &&
-          item.reply?.root.author.did === item.post.author.did),
-    )
+          item.reply?.root.author.did === item.post.author.did)
+      )
+    })
+    return nonReplyFeed
   }
 
   setHasNewLatest(v: boolean) {
@@ -246,21 +259,44 @@ export class FeedModel {
   // =
 
   /**
+   * 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.feed = []
+  }
+
+  /**
    * Load for first render
    */
-  async setup(isRefreshing = false) {
+  setup = bundleAsync(async (isRefreshing: boolean = false) => {
+    this.rootStore.log.debug('FeedModel:setup', {isRefreshing})
     if (isRefreshing) {
       this.isRefreshing = true // set optimistically for UI
     }
-    if (this._loadPromise) {
-      return this._loadPromise
+    await this.lock.acquireAsync()
+    try {
+      this.setHasNewLatest(false)
+      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()
     }
-    await this._pendingWork()
-    this.setHasNewLatest(false)
-    this._loadPromise = this._initialLoad(isRefreshing)
-    await this._loadPromise
-    this._loadPromise = undefined
-  }
+  })
 
   /**
    * Register any event listeners. Returns a cleanup function.
@@ -280,42 +316,93 @@ export class FeedModel {
   /**
    * Load more posts to the end of the feed
    */
-  async loadMore() {
-    if (this._loadMorePromise) {
-      return this._loadMorePromise
+  loadMore = bundleAsync(async () => {
+    await this.lock.acquireAsync()
+    try {
+      if (!this.hasMore || this.hasError) {
+        return
+      }
+      this._xLoading()
+      try {
+        const res = await this._getFeed({
+          before: 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,
+        })
+      }
+    } finally {
+      this.lock.release()
     }
-    await this._pendingWork()
-    this._loadMorePromise = this._loadMore()
-    await this._loadMorePromise
-    this._loadMorePromise = undefined
-  }
+  })
 
   /**
    * Load more posts to the start of the feed
    */
-  async loadLatest() {
-    if (this._loadLatestPromise) {
-      return this._loadLatestPromise
+  loadLatest = bundleAsync(async () => {
+    await this.lock.acquireAsync()
+    try {
+      this.setHasNewLatest(false)
+      this._xLoading()
+      try {
+        const res = await this._getFeed({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('FeedView: Failed to load latest', {
+          params: this.params,
+          e,
+        })
+      }
+    } finally {
+      this.lock.release()
     }
-    await this._pendingWork()
-    this.setHasNewLatest(false)
-    this._loadLatestPromise = this._loadLatest()
-    await this._loadLatestPromise
-    this._loadLatestPromise = undefined
-  }
+  })
 
   /**
    * Update content in-place
    */
-  async update() {
-    if (this._updatePromise) {
-      return this._updatePromise
+  update = bundleAsync(async () => {
+    await this.lock.acquireAsync()
+    try {
+      if (!this.feed.length) {
+        return
+      }
+      this._xLoading()
+      let numToFetch = this.feed.length
+      let cursor
+      try {
+        do {
+          const res: GetTimeline.Response = await this._getFeed({
+            before: 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()
     }
-    await this._pendingWork()
-    this._updatePromise = this._update()
-    await this._updatePromise
-    this._updatePromise = undefined
-  }
+  })
 
   /**
    * Check if new posts are available
@@ -324,17 +411,18 @@ export class FeedModel {
     if (this.hasNewLatest) {
       return
     }
-    await this._pendingWork()
     const res = await this._getFeed({limit: 1})
     const currentLatestUri = this.pollCursor
-    const receivedLatestUri = res.data.feed[0]
-      ? res.data.feed[0].post.uri
-      : undefined
-    const hasNewLatest = Boolean(
-      receivedLatestUri &&
-        (this.feed.length === 0 || receivedLatestUri !== currentLatestUri),
-    )
-    this.setHasNewLatest(hasNewLatest)
+    const item = res.data.feed[0]
+    if (!item) {
+      return
+    }
+    if (AppBskyFeedFeedViewPost.isReasonRepost(item.reason)) {
+      if (item.reason.by.did === this.rootStore.me.did) {
+        return // ignore reposts by the user
+      }
+    }
+    this.setHasNewLatest(item.post.uri !== currentLatestUri)
   }
 
   /**
@@ -363,95 +451,15 @@ export class FeedModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = err ? cleanError(err.toString()) : ''
+    this.error = cleanError(err)
     if (err) {
       this.rootStore.log.error('Posts feed request failed', err)
     }
   }
 
-  // loader functions
+  // helper functions
   // =
 
-  private async _pendingWork() {
-    if (this._loadPromise) {
-      await this._loadPromise
-    }
-    if (this._loadMorePromise) {
-      await this._loadMorePromise
-    }
-    if (this._loadLatestPromise) {
-      await this._loadLatestPromise
-    }
-    if (this._updatePromise) {
-      await this._updatePromise
-    }
-  }
-
-  private async _initialLoad(isRefreshing = false) {
-    this._xLoading(isRefreshing)
-    try {
-      const res = await this._getFeed({limit: PAGE_SIZE})
-      await this._replaceAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  private async _loadLatest() {
-    this._xLoading()
-    try {
-      const res = await this._getFeed({limit: PAGE_SIZE})
-      await this._prependAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  private async _loadMore() {
-    if (!this.hasMore || this.hasError) {
-      return
-    }
-    this._xLoading()
-    try {
-      const res = await this._getFeed({
-        before: this.loadMoreCursor,
-        limit: PAGE_SIZE,
-      })
-      await this._appendAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  private async _update() {
-    if (!this.feed.length) {
-      return
-    }
-    this._xLoading()
-    let numToFetch = this.feed.length
-    let cursor
-    try {
-      do {
-        const res: GetTimeline.Response = await this._getFeed({
-          before: 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(e)
-    }
-  }
-
   private async _replaceAll(
     res: GetTimeline.Response | GetAuthorFeed.Response,
   ) {
@@ -570,32 +578,9 @@ function preprocessFeed(feed: FeedViewPost[]): FeedViewPostWithThreadMeta[] {
     reorg.unshift(item)
   }
 
-  // phase two: identify the positions of the threads
-  let activeSlice = -1
-  let threadSlices: Slice[] = []
-  for (let i = 0; i < reorg.length; i++) {
-    const item = reorg[i] as FeedViewPostWithThreadMeta
-    if (activeSlice === -1) {
-      if (item._isThreadParent) {
-        activeSlice = i
-      }
-    } else {
-      if (!item._isThreadChild) {
-        threadSlices.push({index: activeSlice, length: i - activeSlice})
-        if (item._isThreadParent) {
-          activeSlice = i
-        } else {
-          activeSlice = -1
-        }
-      }
-    }
-  }
-  if (activeSlice !== -1) {
-    threadSlices.push({index: activeSlice, length: reorg.length - activeSlice})
-  }
-
-  // phase three: reorder the feed so that the timestamp of the
+  // phase two: reorder the feed so that the timestamp of the
   // last post in a thread establishes its ordering
+  let threadSlices: Slice[] = identifyThreadSlices(reorg)
   for (const slice of threadSlices) {
     const removed: FeedViewPostWithThreadMeta[] = reorg.splice(
       slice.index,
@@ -610,8 +595,10 @@ function preprocessFeed(feed: FeedViewPost[]): FeedViewPostWithThreadMeta[] {
     slice.index = newIndex
   }
 
-  // phase four: compress any threads that are longer than 3 posts
+  // phase three: compress any threads that are longer than 3 posts
   let removedCount = 0
+  // phase 2 moved posts around, so we need to re-identify the slice indices
+  threadSlices = identifyThreadSlices(reorg)
   for (const slice of threadSlices) {
     if (slice.length > 3) {
       reorg.splice(slice.index - removedCount + 1, slice.length - 3)
@@ -626,6 +613,32 @@ function preprocessFeed(feed: FeedViewPost[]): FeedViewPostWithThreadMeta[] {
   return reorg
 }
 
+function identifyThreadSlices(feed: FeedViewPost[]): Slice[] {
+  let activeSlice = -1
+  let threadSlices: Slice[] = []
+  for (let i = 0; i < feed.length; i++) {
+    const item = feed[i] as FeedViewPostWithThreadMeta
+    if (activeSlice === -1) {
+      if (item._isThreadParent) {
+        activeSlice = i
+      }
+    } else {
+      if (!item._isThreadChild) {
+        threadSlices.push({index: activeSlice, length: i - activeSlice})
+        if (item._isThreadParent) {
+          activeSlice = i
+        } else {
+          activeSlice = -1
+        }
+      }
+    }
+  }
+  if (activeSlice !== -1) {
+    threadSlices.push({index: activeSlice, length: feed.length - activeSlice})
+  }
+  return threadSlices
+}
+
 // WARNING: mutates `feed`
 function dedupReposts(feed: FeedItemModel[]) {
   // remove duplicates caused by reposts
diff --git a/src/state/models/get-assertions-view.ts b/src/state/models/get-assertions-view.ts
deleted file mode 100644
index bdb2c0894..000000000
--- a/src/state/models/get-assertions-view.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import {makeAutoObservable} from 'mobx'
-import {AppBskyGraphGetAssertions as GetAssertions} from '@atproto/api'
-import {RootStoreModel} from './root-store'
-
-export type Assertion = GetAssertions.Assertion & {
-  _reactKey: string
-}
-
-export class GetAssertionsView {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasLoaded = false
-  error = ''
-  params: GetAssertions.QueryParams
-
-  // data
-  assertions: Assertion[] = []
-
-  constructor(
-    public rootStore: RootStoreModel,
-    params: GetAssertions.QueryParams,
-  ) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-        params: false,
-      },
-      {autoBind: true},
-    )
-    this.params = params
-  }
-
-  get hasContent() {
-    return this.assertions.length > 0
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  getBySubject(did: string) {
-    return this.assertions.find(assertion => assertion.subject.did === did)
-  }
-
-  get confirmed() {
-    return this.assertions.filter(assertion => !!assertion.confirmation)
-  }
-
-  get unconfirmed() {
-    return this.assertions.filter(assertion => !assertion.confirmation)
-  }
-
-  // public api
-  // =
-
-  async setup() {
-    await this._fetch()
-  }
-
-  async refresh() {
-    await this._fetch(true)
-  }
-
-  async loadMore() {
-    // TODO
-  }
-
-  // state transitions
-  // =
-
-  private _xLoading(isRefreshing = false) {
-    this.isLoading = true
-    this.isRefreshing = isRefreshing
-    this.error = ''
-  }
-
-  private _xIdle(err?: any) {
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = true
-    this.error = err ? err.toString() : ''
-    if (err) {
-      this.rootStore.log.error('Failed to fetch assertions', err)
-    }
-  }
-
-  // loader functions
-  // =
-
-  private async _fetch(isRefreshing = false) {
-    this._xLoading(isRefreshing)
-    try {
-      const res = await this.rootStore.api.app.bsky.graph.getAssertions(
-        this.params,
-      )
-      this._replaceAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  private _replaceAll(res: GetAssertions.Response) {
-    this.assertions.length = 0
-    let counter = 0
-    for (const item of res.data.assertions) {
-      this._append({
-        _reactKey: `item-${counter++}`,
-        ...item,
-      })
-    }
-  }
-
-  private _append(item: Assertion) {
-    this.assertions.push(item)
-  }
-}
diff --git a/src/state/models/link-metas-view.ts b/src/state/models/link-metas-view.ts
index 6b787987d..59447008a 100644
--- a/src/state/models/link-metas-view.ts
+++ b/src/state/models/link-metas-view.ts
@@ -1,7 +1,7 @@
 import {makeAutoObservable} from 'mobx'
 import {LRUMap} from 'lru_map'
 import {RootStoreModel} from './root-store'
-import {LinkMeta, getLinkMeta} from '../../lib/link-meta'
+import {LinkMeta, getLinkMeta} from 'lib/link-meta/link-meta'
 
 type CacheValue = Promise<LinkMeta> | LinkMeta
 export class LinkMetasViewModel {
diff --git a/src/state/models/log.ts b/src/state/models/log.ts
index 67f4a210c..f2709f2f1 100644
--- a/src/state/models/log.ts
+++ b/src/state/models/log.ts
@@ -1,6 +1,6 @@
 import {makeAutoObservable} from 'mobx'
 import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc'
-import {isObj, hasProp} from '../lib/type-guards'
+import {isObj, hasProp} from 'lib/type-guards'
 
 interface LogEntry {
   id: string
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index 0d0c1d1de..0cb84c9fc 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -1,10 +1,9 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import notifee from '@notifee/react-native'
 import {RootStoreModel} from './root-store'
 import {FeedModel} from './feed-view'
 import {NotificationsViewModel} from './notifications-view'
-import {isObj, hasProp} from '../lib/type-guards'
-import {displayNotificationFromModel} from '../../view/lib/notifee'
+import {MyFollowsModel} from './my-follows'
+import {isObj, hasProp} from 'lib/type-guards'
 
 export class MeModel {
   did: string = ''
@@ -12,9 +11,9 @@ export class MeModel {
   displayName: string = ''
   description: string = ''
   avatar: string = ''
-  notificationCount: number = 0
   mainFeed: FeedModel
   notifications: NotificationsViewModel
+  follows: MyFollowsModel
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(
@@ -26,15 +25,17 @@ export class MeModel {
       algorithm: 'reverse-chronological',
     })
     this.notifications = new NotificationsViewModel(this.rootStore, {})
+    this.follows = new MyFollowsModel(this.rootStore)
   }
 
   clear() {
+    this.mainFeed.clear()
+    this.notifications.clear()
     this.did = ''
     this.handle = ''
     this.displayName = ''
     this.description = ''
     this.avatar = ''
-    this.notificationCount = 0
   }
 
   serialize(): unknown {
@@ -77,9 +78,10 @@ export class MeModel {
 
   async load() {
     const sess = this.rootStore.session
-    if (sess.hasSession && sess.data) {
-      this.did = sess.data.did || ''
-      this.handle = sess.data.handle
+    this.rootStore.log.debug('MeModel:load', {hasSession: sess.hasSession})
+    if (sess.hasSession) {
+      this.did = sess.currentSession?.did || ''
+      this.handle = sess.currentSession?.handle || ''
       const profile = await this.rootStore.api.app.bsky.actor.getProfile({
         actor: this.did,
       })
@@ -94,10 +96,6 @@ export class MeModel {
           this.avatar = ''
         }
       })
-      this.mainFeed = new FeedModel(this.rootStore, 'home', {
-        algorithm: 'reverse-chronological',
-      })
-      this.notifications = new NotificationsViewModel(this.rootStore, {})
       await Promise.all([
         this.mainFeed.setup().catch(e => {
           this.rootStore.log.error('Failed to setup main feed model', e)
@@ -105,51 +103,13 @@ export class MeModel {
         this.notifications.setup().catch(e => {
           this.rootStore.log.error('Failed to setup notifications model', e)
         }),
+        this.follows.fetch().catch(e => {
+          this.rootStore.log.error('Failed to load my follows', e)
+        }),
       ])
-
-      // request notifications permission once the user has logged in
-      notifee.requestPermission()
+      this.rootStore.emitSessionLoaded()
     } else {
       this.clear()
     }
   }
-
-  clearNotificationCount() {
-    this.notificationCount = 0
-    notifee.setBadgeCount(0)
-  }
-
-  async fetchNotifications() {
-    const res = await this.rootStore.api.app.bsky.notification.getCount()
-    runInAction(() => {
-      const newNotifications = this.notificationCount !== res.data.count
-      this.notificationCount = res.data.count
-      notifee.setBadgeCount(this.notificationCount)
-      if (newNotifications) {
-        this.notifications.refresh()
-      }
-    })
-  }
-
-  async bgFetchNotifications() {
-    const res = await this.rootStore.api.app.bsky.notification.getCount()
-    // NOTE we don't update this.notificationCount to avoid repaints during bg
-    //      this means `newNotifications` may not be accurate, so we rely on
-    //      `mostRecent` to determine if there really is a new notif to show -prf
-    const newNotifications = this.notificationCount !== res.data.count
-    notifee.setBadgeCount(res.data.count)
-    this.rootStore.log.debug(
-      `Background fetch received unread count = ${res.data.count}`,
-    )
-    if (newNotifications) {
-      this.rootStore.log.debug(
-        'Background fetch detected potentially a new notification',
-      )
-      const mostRecent = await this.notifications.getNewMostRecent()
-      if (mostRecent) {
-        this.rootStore.log.debug('Got the notification, triggering a push')
-        displayNotificationFromModel(mostRecent)
-      }
-    }
-  }
 }
diff --git a/src/state/models/my-follows.ts b/src/state/models/my-follows.ts
new file mode 100644
index 000000000..252e8a3d3
--- /dev/null
+++ b/src/state/models/my-follows.ts
@@ -0,0 +1,109 @@
+import {makeAutoObservable, runInAction} from 'mobx'
+import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api'
+import {RootStoreModel} from './root-store'
+import {bundleAsync} from 'lib/async/bundle'
+
+const CACHE_TTL = 1000 * 60 * 60 // hourly
+type FollowsListResponse = Awaited<ReturnType<FollowRecord['list']>>
+type FollowsListResponseRecord = FollowsListResponse['records'][0]
+type Profile =
+  | AppBskyActorProfile.ViewBasic
+  | AppBskyActorProfile.View
+  | AppBskyActorRef.WithInfo
+
+/**
+ * This model is used to maintain a synced local cache of the user's
+ * follows. It should be periodically refreshed and updated any time
+ * the user makes a change to their follows.
+ */
+export class MyFollowsModel {
+  // data
+  followDidToRecordMap: Record<string, string> = {}
+  lastSync = 0
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  // public api
+  // =
+
+  fetchIfNeeded = bundleAsync(async () => {
+    if (
+      Object.keys(this.followDidToRecordMap).length === 0 ||
+      Date.now() - this.lastSync > CACHE_TTL
+    ) {
+      return await this.fetch()
+    }
+  })
+
+  fetch = bundleAsync(async () => {
+    this.rootStore.log.debug('MyFollowsModel:fetch running full fetch')
+    let before
+    let records: FollowsListResponseRecord[] = []
+    do {
+      const res: FollowsListResponse =
+        await this.rootStore.api.app.bsky.graph.follow.list({
+          user: this.rootStore.me.did,
+          before,
+        })
+      records = records.concat(res.records)
+      before = res.cursor
+    } while (typeof before !== 'undefined')
+    runInAction(() => {
+      this.followDidToRecordMap = {}
+      for (const record of records) {
+        this.followDidToRecordMap[record.value.subject.did] = record.uri
+      }
+      this.lastSync = Date.now()
+    })
+  })
+
+  isFollowing(did: string) {
+    return !!this.followDidToRecordMap[did]
+  }
+
+  getFollowUri(did: string): string {
+    const v = this.followDidToRecordMap[did]
+    if (!v) {
+      throw new Error('Not a followed user')
+    }
+    return v
+  }
+
+  addFollow(did: string, recordUri: string) {
+    this.followDidToRecordMap[did] = recordUri
+  }
+
+  removeFollow(did: string) {
+    delete this.followDidToRecordMap[did]
+  }
+
+  /**
+   * Use this to incrementally update the cache as views provide information
+   */
+  hydrate(did: string, recordUri: string | undefined) {
+    if (recordUri) {
+      this.followDidToRecordMap[did] = recordUri
+    } else {
+      delete this.followDidToRecordMap[did]
+    }
+  }
+
+  /**
+   * Use this to incrementally update the cache as views provide information
+   */
+  hydrateProfiles(profiles: Profile[]) {
+    for (const profile of profiles) {
+      if (profile.viewer) {
+        this.hydrate(profile.did, profile.viewer.following)
+      }
+    }
+  }
+}
diff --git a/src/state/models/navigation.ts b/src/state/models/navigation.ts
index 224ffef0d..feb03b367 100644
--- a/src/state/models/navigation.ts
+++ b/src/state/models/navigation.ts
@@ -1,5 +1,7 @@
+import {RootStoreModel} from './root-store'
 import {makeAutoObservable} from 'mobx'
-import {TABS_ENABLED} from '../../build-flags'
+import {TABS_ENABLED} from 'lib/build-flags'
+import {segmentClient} from 'lib/analytics'
 
 let __id = 0
 function genId() {
@@ -11,13 +13,20 @@ function genId() {
 // we've since decided to pause that idea and do something more traditional
 // until we're fully sure what that is, the tabs are being repurposed into a fixed topology
 // - Tab 0: The "Default" tab
-// - Tab 1: The "Notifications" tab
+// - Tab 1: The "Search" tab
+// - Tab 2: The "Notifications" tab
 // These tabs always retain the first item in their history.
-// The default tab is used for basically everything except notifications.
 // -prf
 export enum TabPurpose {
   Default = 0,
-  Notifs = 1,
+  Search = 1,
+  Notifs = 2,
+}
+
+export const TabPurposeMainPath: Record<TabPurpose, string> = {
+  [TabPurpose.Default]: '/',
+  [TabPurpose.Search]: '/search',
+  [TabPurpose.Notifs]: '/notifications',
 }
 
 interface HistoryItem {
@@ -36,11 +45,9 @@ export class NavigationTabModel {
   isNewTab = false
 
   constructor(public fixedTabPurpose: TabPurpose) {
-    if (fixedTabPurpose === TabPurpose.Notifs) {
-      this.history = [{url: '/notifications', ts: Date.now(), id: genId()}]
-    } else {
-      this.history = [{url: '/', ts: Date.now(), id: genId()}]
-    }
+    this.history = [
+      {url: TabPurposeMainPath[fixedTabPurpose], ts: Date.now(), id: genId()},
+    ]
     makeAutoObservable(this, {
       serialize: false,
       hydrate: false,
@@ -96,6 +103,13 @@ export class NavigationTabModel {
   // =
 
   navigate(url: string, title?: string) {
+    try {
+      const path = url.split('/')[1]
+      segmentClient.track('Navigation', {
+        path,
+      })
+    } catch (error) {}
+
     if (this.current?.url === url) {
       this.refresh()
     } else {
@@ -104,8 +118,7 @@ export class NavigationTabModel {
       }
       // TEMP ensure the tab has its purpose's main view -prf
       if (this.history.length < 1) {
-        const fixedUrl =
-          this.fixedTabPurpose === TabPurpose.Notifs ? '/notifications' : '/'
+        const fixedUrl = TabPurposeMainPath[this.fixedTabPurpose]
         this.history.push({url: fixedUrl, ts: Date.now(), id: genId()})
       }
       this.history.push({url, title, ts: Date.now(), id: genId()})
@@ -211,12 +224,14 @@ export class NavigationTabModel {
 export class NavigationModel {
   tabs: NavigationTabModel[] = [
     new NavigationTabModel(TabPurpose.Default),
+    new NavigationTabModel(TabPurpose.Search),
     new NavigationTabModel(TabPurpose.Notifs),
   ]
   tabIndex = 0
 
-  constructor() {
+  constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(this, {
+      rootStore: false,
       serialize: false,
       hydrate: false,
     })
@@ -225,6 +240,7 @@ export class NavigationModel {
   clear() {
     this.tabs = [
       new NavigationTabModel(TabPurpose.Default),
+      new NavigationTabModel(TabPurpose.Search),
       new NavigationTabModel(TabPurpose.Notifs),
     ]
     this.tabIndex = 0
@@ -249,6 +265,7 @@ export class NavigationModel {
   // =
 
   navigate(url: string, title?: string) {
+    this.rootStore.emitNavigation()
     this.tab.navigate(url, title)
   }
 
@@ -286,10 +303,16 @@ export class NavigationModel {
   // fixed tab helper function
   // -prf
   switchTo(purpose: TabPurpose, reset: boolean) {
-    if (purpose === TabPurpose.Notifs) {
-      this.tabIndex = 1
-    } else {
-      this.tabIndex = 0
+    this.rootStore.emitNavigation()
+    switch (purpose) {
+      case TabPurpose.Notifs:
+        this.tabIndex = 2
+        break
+      case TabPurpose.Search:
+        this.tabIndex = 1
+        break
+      default:
+        this.tabIndex = 0
     }
     if (reset) {
       this.tab.fixedTabReset()
diff --git a/src/state/models/notifications-view.ts b/src/state/models/notifications-view.ts
index 93b6a398f..048de968d 100644
--- a/src/state/models/notifications-view.ts
+++ b/src/state/models/notifications-view.ts
@@ -8,11 +8,13 @@ import {
   AppBskyGraphAssertion,
   AppBskyGraphFollow,
 } from '@atproto/api'
+import AwaitLock from 'await-lock'
+import {bundleAsync} from 'lib/async/bundle'
 import {RootStoreModel} from './root-store'
 import {PostThreadViewModel} from './post-thread-view'
-import {cleanError} from '../../lib/strings'
+import {cleanError} from 'lib/strings/errors'
 
-const UNGROUPABLE_REASONS = ['assertion']
+const GROUPABLE_REASONS = ['vote', 'repost', 'follow']
 const PAGE_SIZE = 30
 const MS_1HR = 1e3 * 60 * 60
 const MS_2DAY = MS_1HR * 48
@@ -190,15 +192,16 @@ export class NotificationsViewModel {
   params: ListNotifications.QueryParams
   hasMore = true
   loadMoreCursor?: string
-  _loadPromise: Promise<void> | undefined
-  _loadMorePromise: Promise<void> | undefined
-  _updatePromise: Promise<void> | undefined
+
+  // used to linearize async modifications to state
+  private lock = new AwaitLock()
 
   // data
   notifications: NotificationsViewItemModel[] = []
+  unreadCount = 0
 
   // this is used to help trigger push notifications
-  mostRecentNotification: NotificationsViewItemModel | undefined
+  mostRecentNotificationUri: string | undefined
 
   constructor(
     public rootStore: RootStoreModel,
@@ -209,10 +212,7 @@ export class NotificationsViewModel {
       {
         rootStore: false,
         params: false,
-        mostRecentNotification: false,
-        _loadPromise: false,
-        _loadMorePromise: false,
-        _updatePromise: false,
+        mostRecentNotificationUri: false,
       },
       {autoBind: true},
     )
@@ -235,20 +235,47 @@ export class NotificationsViewModel {
   // =
 
   /**
+   * 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
    */
-  async setup(isRefreshing = false) {
+  setup = bundleAsync(async (isRefreshing: boolean = false) => {
+    this.rootStore.log.debug('NotificationsModel:setup', {isRefreshing})
     if (isRefreshing) {
       this.isRefreshing = true // set optimistically for UI
     }
-    if (this._loadPromise) {
-      return this._loadPromise
+    await this.lock.acquireAsync()
+    try {
+      this._xLoading(isRefreshing)
+      try {
+        const params = Object.assign({}, this.params, {
+          limit: PAGE_SIZE,
+        })
+        const res = await this.rootStore.api.app.bsky.notification.list(params)
+        await this._replaceAll(res)
+        this._xIdle()
+      } catch (e: any) {
+        this._xIdle(e)
+      }
+    } finally {
+      this.lock.release()
     }
-    await this._pendingWork()
-    this._loadPromise = this._initialLoad(isRefreshing)
-    await this._loadPromise
-    this._loadPromise = undefined
-  }
+  })
 
   /**
    * Reset and load
@@ -260,59 +287,148 @@ export class NotificationsViewModel {
   /**
    * Load more posts to the end of the notifications
    */
-  async loadMore() {
-    if (this._loadMorePromise) {
-      return this._loadMorePromise
+  loadMore = bundleAsync(async () => {
+    if (!this.hasMore) {
+      return
     }
-    await this._pendingWork()
-    this._loadMorePromise = this._loadMore()
-    await this._loadMorePromise
-    this._loadMorePromise = undefined
-  }
+    this.lock.acquireAsync()
+    try {
+      this._xLoading()
+      try {
+        const params = Object.assign({}, this.params, {
+          limit: PAGE_SIZE,
+          before: this.loadMoreCursor,
+        })
+        const res = await this.rootStore.api.app.bsky.notification.list(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.api.app.bsky.notification.list({
+          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
    */
-  async update() {
-    if (this._updatePromise) {
-      return this._updatePromise
+  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.api.app.bsky.notification.list({
+              before: 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()
     }
-    await this._pendingWork()
-    this._updatePromise = this._update()
-    await this._updatePromise
-    this._updatePromise = undefined
-  }
+  })
+
+  // 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.api.app.bsky.notification.getCount()
+    runInAction(() => {
+      this.unreadCount = res.data.count
+    })
+    this.rootStore.emitUnreadNotifications(this.unreadCount)
+    return this.unreadCount !== old
+  })
 
   /**
    * Update read/unread state
    */
-  async updateReadState() {
+  async markAllRead() {
     try {
+      this.unreadCount = 0
+      this.rootStore.emitUnreadNotifications(0)
       await this.rootStore.api.app.bsky.notification.updateSeen({
         seenAt: new Date().toISOString(),
       })
-      this.rootStore.me.clearNotificationCount()
     } catch (e: any) {
       this.rootStore.log.warn('Failed to update notifications read state', e)
     }
   }
 
   async getNewMostRecent(): Promise<NotificationsViewItemModel | undefined> {
-    let old = this.mostRecentNotification
-    const res = await this.rootStore.api.app.bsky.notification.list({limit: 1})
-    if (
-      !res.data.notifications[0] ||
-      old?.uri === res.data.notifications[0].uri
-    ) {
+    let old = this.mostRecentNotificationUri
+    const res = await this.rootStore.api.app.bsky.notification.list({
+      limit: 1,
+    })
+    if (!res.data.notifications[0] || old === res.data.notifications[0].uri) {
       return
     }
-    this.mostRecentNotification = new NotificationsViewItemModel(
+    this.mostRecentNotificationUri = res.data.notifications[0].uri
+    const notif = new NotificationsViewItemModel(
       this.rootStore,
       'mostRecent',
       res.data.notifications[0],
     )
-    await this.mostRecentNotification.fetchAdditionalData()
-    return this.mostRecentNotification
+    await notif.fetchAdditionalData()
+    return notif
   }
 
   // state transitions
@@ -329,93 +445,17 @@ export class NotificationsViewModel {
     this.isRefreshing = false
     this.hasLoaded = true
     this.error = cleanError(err)
-    this.error = err ? cleanError(err) : ''
     if (err) {
       this.rootStore.log.error('Failed to fetch notifications', err)
     }
   }
 
-  // loader functions
+  // helper functions
   // =
 
-  private async _pendingWork() {
-    if (this._loadPromise) {
-      await this._loadPromise
-    }
-    if (this._loadMorePromise) {
-      await this._loadMorePromise
-    }
-    if (this._updatePromise) {
-      await this._updatePromise
-    }
-  }
-
-  private async _initialLoad(isRefreshing = false) {
-    this._xLoading(isRefreshing)
-    try {
-      const params = Object.assign({}, this.params, {
-        limit: PAGE_SIZE,
-      })
-      const res = await this.rootStore.api.app.bsky.notification.list(params)
-      await this._replaceAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  private async _loadMore() {
-    if (!this.hasMore) {
-      return
-    }
-    this._xLoading()
-    try {
-      const params = Object.assign({}, this.params, {
-        limit: PAGE_SIZE,
-        before: this.loadMoreCursor,
-      })
-      const res = await this.rootStore.api.app.bsky.notification.list(params)
-      await this._appendAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  private async _update() {
-    if (!this.notifications.length) {
-      return
-    }
-    this._xLoading()
-    let numToFetch = this.notifications.length
-    let cursor
-    try {
-      do {
-        const res: ListNotifications.Response =
-          await this.rootStore.api.app.bsky.notification.list({
-            before: 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(e)
-    }
-  }
-
   private async _replaceAll(res: ListNotifications.Response) {
     if (res.data.notifications[0]) {
-      this.mostRecentNotification = new NotificationsViewItemModel(
-        this.rootStore,
-        'mostRecent',
-        res.data.notifications[0],
-      )
+      this.mostRecentNotificationUri = res.data.notifications[0].uri
     }
     return this._appendAll(res, true)
   }
@@ -451,14 +491,40 @@ export class NotificationsViewModel {
     })
   }
 
+  private async _prependAll(res: ListNotifications.Response) {
+    const promises = []
+    const itemModels: NotificationsViewItemModel[] = []
+    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 NotificationsViewItemModel(
+        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)
+    })
+  }
+
   private _updateAll(res: ListNotifications.Response) {
     for (const item of res.data.notifications) {
-      const existingItem = this.notifications.find(
-        // this find 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
-        item2 => item.uri === item2.uri && item.indexedAt === item2.indexedAt,
-      )
+      const existingItem = this.notifications.find(item2 => isEq(item, item2))
       if (existingItem) {
         existingItem.copy(item, true)
       }
@@ -473,7 +539,7 @@ function groupNotifications(
   for (const item of items) {
     const ts = +new Date(item.indexedAt)
     let grouped = false
-    if (!UNGROUPABLE_REASONS.includes(item.reason)) {
+    if (GROUPABLE_REASONS.includes(item.reason)) {
       for (const item2 of items2) {
         const ts2 = +new Date(item2.indexedAt)
         if (
@@ -495,3 +561,11 @@ function groupNotifications(
   }
   return items2
 }
+
+type N = ListNotifications.Notification | NotificationsViewItemModel
+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/onboard.ts b/src/state/models/onboard.ts
index 5ab5ecb62..aa275c6b7 100644
--- a/src/state/models/onboard.ts
+++ b/src/state/models/onboard.ts
@@ -1,5 +1,5 @@
 import {makeAutoObservable} from 'mobx'
-import {isObj, hasProp} from '../lib/type-guards'
+import {isObj, hasProp} from 'lib/type-guards'
 
 export const OnboardStage = {
   Explainers: 'explainers',
diff --git a/src/state/models/post-thread-view.ts b/src/state/models/post-thread-view.ts
index 584658e14..ad989cc53 100644
--- a/src/state/models/post-thread-view.ts
+++ b/src/state/models/post-thread-view.ts
@@ -5,7 +5,9 @@ import {
 } from '@atproto/api'
 import {AtUri} from '../../third-party/uri'
 import {RootStoreModel} from './root-store'
-import * as apilib from '../lib/api'
+import * as apilib from 'lib/api/index'
+import {cleanError} from 'lib/strings/errors'
+import {RichText} from 'lib/strings/rich-text'
 
 function* reactKeyGenerator(): Generator<string> {
   let counter = 0
@@ -26,6 +28,7 @@ export class PostThreadViewPostModel {
   postRecord?: FeedPost.Record
   parent?: PostThreadViewPostModel | GetPostThread.NotFoundPost
   replies?: (PostThreadViewPostModel | GetPostThread.NotFoundPost)[]
+  richText?: RichText
 
   constructor(
     public rootStore: RootStoreModel,
@@ -38,6 +41,11 @@ export class PostThreadViewPostModel {
       const valid = FeedPost.validateRecord(this.post.record)
       if (valid.success) {
         this.postRecord = this.post.record
+        this.richText = new RichText(
+          this.postRecord.text,
+          this.postRecord.entities,
+          {cleanNewlines: true},
+        )
       } else {
         rootStore.log.warn(
           'Received an invalid app.bsky.feed.post record',
@@ -276,7 +284,7 @@ export class PostThreadViewModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = err ? err.toString() : ''
+    this.error = cleanError(err)
     if (err) {
       this.rootStore.log.error('Failed to fetch post thread', err)
     }
@@ -290,7 +298,7 @@ export class PostThreadViewModel {
     const urip = new AtUri(this.params.uri)
     if (!urip.host.startsWith('did:')) {
       try {
-        urip.host = await this.rootStore.resolveName(urip.host)
+        urip.host = await apilib.resolveName(this.rootStore, urip.host)
       } catch (e: any) {
         this.error = e.toString()
       }
@@ -314,7 +322,7 @@ export class PostThreadViewModel {
   }
 
   private _replaceAll(res: GetPostThread.Response) {
-    // sortThread(res.data.thread) TODO needed?
+    sortThread(res.data.thread)
     const keyGen = reactKeyGenerator()
     const thread = new PostThreadViewPostModel(
       this.rootStore,
@@ -330,36 +338,37 @@ export class PostThreadViewModel {
   }
 }
 
-/*
-TODO needed?
+type MaybePost =
+  | GetPostThread.ThreadViewPost
+  | GetPostThread.NotFoundPost
+  | {[k: string]: unknown; $type: string}
 function sortThread(post: MaybePost) {
   if (post.notFound) {
     return
   }
-  post = post as GetPostThread.Post
+  post = post as GetPostThread.ThreadViewPost
   if (post.replies) {
     post.replies.sort((a: MaybePost, b: MaybePost) => {
-      post = post as GetPostThread.Post
+      post = post as GetPostThread.ThreadViewPost
       if (a.notFound) {
         return 1
       }
       if (b.notFound) {
         return -1
       }
-      a = a as GetPostThread.Post
-      b = b as GetPostThread.Post
-      const aIsByOp = a.author.did === post.author.did
-      const bIsByOp = b.author.did === post.author.did
+      a = a as GetPostThread.ThreadViewPost
+      b = b as GetPostThread.ThreadViewPost
+      const aIsByOp = a.post.author.did === post.post.author.did
+      const bIsByOp = b.post.author.did === post.post.author.did
       if (aIsByOp && bIsByOp) {
-        return a.indexedAt.localeCompare(b.indexedAt) // oldest
+        return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
       } else if (aIsByOp) {
         return -1 // op's own reply
       } else if (bIsByOp) {
         return 1 // op's own reply
       }
-      return b.indexedAt.localeCompare(a.indexedAt) // newest
+      return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
     })
     post.replies.forEach(reply => sortThread(reply))
   }
 }
-*/
diff --git a/src/state/models/post.ts b/src/state/models/post.ts
index 497c8e4c9..749e98bb0 100644
--- a/src/state/models/post.ts
+++ b/src/state/models/post.ts
@@ -2,7 +2,7 @@ import {makeAutoObservable} from 'mobx'
 import {AppBskyFeedPost as Post} from '@atproto/api'
 import {AtUri} from '../../third-party/uri'
 import {RootStoreModel} from './root-store'
-import {cleanError} from '../../lib/strings'
+import {cleanError} from 'lib/strings/errors'
 
 type RemoveIndex<T> = {
   [P in keyof T as string extends P
@@ -67,7 +67,6 @@ export class PostModel implements RemoveIndex<Post.Record> {
     this.isLoading = false
     this.hasLoaded = true
     this.error = cleanError(err)
-    this.error = err ? cleanError(err) : ''
     if (err) {
       this.rootStore.log.error('Failed to fetch post', err)
     }
diff --git a/src/state/models/profile-view.ts b/src/state/models/profile-view.ts
index 79882a562..8630eae52 100644
--- a/src/state/models/profile-view.ts
+++ b/src/state/models/profile-view.ts
@@ -1,22 +1,23 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import {Image as PickedImage} from '../../view/com/util/images/image-crop-picker/ImageCropPicker'
+import {PickedMedia} from 'view/com/util/images/image-crop-picker/ImageCropPicker'
 import {
   AppBskyActorGetProfile as GetProfile,
   AppBskyActorProfile as Profile,
   AppBskySystemDeclRef,
-  AppBskyFeedPost,
 } from '@atproto/api'
 type DeclRef = AppBskySystemDeclRef.Main
-type Entity = AppBskyFeedPost.Entity
-import {extractEntities} from '../../lib/strings'
+import {extractEntities} from 'lib/strings/rich-text-detection'
 import {RootStoreModel} from './root-store'
-import * as apilib from '../lib/api'
+import * as apilib from 'lib/api/index'
+import {cleanError} from 'lib/strings/errors'
+import {RichText} from 'lib/strings/rich-text'
 
 export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
 
-export class ProfileViewMyStateModel {
-  follow?: string
+export class ProfileViewViewerModel {
   muted?: boolean
+  following?: string
+  followedBy?: string
 
   constructor() {
     makeAutoObservable(this)
@@ -46,10 +47,10 @@ export class ProfileViewModel {
   followersCount: number = 0
   followsCount: number = 0
   postsCount: number = 0
-  myState = new ProfileViewMyStateModel()
+  viewer = new ProfileViewViewerModel()
 
   // added data
-  descriptionEntities?: Entity[]
+  descriptionRichText?: RichText
 
   constructor(
     public rootStore: RootStoreModel,
@@ -97,11 +98,24 @@ export class ProfileViewModel {
     if (!this.rootStore.me.did) {
       throw new Error('Not logged in')
     }
-    if (this.myState.follow) {
-      await apilib.unfollow(this.rootStore, this.myState.follow)
+
+    const follows = this.rootStore.me.follows
+    const followUri = follows.isFollowing(this.did)
+      ? follows.getFollowUri(this.did)
+      : undefined
+
+    // guard against this view getting out of sync with the follows cache
+    if (followUri !== this.viewer.following) {
+      this.viewer.following = followUri
+      return
+    }
+
+    if (followUri) {
+      await apilib.unfollow(this.rootStore, followUri)
       runInAction(() => {
         this.followersCount--
-        this.myState.follow = undefined
+        this.viewer.following = undefined
+        this.rootStore.me.follows.removeFollow(this.did)
       })
     } else {
       const res = await apilib.follow(
@@ -111,15 +125,16 @@ export class ProfileViewModel {
       )
       runInAction(() => {
         this.followersCount++
-        this.myState.follow = res.uri
+        this.viewer.following = res.uri
+        this.rootStore.me.follows.addFollow(this.did, res.uri)
       })
     }
   }
 
   async updateProfile(
     updates: Profile.Record,
-    newUserAvatar: PickedImage | undefined,
-    newUserBanner: PickedImage | undefined,
+    newUserAvatar: PickedMedia | undefined,
+    newUserBanner: PickedMedia | undefined,
   ) {
     if (newUserAvatar) {
       const res = await this.rootStore.api.com.atproto.blob.upload(
@@ -152,13 +167,13 @@ export class ProfileViewModel {
 
   async muteAccount() {
     await this.rootStore.api.app.bsky.graph.mute({user: this.did})
-    this.myState.muted = true
+    this.viewer.muted = true
     await this.refresh()
   }
 
   async unmuteAccount() {
     await this.rootStore.api.app.bsky.graph.unmute({user: this.did})
-    this.myState.muted = false
+    this.viewer.muted = false
     await this.refresh()
   }
 
@@ -175,7 +190,7 @@ export class ProfileViewModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = err ? err.toString() : ''
+    this.error = cleanError(err)
     if (err) {
       this.rootStore.log.error('Failed to fetch profile', err)
     }
@@ -210,9 +225,14 @@ export class ProfileViewModel {
     this.followersCount = res.data.followersCount
     this.followsCount = res.data.followsCount
     this.postsCount = res.data.postsCount
-    if (res.data.myState) {
-      Object.assign(this.myState, res.data.myState)
+    if (res.data.viewer) {
+      Object.assign(this.viewer, res.data.viewer)
+      this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following)
     }
-    this.descriptionEntities = extractEntities(this.description || '')
+    this.descriptionRichText = new RichText(
+      this.description || '',
+      extractEntities(this.description || ''),
+      {cleanNewlines: true},
+    )
   }
 }
diff --git a/src/state/models/profiles-view.ts b/src/state/models/profiles-view.ts
index 804491c8b..4241e50e1 100644
--- a/src/state/models/profiles-view.ts
+++ b/src/state/models/profiles-view.ts
@@ -31,7 +31,9 @@ export class ProfilesViewModel {
       }
     }
     try {
-      const promise = this.rootStore.api.app.bsky.actor.getProfile({actor: did})
+      const promise = this.rootStore.api.app.bsky.actor.getProfile({
+        actor: did,
+      })
       this.cache.set(did, promise)
       const res = await promise
       this.cache.set(did, res)
diff --git a/src/state/models/reposted-by-view.ts b/src/state/models/reposted-by-view.ts
index 1de6b7c58..69a728d6f 100644
--- a/src/state/models/reposted-by-view.ts
+++ b/src/state/models/reposted-by-view.ts
@@ -1,11 +1,17 @@
 import {makeAutoObservable, runInAction} from 'mobx'
 import {AtUri} from '../../third-party/uri'
-import {AppBskyFeedGetRepostedBy as GetRepostedBy} from '@atproto/api'
+import {
+  AppBskyFeedGetRepostedBy as GetRepostedBy,
+  AppBskyActorRef as ActorRef,
+} from '@atproto/api'
 import {RootStoreModel} from './root-store'
+import {bundleAsync} from 'lib/async/bundle'
+import {cleanError} from 'lib/strings/errors'
+import * as apilib from 'lib/api/index'
 
 const PAGE_SIZE = 30
 
-export type RepostedByItem = GetRepostedBy.RepostedBy
+export type RepostedByItem = ActorRef.WithInfo
 
 export class RepostedByViewModel {
   // state
@@ -17,7 +23,6 @@ export class RepostedByViewModel {
   params: GetRepostedBy.QueryParams
   hasMore = true
   loadMoreCursor?: string
-  private _loadMorePromise: Promise<void> | undefined
 
   // data
   uri: string = ''
@@ -57,17 +62,28 @@ export class RepostedByViewModel {
     return this.loadMore(true)
   }
 
-  async loadMore(isRefreshing = false) {
-    if (this._loadMorePromise) {
-      return this._loadMorePromise
-    }
-    if (!this.resolvedUri) {
-      await this._resolveUri()
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    this._xLoading(replace)
+    try {
+      if (!this.resolvedUri) {
+        await this._resolveUri()
+      }
+      const params = Object.assign({}, this.params, {
+        uri: this.resolvedUri,
+        limit: PAGE_SIZE,
+        before: replace ? undefined : this.loadMoreCursor,
+      })
+      const res = await this.rootStore.api.app.bsky.feed.getRepostedBy(params)
+      if (replace) {
+        this._replaceAll(res)
+      } else {
+        this._appendAll(res)
+      }
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
     }
-    this._loadMorePromise = this._loadMore(isRefreshing)
-    await this._loadMorePromise
-    this._loadMorePromise = undefined
-  }
+  })
 
   // state transitions
   // =
@@ -82,20 +98,20 @@ export class RepostedByViewModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = err ? err.toString() : ''
+    this.error = cleanError(err)
     if (err) {
       this.rootStore.log.error('Failed to fetch reposted by view', err)
     }
   }
 
-  // loader functions
+  // helper functions
   // =
 
   private async _resolveUri() {
     const urip = new AtUri(this.params.uri)
     if (!urip.host.startsWith('did:')) {
       try {
-        urip.host = await this.rootStore.resolveName(urip.host)
+        urip.host = await apilib.resolveName(this.rootStore, urip.host)
       } catch (e: any) {
         this.error = e.toString()
       }
@@ -105,28 +121,15 @@ export class RepostedByViewModel {
     })
   }
 
-  private async _loadMore(isRefreshing = false) {
-    this._xLoading(isRefreshing)
-    try {
-      const params = Object.assign({}, this.params, {
-        uri: this.resolvedUri,
-        limit: PAGE_SIZE,
-        before: this.loadMoreCursor,
-      })
-      if (this.isRefreshing) {
-        this.repostedBy = []
-      }
-      const res = await this.rootStore.api.app.bsky.feed.getRepostedBy(params)
-      await this._appendAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
+  private _replaceAll(res: GetRepostedBy.Response) {
+    this.repostedBy = []
+    this._appendAll(res)
   }
 
   private _appendAll(res: GetRepostedBy.Response) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
     this.repostedBy = this.repostedBy.concat(res.data.repostedBy)
+    this.rootStore.me.follows.hydrateProfiles(res.data.repostedBy)
   }
 }
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index 2f6931cdc..11d496351 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -3,71 +3,63 @@
  */
 
 import {makeAutoObservable} from 'mobx'
-import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
+import {AtpAgent} from '@atproto/api'
 import {createContext, useContext} from 'react'
 import {DeviceEventEmitter, EmitterSubscription} from 'react-native'
-import * as BgScheduler from '../lib/bg-scheduler'
-import {isObj, hasProp} from '../lib/type-guards'
+import * as BgScheduler from 'lib/bg-scheduler'
+import {z} from 'zod'
+import {isObj, hasProp} from 'lib/type-guards'
 import {LogModel} from './log'
 import {SessionModel} from './session'
 import {NavigationModel} from './navigation'
 import {ShellUiModel} from './shell-ui'
 import {ProfilesViewModel} from './profiles-view'
 import {LinkMetasViewModel} from './link-metas-view'
+import {NotificationsViewItemModel} from './notifications-view'
 import {MeModel} from './me'
 import {OnboardModel} from './onboard'
-import {isNetworkError} from '../../lib/errors'
+
+export const appInfo = z.object({
+  build: z.string(),
+  name: z.string(),
+  namespace: z.string(),
+  version: z.string(),
+})
+export type AppInfo = z.infer<typeof appInfo>
 
 export class RootStoreModel {
+  agent: AtpAgent
+  appInfo?: AppInfo
   log = new LogModel()
   session = new SessionModel(this)
-  nav = new NavigationModel()
-  shell = new ShellUiModel()
+  nav = new NavigationModel(this)
+  shell = new ShellUiModel(this)
   me = new MeModel(this)
   onboard = new OnboardModel()
   profiles = new ProfilesViewModel(this)
   linkMetas = new LinkMetasViewModel(this)
 
-  constructor(public api: SessionServiceClient) {
+  constructor(agent: AtpAgent) {
+    this.agent = agent
     makeAutoObservable(this, {
       api: false,
-      resolveName: false,
       serialize: false,
       hydrate: false,
     })
     this.initBgFetch()
   }
 
-  async resolveName(didOrHandle: string) {
-    if (!didOrHandle) {
-      throw new Error('Invalid handle: ""')
-    }
-    if (didOrHandle.startsWith('did:')) {
-      return didOrHandle
-    }
-    const res = await this.api.com.atproto.handle.resolve({handle: didOrHandle})
-    return res.data.did
+  get api() {
+    return this.agent.api
   }
 
-  async fetchStateUpdate() {
-    if (!this.session.hasSession) {
-      return
-    }
-    try {
-      if (!this.session.online) {
-        await this.session.connect()
-      }
-      await this.me.fetchNotifications()
-    } catch (e: any) {
-      if (isNetworkError(e)) {
-        this.session.setOnline(false) // connection lost
-      }
-      this.log.error('Failed to fetch latest state', e)
-    }
+  setAppInfo(info: AppInfo) {
+    this.appInfo = info
   }
 
   serialize(): unknown {
     return {
+      appInfo: this.appInfo,
       log: this.log.serialize(),
       session: this.session.serialize(),
       me: this.me.serialize(),
@@ -79,6 +71,12 @@ export class RootStoreModel {
 
   hydrate(v: unknown) {
     if (isObj(v)) {
+      if (hasProp(v, 'appInfo')) {
+        const appInfoParsed = appInfo.safeParse(v.appInfo)
+        if (appInfoParsed.success) {
+          this.setAppInfo(appInfoParsed.data)
+        }
+      }
       if (hasProp(v, 'log')) {
         this.log.hydrate(v.log)
       }
@@ -100,20 +98,131 @@ export class RootStoreModel {
     }
   }
 
-  clearAll() {
+  /**
+   * Called during init to resume any stored session.
+   */
+  async attemptSessionResumption() {
+    this.log.debug('RootStoreModel:attemptSessionResumption')
+    try {
+      await this.session.attemptSessionResumption()
+      this.log.debug('Session initialized', {
+        hasSession: this.session.hasSession,
+      })
+      this.updateSessionState()
+    } catch (e: any) {
+      this.log.warn('Failed to initialize session', e)
+    }
+  }
+
+  /**
+   * Called by the session model. Refreshes session-oriented state.
+   */
+  async handleSessionChange(agent: AtpAgent) {
+    this.log.debug('RootStoreModel:handleSessionChange')
+    this.agent = agent
+    this.nav.clear()
+    this.me.clear()
+    await this.me.load()
+  }
+
+  /**
+   * Called by the session model. Handles session drops by informing the user.
+   */
+  async handleSessionDrop() {
+    this.log.debug('RootStoreModel:handleSessionDrop')
+    this.nav.clear()
+    this.me.clear()
+    this.emitSessionDropped()
+  }
+
+  /**
+   * Clears all session-oriented state.
+   */
+  clearAllSessionState() {
+    this.log.debug('RootStoreModel:clearAllSessionState')
     this.session.clear()
     this.nav.clear()
     this.me.clear()
   }
 
+  /**
+   * Periodic poll for new session state.
+   */
+  async updateSessionState() {
+    if (!this.session.hasSession) {
+      return
+    }
+    try {
+      await this.me.follows.fetchIfNeeded()
+    } catch (e: any) {
+      this.log.error('Failed to fetch latest state', e)
+    }
+  }
+
+  // global event bus
+  // =
+  // - some events need to be passed around between views and models
+  //   in order to keep state in sync; these methods are for that
+
+  // a post was deleted by the local user
   onPostDeleted(handler: (uri: string) => void): EmitterSubscription {
     return DeviceEventEmitter.addListener('post-deleted', handler)
   }
-
   emitPostDeleted(uri: string) {
     DeviceEventEmitter.emit('post-deleted', uri)
   }
 
+  // the session has started and been fully hydrated
+  onSessionLoaded(handler: () => void): EmitterSubscription {
+    return DeviceEventEmitter.addListener('session-loaded', handler)
+  }
+  emitSessionLoaded() {
+    DeviceEventEmitter.emit('session-loaded')
+  }
+
+  // the session was dropped due to bad/expired refresh tokens
+  onSessionDropped(handler: () => void): EmitterSubscription {
+    return DeviceEventEmitter.addListener('session-dropped', handler)
+  }
+  emitSessionDropped() {
+    DeviceEventEmitter.emit('session-dropped')
+  }
+
+  // the current screen has changed
+  onNavigation(handler: () => void): EmitterSubscription {
+    return DeviceEventEmitter.addListener('navigation', handler)
+  }
+  emitNavigation() {
+    DeviceEventEmitter.emit('navigation')
+  }
+
+  // a "soft reset" typically means scrolling to top and loading latest
+  // but it can depend on the screen
+  onScreenSoftReset(handler: () => void): EmitterSubscription {
+    return DeviceEventEmitter.addListener('screen-soft-reset', handler)
+  }
+  emitScreenSoftReset() {
+    DeviceEventEmitter.emit('screen-soft-reset')
+  }
+
+  // the unread notifications count has changed
+  onUnreadNotifications(handler: (count: number) => void): EmitterSubscription {
+    return DeviceEventEmitter.addListener('unread-notifications', handler)
+  }
+  emitUnreadNotifications(count: number) {
+    DeviceEventEmitter.emit('unread-notifications', count)
+  }
+
+  // a notification has been queued for push
+  onPushNotification(
+    handler: (notif: NotificationsViewItemModel) => void,
+  ): EmitterSubscription {
+    return DeviceEventEmitter.addListener('push-notification', handler)
+  }
+  emitPushNotification(notif: NotificationsViewItemModel) {
+    DeviceEventEmitter.emit('push-notification', notif)
+  }
+
   // background fetch
   // =
   // - we use this to poll for unread notifications, which is not "ideal" behavior but
@@ -135,7 +244,22 @@ export class RootStoreModel {
   async onBgFetch(taskId: string) {
     this.log.debug(`Background fetch fired for task ${taskId}`)
     if (this.session.hasSession) {
-      await this.me.bgFetchNotifications()
+      const res = await this.api.app.bsky.notification.getCount()
+      const hasNewNotifs = this.me.notifications.unreadCount !== res.data.count
+      this.emitUnreadNotifications(res.data.count)
+      this.log.debug(
+        `Background fetch received unread count = ${res.data.count}`,
+      )
+      if (hasNewNotifs) {
+        this.log.debug(
+          'Background fetch detected potentially a new notification',
+        )
+        const mostRecent = await this.me.notifications.getNewMostRecent()
+        if (mostRecent) {
+          this.log.debug('Got the notification, triggering a push')
+          this.emitPushNotification(mostRecent)
+        }
+      }
     }
     BgScheduler.finish(taskId)
   }
@@ -146,7 +270,9 @@ export class RootStoreModel {
   }
 }
 
-const throwawayInst = new RootStoreModel(AtpApi.service('http://localhost')) // this will be replaced by the loader, we just need to supply a value at init
+const throwawayInst = new RootStoreModel(
+  new AtpAgent({service: 'http://localhost'}),
+) // this will be replaced by the loader, we just need to supply a value at init
 const RootStoreContext = createContext<RootStoreModel>(throwawayInst)
 export const RootStoreProvider = RootStoreContext.Provider
 export const useStores = () => useContext(RootStoreContext)
diff --git a/src/state/models/session.ts b/src/state/models/session.ts
index bc0a9123f..6e816120d 100644
--- a/src/state/models/session.ts
+++ b/src/state/models/session.ts
@@ -1,25 +1,22 @@
-import {makeAutoObservable, runInAction} from 'mobx'
+import {makeAutoObservable} from 'mobx'
 import {
-  sessionClient as AtpApi,
-  Session,
-  SessionServiceClient,
+  AtpAgent,
+  AtpSessionEvent,
+  AtpSessionData,
   ComAtprotoServerGetAccountsConfig as GetAccountsConfig,
 } from '@atproto/api'
-import {isObj, hasProp} from '../lib/type-guards'
+import normalizeUrl from 'normalize-url'
+import {isObj, hasProp} from 'lib/type-guards'
 import {z} from 'zod'
 import {RootStoreModel} from './root-store'
-import {isNetworkError} from '../../lib/errors'
 
 export type ServiceDescription = GetAccountsConfig.OutputSchema
 
-export const sessionData = z.object({
+export const activeSession = z.object({
   service: z.string(),
-  refreshJwt: z.string(),
-  accessJwt: z.string(),
-  handle: z.string(),
   did: z.string(),
 })
-export type SessionData = z.infer<typeof sessionData>
+export type ActiveSession = z.infer<typeof activeSession>
 
 export const accountData = z.object({
   service: z.string(),
@@ -32,18 +29,24 @@ export const accountData = z.object({
 })
 export type AccountData = z.infer<typeof accountData>
 
+interface AdditionalAccountData {
+  displayName?: string
+  aviUrl?: string
+}
+
 export class SessionModel {
   /**
-   * Current session data
+   * Currently-active session
    */
-  data: SessionData | null = null
+  data: ActiveSession | null = null
   /**
-   * A listing of the currently & previous sessions, used for account switching
+   * A listing of the currently & previous sessions
    */
   accounts: AccountData[] = []
-  online = false
-  attemptingConnect = false
-  private _connectPromise: Promise<boolean> | undefined
+  /**
+   * Flag to indicate if we're doing our initial-load session resumption
+   */
+  isResumingSession = false
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(this, {
@@ -53,8 +56,22 @@ export class SessionModel {
     })
   }
 
+  get currentSession() {
+    if (!this.data) {
+      return undefined
+    }
+    const {did, service} = this.data
+    return this.accounts.find(
+      account =>
+        normalizeUrl(account.service) === normalizeUrl(service) &&
+        account.did === did &&
+        !!account.accessJwt &&
+        !!account.refreshJwt,
+    )
+  }
+
   get hasSession() {
-    return this.data !== null
+    return !!this.currentSession && !!this.rootStore.agent.session
   }
 
   get hasAccounts() {
@@ -75,8 +92,8 @@ export class SessionModel {
   hydrate(v: unknown) {
     this.accounts = []
     if (isObj(v)) {
-      if (hasProp(v, 'data') && sessionData.safeParse(v.data)) {
-        this.data = v.data as SessionData
+      if (hasProp(v, 'data') && activeSession.safeParse(v.data)) {
+        this.data = v.data as ActiveSession
       }
       if (hasProp(v, 'accounts') && Array.isArray(v.accounts)) {
         for (const account of v.accounts) {
@@ -90,92 +107,96 @@ export class SessionModel {
 
   clear() {
     this.data = null
-    this.setOnline(false)
   }
 
-  setState(data: SessionData) {
-    this.data = data
-  }
-
-  setOnline(online: boolean, attemptingConnect?: boolean) {
-    this.online = online
-    if (typeof attemptingConnect === 'boolean') {
-      this.attemptingConnect = attemptingConnect
-    }
-  }
-
-  updateAuthTokens(session: Session) {
-    if (this.data) {
-      this.setState({
-        ...this.data,
-        accessJwt: session.accessJwt,
-        refreshJwt: session.refreshJwt,
-      })
+  /**
+   * Attempts to resume the previous session loaded from storage
+   */
+  async attemptSessionResumption() {
+    const sess = this.currentSession
+    if (sess) {
+      this.rootStore.log.debug(
+        'SessionModel:attemptSessionResumption found stored session',
+      )
+      this.isResumingSession = true
+      try {
+        return await this.resumeSession(sess)
+      } finally {
+        this.isResumingSession = false
+      }
+    } else {
+      this.rootStore.log.debug(
+        'SessionModel:attemptSessionResumption has no session to resume',
+      )
     }
   }
 
   /**
-   * Sets up the XRPC API, must be called before connecting to a service
+   * Sets the active session
    */
-  private configureApi(): boolean {
-    if (!this.data) {
-      return false
+  setActiveSession(agent: AtpAgent, did: string) {
+    this.rootStore.log.debug('SessionModel:setActiveSession')
+    this.data = {
+      service: agent.service.toString(),
+      did,
     }
-
-    try {
-      const serviceUri = new URL(this.data.service)
-      this.rootStore.api.xrpc.uri = serviceUri
-    } catch (e: any) {
-      this.rootStore.log.error(
-        `Invalid service URL: ${this.data.service}. Resetting session.`,
-        e,
-      )
-      this.clear()
-      return false
-    }
-
-    this.rootStore.api.sessionManager.set({
-      refreshJwt: this.data.refreshJwt,
-      accessJwt: this.data.accessJwt,
-    })
-    return true
+    this.rootStore.handleSessionChange(agent)
   }
 
   /**
-   * Upserts the current session into the accounts
+   * Upserts a session into the accounts
    */
-  private addSessionToAccounts() {
-    if (!this.data) {
-      return
-    }
+  private persistSession(
+    service: string,
+    did: string,
+    event: AtpSessionEvent,
+    session?: AtpSessionData,
+    addedInfo?: AdditionalAccountData,
+  ) {
+    this.rootStore.log.debug('SessionModel:persistSession', {
+      service,
+      did,
+      event,
+      hasSession: !!session,
+    })
+
+    // upsert the account in our listing
     const existingAccount = this.accounts.find(
-      acc => acc.service === this.data?.service && acc.did === this.data.did,
+      account => account.service === service && account.did === did,
     )
     const newAccount = {
-      service: this.data.service,
-      refreshJwt: this.data.refreshJwt,
-      accessJwt: this.data.accessJwt,
-      handle: this.data.handle,
-      did: this.data.did,
-      displayName: this.rootStore.me.displayName,
-      aviUrl: this.rootStore.me.avatar,
+      service,
+      did,
+      refreshJwt: session?.refreshJwt,
+      accessJwt: session?.accessJwt,
+      handle: session?.handle || existingAccount?.handle || '',
+      displayName: addedInfo
+        ? addedInfo.displayName
+        : existingAccount?.displayName || '',
+      aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '',
     }
     if (!existingAccount) {
       this.accounts.push(newAccount)
     } else {
-      this.accounts = this.accounts
-        .filter(
-          acc =>
-            !(acc.service === this.data?.service && acc.did === this.data.did),
-        )
-        .concat([newAccount])
+      this.accounts = [
+        newAccount,
+        ...this.accounts.filter(
+          account => !(account.service === service && account.did === did),
+        ),
+      ]
+    }
+
+    // if the session expired, fire an event to let the user know
+    if (event === 'expired') {
+      this.rootStore.handleSessionDrop()
     }
   }
 
   /**
    * Clears any session tokens from the accounts; used on logout.
    */
-  private clearSessionTokensFromAccounts() {
+  private clearSessionTokens() {
+    this.rootStore.log.debug('SessionModel:clearSessionTokens')
     this.accounts = this.accounts.map(acct => ({
       service: acct.service,
       handle: acct.handle,
@@ -186,65 +207,73 @@ export class SessionModel {
   }
 
   /**
-   * Fetches the current session from the service, if possible.
-   * Requires an existing session (.data) to be populated with access tokens.
+   * Fetches additional information about an account on load.
    */
-  async connect(): Promise<boolean> {
-    if (this._connectPromise) {
-      return this._connectPromise
+  private async loadAccountInfo(agent: AtpAgent, did: string) {
+    const res = await agent.api.app.bsky.actor
+      .getProfile({actor: did})
+      .catch(_e => undefined)
+    if (res) {
+      return {
+        dispayName: res.data.displayName,
+        aviUrl: res.data.avatar,
+      }
     }
-    this._connectPromise = this._connect()
-    const res = await this._connectPromise
-    this._connectPromise = undefined
-    return res
   }
 
-  private async _connect(): Promise<boolean> {
-    this.attemptingConnect = true
-    if (!this.configureApi()) {
+  /**
+   * Helper to fetch the accounts config settings from an account.
+   */
+  async describeService(service: string): Promise<ServiceDescription> {
+    const agent = new AtpAgent({service})
+    const res = await agent.api.com.atproto.server.getAccountsConfig({})
+    return res.data
+  }
+
+  /**
+   * Attempt to resume a session that we still have access tokens for.
+   */
+  async resumeSession(account: AccountData): Promise<boolean> {
+    this.rootStore.log.debug('SessionModel:resumeSession')
+    if (!(account.accessJwt && account.refreshJwt && account.service)) {
+      this.rootStore.log.debug(
+        'SessionModel:resumeSession aborted due to lack of access tokens',
+      )
       return false
     }
 
+    const agent = new AtpAgent({
+      service: account.service,
+      persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => {
+        this.persistSession(account.service, account.did, evt, sess)
+      },
+    })
+
     try {
-      const sess = await this.rootStore.api.com.atproto.session.get()
-      if (sess.success && this.data && this.data.did === sess.data.did) {
-        this.setOnline(true, false)
-        if (this.rootStore.me.did !== sess.data.did) {
-          this.rootStore.me.clear()
-        }
-        this.rootStore.me
-          .load()
-          .catch(e => {
-            this.rootStore.log.error(
-              'Failed to fetch local user information',
-              e,
-            )
-          })
-          .then(() => {
-            this.addSessionToAccounts()
-          })
-        return true // success
-      }
+      await agent.resumeSession({
+        accessJwt: account.accessJwt,
+        refreshJwt: account.refreshJwt,
+        did: account.did,
+        handle: account.handle,
+      })
+      const addedInfo = await this.loadAccountInfo(agent, account.did)
+      this.persistSession(
+        account.service,
+        account.did,
+        'create',
+        agent.session,
+        addedInfo,
+      )
+      this.rootStore.log.debug('SessionModel:resumeSession succeeded')
     } catch (e: any) {
-      if (isNetworkError(e)) {
-        this.setOnline(false, false) // connection issue
-        return false
-      } else {
-        this.clear() // invalid session cached
-      }
+      this.rootStore.log.debug('SessionModel:resumeSession failed', {
+        error: e.toString(),
+      })
+      return false
     }
 
-    this.setOnline(false, false)
-    return false
-  }
-
-  /**
-   * Helper to fetch the accounts config settings from an account.
-   */
-  async describeService(service: string): Promise<ServiceDescription> {
-    const api = AtpApi.service(service) as SessionServiceClient
-    const res = await api.com.atproto.server.getAccountsConfig({})
-    return res.data
+    this.setActiveSession(agent, account.did)
+    return true
   }
 
   /**
@@ -252,78 +281,32 @@ export class SessionModel {
    */
   async login({
     service,
-    handle,
+    identifier,
     password,
   }: {
     service: string
-    handle: string
+    identifier: string
     password: string
   }) {
-    const api = AtpApi.service(service) as SessionServiceClient
-    const res = await api.com.atproto.session.create({handle, password})
-    if (res.data.accessJwt && res.data.refreshJwt) {
-      this.setState({
-        service: service,
-        accessJwt: res.data.accessJwt,
-        refreshJwt: res.data.refreshJwt,
-        handle: res.data.handle,
-        did: res.data.did,
-      })
-      this.configureApi()
-      this.setOnline(true, false)
-      this.rootStore.me
-        .load()
-        .catch(e => {
-          this.rootStore.log.error('Failed to fetch local user information', e)
-        })
-        .then(() => {
-          this.addSessionToAccounts()
-        })
-    }
-  }
-
-  /**
-   * Attempt to resume a session that we still have access tokens for.
-   */
-  async resumeSession(account: AccountData): Promise<boolean> {
-    if (!(account.accessJwt && account.refreshJwt && account.service)) {
-      return false
+    this.rootStore.log.debug('SessionModel:login')
+    const agent = new AtpAgent({service})
+    await agent.login({identifier, password})
+    if (!agent.session) {
+      throw new Error('Failed to establish session')
     }
 
-    // test that the session is good
-    const api = AtpApi.service(account.service)
-    api.sessionManager.set({
-      refreshJwt: account.refreshJwt,
-      accessJwt: account.accessJwt,
-    })
-    try {
-      const sess = await api.com.atproto.session.get()
-      if (
-        !sess.success ||
-        sess.data.did !== account.did ||
-        !api.sessionManager.session
-      ) {
-        return false
-      }
+    const did = agent.session.did
+    const addedInfo = await this.loadAccountInfo(agent, did)
 
-      // copy over the access tokens, as they may have refreshed during the .get() above
-      runInAction(() => {
-        account.refreshJwt = api.sessionManager.session?.refreshJwt
-        account.accessJwt = api.sessionManager.session?.accessJwt
-      })
-    } catch (_e) {
-      return false
-    }
+    this.persistSession(service, did, 'create', agent.session, addedInfo)
+    agent.setPersistSessionHandler(
+      (evt: AtpSessionEvent, sess?: AtpSessionData) => {
+        this.persistSession(service, did, evt, sess)
+      },
+    )
 
-    // session is good, connect
-    this.setState({
-      service: account.service,
-      accessJwt: account.accessJwt,
-      refreshJwt: account.refreshJwt,
-      handle: account.handle,
-      did: account.did,
-    })
-    return this.connect()
+    this.setActiveSession(agent, did)
+    this.rootStore.log.debug('SessionModel:login succeeded')
   }
 
   async createAccount({
@@ -339,38 +322,41 @@ export class SessionModel {
     handle: string
     inviteCode?: string
   }) {
-    const api = AtpApi.service(service) as SessionServiceClient
-    const res = await api.com.atproto.account.create({
+    this.rootStore.log.debug('SessionModel:createAccount')
+    const agent = new AtpAgent({service})
+    await agent.createAccount({
       handle,
       password,
       email,
       inviteCode,
     })
-    if (res.data.accessJwt && res.data.refreshJwt) {
-      this.setState({
-        service: service,
-        accessJwt: res.data.accessJwt,
-        refreshJwt: res.data.refreshJwt,
-        handle: res.data.handle,
-        did: res.data.did,
-      })
-      this.rootStore.onboard.start()
-      this.configureApi()
-      this.rootStore.me
-        .load()
-        .catch(e => {
-          this.rootStore.log.error('Failed to fetch local user information', e)
-        })
-        .then(() => {
-          this.addSessionToAccounts()
-        })
+    if (!agent.session) {
+      throw new Error('Failed to establish session')
     }
+
+    const did = agent.session.did
+    const addedInfo = await this.loadAccountInfo(agent, did)
+
+    this.persistSession(service, did, 'create', agent.session, addedInfo)
+    agent.setPersistSessionHandler(
+      (evt: AtpSessionEvent, sess?: AtpSessionData) => {
+        this.persistSession(service, did, evt, sess)
+      },
+    )
+
+    this.setActiveSession(agent, did)
+    this.rootStore.onboard.start()
+    this.rootStore.log.debug('SessionModel:createAccount succeeded')
   }
 
   /**
    * Close all sessions across all accounts.
    */
   async logout() {
+    this.rootStore.log.debug('SessionModel:logout')
+    // TODO
+    // need to evaluate why deleting the session has caused errors at times
+    // -prf
     /*if (this.hasSession) {
       this.rootStore.api.com.atproto.session.delete().catch((e: any) => {
         this.rootStore.log.warn(
@@ -379,7 +365,7 @@ export class SessionModel {
         )
       })
     }*/
-    this.clearSessionTokensFromAccounts()
-    this.rootStore.clearAll()
+    this.clearSessionTokens()
+    this.rootStore.clearAllSessionState()
   }
 }
diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts
index 09ffd265a..b9f480ecd 100644
--- a/src/state/models/shell-ui.ts
+++ b/src/state/models/shell-ui.ts
@@ -1,7 +1,8 @@
+import {RootStoreModel} from './root-store'
 import {makeAutoObservable} from 'mobx'
 import {ProfileViewModel} from './profile-view'
-import {isObj, hasProp} from '../lib/type-guards'
-import {PickedMedia} from '../../view/com/util/images/image-crop-picker/types'
+import {isObj, hasProp} from 'lib/type-guards'
+import {PickedMedia} from 'view/com/util/images/image-crop-picker/types'
 
 export class ConfirmModal {
   name = 'confirm'
@@ -40,7 +41,7 @@ export class ServerInputModal {
 export class ReportPostModal {
   name = 'report-post'
 
-  constructor(public postUrl: string) {
+  constructor(public postUri: string, public postCid: string) {
     makeAutoObservable(this)
   }
 }
@@ -59,7 +60,13 @@ export class CropImageModal {
   constructor(
     public uri: string,
     public onSelect: (img?: PickedMedia) => void,
-  ) {
+  ) {}
+}
+
+export class DeleteAccountModal {
+  name = 'delete-account'
+
+  constructor() {
     makeAutoObservable(this)
   }
 }
@@ -111,14 +118,19 @@ export class ShellUiModel {
     | ReportPostModal
     | ReportAccountModal
     | CropImageModal
+    | DeleteAccountModal
     | undefined
   isLightboxActive = false
   activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined
   isComposerActive = false
   composerOpts: ComposerOpts | undefined
 
-  constructor() {
-    makeAutoObservable(this, {serialize: false, hydrate: false})
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(this, {
+      serialize: false,
+      rootStore: false,
+      hydrate: false,
+    })
   }
 
   serialize(): unknown {
@@ -154,8 +166,10 @@ export class ShellUiModel {
       | ServerInputModal
       | ReportPostModal
       | ReportAccountModal
-      | CropImageModal,
+      | CropImageModal
+      | DeleteAccountModal,
   ) {
+    this.rootStore.emitNavigation()
     this.isModalActive = true
     this.activeModal = modal
   }
@@ -166,6 +180,7 @@ export class ShellUiModel {
   }
 
   openLightbox(lightbox: ProfileImageLightbox | ImagesLightbox) {
+    this.rootStore.emitNavigation()
     this.isLightboxActive = true
     this.activeLightbox = lightbox
   }
@@ -176,6 +191,7 @@ export class ShellUiModel {
   }
 
   openComposer(opts: ComposerOpts) {
+    this.rootStore.emitNavigation()
     this.isComposerActive = true
     this.composerOpts = opts
   }
diff --git a/src/state/models/suggested-actors-view.ts b/src/state/models/suggested-actors-view.ts
index 0c9e0c3e1..4764f581e 100644
--- a/src/state/models/suggested-actors-view.ts
+++ b/src/state/models/suggested-actors-view.ts
@@ -1,25 +1,48 @@
-import {makeAutoObservable} from 'mobx'
-import {AppBskyActorGetSuggestions as GetSuggestions} from '@atproto/api'
+import {makeAutoObservable, runInAction} from 'mobx'
+import {AppBskyActorProfile as Profile} from '@atproto/api'
+import shuffle from 'lodash.shuffle'
 import {RootStoreModel} from './root-store'
+import {cleanError} from 'lib/strings/errors'
+import {bundleAsync} from 'lib/async/bundle'
+import {
+  DEV_SUGGESTED_FOLLOWS,
+  PROD_SUGGESTED_FOLLOWS,
+  STAGING_SUGGESTED_FOLLOWS,
+} from 'lib/constants'
 
 const PAGE_SIZE = 30
 
-export type SuggestedActor = GetSuggestions.Actor
+export type SuggestedActor = Profile.ViewBasic | Profile.View
+
+const getSuggestionList = ({serviceUrl}: {serviceUrl: string}) => {
+  if (serviceUrl.includes('localhost')) {
+    return DEV_SUGGESTED_FOLLOWS
+  } else if (serviceUrl.includes('staging')) {
+    return STAGING_SUGGESTED_FOLLOWS
+  } else {
+    return PROD_SUGGESTED_FOLLOWS
+  }
+}
 
 export class SuggestedActorsViewModel {
   // state
+  pageSize = PAGE_SIZE
   isLoading = false
   isRefreshing = false
   hasLoaded = false
   error = ''
   hasMore = true
   loadMoreCursor?: string
-  private _loadMorePromise: Promise<void> | undefined
+
+  private hardCodedSuggestions: SuggestedActor[] | undefined
 
   // data
   suggestions: SuggestedActor[] = []
 
-  constructor(public rootStore: RootStoreModel) {
+  constructor(public rootStore: RootStoreModel, opts?: {pageSize?: number}) {
+    if (opts?.pageSize) {
+      this.pageSize = opts.pageSize
+    }
     makeAutoObservable(
       this,
       {
@@ -48,13 +71,96 @@ export class SuggestedActorsViewModel {
     return this.loadMore(true)
   }
 
-  async loadMore(isRefreshing = false) {
-    if (this._loadMorePromise) {
-      return this._loadMorePromise
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    if (!replace && !this.hasMore) {
+      return
+    }
+    if (replace) {
+      this.hardCodedSuggestions = undefined
+    }
+    this._xLoading(replace)
+    try {
+      let items: SuggestedActor[] = this.suggestions
+      if (replace) {
+        items = []
+        this.loadMoreCursor = undefined
+      }
+      let res
+      do {
+        await this.fetchHardcodedSuggestions()
+        if (this.hardCodedSuggestions && this.hardCodedSuggestions.length > 0) {
+          // pull from the hard-coded suggestions
+          const newItems = this.hardCodedSuggestions.splice(0, this.pageSize)
+          items = items.concat(newItems)
+          this.hasMore = true
+          this.loadMoreCursor = undefined
+        } else {
+          // pull from the PDS' algo
+          res = await this.rootStore.api.app.bsky.actor.getSuggestions({
+            limit: this.pageSize,
+            cursor: this.loadMoreCursor,
+          })
+          this.loadMoreCursor = res.data.cursor
+          this.hasMore = !!this.loadMoreCursor
+          items = items.concat(
+            res.data.actors.filter(
+              actor => !items.find(i => i.did === actor.did),
+            ),
+          )
+        }
+      } while (items.length < this.pageSize && this.hasMore)
+      runInAction(() => {
+        this.suggestions = items
+      })
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
+
+  private async fetchHardcodedSuggestions() {
+    if (this.hardCodedSuggestions) {
+      return
+    }
+    await this.rootStore.me.follows.fetchIfNeeded()
+    try {
+      // clone the array so we can mutate it
+      const actors = [
+        ...getSuggestionList({
+          serviceUrl: this.rootStore.session.currentSession?.service || '',
+        }),
+      ]
+
+      // fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`)
+      let profiles: Profile.View[] = []
+      do {
+        const res = await this.rootStore.api.app.bsky.actor.getProfiles({
+          actors: actors.splice(0, 25),
+        })
+        profiles = profiles.concat(res.data.profiles)
+      } while (actors.length)
+
+      runInAction(() => {
+        profiles = profiles.filter(profile => {
+          if (this.rootStore.me.follows.isFollowing(profile.did)) {
+            return false
+          }
+          if (profile.did === this.rootStore.me.did) {
+            return false
+          }
+          return true
+        })
+        this.hardCodedSuggestions = shuffle(profiles)
+      })
+    } catch (e) {
+      this.rootStore.log.error(
+        'Failed to getProfiles() for suggested follows',
+        {e},
+      )
+      runInAction(() => {
+        this.hardCodedSuggestions = []
+      })
     }
-    this._loadMorePromise = this._loadMore(isRefreshing)
-    await this._loadMorePromise
-    this._loadMorePromise = undefined
   }
 
   // state transitions
@@ -70,52 +176,9 @@ export class SuggestedActorsViewModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = err ? err.toString() : ''
+    this.error = cleanError(err)
     if (err) {
       this.rootStore.log.error('Failed to fetch suggested actors', err)
     }
   }
-
-  // loader functions
-  // =
-
-  private async _loadMore(isRefreshing = false) {
-    if (!this.hasMore) {
-      return
-    }
-    this._xLoading(isRefreshing)
-    try {
-      if (this.isRefreshing) {
-        this.suggestions = []
-      }
-      let res
-      let totalAdded = 0
-      do {
-        res = await this.rootStore.api.app.bsky.actor.getSuggestions({
-          limit: PAGE_SIZE,
-          cursor: this.loadMoreCursor,
-        })
-        totalAdded += await this._appendAll(res)
-      } while (totalAdded < PAGE_SIZE && this.hasMore)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  private async _appendAll(res: GetSuggestions.Response) {
-    this.loadMoreCursor = res.data.cursor
-    this.hasMore = !!this.loadMoreCursor
-    const newSuggestions = res.data.actors.filter(actor => {
-      if (actor.did === this.rootStore.me.did) {
-        return false // skip self
-      }
-      if (actor.myState?.follow) {
-        return false // skip already-followed users
-      }
-      return true
-    })
-    this.suggestions = this.suggestions.concat(newSuggestions)
-    return newSuggestions.length
-  }
 }
diff --git a/src/state/models/suggested-posts-view.ts b/src/state/models/suggested-posts-view.ts
new file mode 100644
index 000000000..7b44370de
--- /dev/null
+++ b/src/state/models/suggested-posts-view.ts
@@ -0,0 +1,148 @@
+import {makeAutoObservable, runInAction} from 'mobx'
+import {
+  AppBskyFeedFeedViewPost,
+  AppBskyFeedGetAuthorFeed as GetAuthorFeed,
+} from '@atproto/api'
+type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
+import {RootStoreModel} from './root-store'
+import {FeedItemModel} from './feed-view'
+import {cleanError} from 'lib/strings/errors'
+
+const TEAM_HANDLES = [
+  'jay.bsky.social',
+  'paul.bsky.social',
+  'dan.bsky.social',
+  'divy.bsky.social',
+  'why.bsky.social',
+  'iamrosewang.bsky.social',
+]
+
+export class SuggestedPostsView {
+  // state
+  isLoading = false
+  hasLoaded = false
+  error = ''
+
+  // data
+  posts: FeedItemModel[] = []
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get hasContent() {
+    return this.posts.length > 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  // public api
+  // =
+
+  async setup() {
+    this._xLoading()
+    try {
+      const responses = await Promise.all(
+        TEAM_HANDLES.map(handle =>
+          this.rootStore.api.app.bsky.feed
+            .getAuthorFeed({author: handle, limit: 10})
+            .catch(_err => ({success: false, headers: {}, data: {feed: []}})),
+        ),
+      )
+      runInAction(() => {
+        this.posts = mergeAndFilterResponses(this.rootStore, responses)
+      })
+      this._xIdle()
+    } catch (e: any) {
+      this.rootStore.log.error('SuggestedPostsView: Failed to load posts', {
+        e,
+      })
+      this._xIdle() // dont bubble to the user
+    }
+  }
+
+  // state transitions
+  // =
+
+  private _xLoading() {
+    this.isLoading = true
+    this.error = ''
+  }
+
+  private _xIdle(err?: any) {
+    this.isLoading = false
+    this.hasLoaded = true
+    this.error = cleanError(err)
+    if (err) {
+      this.rootStore.log.error('Failed to fetch suggested posts', err)
+    }
+  }
+}
+
+function mergeAndFilterResponses(
+  store: RootStoreModel,
+  responses: GetAuthorFeed.Response[],
+): FeedItemModel[] {
+  let posts: AppBskyFeedFeedViewPost.Main[] = []
+
+  // merge into one array
+  for (const res of responses) {
+    if (res.success) {
+      posts = posts.concat(res.data.feed)
+    }
+  }
+
+  // filter down to reposts of other users
+  const now = Date.now()
+  const uris = new Set()
+  posts = posts.filter(p => {
+    if (isARepostOfSomeoneElse(p) && isRecentEnough(now, p)) {
+      if (uris.has(p.post.uri)) {
+        return false
+      }
+      uris.add(p.post.uri)
+      return true
+    }
+    return false
+  })
+
+  // sort by index time
+  posts.sort((a, b) => {
+    return (
+      Number(new Date(b.post.indexedAt)) - Number(new Date(a.post.indexedAt))
+    )
+  })
+
+  // hydrate into models and strip the reasons to hide that these are reposts
+  return posts.map((post, i) => {
+    delete post.reason
+    return new FeedItemModel(store, `post-${i}`, post)
+  })
+}
+
+function isARepostOfSomeoneElse(post: AppBskyFeedFeedViewPost.Main): boolean {
+  return (
+    post.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost' &&
+    post.post.author.did !== (post.reason as ReasonRepost).by.did
+  )
+}
+
+const THREE_DAYS = 3 * 24 * 60 * 60 * 1000
+function isRecentEnough(
+  now: number,
+  post: AppBskyFeedFeedViewPost.Main,
+): boolean {
+  return now - Number(new Date(post.post.indexedAt)) < THREE_DAYS
+}
diff --git a/src/state/models/user-autocomplete-view.ts b/src/state/models/user-autocomplete-view.ts
index 8f467da69..8e4211c27 100644
--- a/src/state/models/user-autocomplete-view.ts
+++ b/src/state/models/user-autocomplete-view.ts
@@ -1,8 +1,6 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import {
-  AppBskyGraphGetFollows as GetFollows,
-  AppBskyActorSearchTypeahead as SearchTypeahead,
-} from '@atproto/api'
+import {AppBskyActorRef} from '@atproto/api'
+import AwaitLock from 'await-lock'
 import {RootStoreModel} from './root-store'
 
 export class UserAutocompleteViewModel {
@@ -10,11 +8,11 @@ export class UserAutocompleteViewModel {
   isLoading = false
   isActive = false
   prefix = ''
-  _searchPromise: Promise<any> | undefined
+  lock = new AwaitLock()
 
   // data
-  follows: GetFollows.Follow[] = []
-  searchRes: SearchTypeahead.User[] = []
+  follows: AppBskyActorRef.WithInfo[] = []
+  searchRes: AppBskyActorRef.WithInfo[] = []
   knownHandles: Set<string> = new Set()
 
   constructor(public rootStore: RootStoreModel) {
@@ -58,16 +56,20 @@ export class UserAutocompleteViewModel {
   }
 
   async setPrefix(prefix: string) {
-    const origPrefix = prefix
-    this.prefix = prefix.trim()
-    if (this.prefix) {
-      await this._searchPromise
-      if (this.prefix !== origPrefix) {
-        return // another prefix was set before we got our chance
+    const origPrefix = prefix.trim()
+    this.prefix = origPrefix
+    await this.lock.acquireAsync()
+    try {
+      if (this.prefix) {
+        if (this.prefix !== origPrefix) {
+          return // another prefix was set before we got our chance
+        }
+        await this._search()
+      } else {
+        this.searchRes = []
       }
-      this._searchPromise = this._search()
-    } else {
-      this.searchRes = []
+    } finally {
+      this.lock.release()
     }
   }
 
diff --git a/src/state/models/user-followers-view.ts b/src/state/models/user-followers-view.ts
index 9daaf35a4..7400262a4 100644
--- a/src/state/models/user-followers-view.ts
+++ b/src/state/models/user-followers-view.ts
@@ -4,10 +4,12 @@ import {
   AppBskyActorRef as ActorRef,
 } from '@atproto/api'
 import {RootStoreModel} from './root-store'
+import {cleanError} from 'lib/strings/errors'
+import {bundleAsync} from 'lib/async/bundle'
 
 const PAGE_SIZE = 30
 
-export type FollowerItem = GetFollowers.Follower
+export type FollowerItem = ActorRef.WithInfo
 
 export class UserFollowersViewModel {
   // state
@@ -18,7 +20,6 @@ export class UserFollowersViewModel {
   params: GetFollowers.QueryParams
   hasMore = true
   loadMoreCursor?: string
-  private _loadMorePromise: Promise<void> | undefined
 
   // data
   subject: ActorRef.WithInfo = {
@@ -62,14 +63,27 @@ export class UserFollowersViewModel {
     return this.loadMore(true)
   }
 
-  async loadMore(isRefreshing = false) {
-    if (this._loadMorePromise) {
-      return this._loadMorePromise
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    if (!replace && !this.hasMore) {
+      return
     }
-    this._loadMorePromise = this._loadMore(isRefreshing)
-    await this._loadMorePromise
-    this._loadMorePromise = undefined
-  }
+    this._xLoading(replace)
+    try {
+      const params = Object.assign({}, this.params, {
+        limit: PAGE_SIZE,
+        before: replace ? undefined : this.loadMoreCursor,
+      })
+      const res = await this.rootStore.api.app.bsky.graph.getFollowers(params)
+      if (replace) {
+        this._replaceAll(res)
+      } else {
+        this._appendAll(res)
+      }
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
 
   // state transitions
   // =
@@ -84,39 +98,24 @@ export class UserFollowersViewModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = err ? err.toString() : ''
+    this.error = cleanError(err)
     if (err) {
       this.rootStore.log.error('Failed to fetch user followers', err)
     }
   }
 
-  // loader functions
+  // helper functions
   // =
 
-  private async _loadMore(isRefreshing = false) {
-    if (!this.hasMore) {
-      return
-    }
-    this._xLoading(isRefreshing)
-    try {
-      const params = Object.assign({}, this.params, {
-        limit: PAGE_SIZE,
-        before: this.loadMoreCursor,
-      })
-      if (this.isRefreshing) {
-        this.followers = []
-      }
-      const res = await this.rootStore.api.app.bsky.graph.getFollowers(params)
-      await this._appendAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
+  private _replaceAll(res: GetFollowers.Response) {
+    this.followers = []
+    this._appendAll(res)
   }
 
-  private async _appendAll(res: GetFollowers.Response) {
+  private _appendAll(res: GetFollowers.Response) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
     this.followers = this.followers.concat(res.data.followers)
+    this.rootStore.me.follows.hydrateProfiles(res.data.followers)
   }
 }
diff --git a/src/state/models/user-follows-view.ts b/src/state/models/user-follows-view.ts
index d43a10c75..7d28d7ebd 100644
--- a/src/state/models/user-follows-view.ts
+++ b/src/state/models/user-follows-view.ts
@@ -4,10 +4,12 @@ import {
   AppBskyActorRef as ActorRef,
 } from '@atproto/api'
 import {RootStoreModel} from './root-store'
+import {cleanError} from 'lib/strings/errors'
+import {bundleAsync} from 'lib/async/bundle'
 
 const PAGE_SIZE = 30
 
-export type FollowItem = GetFollows.Follow
+export type FollowItem = ActorRef.WithInfo
 
 export class UserFollowsViewModel {
   // state
@@ -18,7 +20,6 @@ export class UserFollowsViewModel {
   params: GetFollows.QueryParams
   hasMore = true
   loadMoreCursor?: string
-  private _loadMorePromise: Promise<void> | undefined
 
   // data
   subject: ActorRef.WithInfo = {
@@ -62,14 +63,27 @@ export class UserFollowsViewModel {
     return this.loadMore(true)
   }
 
-  async loadMore(isRefreshing = false) {
-    if (this._loadMorePromise) {
-      return this._loadMorePromise
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    if (!replace && !this.hasMore) {
+      return
     }
-    this._loadMorePromise = this._loadMore(isRefreshing)
-    await this._loadMorePromise
-    this._loadMorePromise = undefined
-  }
+    this._xLoading(replace)
+    try {
+      const params = Object.assign({}, this.params, {
+        limit: PAGE_SIZE,
+        before: replace ? undefined : this.loadMoreCursor,
+      })
+      const res = await this.rootStore.api.app.bsky.graph.getFollows(params)
+      if (replace) {
+        this._replaceAll(res)
+      } else {
+        this._appendAll(res)
+      }
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
 
   // state transitions
   // =
@@ -84,39 +98,24 @@ export class UserFollowsViewModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = err ? err.toString() : ''
+    this.error = cleanError(err)
     if (err) {
       this.rootStore.log.error('Failed to fetch user follows', err)
     }
   }
 
-  // loader functions
+  // helper functions
   // =
 
-  private async _loadMore(isRefreshing = false) {
-    if (!this.hasMore) {
-      return
-    }
-    this._xLoading(isRefreshing)
-    try {
-      const params = Object.assign({}, this.params, {
-        limit: PAGE_SIZE,
-        before: this.loadMoreCursor,
-      })
-      if (this.isRefreshing) {
-        this.follows = []
-      }
-      const res = await this.rootStore.api.app.bsky.graph.getFollows(params)
-      await this._appendAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
+  private _replaceAll(res: GetFollows.Response) {
+    this.follows = []
+    this._appendAll(res)
   }
 
-  private async _appendAll(res: GetFollows.Response) {
+  private _appendAll(res: GetFollows.Response) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
     this.follows = this.follows.concat(res.data.follows)
+    this.rootStore.me.follows.hydrateProfiles(res.data.follows)
   }
 }
diff --git a/src/state/models/votes-view.ts b/src/state/models/votes-view.ts
index df939226f..ad8698d21 100644
--- a/src/state/models/votes-view.ts
+++ b/src/state/models/votes-view.ts
@@ -2,6 +2,9 @@ import {makeAutoObservable, runInAction} from 'mobx'
 import {AtUri} from '../../third-party/uri'
 import {AppBskyFeedGetVotes as GetVotes} from '@atproto/api'
 import {RootStoreModel} from './root-store'
+import {cleanError} from 'lib/strings/errors'
+import {bundleAsync} from 'lib/async/bundle'
+import * as apilib from 'lib/api/index'
 
 const PAGE_SIZE = 30
 
@@ -17,7 +20,6 @@ export class VotesViewModel {
   params: GetVotes.QueryParams
   hasMore = true
   loadMoreCursor?: string
-  private _loadMorePromise: Promise<void> | undefined
 
   // data
   uri: string = ''
@@ -54,17 +56,31 @@ export class VotesViewModel {
     return this.loadMore(true)
   }
 
-  async loadMore(isRefreshing = false) {
-    if (this._loadMorePromise) {
-      return this._loadMorePromise
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    if (!replace && !this.hasMore) {
+      return
     }
-    if (!this.resolvedUri) {
-      await this._resolveUri()
+    this._xLoading(replace)
+    try {
+      if (!this.resolvedUri) {
+        await this._resolveUri()
+      }
+      const params = Object.assign({}, this.params, {
+        uri: this.resolvedUri,
+        limit: PAGE_SIZE,
+        before: replace ? undefined : this.loadMoreCursor,
+      })
+      const res = await this.rootStore.api.app.bsky.feed.getVotes(params)
+      if (replace) {
+        this._replaceAll(res)
+      } else {
+        this._appendAll(res)
+      }
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
     }
-    this._loadMorePromise = this._loadMore(isRefreshing)
-    await this._loadMorePromise
-    this._loadMorePromise = undefined
-  }
+  })
 
   // state transitions
   // =
@@ -79,20 +95,20 @@ export class VotesViewModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
-    this.error = err ? err.toString() : ''
+    this.error = cleanError(err)
     if (err) {
       this.rootStore.log.error('Failed to fetch votes', err)
     }
   }
 
-  // loader functions
+  // helper functions
   // =
 
   private async _resolveUri() {
     const urip = new AtUri(this.params.uri)
     if (!urip.host.startsWith('did:')) {
       try {
-        urip.host = await this.rootStore.resolveName(urip.host)
+        urip.host = await apilib.resolveName(this.rootStore, urip.host)
       } catch (e: any) {
         this.error = e.toString()
       }
@@ -102,23 +118,9 @@ export class VotesViewModel {
     })
   }
 
-  private async _loadMore(isRefreshing = false) {
-    this._xLoading(isRefreshing)
-    try {
-      const params = Object.assign({}, this.params, {
-        uri: this.resolvedUri,
-        limit: PAGE_SIZE,
-        before: this.loadMoreCursor,
-      })
-      if (this.isRefreshing) {
-        this.votes = []
-      }
-      const res = await this.rootStore.api.app.bsky.feed.getVotes(params)
-      this._appendAll(res)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
+  private _replaceAll(res: GetVotes.Response) {
+    this.votes = []
+    this._appendAll(res)
   }
 
   private _appendAll(res: GetVotes.Response) {