about summary refs log tree commit diff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/analytics/analytics.tsx90
-rw-r--r--src/lib/analytics/analytics.web.tsx22
-rw-r--r--src/lib/api/feed-manip.ts45
-rw-r--r--src/lib/api/feed/author.ts28
-rw-r--r--src/lib/api/feed/custom.ts28
-rw-r--r--src/lib/api/feed/following.ts25
-rw-r--r--src/lib/api/feed/likes.ts28
-rw-r--r--src/lib/api/feed/list.ts28
-rw-r--r--src/lib/api/feed/merge.ts82
-rw-r--r--src/lib/api/feed/types.ts21
-rw-r--r--src/lib/api/index.ts48
-rw-r--r--src/lib/async/revertible.ts68
-rw-r--r--src/lib/batchedUpdates.ts1
-rw-r--r--src/lib/batchedUpdates.web.ts2
-rw-r--r--src/lib/broadcast/index.ts11
-rw-r--r--src/lib/broadcast/index.web.ts1
-rw-r--r--src/lib/constants.ts17
-rw-r--r--src/lib/hooks/useAccountSwitcher.ts74
-rw-r--r--src/lib/hooks/useAnimatedScrollHandler_FIXED.ts15
-rw-r--r--src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts44
-rw-r--r--src/lib/hooks/useCustomFeed.ts18
-rw-r--r--src/lib/hooks/useDesktopRightNavItems.ts51
-rw-r--r--src/lib/hooks/useFollowProfile.ts55
-rw-r--r--src/lib/hooks/useHomeTabs.ts29
-rw-r--r--src/lib/hooks/useMinimalShellMode.tsx53
-rw-r--r--src/lib/hooks/useNonReactiveCallback.ts23
-rw-r--r--src/lib/hooks/useOTAUpdate.ts14
-rw-r--r--src/lib/hooks/useOnMainScroll.ts156
-rw-r--r--src/lib/hooks/useSetTitle.ts12
-rw-r--r--src/lib/hooks/useToggleMutationQueue.ts98
-rw-r--r--src/lib/hooks/useWebMediaQueries.tsx4
-rw-r--r--src/lib/labeling/const.ts89
-rw-r--r--src/lib/labeling/types.ts18
-rw-r--r--src/lib/link-meta/bsky.ts40
-rw-r--r--src/lib/link-meta/link-meta.ts12
-rw-r--r--src/lib/media/alt-text.ts12
-rw-r--r--src/lib/media/image-sizes.ts34
-rw-r--r--src/lib/media/picker.e2e.tsx10
-rw-r--r--src/lib/media/picker.tsx20
-rw-r--r--src/lib/media/picker.web.tsx14
-rw-r--r--src/lib/notifications/notifications.ts23
-rw-r--r--src/lib/react-query.ts21
-rw-r--r--src/lib/sentry.ts42
-rw-r--r--src/lib/strings/url-helpers.ts27
44 files changed, 769 insertions, 784 deletions
diff --git a/src/lib/analytics/analytics.tsx b/src/lib/analytics/analytics.tsx
index 71bb8569a..3a8254eb1 100644
--- a/src/lib/analytics/analytics.tsx
+++ b/src/lib/analytics/analytics.tsx
@@ -1,16 +1,26 @@
 import React from 'react'
 import {AppState, AppStateStatus} from 'react-native'
+import AsyncStorage from '@react-native-async-storage/async-storage'
 import {
   createClient,
   AnalyticsProvider,
   useAnalytics as useAnalyticsOrig,
   ClientMethods,
 } from '@segment/analytics-react-native'
-import {RootStoreModel, AppInfo} from 'state/models/root-store'
-import {useStores} from 'state/models/root-store'
+import {z} from 'zod'
+import {useSession} from '#/state/session'
 import {sha256} from 'js-sha256'
 import {ScreenEvent, TrackEvent} from './types'
 import {logger} from '#/logger'
+import {listenSessionLoaded} from '#/state/events'
+
+export const appInfo = z.object({
+  build: z.string().optional(),
+  name: z.string().optional(),
+  namespace: z.string().optional(),
+  version: z.string().optional(),
+})
+export type AppInfo = z.infer<typeof appInfo>
 
 const segmentClient = createClient({
   writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
@@ -21,10 +31,10 @@ const segmentClient = createClient({
 export const track = segmentClient?.track?.bind?.(segmentClient) as TrackEvent
 
 export function useAnalytics() {
-  const store = useStores()
+  const {hasSession} = useSession()
   const methods: ClientMethods = useAnalyticsOrig()
   return React.useMemo(() => {
-    if (store.session.hasSession) {
+    if (hasSession) {
       return {
         screen: methods.screen as ScreenEvent, // ScreenEvents defines all the possible screen names
         track: methods.track as TrackEvent, // TrackEvents defines all the possible track events and their properties
@@ -45,21 +55,18 @@ export function useAnalytics() {
       alias: () => Promise<void>,
       reset: () => Promise<void>,
     }
-  }, [store, methods])
+  }, [hasSession, methods])
 }
 
-export function init(store: RootStoreModel) {
-  store.onSessionLoaded(() => {
-    const sess = store.session.currentSession
-    if (sess) {
-      if (sess.did) {
-        const did_hashed = sha256(sess.did)
-        segmentClient.identify(did_hashed, {did_hashed})
-        logger.debug('Ping w/hash')
-      } else {
-        logger.debug('Ping w/o hash')
-        segmentClient.identify()
-      }
+export function init() {
+  listenSessionLoaded(account => {
+    if (account.did) {
+      const did_hashed = sha256(account.did)
+      segmentClient.identify(did_hashed, {did_hashed})
+      logger.debug('Ping w/hash')
+    } else {
+      logger.debug('Ping w/o hash')
+      segmentClient.identify()
     }
   })
 
@@ -67,7 +74,7 @@ export function init(store: RootStoreModel) {
   // this is a copy of segment's own lifecycle event tracking
   // we handle it manually to ensure that it never fires while the app is backgrounded
   // -prf
-  segmentClient.isReady.onChange(() => {
+  segmentClient.isReady.onChange(async () => {
     if (AppState.currentState !== 'active') {
       logger.debug('Prevented a metrics ping while the app was backgrounded')
       return
@@ -78,35 +85,29 @@ export function init(store: RootStoreModel) {
       return
     }
 
-    const oldAppInfo = store.appInfo
+    const oldAppInfo = await readAppInfo()
     const newAppInfo = context.app as AppInfo
-    store.setAppInfo(newAppInfo)
+    writeAppInfo(newAppInfo)
     logger.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
 
     if (typeof oldAppInfo === 'undefined') {
-      if (store.session.hasSession) {
-        segmentClient.track('Application Installed', {
-          version: newAppInfo.version,
-          build: newAppInfo.build,
-        })
-      }
+      segmentClient.track('Application Installed', {
+        version: newAppInfo.version,
+        build: newAppInfo.build,
+      })
     } else if (newAppInfo.version !== oldAppInfo.version) {
-      if (store.session.hasSession) {
-        segmentClient.track('Application Updated', {
-          version: newAppInfo.version,
-          build: newAppInfo.build,
-          previous_version: oldAppInfo.version,
-          previous_build: oldAppInfo.build,
-        })
-      }
-    }
-    if (store.session.hasSession) {
-      segmentClient.track('Application Opened', {
-        from_background: false,
+      segmentClient.track('Application Updated', {
         version: newAppInfo.version,
         build: newAppInfo.build,
+        previous_version: oldAppInfo.version,
+        previous_build: oldAppInfo.build,
       })
     }
+    segmentClient.track('Application Opened', {
+      from_background: false,
+      version: newAppInfo.version,
+      build: newAppInfo.build,
+    })
   })
 
   let lastState: AppStateStatus = AppState.currentState
@@ -130,3 +131,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     <AnalyticsProvider client={segmentClient}>{children}</AnalyticsProvider>
   )
 }
+
+async function writeAppInfo(value: AppInfo) {
+  await AsyncStorage.setItem('BSKY_APP_INFO', JSON.stringify(value))
+}
+
+async function readAppInfo(): Promise<AppInfo | undefined> {
+  const rawData = await AsyncStorage.getItem('BSKY_APP_INFO')
+  const obj = rawData ? JSON.parse(rawData) : undefined
+  if (!obj || typeof obj !== 'object') {
+    return undefined
+  }
+  return obj
+}
diff --git a/src/lib/analytics/analytics.web.tsx b/src/lib/analytics/analytics.web.tsx
index fe90d1328..0a5d5d689 100644
--- a/src/lib/analytics/analytics.web.tsx
+++ b/src/lib/analytics/analytics.web.tsx
@@ -4,10 +4,11 @@ import {
   AnalyticsProvider,
   useAnalytics as useAnalyticsOrig,
 } from '@segment/analytics-react'
-import {RootStoreModel} from 'state/models/root-store'
-import {useStores} from 'state/models/root-store'
 import {sha256} from 'js-sha256'
+
+import {useSession} from '#/state/session'
 import {logger} from '#/logger'
+import {listenSessionLoaded} from '#/state/events'
 
 const segmentClient = createClient(
   {
@@ -24,10 +25,10 @@ const segmentClient = createClient(
 export const track = segmentClient?.track?.bind?.(segmentClient)
 
 export function useAnalytics() {
-  const store = useStores()
+  const {hasSession} = useSession()
   const methods = useAnalyticsOrig()
   return React.useMemo(() => {
-    if (store.session.hasSession) {
+    if (hasSession) {
       return methods
     }
     // dont send analytics pings for anonymous users
@@ -40,15 +41,14 @@ export function useAnalytics() {
       alias: () => {},
       reset: () => {},
     }
-  }, [store, methods])
+  }, [hasSession, methods])
 }
 
-export function init(store: RootStoreModel) {
-  store.onSessionLoaded(() => {
-    const sess = store.session.currentSession
-    if (sess) {
-      if (sess.did) {
-        const did_hashed = sha256(sess.did)
+export function init() {
+  listenSessionLoaded(account => {
+    if (account.did) {
+      if (account.did) {
+        const did_hashed = sha256(account.did)
         segmentClient.identify(did_hashed, {did_hashed})
         logger.debug('Ping w/hash')
       } else {
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts
index 8f259a910..1123c4e23 100644
--- a/src/lib/api/feed-manip.ts
+++ b/src/lib/api/feed-manip.ts
@@ -4,7 +4,7 @@ import {
   AppBskyEmbedRecordWithMedia,
   AppBskyEmbedRecord,
 } from '@atproto/api'
-import {FeedSourceInfo} from './feed/types'
+import {ReasonFeedSource} from './feed/types'
 import {isPostInLanguage} from '../../locale/helpers'
 type FeedViewPost = AppBskyFeedDefs.FeedViewPost
 
@@ -16,13 +16,7 @@ export type FeedTunerFn = (
 export class FeedViewPostsSlice {
   isFlattenedReply = false
 
-  constructor(public items: FeedViewPost[] = []) {}
-
-  get _reactKey() {
-    return `slice-${this.items[0].post.uri}-${
-      this.items[0].reason?.indexedAt || this.items[0].post.indexedAt
-    }`
-  }
+  constructor(public items: FeedViewPost[], public _reactKey: string) {}
 
   get uri() {
     if (this.isFlattenedReply) {
@@ -65,9 +59,9 @@ export class FeedViewPostsSlice {
     )
   }
 
-  get source(): FeedSourceInfo | undefined {
+  get source(): ReasonFeedSource | undefined {
     return this.items.find(item => '__source' in item && !!item.__source)
-      ?.__source as FeedSourceInfo
+      ?.__source as ReasonFeedSource
   }
 
   containsUri(uri: string) {
@@ -116,18 +110,35 @@ export class FeedViewPostsSlice {
   }
 }
 
+export class NoopFeedTuner {
+  private keyCounter = 0
+
+  reset() {
+    this.keyCounter = 0
+  }
+  tune(
+    feed: FeedViewPost[],
+    _opts?: {dryRun: boolean; maintainOrder: boolean},
+  ): FeedViewPostsSlice[] {
+    return feed.map(
+      item => new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`),
+    )
+  }
+}
+
 export class FeedTuner {
+  private keyCounter = 0
   seenUris: Set<string> = new Set()
 
-  constructor() {}
+  constructor(public tunerFns: FeedTunerFn[]) {}
 
   reset() {
+    this.keyCounter = 0
     this.seenUris.clear()
   }
 
   tune(
     feed: FeedViewPost[],
-    tunerFns: FeedTunerFn[] = [],
     {dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
       dryRun: false,
       maintainOrder: false,
@@ -136,7 +147,9 @@ export class FeedTuner {
     let slices: FeedViewPostsSlice[] = []
 
     if (maintainOrder) {
-      slices = feed.map(item => new FeedViewPostsSlice([item]))
+      slices = feed.map(
+        item => new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`),
+      )
     } else {
       // arrange the posts into thread slices
       for (let i = feed.length - 1; i >= 0; i--) {
@@ -152,12 +165,14 @@ export class FeedTuner {
             continue
           }
         }
-        slices.unshift(new FeedViewPostsSlice([item]))
+        slices.unshift(
+          new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`),
+        )
       }
     }
 
     // run the custom tuners
-    for (const tunerFn of tunerFns) {
+    for (const tunerFn of this.tunerFns) {
       slices = tunerFn(this, slices.slice())
     }
 
diff --git a/src/lib/api/feed/author.ts b/src/lib/api/feed/author.ts
index ec8795e1a..92df84f8b 100644
--- a/src/lib/api/feed/author.ts
+++ b/src/lib/api/feed/author.ts
@@ -2,37 +2,33 @@ import {
   AppBskyFeedDefs,
   AppBskyFeedGetAuthorFeed as GetAuthorFeed,
 } from '@atproto/api'
-import {RootStoreModel} from 'state/index'
 import {FeedAPI, FeedAPIResponse} from './types'
+import {getAgent} from '#/state/session'
 
 export class AuthorFeedAPI implements FeedAPI {
-  cursor: string | undefined
-
-  constructor(
-    public rootStore: RootStoreModel,
-    public params: GetAuthorFeed.QueryParams,
-  ) {}
-
-  reset() {
-    this.cursor = undefined
-  }
+  constructor(public params: GetAuthorFeed.QueryParams) {}
 
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.rootStore.agent.getAuthorFeed({
+    const res = await getAgent().getAuthorFeed({
       ...this.params,
       limit: 1,
     })
     return res.data.feed[0]
   }
 
-  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
-    const res = await this.rootStore.agent.getAuthorFeed({
+  async fetch({
+    cursor,
+    limit,
+  }: {
+    cursor: string | undefined
+    limit: number
+  }): Promise<FeedAPIResponse> {
+    const res = await getAgent().getAuthorFeed({
       ...this.params,
-      cursor: this.cursor,
+      cursor,
       limit,
     })
     if (res.success) {
-      this.cursor = res.data.cursor
       return {
         cursor: res.data.cursor,
         feed: this._filter(res.data.feed),
diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts
index d05d5acd6..47ffc65ed 100644
--- a/src/lib/api/feed/custom.ts
+++ b/src/lib/api/feed/custom.ts
@@ -2,37 +2,33 @@ import {
   AppBskyFeedDefs,
   AppBskyFeedGetFeed as GetCustomFeed,
 } from '@atproto/api'
-import {RootStoreModel} from 'state/index'
 import {FeedAPI, FeedAPIResponse} from './types'
+import {getAgent} from '#/state/session'
 
 export class CustomFeedAPI implements FeedAPI {
-  cursor: string | undefined
-
-  constructor(
-    public rootStore: RootStoreModel,
-    public params: GetCustomFeed.QueryParams,
-  ) {}
-
-  reset() {
-    this.cursor = undefined
-  }
+  constructor(public params: GetCustomFeed.QueryParams) {}
 
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.rootStore.agent.app.bsky.feed.getFeed({
+    const res = await getAgent().app.bsky.feed.getFeed({
       ...this.params,
       limit: 1,
     })
     return res.data.feed[0]
   }
 
-  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
-    const res = await this.rootStore.agent.app.bsky.feed.getFeed({
+  async fetch({
+    cursor,
+    limit,
+  }: {
+    cursor: string | undefined
+    limit: number
+  }): Promise<FeedAPIResponse> {
+    const res = await getAgent().app.bsky.feed.getFeed({
       ...this.params,
-      cursor: this.cursor,
+      cursor,
       limit,
     })
     if (res.success) {
-      this.cursor = res.data.cursor
       // NOTE
       // some custom feeds fail to enforce the pagination limit
       // so we manually truncate here
diff --git a/src/lib/api/feed/following.ts b/src/lib/api/feed/following.ts
index f14807a57..24389b5ed 100644
--- a/src/lib/api/feed/following.ts
+++ b/src/lib/api/feed/following.ts
@@ -1,30 +1,29 @@
 import {AppBskyFeedDefs} from '@atproto/api'
-import {RootStoreModel} from 'state/index'
 import {FeedAPI, FeedAPIResponse} from './types'
+import {getAgent} from '#/state/session'
 
 export class FollowingFeedAPI implements FeedAPI {
-  cursor: string | undefined
-
-  constructor(public rootStore: RootStoreModel) {}
-
-  reset() {
-    this.cursor = undefined
-  }
+  constructor() {}
 
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.rootStore.agent.getTimeline({
+    const res = await getAgent().getTimeline({
       limit: 1,
     })
     return res.data.feed[0]
   }
 
-  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
-    const res = await this.rootStore.agent.getTimeline({
-      cursor: this.cursor,
+  async fetch({
+    cursor,
+    limit,
+  }: {
+    cursor: string | undefined
+    limit: number
+  }): Promise<FeedAPIResponse> {
+    const res = await getAgent().getTimeline({
+      cursor,
       limit,
     })
     if (res.success) {
-      this.cursor = res.data.cursor
       return {
         cursor: res.data.cursor,
         feed: res.data.feed,
diff --git a/src/lib/api/feed/likes.ts b/src/lib/api/feed/likes.ts
index e9bb14b0b..2b0afdf11 100644
--- a/src/lib/api/feed/likes.ts
+++ b/src/lib/api/feed/likes.ts
@@ -2,37 +2,33 @@ import {
   AppBskyFeedDefs,
   AppBskyFeedGetActorLikes as GetActorLikes,
 } from '@atproto/api'
-import {RootStoreModel} from 'state/index'
 import {FeedAPI, FeedAPIResponse} from './types'
+import {getAgent} from '#/state/session'
 
 export class LikesFeedAPI implements FeedAPI {
-  cursor: string | undefined
-
-  constructor(
-    public rootStore: RootStoreModel,
-    public params: GetActorLikes.QueryParams,
-  ) {}
-
-  reset() {
-    this.cursor = undefined
-  }
+  constructor(public params: GetActorLikes.QueryParams) {}
 
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.rootStore.agent.getActorLikes({
+    const res = await getAgent().getActorLikes({
       ...this.params,
       limit: 1,
     })
     return res.data.feed[0]
   }
 
-  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
-    const res = await this.rootStore.agent.getActorLikes({
+  async fetch({
+    cursor,
+    limit,
+  }: {
+    cursor: string | undefined
+    limit: number
+  }): Promise<FeedAPIResponse> {
+    const res = await getAgent().getActorLikes({
       ...this.params,
-      cursor: this.cursor,
+      cursor,
       limit,
     })
     if (res.success) {
-      this.cursor = res.data.cursor
       return {
         cursor: res.data.cursor,
         feed: res.data.feed,
diff --git a/src/lib/api/feed/list.ts b/src/lib/api/feed/list.ts
index e58494675..19f2ff177 100644
--- a/src/lib/api/feed/list.ts
+++ b/src/lib/api/feed/list.ts
@@ -2,37 +2,33 @@ import {
   AppBskyFeedDefs,
   AppBskyFeedGetListFeed as GetListFeed,
 } from '@atproto/api'
-import {RootStoreModel} from 'state/index'
 import {FeedAPI, FeedAPIResponse} from './types'
+import {getAgent} from '#/state/session'
 
 export class ListFeedAPI implements FeedAPI {
-  cursor: string | undefined
-
-  constructor(
-    public rootStore: RootStoreModel,
-    public params: GetListFeed.QueryParams,
-  ) {}
-
-  reset() {
-    this.cursor = undefined
-  }
+  constructor(public params: GetListFeed.QueryParams) {}
 
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.rootStore.agent.app.bsky.feed.getListFeed({
+    const res = await getAgent().app.bsky.feed.getListFeed({
       ...this.params,
       limit: 1,
     })
     return res.data.feed[0]
   }
 
-  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
-    const res = await this.rootStore.agent.app.bsky.feed.getListFeed({
+  async fetch({
+    cursor,
+    limit,
+  }: {
+    cursor: string | undefined
+    limit: number
+  }): Promise<FeedAPIResponse> {
+    const res = await getAgent().app.bsky.feed.getListFeed({
       ...this.params,
-      cursor: this.cursor,
+      cursor,
       limit,
     })
     if (res.success) {
-      this.cursor = res.data.cursor
       return {
         cursor: res.data.cursor,
         feed: res.data.feed,
diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts
index e0fbcecd8..11e963f0a 100644
--- a/src/lib/api/feed/merge.ts
+++ b/src/lib/api/feed/merge.ts
@@ -1,11 +1,13 @@
 import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
 import shuffle from 'lodash.shuffle'
-import {RootStoreModel} from 'state/index'
 import {timeout} from 'lib/async/timeout'
 import {bundleAsync} from 'lib/async/bundle'
 import {feedUriToHref} from 'lib/strings/url-helpers'
 import {FeedTuner} from '../feed-manip'
-import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types'
+import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
+import {FeedParams} from '#/state/queries/post-feed'
+import {FeedTunerFn} from '../feed-manip'
+import {getAgent} from '#/state/session'
 
 const REQUEST_WAIT_MS = 500 // 500ms
 const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
@@ -17,28 +19,44 @@ export class MergeFeedAPI implements FeedAPI {
   itemCursor = 0
   sampleCursor = 0
 
-  constructor(public rootStore: RootStoreModel) {
-    this.following = new MergeFeedSource_Following(this.rootStore)
+  constructor(public params: FeedParams, public feedTuners: FeedTunerFn[]) {
+    this.following = new MergeFeedSource_Following(this.feedTuners)
   }
 
   reset() {
-    this.following = new MergeFeedSource_Following(this.rootStore)
+    this.following = new MergeFeedSource_Following(this.feedTuners)
     this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
     this.feedCursor = 0
     this.itemCursor = 0
     this.sampleCursor = 0
+    if (this.params.mergeFeedEnabled && this.params.mergeFeedSources) {
+      this.customFeeds = shuffle(
+        this.params.mergeFeedSources.map(
+          feedUri => new MergeFeedSource_Custom(feedUri, this.feedTuners),
+        ),
+      )
+    } else {
+      this.customFeeds = []
+    }
   }
 
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await this.rootStore.agent.getTimeline({
+    const res = await getAgent().getTimeline({
       limit: 1,
     })
     return res.data.feed[0]
   }
 
-  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
-    // we capture here to ensure the data has loaded
-    this._captureFeedsIfNeeded()
+  async fetch({
+    cursor,
+    limit,
+  }: {
+    cursor: string | undefined
+    limit: number
+  }): Promise<FeedAPIResponse> {
+    if (!cursor) {
+      this.reset()
+    }
 
     const promises = []
 
@@ -76,7 +94,7 @@ export class MergeFeedAPI implements FeedAPI {
     }
 
     return {
-      cursor: posts.length ? 'fake' : undefined,
+      cursor: posts.length ? String(this.itemCursor) : undefined,
       feed: posts,
     }
   }
@@ -107,28 +125,15 @@ export class MergeFeedAPI implements FeedAPI {
     // provide follow
     return this.following.take(1)
   }
-
-  _captureFeedsIfNeeded() {
-    if (!this.rootStore.preferences.homeFeed.lab_mergeFeedEnabled) {
-      return
-    }
-    if (this.customFeeds.length === 0) {
-      this.customFeeds = shuffle(
-        this.rootStore.preferences.savedFeeds.map(
-          feedUri => new MergeFeedSource_Custom(this.rootStore, feedUri),
-        ),
-      )
-    }
-  }
 }
 
 class MergeFeedSource {
-  sourceInfo: FeedSourceInfo | undefined
+  sourceInfo: ReasonFeedSource | undefined
   cursor: string | undefined = undefined
   queue: AppBskyFeedDefs.FeedViewPost[] = []
   hasMore = true
 
-  constructor(public rootStore: RootStoreModel) {}
+  constructor(public feedTuners: FeedTunerFn[]) {}
 
   get numReady() {
     return this.queue.length
@@ -175,7 +180,7 @@ class MergeFeedSource {
 }
 
 class MergeFeedSource_Following extends MergeFeedSource {
-  tuner = new FeedTuner()
+  tuner = new FeedTuner(this.feedTuners)
 
   reset() {
     super.reset()
@@ -190,16 +195,12 @@ class MergeFeedSource_Following extends MergeFeedSource {
     cursor: string | undefined,
     limit: number,
   ): Promise<AppBskyFeedGetTimeline.Response> {
-    const res = await this.rootStore.agent.getTimeline({cursor, limit})
+    const res = await getAgent().getTimeline({cursor, limit})
     // run the tuner pre-emptively to ensure better mixing
-    const slices = this.tuner.tune(
-      res.data.feed,
-      this.rootStore.preferences.getFeedTuners('home'),
-      {
-        dryRun: false,
-        maintainOrder: true,
-      },
-    )
+    const slices = this.tuner.tune(res.data.feed, {
+      dryRun: false,
+      maintainOrder: true,
+    })
     res.data.feed = slices.map(slice => slice.rootItem)
     return res
   }
@@ -208,15 +209,16 @@ class MergeFeedSource_Following extends MergeFeedSource {
 class MergeFeedSource_Custom extends MergeFeedSource {
   minDate: Date
 
-  constructor(public rootStore: RootStoreModel, public feedUri: string) {
-    super(rootStore)
+  constructor(public feedUri: string, public feedTuners: FeedTunerFn[]) {
+    super(feedTuners)
     this.sourceInfo = {
+      $type: 'reasonFeedSource',
       displayName: feedUri.split('/').pop() || '',
       uri: feedUriToHref(feedUri),
     }
     this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
-    this.rootStore.agent.app.bsky.feed
-      .getFeedGenerator({
+    getAgent()
+      .app.bsky.feed.getFeedGenerator({
         feed: feedUri,
       })
       .then(
@@ -234,7 +236,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
     limit: number,
   ): Promise<AppBskyFeedGetTimeline.Response> {
     try {
-      const res = await this.rootStore.agent.app.bsky.feed.getFeed({
+      const res = await getAgent().app.bsky.feed.getFeed({
         cursor,
         limit,
         feed: this.feedUri,
diff --git a/src/lib/api/feed/types.ts b/src/lib/api/feed/types.ts
index 006344334..5d2a90c1d 100644
--- a/src/lib/api/feed/types.ts
+++ b/src/lib/api/feed/types.ts
@@ -6,12 +6,27 @@ export interface FeedAPIResponse {
 }
 
 export interface FeedAPI {
-  reset(): void
   peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost>
-  fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse>
+  fetch({
+    cursor,
+    limit,
+  }: {
+    cursor: string | undefined
+    limit: number
+  }): Promise<FeedAPIResponse>
 }
 
-export interface FeedSourceInfo {
+export interface ReasonFeedSource {
+  $type: 'reasonFeedSource'
   uri: string
   displayName: string
 }
+
+export function isReasonFeedSource(v: unknown): v is ReasonFeedSource {
+  return (
+    !!v &&
+    typeof v === 'object' &&
+    '$type' in v &&
+    v.$type === 'reasonFeedSource'
+  )
+}
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 9d48a78c0..a78abcacd 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -4,12 +4,12 @@ import {
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
   AppBskyRichtextFacet,
+  BskyAgent,
   ComAtprotoLabelDefs,
   ComAtprotoRepoUploadBlob,
   RichText,
 } from '@atproto/api'
 import {AtUri} from '@atproto/api'
-import {RootStoreModel} from 'state/models/root-store'
 import {isNetworkError} from 'lib/strings/errors'
 import {LinkMeta} from '../link-meta/link-meta'
 import {isWeb} from 'platform/detection'
@@ -25,46 +25,19 @@ export interface ExternalEmbedDraft {
   localThumb?: ImageModel
 }
 
-export async function resolveName(store: RootStoreModel, didOrHandle: string) {
-  if (!didOrHandle) {
-    throw new Error('Invalid handle: ""')
-  }
-  if (didOrHandle.startsWith('did:')) {
-    return didOrHandle
-  }
-
-  // we run the resolution always to ensure freshness
-  const promise = store.agent
-    .resolveHandle({
-      handle: didOrHandle,
-    })
-    .then(res => {
-      store.handleResolutions.cache.set(didOrHandle, res.data.did)
-      return res.data.did
-    })
-
-  // but we can return immediately if it's cached
-  const cached = store.handleResolutions.cache.get(didOrHandle)
-  if (cached) {
-    return cached
-  }
-
-  return promise
-}
-
 export async function uploadBlob(
-  store: RootStoreModel,
+  agent: BskyAgent,
   blob: string,
   encoding: string,
 ): Promise<ComAtprotoRepoUploadBlob.Response> {
   if (isWeb) {
     // `blob` should be a data uri
-    return store.agent.uploadBlob(convertDataURIToUint8Array(blob), {
+    return agent.uploadBlob(convertDataURIToUint8Array(blob), {
       encoding,
     })
   } else {
     // `blob` should be a path to a file in the local FS
-    return store.agent.uploadBlob(
+    return agent.uploadBlob(
       blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
       {encoding},
     )
@@ -81,12 +54,11 @@ interface PostOpts {
   extLink?: ExternalEmbedDraft
   images?: ImageModel[]
   labels?: string[]
-  knownHandles?: Set<string>
   onStateChange?: (state: string) => void
   langs?: string[]
 }
 
-export async function post(store: RootStoreModel, opts: PostOpts) {
+export async function post(agent: BskyAgent, opts: PostOpts) {
   let embed:
     | AppBskyEmbedImages.Main
     | AppBskyEmbedExternal.Main
@@ -102,7 +74,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
   )
 
   opts.onStateChange?.('Processing...')
-  await rt.detectFacets(store.agent)
+  await rt.detectFacets(agent)
   rt = shortenLinks(rt)
 
   // filter out any mention facets that didn't map to a user
@@ -135,7 +107,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
       await image.compress()
       const path = image.compressed?.path ?? image.path
       const {width, height} = image.compressed || image
-      const res = await uploadBlob(store, path, 'image/jpeg')
+      const res = await uploadBlob(agent, path, 'image/jpeg')
       images.push({
         image: res.data.blob,
         alt: image.altText ?? '',
@@ -185,7 +157,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
         }
         if (encoding) {
           const thumbUploadRes = await uploadBlob(
-            store,
+            agent,
             opts.extLink.localThumb.path,
             encoding,
           )
@@ -224,7 +196,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
   // add replyTo if post is a reply to another post
   if (opts.replyTo) {
     const replyToUrip = new AtUri(opts.replyTo)
-    const parentPost = await store.agent.getPost({
+    const parentPost = await agent.getPost({
       repo: replyToUrip.host,
       rkey: replyToUrip.rkey,
     })
@@ -257,7 +229,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
 
   try {
     opts.onStateChange?.('Posting...')
-    return await store.agent.post({
+    return await agent.post({
       text: rt.text,
       facets: rt.facets,
       reply,
diff --git a/src/lib/async/revertible.ts b/src/lib/async/revertible.ts
deleted file mode 100644
index 43383b61e..000000000
--- a/src/lib/async/revertible.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import {runInAction} from 'mobx'
-import {deepObserve} from 'mobx-utils'
-import set from 'lodash.set'
-
-const ongoingActions = new Set<any>()
-
-/**
- * This is a TypeScript function that optimistically updates data on the client-side before sending a
- * request to the server and rolling back changes if the request fails.
- * @param {T} model - The object or record that needs to be updated optimistically.
- * @param preUpdate - `preUpdate` is a function that is called before the server update is executed. It
- * can be used to perform any necessary actions or updates on the model or UI before the server update
- * is initiated.
- * @param serverUpdate - `serverUpdate` is a function that returns a Promise representing the server
- * update operation. This function is called after the previous state of the model has been recorded
- * and the `preUpdate` function has been executed. If the server update is successful, the `postUpdate`
- * function is called with the result
- * @param [postUpdate] - `postUpdate` is an optional callback function that will be called after the
- * server update is successful. It takes in the response from the server update as its parameter. If
- * this parameter is not provided, nothing will happen after the server update.
- * @returns A Promise that resolves to `void`.
- */
-export const updateDataOptimistically = async <
-  T extends Record<string, any>,
-  U,
->(
-  model: T,
-  preUpdate: () => void,
-  serverUpdate: () => Promise<U>,
-  postUpdate?: (res: U) => void,
-): Promise<void> => {
-  if (ongoingActions.has(model)) {
-    return
-  }
-  ongoingActions.add(model)
-
-  const prevState: Map<string, any> = new Map<string, any>()
-  const dispose = deepObserve(model, (change, path) => {
-    if (change.observableKind === 'object') {
-      if (change.type === 'update') {
-        prevState.set(
-          [path, change.name].filter(Boolean).join('.'),
-          change.oldValue,
-        )
-      } else if (change.type === 'add') {
-        prevState.set([path, change.name].filter(Boolean).join('.'), undefined)
-      }
-    }
-  })
-  preUpdate()
-  dispose()
-
-  try {
-    const res = await serverUpdate()
-    runInAction(() => {
-      postUpdate?.(res)
-    })
-  } catch (error) {
-    runInAction(() => {
-      prevState.forEach((value, path) => {
-        set(model, path, value)
-      })
-    })
-    throw error
-  } finally {
-    ongoingActions.delete(model)
-  }
-}
diff --git a/src/lib/batchedUpdates.ts b/src/lib/batchedUpdates.ts
new file mode 100644
index 000000000..2530d6ca9
--- /dev/null
+++ b/src/lib/batchedUpdates.ts
@@ -0,0 +1 @@
+export {unstable_batchedUpdates as batchedUpdates} from 'react-native'
diff --git a/src/lib/batchedUpdates.web.ts b/src/lib/batchedUpdates.web.ts
new file mode 100644
index 000000000..03147ed67
--- /dev/null
+++ b/src/lib/batchedUpdates.web.ts
@@ -0,0 +1,2 @@
+// @ts-ignore
+export {unstable_batchedUpdates as batchedUpdates} from 'react-dom'
diff --git a/src/lib/broadcast/index.ts b/src/lib/broadcast/index.ts
new file mode 100644
index 000000000..aa3aef580
--- /dev/null
+++ b/src/lib/broadcast/index.ts
@@ -0,0 +1,11 @@
+export default class BroadcastChannel {
+  constructor(public name: string) {}
+  postMessage(_data: any) {}
+  close() {}
+  onmessage: (event: MessageEvent) => void = () => {}
+  addEventListener(_type: string, _listener: (event: MessageEvent) => void) {}
+  removeEventListener(
+    _type: string,
+    _listener: (event: MessageEvent) => void,
+  ) {}
+}
diff --git a/src/lib/broadcast/index.web.ts b/src/lib/broadcast/index.web.ts
new file mode 100644
index 000000000..33b3548ad
--- /dev/null
+++ b/src/lib/broadcast/index.web.ts
@@ -0,0 +1 @@
+export default BroadcastChannel
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 472b59d76..aa5983be7 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -1,4 +1,10 @@
-import {Insets} from 'react-native'
+import {Insets, Platform} from 'react-native'
+
+export const LOCAL_DEV_SERVICE =
+  Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
+export const STAGING_SERVICE = 'https://staging.bsky.dev'
+export const PROD_SERVICE = 'https://bsky.social'
+export const DEFAULT_SERVICE = PROD_SERVICE
 
 const HELP_DESK_LANG = 'en-us'
 export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
@@ -43,7 +49,10 @@ export function IS_PROD(url: string) {
   // until open federation, "production" is defined as the main server
   // this definition will not work once federation is enabled!
   // -prf
-  return url.startsWith('https://bsky.social')
+  return (
+    url.startsWith('https://bsky.social') ||
+    url.startsWith('https://api.bsky.app')
+  )
 }
 
 export const PROD_TEAM_HANDLES = [
@@ -107,8 +116,8 @@ export async function DEFAULT_FEEDS(
   } else {
     // production
     return {
-      pinned: [PROD_DEFAULT_FEED('whats-hot')],
-      saved: [PROD_DEFAULT_FEED('whats-hot')],
+      pinned: [],
+      saved: [],
     }
   }
 }
diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts
index 1ddb181a8..8a1dea5fe 100644
--- a/src/lib/hooks/useAccountSwitcher.ts
+++ b/src/lib/hooks/useAccountSwitcher.ts
@@ -1,43 +1,55 @@
-import {useCallback, useState} from 'react'
-import {useStores} from 'state/index'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {StackActions, useNavigation} from '@react-navigation/native'
-import {NavigationProp} from 'lib/routes/types'
-import {AccountData} from 'state/models/session'
-import {reset as resetNavigation} from '../../Navigation'
-import * as Toast from 'view/com/util/Toast'
-import {useSetDrawerOpen} from '#/state/shell/drawer-open'
+import {useCallback} from 'react'
+import {useNavigation} from '@react-navigation/native'
 
-export function useAccountSwitcher(): [
-  boolean,
-  (v: boolean) => void,
-  (acct: AccountData) => Promise<void>,
-] {
+import {isWeb} from '#/platform/detection'
+import {NavigationProp} from '#/lib/routes/types'
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {useSessionApi, SessionAccount} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {useCloseAllActiveElements} from '#/state/util'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+
+export function useAccountSwitcher() {
   const {track} = useAnalytics()
-  const store = useStores()
-  const setDrawerOpen = useSetDrawerOpen()
-  const [isSwitching, setIsSwitching] = useState(false)
+  const {selectAccount, clearCurrentAccount} = useSessionApi()
+  const closeAllActiveElements = useCloseAllActiveElements()
   const navigation = useNavigation<NavigationProp>()
+  const {setShowLoggedOut} = useLoggedOutViewControls()
 
   const onPressSwitchAccount = useCallback(
-    async (acct: AccountData) => {
+    async (account: SessionAccount) => {
       track('Settings:SwitchAccountButtonClicked')
-      setIsSwitching(true)
-      const success = await store.session.resumeSession(acct)
-      setDrawerOpen(false)
-      store.shell.closeAllActiveElements()
-      if (success) {
-        resetNavigation()
-        Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
-      } else {
+
+      try {
+        if (account.accessJwt) {
+          closeAllActiveElements()
+          navigation.navigate(isWeb ? 'Home' : 'HomeTab')
+          await selectAccount(account)
+          setTimeout(() => {
+            Toast.show(`Signed in as @${account.handle}`)
+          }, 100)
+        } else {
+          closeAllActiveElements()
+          setShowLoggedOut(true)
+          Toast.show(
+            `Please sign in as @${account.handle}`,
+            'circle-exclamation',
+          )
+        }
+      } catch (e) {
         Toast.show('Sorry! We need you to enter your password.')
-        navigation.navigate('HomeTab')
-        navigation.dispatch(StackActions.popToTop())
-        store.session.clear()
+        clearCurrentAccount() // back user out to login
       }
     },
-    [track, setIsSwitching, navigation, store, setDrawerOpen],
+    [
+      track,
+      clearCurrentAccount,
+      selectAccount,
+      closeAllActiveElements,
+      navigation,
+      setShowLoggedOut,
+    ],
   )
 
-  return [isSwitching, setIsSwitching, onPressSwitchAccount]
+  return {onPressSwitchAccount}
 }
diff --git a/src/lib/hooks/useAnimatedScrollHandler_FIXED.ts b/src/lib/hooks/useAnimatedScrollHandler_FIXED.ts
new file mode 100644
index 000000000..56a1e8b11
--- /dev/null
+++ b/src/lib/hooks/useAnimatedScrollHandler_FIXED.ts
@@ -0,0 +1,15 @@
+// Be warned. This Hook is very buggy unless used in a very constrained way.
+// To use it safely:
+//
+// - DO NOT pass its return value as a prop to any user-defined component.
+// - DO NOT pass its return value to more than a single component.
+//
+// In other words, the only safe way to use it is next to the leaf Reanimated View.
+//
+// Relevant bug reports:
+// - https://github.com/software-mansion/react-native-reanimated/issues/5345
+// - https://github.com/software-mansion/react-native-reanimated/issues/5360
+// - https://github.com/software-mansion/react-native-reanimated/issues/5364
+//
+// It's great when it works though.
+export {useAnimatedScrollHandler} from 'react-native-reanimated'
diff --git a/src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts b/src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts
new file mode 100644
index 000000000..98e05a8ce
--- /dev/null
+++ b/src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts
@@ -0,0 +1,44 @@
+import {useRef, useEffect} from 'react'
+import {useAnimatedScrollHandler as useAnimatedScrollHandler_BUGGY} from 'react-native-reanimated'
+
+export const useAnimatedScrollHandler: typeof useAnimatedScrollHandler_BUGGY = (
+  config,
+  deps,
+) => {
+  const ref = useRef(config)
+  useEffect(() => {
+    ref.current = config
+  })
+  return useAnimatedScrollHandler_BUGGY(
+    {
+      onBeginDrag(e, ctx) {
+        if (typeof ref.current !== 'function' && ref.current.onBeginDrag) {
+          ref.current.onBeginDrag(e, ctx)
+        }
+      },
+      onEndDrag(e, ctx) {
+        if (typeof ref.current !== 'function' && ref.current.onEndDrag) {
+          ref.current.onEndDrag(e, ctx)
+        }
+      },
+      onMomentumBegin(e, ctx) {
+        if (typeof ref.current !== 'function' && ref.current.onMomentumBegin) {
+          ref.current.onMomentumBegin(e, ctx)
+        }
+      },
+      onMomentumEnd(e, ctx) {
+        if (typeof ref.current !== 'function' && ref.current.onMomentumEnd) {
+          ref.current.onMomentumEnd(e, ctx)
+        }
+      },
+      onScroll(e, ctx) {
+        if (typeof ref.current === 'function') {
+          ref.current(e, ctx)
+        } else if (ref.current.onScroll) {
+          ref.current.onScroll(e, ctx)
+        }
+      },
+    },
+    deps,
+  )
+}
diff --git a/src/lib/hooks/useCustomFeed.ts b/src/lib/hooks/useCustomFeed.ts
deleted file mode 100644
index 04201b9a1..000000000
--- a/src/lib/hooks/useCustomFeed.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import {useEffect, useState} from 'react'
-import {useStores} from 'state/index'
-import {FeedSourceModel} from 'state/models/content/feed-source'
-
-export function useCustomFeed(uri: string): FeedSourceModel | undefined {
-  const store = useStores()
-  const [item, setItem] = useState<FeedSourceModel | undefined>()
-  useEffect(() => {
-    async function buildFeedItem() {
-      const model = new FeedSourceModel(store, uri)
-      await model.setup()
-      setItem(model)
-    }
-    buildFeedItem()
-  }, [store, uri])
-
-  return item
-}
diff --git a/src/lib/hooks/useDesktopRightNavItems.ts b/src/lib/hooks/useDesktopRightNavItems.ts
deleted file mode 100644
index f27efd28f..000000000
--- a/src/lib/hooks/useDesktopRightNavItems.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import {useEffect, useState} from 'react'
-import {useStores} from 'state/index'
-import isEqual from 'lodash.isequal'
-import {AtUri} from '@atproto/api'
-import {FeedSourceModel} from 'state/models/content/feed-source'
-
-interface RightNavItem {
-  uri: string
-  href: string
-  hostname: string
-  collection: string
-  rkey: string
-  displayName: string
-}
-
-export function useDesktopRightNavItems(uris: string[]): RightNavItem[] {
-  const store = useStores()
-  const [items, setItems] = useState<RightNavItem[]>([])
-  const [lastUris, setLastUris] = useState<string[]>([])
-
-  useEffect(() => {
-    if (isEqual(uris, lastUris)) {
-      // no changes
-      return
-    }
-
-    async function fetchFeedInfo() {
-      const models = uris
-        .slice(0, 25)
-        .map(uri => new FeedSourceModel(store, uri))
-      await Promise.all(models.map(m => m.setup()))
-      setItems(
-        models.map(model => {
-          const {hostname, collection, rkey} = new AtUri(model.uri)
-          return {
-            uri: model.uri,
-            href: model.href,
-            hostname,
-            collection,
-            rkey,
-            displayName: model.displayName,
-          }
-        }),
-      )
-      setLastUris(uris)
-    }
-    fetchFeedInfo()
-  }, [store, uris, lastUris, setLastUris, setItems])
-
-  return items
-}
diff --git a/src/lib/hooks/useFollowProfile.ts b/src/lib/hooks/useFollowProfile.ts
deleted file mode 100644
index 98dd63f5f..000000000
--- a/src/lib/hooks/useFollowProfile.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react'
-import {AppBskyActorDefs} from '@atproto/api'
-import {useStores} from 'state/index'
-import {FollowState} from 'state/models/cache/my-follows'
-import {logger} from '#/logger'
-
-export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
-  const store = useStores()
-  const state = store.me.follows.getFollowState(profile.did)
-
-  return {
-    state,
-    following: state === FollowState.Following,
-    toggle: React.useCallback(async () => {
-      if (state === FollowState.Following) {
-        try {
-          await store.agent.deleteFollow(
-            store.me.follows.getFollowUri(profile.did),
-          )
-          store.me.follows.removeFollow(profile.did)
-          return {
-            state: FollowState.NotFollowing,
-            following: false,
-          }
-        } catch (e: any) {
-          logger.error('Failed to delete follow', {error: e})
-          throw e
-        }
-      } else if (state === FollowState.NotFollowing) {
-        try {
-          const res = await store.agent.follow(profile.did)
-          store.me.follows.addFollow(profile.did, {
-            followRecordUri: res.uri,
-            did: profile.did,
-            handle: profile.handle,
-            displayName: profile.displayName,
-            avatar: profile.avatar,
-          })
-          return {
-            state: FollowState.Following,
-            following: true,
-          }
-        } catch (e: any) {
-          logger.error('Failed to create follow', {error: e})
-          throw e
-        }
-      }
-
-      return {
-        state: FollowState.Unknown,
-        following: false,
-      }
-    }, [store, profile, state]),
-  }
-}
diff --git a/src/lib/hooks/useHomeTabs.ts b/src/lib/hooks/useHomeTabs.ts
deleted file mode 100644
index 69183e627..000000000
--- a/src/lib/hooks/useHomeTabs.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import {useEffect, useState} from 'react'
-import {useStores} from 'state/index'
-import isEqual from 'lodash.isequal'
-import {FeedSourceModel} from 'state/models/content/feed-source'
-
-export function useHomeTabs(uris: string[]): string[] {
-  const store = useStores()
-  const [tabs, setTabs] = useState<string[]>(['Following'])
-  const [lastUris, setLastUris] = useState<string[]>([])
-
-  useEffect(() => {
-    if (isEqual(uris, lastUris)) {
-      // no changes
-      return
-    }
-
-    async function fetchFeedInfo() {
-      const models = uris
-        .slice(0, 25)
-        .map(uri => new FeedSourceModel(store, uri))
-      await Promise.all(models.map(m => m.setup()))
-      setTabs(['Following'].concat(models.map(f => f.displayName)))
-      setLastUris(uris)
-    }
-    fetchFeedInfo()
-  }, [store, uris, lastUris, setLastUris, setTabs])
-
-  return tabs
-}
diff --git a/src/lib/hooks/useMinimalShellMode.tsx b/src/lib/hooks/useMinimalShellMode.tsx
index ada934a26..e81fc434f 100644
--- a/src/lib/hooks/useMinimalShellMode.tsx
+++ b/src/lib/hooks/useMinimalShellMode.tsx
@@ -1,60 +1,43 @@
-import React from 'react'
-import {autorun} from 'mobx'
-import {
-  Easing,
-  interpolate,
-  useAnimatedStyle,
-  useSharedValue,
-  withTiming,
-} from 'react-native-reanimated'
-
+import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
 import {useMinimalShellMode as useMinimalShellModeState} from '#/state/shell/minimal-mode'
+import {useShellLayout} from '#/state/shell/shell-layout'
 
 export function useMinimalShellMode() {
-  const minimalShellMode = useMinimalShellModeState()
-  const minimalShellInterp = useSharedValue(0)
+  const mode = useMinimalShellModeState()
+  const {footerHeight, headerHeight} = useShellLayout()
+
   const footerMinimalShellTransform = useAnimatedStyle(() => {
     return {
-      opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
+      pointerEvents: mode.value === 0 ? 'auto' : 'none',
+      opacity: Math.pow(1 - mode.value, 2),
       transform: [
-        {translateY: interpolate(minimalShellInterp.value, [0, 1], [0, 25])},
+        {
+          translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]),
+        },
       ],
     }
   })
   const headerMinimalShellTransform = useAnimatedStyle(() => {
     return {
-      opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
+      pointerEvents: mode.value === 0 ? 'auto' : 'none',
+      opacity: Math.pow(1 - mode.value, 2),
       transform: [
-        {translateY: interpolate(minimalShellInterp.value, [0, 1], [0, -25])},
+        {
+          translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]),
+        },
       ],
     }
   })
   const fabMinimalShellTransform = useAnimatedStyle(() => {
     return {
       transform: [
-        {translateY: interpolate(minimalShellInterp.value, [0, 1], [-44, 0])},
+        {
+          translateY: interpolate(mode.value, [0, 1], [-44, 0]),
+        },
       ],
     }
   })
-
-  React.useEffect(() => {
-    return autorun(() => {
-      if (minimalShellMode) {
-        minimalShellInterp.value = withTiming(1, {
-          duration: 125,
-          easing: Easing.bezier(0.25, 0.1, 0.25, 1),
-        })
-      } else {
-        minimalShellInterp.value = withTiming(0, {
-          duration: 125,
-          easing: Easing.bezier(0.25, 0.1, 0.25, 1),
-        })
-      }
-    })
-  }, [minimalShellInterp, minimalShellMode])
-
   return {
-    minimalShellMode,
     footerMinimalShellTransform,
     headerMinimalShellTransform,
     fabMinimalShellTransform,
diff --git a/src/lib/hooks/useNonReactiveCallback.ts b/src/lib/hooks/useNonReactiveCallback.ts
new file mode 100644
index 000000000..4b3d6abb9
--- /dev/null
+++ b/src/lib/hooks/useNonReactiveCallback.ts
@@ -0,0 +1,23 @@
+import {useCallback, useInsertionEffect, useRef} from 'react'
+
+// This should be used sparingly. It erases reactivity, i.e. when the inputs
+// change, the function itself will remain the same. This means that if you
+// use this at a higher level of your tree, and then some state you read in it
+// changes, there is no mechanism for anything below in the tree to "react"
+// to this change (e.g. by knowing to call your function again).
+//
+// Also, you should avoid calling the returned function during rendering
+// since the values captured by it are going to lag behind.
+export function useNonReactiveCallback<T extends Function>(fn: T): T {
+  const ref = useRef(fn)
+  useInsertionEffect(() => {
+    ref.current = fn
+  }, [fn])
+  return useCallback(
+    (...args: any) => {
+      const latestFn = ref.current
+      return latestFn(...args)
+    },
+    [ref],
+  ) as unknown as T
+}
diff --git a/src/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts
index 0ce97a4c8..55147329b 100644
--- a/src/lib/hooks/useOTAUpdate.ts
+++ b/src/lib/hooks/useOTAUpdate.ts
@@ -1,26 +1,26 @@
 import * as Updates from 'expo-updates'
 import {useCallback, useEffect} from 'react'
 import {AppState} from 'react-native'
-import {useStores} from 'state/index'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
+import {t} from '@lingui/macro'
 
 export function useOTAUpdate() {
-  const store = useStores()
+  const {openModal} = useModalControls()
 
   // HELPER FUNCTIONS
   const showUpdatePopup = useCallback(() => {
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
-      title: 'Update Available',
-      message:
-        'A new version of the app is available. Please update to continue using the app.',
+      title: t`Update Available`,
+      message: t`A new version of the app is available. Please update to continue using the app.`,
       onPressConfirm: async () => {
         Updates.reloadAsync().catch(err => {
           throw err
         })
       },
     })
-  }, [store.shell])
+  }, [openModal])
   const checkForUpdate = useCallback(async () => {
     logger.debug('useOTAUpdate: Checking for update...')
     try {
diff --git a/src/lib/hooks/useOnMainScroll.ts b/src/lib/hooks/useOnMainScroll.ts
index 2eab4b250..2e7a79913 100644
--- a/src/lib/hooks/useOnMainScroll.ts
+++ b/src/lib/hooks/useOnMainScroll.ts
@@ -1,69 +1,125 @@
-import {useState, useCallback, useRef} from 'react'
+import {useState, useCallback, useMemo} from 'react'
 import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
-import {s} from 'lib/styles'
-import {useWebMediaQueries} from './useWebMediaQueries'
 import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
+import {useShellLayout} from '#/state/shell/shell-layout'
+import {s} from 'lib/styles'
+import {isWeb} from 'platform/detection'
+import {
+  useSharedValue,
+  interpolate,
+  runOnJS,
+  ScrollHandlers,
+} from 'react-native-reanimated'
 
-const Y_LIMIT = 10
-
-const useDeviceLimits = () => {
-  const {isDesktop} = useWebMediaQueries()
-  return {
-    dyLimitUp: isDesktop ? 30 : 10,
-    dyLimitDown: isDesktop ? 150 : 10,
-  }
+function clamp(num: number, min: number, max: number) {
+  'worklet'
+  return Math.min(Math.max(num, min), max)
 }
 
 export type OnScrollCb = (
   event: NativeSyntheticEvent<NativeScrollEvent>,
 ) => void
+export type OnScrollHandler = ScrollHandlers<any>
 export type ResetCb = () => void
 
-export function useOnMainScroll(): [OnScrollCb, boolean, ResetCb] {
-  let lastY = useRef(0)
-  let [isScrolledDown, setIsScrolledDown] = useState(false)
-  const {dyLimitUp, dyLimitDown} = useDeviceLimits()
-  const minimalShellMode = useMinimalShellMode()
-  const setMinimalShellMode = useSetMinimalShellMode()
+export function useOnMainScroll(): [OnScrollHandler, boolean, ResetCb] {
+  const {headerHeight} = useShellLayout()
+  const [isScrolledDown, setIsScrolledDown] = useState(false)
+  const mode = useMinimalShellMode()
+  const setMode = useSetMinimalShellMode()
+  const startDragOffset = useSharedValue<number | null>(null)
+  const startMode = useSharedValue<number | null>(null)
 
-  return [
-    useCallback(
-      (event: NativeSyntheticEvent<NativeScrollEvent>) => {
-        const y = event.nativeEvent.contentOffset.y
-        const dy = y - (lastY.current || 0)
-        lastY.current = y
+  const onBeginDrag = useCallback(
+    (e: NativeScrollEvent) => {
+      'worklet'
+      startDragOffset.value = e.contentOffset.y
+      startMode.value = mode.value
+    },
+    [mode, startDragOffset, startMode],
+  )
 
-        if (!minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) {
-          setMinimalShellMode(true)
-        } else if (minimalShellMode && (dy < dyLimitUp * -1 || y <= Y_LIMIT)) {
-          setMinimalShellMode(false)
-        }
+  const onEndDrag = useCallback(
+    (e: NativeScrollEvent) => {
+      'worklet'
+      startDragOffset.value = null
+      startMode.value = null
+      if (e.contentOffset.y < headerHeight.value / 2) {
+        // If we're close to the top, show the shell.
+        setMode(false)
+      } else {
+        // Snap to whichever state is the closest.
+        setMode(Math.round(mode.value) === 1)
+      }
+    },
+    [startDragOffset, startMode, setMode, mode, headerHeight],
+  )
+
+  const onScroll = useCallback(
+    (e: NativeScrollEvent) => {
+      'worklet'
+      // Keep track of whether we want to show "scroll to top".
+      if (!isScrolledDown && e.contentOffset.y > s.window.height) {
+        runOnJS(setIsScrolledDown)(true)
+      } else if (isScrolledDown && e.contentOffset.y < s.window.height) {
+        runOnJS(setIsScrolledDown)(false)
+      }
 
-        if (
-          !isScrolledDown &&
-          event.nativeEvent.contentOffset.y > s.window.height
-        ) {
-          setIsScrolledDown(true)
-        } else if (
-          isScrolledDown &&
-          event.nativeEvent.contentOffset.y < s.window.height
-        ) {
-          setIsScrolledDown(false)
+      if (startDragOffset.value === null || startMode.value === null) {
+        if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
+          // If we're close enough to the top, always show the shell.
+          // Even if we're not dragging.
+          setMode(false)
+          return
         }
-      },
-      [
-        dyLimitDown,
-        dyLimitUp,
-        isScrolledDown,
-        minimalShellMode,
-        setMinimalShellMode,
-      ],
-    ),
+        if (isWeb) {
+          // On the web, there is no concept of "starting" the drag.
+          // When we get the first scroll event, we consider that the start.
+          startDragOffset.value = e.contentOffset.y
+          startMode.value = mode.value
+        }
+        return
+      }
+
+      // The "mode" value is always between 0 and 1.
+      // Figure out how much to move it based on the current dragged distance.
+      const dy = e.contentOffset.y - startDragOffset.value
+      const dProgress = interpolate(
+        dy,
+        [-headerHeight.value, headerHeight.value],
+        [-1, 1],
+      )
+      const newValue = clamp(startMode.value + dProgress, 0, 1)
+      if (newValue !== mode.value) {
+        // Manually adjust the value. This won't be (and shouldn't be) animated.
+        mode.value = newValue
+      }
+      if (isWeb) {
+        // On the web, there is no concept of "starting" the drag,
+        // so we don't have any specific anchor point to calculate the distance.
+        // Instead, update it continuosly along the way and diff with the last event.
+        startDragOffset.value = e.contentOffset.y
+        startMode.value = mode.value
+      }
+    },
+    [headerHeight, mode, setMode, isScrolledDown, startDragOffset, startMode],
+  )
+
+  const scrollHandler: ScrollHandlers<any> = useMemo(
+    () => ({
+      onBeginDrag,
+      onEndDrag,
+      onScroll,
+    }),
+    [onBeginDrag, onEndDrag, onScroll],
+  )
+
+  return [
+    scrollHandler,
     isScrolledDown,
     useCallback(() => {
       setIsScrolledDown(false)
-      setMinimalShellMode(false)
-      lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf
-    }, [setIsScrolledDown, setMinimalShellMode]),
+      setMode(false)
+    }, [setMode]),
   ]
 }
diff --git a/src/lib/hooks/useSetTitle.ts b/src/lib/hooks/useSetTitle.ts
index c5c7a5ca1..129023f71 100644
--- a/src/lib/hooks/useSetTitle.ts
+++ b/src/lib/hooks/useSetTitle.ts
@@ -3,18 +3,14 @@ import {useNavigation} from '@react-navigation/native'
 
 import {NavigationProp} from 'lib/routes/types'
 import {bskyTitle} from 'lib/strings/headings'
-import {useStores} from 'state/index'
+import {useUnreadNotifications} from '#/state/queries/notifications/unread'
 
-/**
- * Requires consuming component to be wrapped in `observer`:
- * https://stackoverflow.com/a/71488009
- */
 export function useSetTitle(title?: string) {
   const navigation = useNavigation<NavigationProp>()
-  const {unreadCountLabel} = useStores().me.notifications
+  const numUnread = useUnreadNotifications()
   useEffect(() => {
     if (title) {
-      navigation.setOptions({title: bskyTitle(title, unreadCountLabel)})
+      navigation.setOptions({title: bskyTitle(title, numUnread)})
     }
-  }, [title, navigation, unreadCountLabel])
+  }, [title, navigation, numUnread])
 }
diff --git a/src/lib/hooks/useToggleMutationQueue.ts b/src/lib/hooks/useToggleMutationQueue.ts
new file mode 100644
index 000000000..28ae86142
--- /dev/null
+++ b/src/lib/hooks/useToggleMutationQueue.ts
@@ -0,0 +1,98 @@
+import {useState, useRef, useEffect, useCallback} from 'react'
+
+type Task<TServerState> = {
+  isOn: boolean
+  resolve: (serverState: TServerState) => void
+  reject: (e: unknown) => void
+}
+
+type TaskQueue<TServerState> = {
+  activeTask: Task<TServerState> | null
+  queuedTask: Task<TServerState> | null
+}
+
+function AbortError() {
+  const e = new Error()
+  e.name = 'AbortError'
+  return e
+}
+
+export function useToggleMutationQueue<TServerState>({
+  initialState,
+  runMutation,
+  onSuccess,
+}: {
+  initialState: TServerState
+  runMutation: (
+    prevState: TServerState,
+    nextIsOn: boolean,
+  ) => Promise<TServerState>
+  onSuccess: (finalState: TServerState) => void
+}) {
+  // We use the queue as a mutable object.
+  // This is safe becuase it is not used for rendering.
+  const [queue] = useState<TaskQueue<TServerState>>({
+    activeTask: null,
+    queuedTask: null,
+  })
+
+  async function processQueue() {
+    if (queue.activeTask) {
+      // There is another active processQueue call iterating over tasks.
+      // It will handle any newly added tasks, so we should exit early.
+      return
+    }
+    // To avoid relying on the rendered state, capture it once at the start.
+    // From that point on, and until the queue is drained, we'll use the real server state.
+    let confirmedState: TServerState = initialState
+    try {
+      while (queue.queuedTask) {
+        const prevTask = queue.activeTask
+        const nextTask = queue.queuedTask
+        queue.activeTask = nextTask
+        queue.queuedTask = null
+        if (prevTask?.isOn === nextTask.isOn) {
+          // Skip multiple requests to update to the same value in a row.
+          prevTask.reject(new (AbortError as any)())
+          continue
+        }
+        try {
+          // The state received from the server feeds into the next task.
+          // This lets us queue deletions of not-yet-created resources.
+          confirmedState = await runMutation(confirmedState, nextTask.isOn)
+          nextTask.resolve(confirmedState)
+        } catch (e) {
+          nextTask.reject(e)
+        }
+      }
+    } finally {
+      onSuccess(confirmedState)
+      queue.activeTask = null
+      queue.queuedTask = null
+    }
+  }
+
+  function queueToggle(isOn: boolean): Promise<TServerState> {
+    return new Promise((resolve, reject) => {
+      // This is a toggle, so the next queued value can safely replace the queued one.
+      if (queue.queuedTask) {
+        queue.queuedTask.reject(new (AbortError as any)())
+      }
+      queue.queuedTask = {isOn, resolve, reject}
+      processQueue()
+    })
+  }
+
+  const queueToggleRef = useRef(queueToggle)
+  useEffect(() => {
+    queueToggleRef.current = queueToggle
+  })
+  const queueToggleStable = useCallback(
+    (isOn: boolean): Promise<TServerState> => {
+      const queueToggleLatest = queueToggleRef.current
+      return queueToggleLatest(isOn)
+    },
+    [],
+  )
+  return queueToggleStable
+}
diff --git a/src/lib/hooks/useWebMediaQueries.tsx b/src/lib/hooks/useWebMediaQueries.tsx
index 3f43a0aaf..71a96a89b 100644
--- a/src/lib/hooks/useWebMediaQueries.tsx
+++ b/src/lib/hooks/useWebMediaQueries.tsx
@@ -3,8 +3,8 @@ import {isNative} from 'platform/detection'
 
 export function useWebMediaQueries() {
   const isDesktop = useMediaQuery({minWidth: 1300})
-  const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300})
-  const isMobile = useMediaQuery({maxWidth: 800})
+  const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300 - 1})
+  const isMobile = useMediaQuery({maxWidth: 800 - 1})
   const isTabletOrMobile = isMobile || isTablet
   const isTabletOrDesktop = isDesktop || isTablet
   if (isNative) {
diff --git a/src/lib/labeling/const.ts b/src/lib/labeling/const.ts
deleted file mode 100644
index 5c2e68137..000000000
--- a/src/lib/labeling/const.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import {LabelPreferencesModel} from 'state/models/ui/preferences'
-import {LabelValGroup} from './types'
-
-export const ILLEGAL_LABEL_GROUP: LabelValGroup = {
-  id: 'illegal',
-  title: 'Illegal Content',
-  warning: 'Illegal Content',
-  values: ['csam', 'dmca-violation', 'nudity-nonconsensual'],
-}
-
-export const ALWAYS_FILTER_LABEL_GROUP: LabelValGroup = {
-  id: 'always-filter',
-  title: 'Content Warning',
-  warning: 'Content Warning',
-  values: ['!filter'],
-}
-
-export const ALWAYS_WARN_LABEL_GROUP: LabelValGroup = {
-  id: 'always-warn',
-  title: 'Content Warning',
-  warning: 'Content Warning',
-  values: ['!warn', 'account-security'],
-}
-
-export const UNKNOWN_LABEL_GROUP: LabelValGroup = {
-  id: 'unknown',
-  title: 'Unknown Label',
-  warning: 'Content Warning',
-  values: [],
-}
-
-export const CONFIGURABLE_LABEL_GROUPS: Record<
-  keyof LabelPreferencesModel,
-  LabelValGroup
-> = {
-  nsfw: {
-    id: 'nsfw',
-    title: 'Explicit Sexual Images',
-    subtitle: 'i.e. pornography',
-    warning: 'Sexually Explicit',
-    values: ['porn', 'nsfl'],
-    isAdultImagery: true,
-  },
-  nudity: {
-    id: 'nudity',
-    title: 'Other Nudity',
-    subtitle: 'Including non-sexual and artistic',
-    warning: 'Nudity',
-    values: ['nudity'],
-    isAdultImagery: true,
-  },
-  suggestive: {
-    id: 'suggestive',
-    title: 'Sexually Suggestive',
-    subtitle: 'Does not include nudity',
-    warning: 'Sexually Suggestive',
-    values: ['sexual'],
-    isAdultImagery: true,
-  },
-  gore: {
-    id: 'gore',
-    title: 'Violent / Bloody',
-    subtitle: 'Gore, self-harm, torture',
-    warning: 'Violence',
-    values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'],
-    isAdultImagery: true,
-  },
-  hate: {
-    id: 'hate',
-    title: 'Hate Group Iconography',
-    subtitle: 'Images of terror groups, articles covering events, etc.',
-    warning: 'Hate Groups',
-    values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'],
-  },
-  spam: {
-    id: 'spam',
-    title: 'Spam',
-    subtitle: 'Excessive unwanted interactions',
-    warning: 'Spam',
-    values: ['spam'],
-  },
-  impersonation: {
-    id: 'impersonation',
-    title: 'Impersonation',
-    subtitle: 'Accounts falsely claiming to be people or orgs',
-    warning: 'Impersonation',
-    values: ['impersonation'],
-  },
-}
diff --git a/src/lib/labeling/types.ts b/src/lib/labeling/types.ts
deleted file mode 100644
index 84d59be7f..000000000
--- a/src/lib/labeling/types.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import {ComAtprotoLabelDefs} from '@atproto/api'
-import {LabelPreferencesModel} from 'state/models/ui/preferences'
-
-export type Label = ComAtprotoLabelDefs.Label
-
-export interface LabelValGroup {
-  id:
-    | keyof LabelPreferencesModel
-    | 'illegal'
-    | 'always-filter'
-    | 'always-warn'
-    | 'unknown'
-  title: string
-  isAdultImagery?: boolean
-  subtitle?: string
-  warning: string
-  values: string[]
-}
diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts
index b052ed04b..322b02332 100644
--- a/src/lib/link-meta/bsky.ts
+++ b/src/lib/link-meta/bsky.ts
@@ -1,10 +1,10 @@
+import {AppBskyFeedPost, BskyAgent} from '@atproto/api'
 import * as apilib from 'lib/api/index'
 import {LikelyType, LinkMeta} from './link-meta'
 // import {match as matchRoute} from 'view/routes'
 import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
-import {RootStoreModel} from 'state/index'
-import {PostThreadModel} from 'state/models/content/post-thread'
-import {ComposerOptsQuote} from 'state/models/ui/shell'
+import {ComposerOptsQuote} from 'state/shell/composer'
+import {useGetPost} from '#/state/queries/post'
 
 // TODO
 // import {Home} from 'view/screens/Home'
@@ -22,7 +22,7 @@ import {ComposerOptsQuote} from 'state/models/ui/shell'
 // remove once that's implemented
 // -prf
 export async function extractBskyMeta(
-  store: RootStoreModel,
+  agent: BskyAgent,
   url: string,
 ): Promise<LinkMeta> {
   url = convertBskyAppUrlIfNeeded(url)
@@ -102,38 +102,30 @@ export async function extractBskyMeta(
 }
 
 export async function getPostAsQuote(
-  store: RootStoreModel,
+  getPost: ReturnType<typeof useGetPost>,
   url: string,
 ): Promise<ComposerOptsQuote> {
   url = convertBskyAppUrlIfNeeded(url)
   const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
-  const threadUri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
-
-  const threadView = new PostThreadModel(store, {
-    uri: threadUri,
-    depth: 0,
-  })
-  await threadView.setup()
-  if (!threadView.thread || threadView.notFound) {
-    throw new Error('Not found')
-  }
+  const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
+  const post = await getPost({uri: uri})
   return {
-    uri: threadView.thread.post.uri,
-    cid: threadView.thread.post.cid,
-    text: threadView.thread.postRecord?.text || '',
-    indexedAt: threadView.thread.post.indexedAt,
-    author: threadView.thread.post.author,
+    uri: post.uri,
+    cid: post.cid,
+    text: AppBskyFeedPost.isRecord(post.record) ? post.record.text : '',
+    indexedAt: post.indexedAt,
+    author: post.author,
   }
 }
 
 export async function getFeedAsEmbed(
-  store: RootStoreModel,
+  agent: BskyAgent,
   url: string,
 ): Promise<apilib.ExternalEmbedDraft> {
   url = convertBskyAppUrlIfNeeded(url)
   const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
   const feed = makeRecordUri(user, 'app.bsky.feed.generator', rkey)
-  const res = await store.agent.app.bsky.feed.getFeedGenerator({feed})
+  const res = await agent.app.bsky.feed.getFeedGenerator({feed})
   return {
     isLoading: false,
     uri: feed,
@@ -153,13 +145,13 @@ export async function getFeedAsEmbed(
 }
 
 export async function getListAsEmbed(
-  store: RootStoreModel,
+  agent: BskyAgent,
   url: string,
 ): Promise<apilib.ExternalEmbedDraft> {
   url = convertBskyAppUrlIfNeeded(url)
   const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
   const list = makeRecordUri(user, 'app.bsky.graph.list', rkey)
-  const res = await store.agent.app.bsky.graph.getList({list})
+  const res = await agent.app.bsky.graph.getList({list})
   return {
     isLoading: false,
     uri: list,
diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts
index c490fa292..c17dee51f 100644
--- a/src/lib/link-meta/link-meta.ts
+++ b/src/lib/link-meta/link-meta.ts
@@ -1,5 +1,5 @@
+import {BskyAgent} from '@atproto/api'
 import {isBskyAppUrl} from '../strings/url-helpers'
-import {RootStoreModel} from 'state/index'
 import {extractBskyMeta} from './bsky'
 import {LINK_META_PROXY} from 'lib/constants'
 
@@ -23,12 +23,12 @@ export interface LinkMeta {
 }
 
 export async function getLinkMeta(
-  store: RootStoreModel,
+  agent: BskyAgent,
   url: string,
   timeout = 5e3,
 ): Promise<LinkMeta> {
   if (isBskyAppUrl(url)) {
-    return extractBskyMeta(store, url)
+    return extractBskyMeta(agent, url)
   }
 
   let urlp
@@ -55,9 +55,9 @@ export async function getLinkMeta(
     const to = setTimeout(() => controller.abort(), timeout || 5e3)
 
     const response = await fetch(
-      `${LINK_META_PROXY(
-        store.session.currentSession?.service || '',
-      )}${encodeURIComponent(url)}`,
+      `${LINK_META_PROXY(agent.service.toString() || '')}${encodeURIComponent(
+        url,
+      )}`,
       {signal: controller.signal},
     )
 
diff --git a/src/lib/media/alt-text.ts b/src/lib/media/alt-text.ts
deleted file mode 100644
index 4109f667a..000000000
--- a/src/lib/media/alt-text.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import {RootStoreModel} from 'state/index'
-import {ImageModel} from 'state/models/media/image'
-
-export async function openAltTextModal(
-  store: RootStoreModel,
-  image: ImageModel,
-) {
-  store.shell.openModal({
-    name: 'alt-text-image',
-    image,
-  })
-}
diff --git a/src/lib/media/image-sizes.ts b/src/lib/media/image-sizes.ts
new file mode 100644
index 000000000..4ea95ea23
--- /dev/null
+++ b/src/lib/media/image-sizes.ts
@@ -0,0 +1,34 @@
+import {Image} from 'react-native'
+import type {Dimensions} from 'lib/media/types'
+
+const sizes: Map<string, Dimensions> = new Map()
+const activeRequests: Map<string, Promise<Dimensions>> = new Map()
+
+export function get(uri: string): Dimensions | undefined {
+  return sizes.get(uri)
+}
+
+export async function fetch(uri: string): Promise<Dimensions> {
+  const Dimensions = sizes.get(uri)
+  if (Dimensions) {
+    return Dimensions
+  }
+
+  const prom =
+    activeRequests.get(uri) ||
+    new Promise<Dimensions>(resolve => {
+      Image.getSize(
+        uri,
+        (width: number, height: number) => resolve({width, height}),
+        (err: any) => {
+          console.error('Failed to fetch image dimensions for', uri, err)
+          resolve({width: 0, height: 0})
+        },
+      )
+    })
+  activeRequests.set(uri, prom)
+  const res = await prom
+  activeRequests.delete(uri)
+  sizes.set(uri, res)
+  return res
+}
diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx
index 9805c3464..096667479 100644
--- a/src/lib/media/picker.e2e.tsx
+++ b/src/lib/media/picker.e2e.tsx
@@ -1,4 +1,3 @@
-import {RootStoreModel} from 'state/index'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import RNFS from 'react-native-fs'
 import {CropperOptions} from './types'
@@ -22,18 +21,15 @@ async function getFile() {
   })
 }
 
-export async function openPicker(_store: RootStoreModel): Promise<RNImage[]> {
+export async function openPicker(): Promise<RNImage[]> {
   return [await getFile()]
 }
 
-export async function openCamera(_store: RootStoreModel): Promise<RNImage> {
+export async function openCamera(): Promise<RNImage> {
   return await getFile()
 }
 
-export async function openCropper(
-  _store: RootStoreModel,
-  opts: CropperOptions,
-): Promise<RNImage> {
+export async function openCropper(opts: CropperOptions): Promise<RNImage> {
   return {
     path: opts.path,
     mime: 'image/jpeg',
diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx
index d0ee1ae22..bf531c981 100644
--- a/src/lib/media/picker.tsx
+++ b/src/lib/media/picker.tsx
@@ -3,23 +3,10 @@ import {
   openCropper as openCropperFn,
   Image as RNImage,
 } from 'react-native-image-crop-picker'
-import {RootStoreModel} from 'state/index'
 import {CameraOpts, CropperOptions} from './types'
 export {openPicker} from './picker.shared'
 
-/**
- * NOTE
- * These methods all include the RootStoreModel as the first param
- * because the web versions require it. The signatures have to remain
- * equivalent between the different forms, but the store param is not
- * used here.
- * -prf
- */
-
-export async function openCamera(
-  _store: RootStoreModel,
-  opts: CameraOpts,
-): Promise<RNImage> {
+export async function openCamera(opts: CameraOpts): Promise<RNImage> {
   const item = await openCameraFn({
     width: opts.width,
     height: opts.height,
@@ -39,10 +26,7 @@ export async function openCamera(
   }
 }
 
-export async function openCropper(
-  _store: RootStoreModel,
-  opts: CropperOptions,
-) {
+export async function openCropper(opts: CropperOptions) {
   const item = await openCropperFn({
     ...opts,
     forceJpg: true, // ios only
diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx
index d12685b0c..995a0c95f 100644
--- a/src/lib/media/picker.web.tsx
+++ b/src/lib/media/picker.web.tsx
@@ -1,25 +1,19 @@
 /// <reference lib="dom" />
 
 import {CameraOpts, CropperOptions} from './types'
-import {RootStoreModel} from 'state/index'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 export {openPicker} from './picker.shared'
+import {unstable__openModal} from '#/state/modals'
 
-export async function openCamera(
-  _store: RootStoreModel,
-  _opts: CameraOpts,
-): Promise<RNImage> {
+export async function openCamera(_opts: CameraOpts): Promise<RNImage> {
   // const mediaType = opts.mediaType || 'photo' TODO
   throw new Error('TODO')
 }
 
-export async function openCropper(
-  store: RootStoreModel,
-  opts: CropperOptions,
-): Promise<RNImage> {
+export async function openCropper(opts: CropperOptions): Promise<RNImage> {
   // TODO handle more opts
   return new Promise((resolve, reject) => {
-    store.shell.openModal({
+    unstable__openModal({
       name: 'crop-image',
       uri: opts.path,
       onSelect: (img?: RNImage) => {
diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts
index 73f9c56f6..6e79e6b91 100644
--- a/src/lib/notifications/notifications.ts
+++ b/src/lib/notifications/notifications.ts
@@ -1,19 +1,20 @@
 import * as Notifications from 'expo-notifications'
-import {RootStoreModel} from '../../state'
+import {QueryClient} from '@tanstack/react-query'
 import {resetToTab} from '../../Navigation'
 import {devicePlatform, isIOS} from 'platform/detection'
 import {track} from 'lib/analytics/analytics'
 import {logger} from '#/logger'
+import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
+import {truncateAndInvalidate} from '#/state/queries/util'
+import {listenSessionLoaded} from '#/state/events'
 
 const SERVICE_DID = (serviceUrl?: string) =>
   serviceUrl?.includes('staging')
     ? 'did:web:api.staging.bsky.dev'
     : 'did:web:api.bsky.app'
 
-export function init(store: RootStoreModel) {
-  store.onUnreadNotifications(count => Notifications.setBadgeCountAsync(count))
-
-  store.onSessionLoaded(async () => {
+export function init(queryClient: QueryClient) {
+  listenSessionLoaded(async (account, agent) => {
     // request notifications permission once the user has logged in
     const perms = await Notifications.getPermissionsAsync()
     if (!perms.granted) {
@@ -24,8 +25,8 @@ export function init(store: RootStoreModel) {
     const token = await getPushToken()
     if (token) {
       try {
-        await store.agent.api.app.bsky.notification.registerPush({
-          serviceDid: SERVICE_DID(store.session.data?.service),
+        await agent.api.app.bsky.notification.registerPush({
+          serviceDid: SERVICE_DID(account.service),
           platform: devicePlatform,
           token: token.data,
           appId: 'xyz.blueskyweb.app',
@@ -53,8 +54,8 @@ export function init(store: RootStoreModel) {
       )
       if (t) {
         try {
-          await store.agent.api.app.bsky.notification.registerPush({
-            serviceDid: SERVICE_DID(store.session.data?.service),
+          await agent.api.app.bsky.notification.registerPush({
+            serviceDid: SERVICE_DID(account.service),
             platform: devicePlatform,
             token: t,
             appId: 'xyz.blueskyweb.app',
@@ -83,7 +84,7 @@ export function init(store: RootStoreModel) {
     )
     if (event.request.trigger.type === 'push') {
       // refresh notifications in the background
-      store.me.notifications.syncQueue()
+      truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
       // handle payload-based deeplinks
       let payload
       if (isIOS) {
@@ -121,7 +122,7 @@ export function init(store: RootStoreModel) {
           logger.DebugContext.notifications,
         )
         track('Notificatons:OpenApp')
-        store.me.notifications.refresh() // refresh notifications
+        truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
         resetToTab('NotificationsTab') // open notifications tab
       }
     },
diff --git a/src/lib/react-query.ts b/src/lib/react-query.ts
index 2a8f1d759..6ec620f74 100644
--- a/src/lib/react-query.ts
+++ b/src/lib/react-query.ts
@@ -1,3 +1,22 @@
 import {QueryClient} from '@tanstack/react-query'
 
-export const queryClient = new QueryClient()
+export const queryClient = new QueryClient({
+  defaultOptions: {
+    queries: {
+      // NOTE
+      // refetchOnWindowFocus breaks some UIs (like feeds)
+      // so we NEVER want to enable this
+      // -prf
+      refetchOnWindowFocus: false,
+      // Structural sharing between responses makes it impossible to rely on
+      // "first seen" timestamps on objects to determine if they're fresh.
+      // Disable this optimization so that we can rely on "first seen" timestamps.
+      structuralSharing: false,
+      // We don't want to retry queries by default, because in most cases we
+      // want to fail early and show a response to the user. There are
+      // exceptions, and those can be made on a per-query basis. For others, we
+      // should give users controls to retry.
+      retry: false,
+    },
+  },
+})
diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts
index b080bcc5c..63a21a43c 100644
--- a/src/lib/sentry.ts
+++ b/src/lib/sentry.ts
@@ -1,8 +1,46 @@
+/**
+ * Importing these separately from `platform/detection` and `lib/app-info` to
+ * avoid future conflicts and/or circular deps
+ */
+
+import {Platform} from 'react-native'
+import app from 'react-native-version-number'
+import * as info from 'expo-updates'
 import {init} from 'sentry-expo'
 
+/**
+ * Matches the build profile `channel` props in `eas.json`
+ */
+const buildChannel = (info.channel || 'development') as
+  | 'development'
+  | 'preview'
+  | 'production'
+
+/**
+ * Examples:
+ * - `dev`
+ * - `1.57.0`
+ */
+const release = app.appVersion ?? 'dev'
+
+/**
+ * Examples:
+ * - `web.dev`
+ * - `ios.dev`
+ * - `android.dev`
+ * - `web.1.57.0`
+ * - `ios.1.57.0.3`
+ * - `android.1.57.0.46`
+ */
+const dist = `${Platform.OS}.${release}${
+  app.buildVersion ? `.${app.buildVersion}` : ''
+}`
+
 init({
   dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432',
-  enableInExpoDevelopment: false, // if true, Sentry will try to send events/errors in development mode.
   debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production
-  environment: __DEV__ ? 'development' : 'production', // Set the environment
+  enableInExpoDevelopment: true,
+  environment: buildChannel,
+  dist,
+  release,
 })
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 106d2ca31..e9bf4111d 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -1,5 +1,5 @@
 import {AtUri} from '@atproto/api'
-import {PROD_SERVICE} from 'state/index'
+import {PROD_SERVICE} from 'lib/constants'
 import TLDs from 'tlds'
 import psl from 'psl'
 
@@ -168,8 +168,15 @@ export function getYoutubeVideoId(link: string): string | undefined {
   return videoId
 }
 
+/**
+ * Checks if the label in the post text matches the host of the link facet.
+ *
+ * Hosts are case-insensitive, so should be lowercase for comparison.
+ * @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
+ */
 export function linkRequiresWarning(uri: string, label: string) {
   const labelDomain = labelToDomain(label)
+
   let urip
   try {
     urip = new URL(uri)
@@ -177,7 +184,9 @@ export function linkRequiresWarning(uri: string, label: string) {
     return true
   }
 
-  if (urip.hostname === 'bsky.app') {
+  const host = urip.hostname.toLowerCase()
+
+  if (host === 'bsky.app') {
     // if this is a link to internal content,
     // warn if it represents itself as a URL to another app
     if (
@@ -194,20 +203,26 @@ export function linkRequiresWarning(uri: string, label: string) {
     if (!labelDomain) {
       return true
     }
-    return labelDomain !== urip.hostname
+    return labelDomain !== host
   }
 }
 
-function labelToDomain(label: string): string | undefined {
+/**
+ * Returns a lowercase domain hostname if the label is a valid URL.
+ *
+ * Hosts are case-insensitive, so should be lowercase for comparison.
+ * @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
+ */
+export function labelToDomain(label: string): string | undefined {
   // any spaces just immediately consider the label a non-url
   if (/\s/.test(label)) {
     return undefined
   }
   try {
-    return new URL(label).hostname
+    return new URL(label).hostname.toLowerCase()
   } catch {}
   try {
-    return new URL('https://' + label).hostname
+    return new URL('https://' + label).hostname.toLowerCase()
   } catch {}
   return undefined
 }