From f18b15241ab708f8c25a11937a875e361e9f1221 Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
Date: Wed, 8 Nov 2023 12:34:10 -0600
Subject: [PATCH] Add modal state provider, replace usage except methods
 (#1833)

* Add modal state provider, replace usage except methods

* Replace easy spots

* Fix sticky spots

* Replace final usages

* Memorize context objects

* Add more warnings
---
 src/App.native.tsx                            |   5 +-
 src/App.web.tsx                               |   5 +-
 src/lib/hooks/useAccountSwitcher.ts           |   5 +-
 src/lib/hooks/useOTAUpdate.ts                 |   8 +-
 src/lib/media/alt-text.ts                     |  12 -
 src/lib/media/picker.web.tsx                  |   5 +-
 src/state/modals/index.tsx                    | 284 ++++++++++++++++++
 src/state/models/media/gallery.ts             |  13 -
 src/state/models/ui/shell.ts                  | 224 +-------------
 src/view/com/auth/create/Step2.tsx            |   8 +-
 src/view/com/auth/login/Login.tsx             |   8 +-
 src/view/com/composer/Composer.tsx            |  13 +-
 src/view/com/composer/labels/LabelsBtn.tsx    |   6 +-
 src/view/com/composer/photos/Gallery.tsx      |  28 +-
 .../select-language/SelectLangBtn.tsx         |   8 +-
 src/view/com/feeds/FeedSourceCard.tsx         |   8 +-
 src/view/com/lists/ListItems.tsx              |   8 +-
 src/view/com/modals/AddAppPasswords.tsx       |   6 +-
 src/view/com/modals/AltImage.tsx              |  10 +-
 src/view/com/modals/BirthDateSettings.tsx     |   4 +-
 src/view/com/modals/ChangeEmail.tsx           |   8 +-
 src/view/com/modals/ChangeHandle.tsx          |   9 +-
 src/view/com/modals/Confirm.tsx               |   8 +-
 .../com/modals/ContentFilteringSettings.tsx   |   9 +-
 src/view/com/modals/CreateOrEditList.tsx      |   9 +-
 src/view/com/modals/DeleteAccount.tsx         |   6 +-
 src/view/com/modals/EditImage.tsx             |   8 +-
 src/view/com/modals/EditProfile.tsx           |  10 +-
 src/view/com/modals/InviteCodes.tsx           |   6 +-
 src/view/com/modals/LinkWarning.tsx           |   8 +-
 src/view/com/modals/ListAddUser.tsx           |   4 +-
 src/view/com/modals/Modal.tsx                 |  24 +-
 src/view/com/modals/Modal.web.tsx             |  17 +-
 src/view/com/modals/ModerationDetails.tsx     |   9 +-
 src/view/com/modals/Repost.tsx                |   6 +-
 src/view/com/modals/SelfLabel.tsx             |   6 +-
 src/view/com/modals/ServerInput.tsx           |   6 +-
 src/view/com/modals/UserAddRemoveLists.tsx    |  10 +-
 src/view/com/modals/VerifyEmail.tsx           |  10 +-
 src/view/com/modals/Waitlist.tsx              |   6 +-
 .../com/modals/crop-image/CropImage.web.tsx   |   8 +-
 .../ContentLanguagesSettings.tsx              |   8 +-
 .../lang-settings/PostLanguagesSettings.tsx   |   8 +-
 src/view/com/modals/report/Modal.tsx          |   6 +-
 src/view/com/posts/FeedErrorMessage.tsx       |   8 +-
 src/view/com/profile/ProfileHeader.tsx        |  22 +-
 src/view/com/testing/TestCtrls.e2e.tsx        |   4 +-
 src/view/com/util/Link.tsx                    |  28 +-
 src/view/com/util/UserPreviewLink.tsx         |   6 +-
 src/view/com/util/forms/PostDropdownBtn.tsx   |   8 +-
 src/view/com/util/moderation/ContentHider.tsx |   8 +-
 src/view/com/util/moderation/PostAlerts.tsx   |   6 +-
 src/view/com/util/moderation/PostHider.tsx    |   6 +-
 .../util/moderation/ProfileHeaderAlerts.tsx   |   6 +-
 src/view/com/util/moderation/ScreenHider.tsx  |   6 +-
 src/view/com/util/post-ctrls/PostCtrls.tsx    |   9 +-
 src/view/com/util/post-ctrls/RepostButton.tsx |   8 +-
 src/view/screens/AppPasswords.tsx             |  11 +-
 src/view/screens/LanguageSettings.tsx         |   8 +-
 src/view/screens/Lists.tsx                    |   6 +-
 src/view/screens/Moderation.tsx               |   8 +-
 src/view/screens/ModerationModlists.tsx       |   6 +-
 src/view/screens/ProfileFeed.tsx              |   6 +-
 src/view/screens/ProfileList.tsx              |  32 +-
 src/view/screens/Settings.tsx                 |  25 +-
 src/view/shell/Drawer.tsx                     |   6 +-
 src/view/shell/bottom-bar/BottomBar.tsx       |   6 +-
 src/view/shell/desktop/RightNav.tsx           |   6 +-
 src/view/shell/index.tsx                      |   5 +-
 src/view/shell/index.web.tsx                  |   5 +-
 70 files changed, 634 insertions(+), 498 deletions(-)
 delete mode 100644 src/lib/media/alt-text.ts
 create mode 100644 src/state/modals/index.tsx

diff --git a/src/App.native.tsx b/src/App.native.tsx
index ccc7de32..5955504e 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -22,6 +22,7 @@ import * as Toast from 'view/com/util/Toast'
 import {queryClient} from 'lib/react-query'
 import {TestCtrls} from 'view/com/testing/TestCtrls'
 import {Provider as ShellStateProvider} from 'state/shell'
+import {Provider as ModalStateProvider} from 'state/modals'
 import {Provider as MutedThreadsProvider} from 'state/muted-threads'
 import {Provider as InvitesStateProvider} from 'state/invites'
 import {Provider as PrefsStateProvider} from 'state/preferences'
@@ -84,7 +85,9 @@ function App() {
       <PrefsStateProvider>
         <MutedThreadsProvider>
           <InvitesStateProvider>
-            <InnerApp />
+            <ModalStateProvider>
+              <InnerApp />
+            </ModalStateProvider>
           </InvitesStateProvider>
         </MutedThreadsProvider>
       </PrefsStateProvider>
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 363161bf..9e5b99a9 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -17,6 +17,7 @@ import {ToastContainer} from 'view/com/util/Toast.web'
 import {ThemeProvider} from 'lib/ThemeContext'
 import {queryClient} from 'lib/react-query'
 import {Provider as ShellStateProvider} from 'state/shell'
+import {Provider as ModalStateProvider} from 'state/modals'
 import {Provider as MutedThreadsProvider} from 'state/muted-threads'
 import {Provider as InvitesStateProvider} from 'state/invites'
 import {Provider as PrefsStateProvider} from 'state/preferences'
@@ -74,7 +75,9 @@ function App() {
       <PrefsStateProvider>
         <MutedThreadsProvider>
           <InvitesStateProvider>
-            <InnerApp />
+            <ModalStateProvider>
+              <InnerApp />
+            </ModalStateProvider>
           </InvitesStateProvider>
         </MutedThreadsProvider>
       </PrefsStateProvider>
diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts
index 1ddb181a..b165fddb 100644
--- a/src/lib/hooks/useAccountSwitcher.ts
+++ b/src/lib/hooks/useAccountSwitcher.ts
@@ -7,6 +7,7 @@ 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 {useModalControls} from '#/state/modals'
 
 export function useAccountSwitcher(): [
   boolean,
@@ -16,6 +17,7 @@ export function useAccountSwitcher(): [
   const {track} = useAnalytics()
   const store = useStores()
   const setDrawerOpen = useSetDrawerOpen()
+  const {closeModal} = useModalControls()
   const [isSwitching, setIsSwitching] = useState(false)
   const navigation = useNavigation<NavigationProp>()
 
@@ -25,6 +27,7 @@ export function useAccountSwitcher(): [
       setIsSwitching(true)
       const success = await store.session.resumeSession(acct)
       setDrawerOpen(false)
+      closeModal()
       store.shell.closeAllActiveElements()
       if (success) {
         resetNavigation()
@@ -36,7 +39,7 @@ export function useAccountSwitcher(): [
         store.session.clear()
       }
     },
-    [track, setIsSwitching, navigation, store, setDrawerOpen],
+    [track, setIsSwitching, navigation, store, setDrawerOpen, closeModal],
   )
 
   return [isSwitching, setIsSwitching, onPressSwitchAccount]
diff --git a/src/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts
index 0ce97a4c..a3584fc9 100644
--- a/src/lib/hooks/useOTAUpdate.ts
+++ b/src/lib/hooks/useOTAUpdate.ts
@@ -1,15 +1,15 @@
 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'
 
 export function useOTAUpdate() {
-  const store = useStores()
+  const {openModal} = useModalControls()
 
   // HELPER FUNCTIONS
   const showUpdatePopup = useCallback(() => {
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
       title: 'Update Available',
       message:
@@ -20,7 +20,7 @@ export function useOTAUpdate() {
         })
       },
     })
-  }, [store.shell])
+  }, [openModal])
   const checkForUpdate = useCallback(async () => {
     logger.debug('useOTAUpdate: Checking for update...')
     try {
diff --git a/src/lib/media/alt-text.ts b/src/lib/media/alt-text.ts
deleted file mode 100644
index 4109f667..00000000
--- 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/picker.web.tsx b/src/lib/media/picker.web.tsx
index d12685b0..50b9c73e 100644
--- a/src/lib/media/picker.web.tsx
+++ b/src/lib/media/picker.web.tsx
@@ -4,6 +4,7 @@ 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,
@@ -14,12 +15,12 @@ export async function openCamera(
 }
 
 export async function openCropper(
-  store: RootStoreModel,
+  _store: RootStoreModel,
   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/state/modals/index.tsx b/src/state/modals/index.tsx
new file mode 100644
index 00000000..f9bd1e3c
--- /dev/null
+++ b/src/state/modals/index.tsx
@@ -0,0 +1,284 @@
+import React from 'react'
+import {AppBskyActorDefs, ModerationUI} from '@atproto/api'
+import {StyleProp, ViewStyle, DeviceEventEmitter} from 'react-native'
+import {Image as RNImage} from 'react-native-image-crop-picker'
+
+import {ProfileModel} from '#/state/models/content/profile'
+import {ImageModel} from '#/state/models/media/image'
+import {ListModel} from '#/state/models/content/list'
+import {GalleryModel} from '#/state/models/media/gallery'
+
+export interface ConfirmModal {
+  name: 'confirm'
+  title: string
+  message: string | (() => JSX.Element)
+  onPressConfirm: () => void | Promise<void>
+  onPressCancel?: () => void | Promise<void>
+  confirmBtnText?: string
+  confirmBtnStyle?: StyleProp<ViewStyle>
+  cancelBtnText?: string
+}
+
+export interface EditProfileModal {
+  name: 'edit-profile'
+  profileView: ProfileModel
+  onUpdate?: () => void
+}
+
+export interface ProfilePreviewModal {
+  name: 'profile-preview'
+  did: string
+}
+
+export interface ServerInputModal {
+  name: 'server-input'
+  initialService: string
+  onSelect: (url: string) => void
+}
+
+export interface ModerationDetailsModal {
+  name: 'moderation-details'
+  context: 'account' | 'content'
+  moderation: ModerationUI
+}
+
+export type ReportModal = {
+  name: 'report'
+} & (
+  | {
+      uri: string
+      cid: string
+    }
+  | {did: string}
+)
+
+export interface CreateOrEditListModal {
+  name: 'create-or-edit-list'
+  purpose?: string
+  list?: ListModel
+  onSave?: (uri: string) => void
+}
+
+export interface UserAddRemoveListsModal {
+  name: 'user-add-remove-lists'
+  subject: string
+  displayName: string
+  onAdd?: (listUri: string) => void
+  onRemove?: (listUri: string) => void
+}
+
+export interface ListAddUserModal {
+  name: 'list-add-user'
+  list: ListModel
+  onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void
+}
+
+export interface EditImageModal {
+  name: 'edit-image'
+  image: ImageModel
+  gallery: GalleryModel
+}
+
+export interface CropImageModal {
+  name: 'crop-image'
+  uri: string
+  onSelect: (img?: RNImage) => void
+}
+
+export interface AltTextImageModal {
+  name: 'alt-text-image'
+  image: ImageModel
+}
+
+export interface DeleteAccountModal {
+  name: 'delete-account'
+}
+
+export interface RepostModal {
+  name: 'repost'
+  onRepost: () => void
+  onQuote: () => void
+  isReposted: boolean
+}
+
+export interface SelfLabelModal {
+  name: 'self-label'
+  labels: string[]
+  hasMedia: boolean
+  onChange: (labels: string[]) => void
+}
+
+export interface ChangeHandleModal {
+  name: 'change-handle'
+  onChanged: () => void
+}
+
+export interface WaitlistModal {
+  name: 'waitlist'
+}
+
+export interface InviteCodesModal {
+  name: 'invite-codes'
+}
+
+export interface AddAppPasswordModal {
+  name: 'add-app-password'
+}
+
+export interface ContentFilteringSettingsModal {
+  name: 'content-filtering-settings'
+}
+
+export interface ContentLanguagesSettingsModal {
+  name: 'content-languages-settings'
+}
+
+export interface PostLanguagesSettingsModal {
+  name: 'post-languages-settings'
+}
+
+export interface BirthDateSettingsModal {
+  name: 'birth-date-settings'
+}
+
+export interface VerifyEmailModal {
+  name: 'verify-email'
+  showReminder?: boolean
+}
+
+export interface ChangeEmailModal {
+  name: 'change-email'
+}
+
+export interface SwitchAccountModal {
+  name: 'switch-account'
+}
+
+export interface LinkWarningModal {
+  name: 'link-warning'
+  text: string
+  href: string
+}
+
+export type Modal =
+  // Account
+  | AddAppPasswordModal
+  | ChangeHandleModal
+  | DeleteAccountModal
+  | EditProfileModal
+  | ProfilePreviewModal
+  | BirthDateSettingsModal
+  | VerifyEmailModal
+  | ChangeEmailModal
+  | SwitchAccountModal
+
+  // Curation
+  | ContentFilteringSettingsModal
+  | ContentLanguagesSettingsModal
+  | PostLanguagesSettingsModal
+
+  // Moderation
+  | ModerationDetailsModal
+  | ReportModal
+
+  // Lists
+  | CreateOrEditListModal
+  | UserAddRemoveListsModal
+  | ListAddUserModal
+
+  // Posts
+  | AltTextImageModal
+  | CropImageModal
+  | EditImageModal
+  | ServerInputModal
+  | RepostModal
+  | SelfLabelModal
+
+  // Bluesky access
+  | WaitlistModal
+  | InviteCodesModal
+
+  // Generic
+  | ConfirmModal
+  | LinkWarningModal
+
+const ModalContext = React.createContext<{
+  isModalActive: boolean
+  activeModals: Modal[]
+}>({
+  isModalActive: false,
+  activeModals: [],
+})
+
+const ModalControlContext = React.createContext<{
+  openModal: (modal: Modal) => void
+  closeModal: () => void
+}>({
+  openModal: () => {},
+  closeModal: () => {},
+})
+
+/**
+ * @deprecated DO NOT USE THIS unless you have no other choice.
+ */
+export let unstable__openModal: (modal: Modal) => void = () => {
+  throw new Error(`ModalContext is not initialized`)
+}
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const [isModalActive, setIsModalActive] = React.useState(false)
+  const [activeModals, setActiveModals] = React.useState<Modal[]>([])
+
+  const openModal = React.useCallback(
+    (modal: Modal) => {
+      DeviceEventEmitter.emit('navigation')
+      setActiveModals(activeModals => [...activeModals, modal])
+      setIsModalActive(true)
+    },
+    [setIsModalActive, setActiveModals],
+  )
+
+  unstable__openModal = openModal
+
+  const closeModal = React.useCallback(() => {
+    let totalActiveModals = 0
+    setActiveModals(activeModals => {
+      activeModals.pop()
+      totalActiveModals = activeModals.length
+      return activeModals
+    })
+    setIsModalActive(totalActiveModals > 0)
+  }, [setIsModalActive, setActiveModals])
+
+  const state = React.useMemo(
+    () => ({
+      isModalActive,
+      activeModals,
+    }),
+    [isModalActive, activeModals],
+  )
+
+  const methods = React.useMemo(
+    () => ({
+      openModal,
+      closeModal,
+    }),
+    [openModal, closeModal],
+  )
+
+  return (
+    <ModalContext.Provider value={state}>
+      <ModalControlContext.Provider value={methods}>
+        {children}
+      </ModalControlContext.Provider>
+    </ModalContext.Provider>
+  )
+}
+
+export function useModals() {
+  return React.useContext(ModalContext)
+}
+
+export function useModalControls() {
+  return React.useContext(ModalControlContext)
+}
diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts
index 1b22fadb..f9c3efca 100644
--- a/src/state/models/media/gallery.ts
+++ b/src/state/models/media/gallery.ts
@@ -4,7 +4,6 @@ import {ImageModel} from './image'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {openPicker} from 'lib/media/picker'
 import {getImageDim} from 'lib/media/manip'
-import {isNative} from 'platform/detection'
 
 export class GalleryModel {
   images: ImageModel[] = []
@@ -42,18 +41,6 @@ export class GalleryModel {
     }
   }
 
-  async edit(image: ImageModel) {
-    if (isNative) {
-      this.crop(image)
-    } else {
-      this.rootStore.shell.openModal({
-        name: 'edit-image',
-        image,
-        gallery: this,
-      })
-    }
-  }
-
   async paste(uri: string) {
     if (this.size >= 4) {
       return
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index b5fa4e59..8ef322db 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -1,16 +1,12 @@
-import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api'
+import {AppBskyEmbedRecord} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {makeAutoObservable, runInAction} from 'mobx'
 import {ProfileModel} from '../content/profile'
-import {Image as RNImage} from 'react-native-image-crop-picker'
-import {ImageModel} from '../media/image'
-import {ListModel} from '../content/list'
-import {GalleryModel} from '../media/gallery'
-import {StyleProp, ViewStyle} from 'react-native'
 import {
   shouldRequestEmailConfirmation,
   setEmailConfirmationRequested,
 } from '#/state/shell/reminders'
+import {unstable__openModal} from '#/state/modals'
 
 export type ColorMode = 'system' | 'light' | 'dark'
 
@@ -18,200 +14,6 @@ export function isColorMode(v: unknown): v is ColorMode {
   return v === 'system' || v === 'light' || v === 'dark'
 }
 
-export interface ConfirmModal {
-  name: 'confirm'
-  title: string
-  message: string | (() => JSX.Element)
-  onPressConfirm: () => void | Promise<void>
-  onPressCancel?: () => void | Promise<void>
-  confirmBtnText?: string
-  confirmBtnStyle?: StyleProp<ViewStyle>
-  cancelBtnText?: string
-}
-
-export interface EditProfileModal {
-  name: 'edit-profile'
-  profileView: ProfileModel
-  onUpdate?: () => void
-}
-
-export interface ProfilePreviewModal {
-  name: 'profile-preview'
-  did: string
-}
-
-export interface ServerInputModal {
-  name: 'server-input'
-  initialService: string
-  onSelect: (url: string) => void
-}
-
-export interface ModerationDetailsModal {
-  name: 'moderation-details'
-  context: 'account' | 'content'
-  moderation: ModerationUI
-}
-
-export type ReportModal = {
-  name: 'report'
-} & (
-  | {
-      uri: string
-      cid: string
-    }
-  | {did: string}
-)
-
-export interface CreateOrEditListModal {
-  name: 'create-or-edit-list'
-  purpose?: string
-  list?: ListModel
-  onSave?: (uri: string) => void
-}
-
-export interface UserAddRemoveListsModal {
-  name: 'user-add-remove-lists'
-  subject: string
-  displayName: string
-  onAdd?: (listUri: string) => void
-  onRemove?: (listUri: string) => void
-}
-
-export interface ListAddUserModal {
-  name: 'list-add-user'
-  list: ListModel
-  onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void
-}
-
-export interface EditImageModal {
-  name: 'edit-image'
-  image: ImageModel
-  gallery: GalleryModel
-}
-
-export interface CropImageModal {
-  name: 'crop-image'
-  uri: string
-  onSelect: (img?: RNImage) => void
-}
-
-export interface AltTextImageModal {
-  name: 'alt-text-image'
-  image: ImageModel
-}
-
-export interface DeleteAccountModal {
-  name: 'delete-account'
-}
-
-export interface RepostModal {
-  name: 'repost'
-  onRepost: () => void
-  onQuote: () => void
-  isReposted: boolean
-}
-
-export interface SelfLabelModal {
-  name: 'self-label'
-  labels: string[]
-  hasMedia: boolean
-  onChange: (labels: string[]) => void
-}
-
-export interface ChangeHandleModal {
-  name: 'change-handle'
-  onChanged: () => void
-}
-
-export interface WaitlistModal {
-  name: 'waitlist'
-}
-
-export interface InviteCodesModal {
-  name: 'invite-codes'
-}
-
-export interface AddAppPasswordModal {
-  name: 'add-app-password'
-}
-
-export interface ContentFilteringSettingsModal {
-  name: 'content-filtering-settings'
-}
-
-export interface ContentLanguagesSettingsModal {
-  name: 'content-languages-settings'
-}
-
-export interface PostLanguagesSettingsModal {
-  name: 'post-languages-settings'
-}
-
-export interface BirthDateSettingsModal {
-  name: 'birth-date-settings'
-}
-
-export interface VerifyEmailModal {
-  name: 'verify-email'
-  showReminder?: boolean
-}
-
-export interface ChangeEmailModal {
-  name: 'change-email'
-}
-
-export interface SwitchAccountModal {
-  name: 'switch-account'
-}
-
-export interface LinkWarningModal {
-  name: 'link-warning'
-  text: string
-  href: string
-}
-
-export type Modal =
-  // Account
-  | AddAppPasswordModal
-  | ChangeHandleModal
-  | DeleteAccountModal
-  | EditProfileModal
-  | ProfilePreviewModal
-  | BirthDateSettingsModal
-  | VerifyEmailModal
-  | ChangeEmailModal
-  | SwitchAccountModal
-
-  // Curation
-  | ContentFilteringSettingsModal
-  | ContentLanguagesSettingsModal
-  | PostLanguagesSettingsModal
-
-  // Moderation
-  | ModerationDetailsModal
-  | ReportModal
-
-  // Lists
-  | CreateOrEditListModal
-  | UserAddRemoveListsModal
-  | ListAddUserModal
-
-  // Posts
-  | AltTextImageModal
-  | CropImageModal
-  | EditImageModal
-  | ServerInputModal
-  | RepostModal
-  | SelfLabelModal
-
-  // Bluesky access
-  | WaitlistModal
-  | InviteCodesModal
-
-  // Generic
-  | ConfirmModal
-  | LinkWarningModal
-
 interface LightboxModel {}
 
 export class ProfileImageLightbox implements LightboxModel {
@@ -267,8 +69,6 @@ export interface ComposerOpts {
 }
 
 export class ShellUiModel {
-  isModalActive = false
-  activeModals: Modal[] = []
   isLightboxActive = false
   activeLightbox: ProfileImageLightbox | ImagesLightbox | null = null
   isComposerActive = false
@@ -293,10 +93,6 @@ export class ShellUiModel {
       this.closeLightbox()
       return true
     }
-    if (this.isModalActive) {
-      this.closeModal()
-      return true
-    }
     if (this.isComposerActive) {
       this.closeComposer()
       return true
@@ -311,25 +107,11 @@ export class ShellUiModel {
     if (this.isLightboxActive) {
       this.closeLightbox()
     }
-    while (this.isModalActive) {
-      this.closeModal()
-    }
     if (this.isComposerActive) {
       this.closeComposer()
     }
   }
 
-  openModal(modal: Modal) {
-    this.rootStore.emitNavigation()
-    this.isModalActive = true
-    this.activeModals.push(modal)
-  }
-
-  closeModal() {
-    this.activeModals.pop()
-    this.isModalActive = this.activeModals.length > 0
-  }
-
   openLightbox(lightbox: ProfileImageLightbox | ImagesLightbox) {
     this.rootStore.emitNavigation()
     this.isLightboxActive = true
@@ -363,7 +145,7 @@ export class ShellUiModel {
   setupLoginModals() {
     this.rootStore.onSessionReady(() => {
       if (shouldRequestEmailConfirmation(this.rootStore.session)) {
-        this.openModal({name: 'verify-email', showReminder: true})
+        unstable__openModal({name: 'verify-email', showReminder: true})
         setEmailConfirmationRequested()
       }
     })
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx
index 60e19756..b2054150 100644
--- a/src/view/com/auth/create/Step2.tsx
+++ b/src/view/com/auth/create/Step2.tsx
@@ -10,8 +10,8 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {TextInput} from '../util/TextInput'
 import {Policies} from './Policies'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
-import {useStores} from 'state/index'
 import {isWeb} from 'platform/detection'
+import {useModalControls} from '#/state/modals'
 
 /** STEP 2: Your account
  * @field Invite code or waitlist
@@ -28,11 +28,11 @@ export const Step2 = observer(function Step2Impl({
   model: CreateAccountModel
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {openModal} = useModalControls()
 
   const onPressWaitlist = React.useCallback(() => {
-    store.shell.openModal({name: 'waitlist'})
-  }, [store])
+    openModal({name: 'waitlist'})
+  }, [openModal])
 
   return (
     <View>
diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx
index acc05b6c..24a657c6 100644
--- a/src/view/com/auth/login/Login.tsx
+++ b/src/view/com/auth/login/Login.tsx
@@ -31,6 +31,7 @@ import {useTheme} from 'lib/ThemeContext'
 import {cleanError} from 'lib/strings/errors'
 import {isWeb} from 'platform/detection'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
 
 enum Forms {
   Login,
@@ -303,9 +304,10 @@ const LoginForm = ({
   const [identifier, setIdentifier] = useState<string>(initialHandle)
   const [password, setPassword] = useState<string>('')
   const passwordInputRef = useRef<TextInput>(null)
+  const {openModal} = useModalControls()
 
   const onPressSelectService = () => {
-    store.shell.openModal({
+    openModal({
       name: 'server-input',
       initialService: serviceUrl,
       onSelect: setServiceUrl,
@@ -528,7 +530,6 @@ const LoginForm = ({
 }
 
 const ForgotPasswordForm = ({
-  store,
   error,
   serviceUrl,
   serviceDescription,
@@ -551,13 +552,14 @@ const ForgotPasswordForm = ({
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
   const [email, setEmail] = useState<string>('')
   const {screen} = useAnalytics()
+  const {openModal} = useModalControls()
 
   useEffect(() => {
     screen('Signin:ForgotPassword')
   }, [screen])
 
   const onPressSelectService = () => {
-    store.shell.openModal({
+    openModal({
       name: 'server-input',
       initialService: serviceUrl,
       onSelect: setServiceUrl,
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 632e72fd..68f70682 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -49,6 +49,7 @@ import {LabelsBtn} from './labels/LabelsBtn'
 import {SelectLangBtn} from './select-language/SelectLangBtn'
 import {EmojiPickerButton} from './text-input/web/EmojiPicker.web'
 import {insertMentionAt} from 'lib/strings/mention-manip'
+import {useModals, useModalControls} from '#/state/modals'
 import {useRequireAltTextEnabled} from '#/state/preferences'
 import {
   useLanguagePrefs,
@@ -64,6 +65,8 @@ export const ComposePost = observer(function ComposePost({
   quote: initQuote,
   mention: initMention,
 }: Props) {
+  const {activeModals} = useModals()
+  const {openModal, closeModal} = useModalControls()
   const {track} = useAnalytics()
   const pal = usePalette('default')
   const {isDesktop, isMobile} = useWebMediaQueries()
@@ -118,18 +121,18 @@ export const ComposePost = observer(function ComposePost({
 
   const onPressCancel = useCallback(() => {
     if (graphemeLength > 0 || !gallery.isEmpty) {
-      if (store.shell.activeModals.some(modal => modal.name === 'confirm')) {
-        store.shell.closeModal()
+      if (activeModals.some(modal => modal.name === 'confirm')) {
+        closeModal()
       }
       if (Keyboard) {
         Keyboard.dismiss()
       }
-      store.shell.openModal({
+      openModal({
         name: 'confirm',
         title: 'Discard draft',
         onPressConfirm: onClose,
         onPressCancel: () => {
-          store.shell.closeModal()
+          closeModal()
         },
         message: "Are you sure you'd like to discard this draft?",
         confirmBtnText: 'Discard',
@@ -138,7 +141,7 @@ export const ComposePost = observer(function ComposePost({
     } else {
       onClose()
     }
-  }, [store, onClose, graphemeLength, gallery])
+  }, [openModal, closeModal, activeModals, onClose, graphemeLength, gallery])
   // android back button
   useEffect(() => {
     if (!isAndroid) {
diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx
index 96908d47..4b6ad81c 100644
--- a/src/view/com/composer/labels/LabelsBtn.tsx
+++ b/src/view/com/composer/labels/LabelsBtn.tsx
@@ -3,11 +3,11 @@ import {Keyboard, StyleSheet} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {Button} from 'view/com/util/forms/Button'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {ShieldExclamation} from 'lib/icons'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
 import {isNative} from 'platform/detection'
+import {useModalControls} from '#/state/modals'
 
 export const LabelsBtn = observer(function LabelsBtn({
   labels,
@@ -19,7 +19,7 @@ export const LabelsBtn = observer(function LabelsBtn({
   onChange: (v: string[]) => void
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {openModal} = useModalControls()
 
   return (
     <Button
@@ -34,7 +34,7 @@ export const LabelsBtn = observer(function LabelsBtn({
             Keyboard.dismiss()
           }
         }
-        store.shell.openModal({name: 'self-label', labels, hasMedia, onChange})
+        openModal({name: 'self-label', labels, hasMedia, onChange})
       }}>
       <ShieldExclamation style={pal.link} size={26} />
       {labels.length > 0 ? (
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index fcd99842..069a0547 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -7,11 +7,11 @@ import {s, colors} from 'lib/styles'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {Image} from 'expo-image'
 import {Text} from 'view/com/util/text/Text'
-import {openAltTextModal} from 'lib/media/alt-text'
 import {Dimensions} from 'lib/media/types'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useModalControls} from '#/state/modals'
+import {isNative} from 'platform/detection'
 
 const IMAGE_GAP = 8
 
@@ -47,9 +47,9 @@ const GalleryInner = observer(function GalleryImpl({
   gallery,
   containerInfo,
 }: GalleryInnerProps) {
-  const store = useStores()
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
+  const {openModal} = useModalControls()
 
   let side: number
 
@@ -117,7 +117,10 @@ const GalleryInner = observer(function GalleryImpl({
               accessibilityHint=""
               onPress={() => {
                 Keyboard.dismiss()
-                openAltTextModal(store, image)
+                openModal({
+                  name: 'alt-text-image',
+                  image,
+                })
               }}
               style={[styles.altTextControl, altTextControlStyle]}>
               <Text style={styles.altTextControlLabel} accessible={false}>
@@ -137,7 +140,17 @@ const GalleryInner = observer(function GalleryImpl({
                 accessibilityRole="button"
                 accessibilityLabel="Edit image"
                 accessibilityHint=""
-                onPress={() => gallery.edit(image)}
+                onPress={() => {
+                  if (isNative) {
+                    gallery.crop(image)
+                  } else {
+                    openModal({
+                      name: 'edit-image',
+                      image,
+                      gallery,
+                    })
+                  }
+                }}
                 style={styles.imageControl}>
                 <FontAwesomeIcon
                   icon="pen"
@@ -165,7 +178,10 @@ const GalleryInner = observer(function GalleryImpl({
               accessibilityHint=""
               onPress={() => {
                 Keyboard.dismiss()
-                openAltTextModal(store, image)
+                openModal({
+                  name: 'alt-text-image',
+                  image,
+                })
               }}
               style={styles.altTextHiddenRegion}
             />
diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx
index 64654238..6c45f338 100644
--- a/src/view/com/composer/select-language/SelectLangBtn.tsx
+++ b/src/view/com/composer/select-language/SelectLangBtn.tsx
@@ -12,9 +12,9 @@ import {
   DropdownItemButton,
 } from 'view/com/util/forms/DropdownButton'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {isNative} from 'platform/detection'
 import {codeToLanguageName} from '../../../../locale/helpers'
+import {useModalControls} from '#/state/modals'
 import {
   useLanguagePrefs,
   useSetLanguagePrefs,
@@ -24,7 +24,7 @@ import {
 
 export const SelectLangBtn = observer(function SelectLangBtn() {
   const pal = usePalette('default')
-  const store = useStores()
+  const {openModal} = useModalControls()
   const langPrefs = useLanguagePrefs()
   const setLangPrefs = useSetLanguagePrefs()
 
@@ -34,8 +34,8 @@ export const SelectLangBtn = observer(function SelectLangBtn() {
         Keyboard.dismiss()
       }
     }
-    store.shell.openModal({name: 'post-languages-settings'})
-  }, [store])
+    openModal({name: 'post-languages-settings'})
+  }, [openModal])
 
   const postLanguagesPref = toPostLanguages(langPrefs.postLanguage)
   const items: DropdownItem[] = useMemo(() => {
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index 2c4335dc..63af5261 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -10,12 +10,12 @@ import {observer} from 'mobx-react-lite'
 import {FeedSourceModel} from 'state/models/content/feed-source'
 import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
-import {useStores} from 'state/index'
 import {pluralize} from 'lib/strings/helpers'
 import {AtUri} from '@atproto/api'
 import * as Toast from 'view/com/util/Toast'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
 
 export const FeedSourceCard = observer(function FeedSourceCardImpl({
   item,
@@ -30,13 +30,13 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({
   showDescription?: boolean
   showLikes?: boolean
 }) {
-  const store = useStores()
   const pal = usePalette('default')
   const navigation = useNavigation<NavigationProp>()
+  const {openModal} = useModalControls()
 
   const onToggleSaved = React.useCallback(async () => {
     if (item.isSaved) {
-      store.shell.openModal({
+      openModal({
         name: 'confirm',
         title: 'Remove from my feeds',
         message: `Remove ${item.displayName} from my feeds?`,
@@ -59,7 +59,7 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({
         logger.error('Failed to save feed', {error: e})
       }
     }
-  }, [store, item])
+  }, [openModal, item])
 
   return (
     <Pressable
diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx
index 192cdd9d..3658e552 100644
--- a/src/view/com/lists/ListItems.tsx
+++ b/src/view/com/lists/ListItems.tsx
@@ -17,11 +17,11 @@ import {Button} from '../util/forms/Button'
 import {ListModel} from 'state/models/content/list'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {s} from 'lib/styles'
 import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
 
 const LOADING_ITEM = {_reactKey: '__loading__'}
 const EMPTY_ITEM = {_reactKey: '__empty__'}
@@ -54,10 +54,10 @@ export const ListItems = observer(function ListItemsImpl({
   desktopFixedHeightOffset?: number
 }) {
   const pal = usePalette('default')
-  const store = useStores()
   const {track} = useAnalytics()
   const [isRefreshing, setIsRefreshing] = React.useState(false)
   const {isMobile} = useWebMediaQueries()
+  const {openModal} = useModalControls()
 
   const data = React.useMemo(() => {
     let items: any[] = []
@@ -115,7 +115,7 @@ export const ListItems = observer(function ListItemsImpl({
 
   const onPressEditMembership = React.useCallback(
     (profile: AppBskyActorDefs.ProfileViewBasic) => {
-      store.shell.openModal({
+      openModal({
         name: 'user-add-remove-lists',
         subject: profile.did,
         displayName: profile.displayName || profile.handle,
@@ -131,7 +131,7 @@ export const ListItems = observer(function ListItemsImpl({
         },
       })
     },
-    [store, list],
+    [openModal, list],
   )
 
   // rendering
diff --git a/src/view/com/modals/AddAppPasswords.tsx b/src/view/com/modals/AddAppPasswords.tsx
index 29763620..621c61b9 100644
--- a/src/view/com/modals/AddAppPasswords.tsx
+++ b/src/view/com/modals/AddAppPasswords.tsx
@@ -13,6 +13,7 @@ import {
 import Clipboard from '@react-native-clipboard/clipboard'
 import * as Toast from '../util/Toast'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['70%']
 
@@ -54,6 +55,7 @@ const shadesOfBlue: string[] = [
 export function Component({}: {}) {
   const pal = usePalette('default')
   const store = useStores()
+  const {closeModal} = useModalControls()
   const [name, setName] = useState(
     shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)],
   )
@@ -69,8 +71,8 @@ export function Component({}: {}) {
   }, [appPassword])
 
   const onDone = React.useCallback(() => {
-    store.shell.closeModal()
-  }, [store])
+    closeModal()
+  }, [closeModal])
 
   const createAppPassword = async () => {
     // if name is all whitespace, we don't allow it
diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx
index c084e84a..9c377a12 100644
--- a/src/view/com/modals/AltImage.tsx
+++ b/src/view/com/modals/AltImage.tsx
@@ -17,9 +17,9 @@ import {MAX_ALT_TEXT} from 'lib/constants'
 import {useTheme} from 'lib/ThemeContext'
 import {Text} from '../util/text/Text'
 import LinearGradient from 'react-native-linear-gradient'
-import {useStores} from 'state/index'
 import {isAndroid, isWeb} from 'platform/detection'
 import {ImageModel} from 'state/models/media/image'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['fullscreen']
 
@@ -29,10 +29,10 @@ interface Props {
 
 export function Component({image}: Props) {
   const pal = usePalette('default')
-  const store = useStores()
   const theme = useTheme()
   const [altText, setAltText] = useState(image.altText)
   const windim = useWindowDimensions()
+  const {closeModal} = useModalControls()
 
   const imageStyles = useMemo<ImageStyle>(() => {
     const maxWidth = isWeb ? 450 : windim.width
@@ -53,11 +53,11 @@ export function Component({image}: Props) {
 
   const onPressSave = useCallback(() => {
     image.setAltText(altText)
-    store.shell.closeModal()
-  }, [store, image, altText])
+    closeModal()
+  }, [closeModal, image, altText])
 
   const onPressCancel = () => {
-    store.shell.closeModal()
+    closeModal()
   }
 
   return (
diff --git a/src/view/com/modals/BirthDateSettings.tsx b/src/view/com/modals/BirthDateSettings.tsx
index 6927ba8d..7b0778f8 100644
--- a/src/view/com/modals/BirthDateSettings.tsx
+++ b/src/view/com/modals/BirthDateSettings.tsx
@@ -15,12 +15,14 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {cleanError} from 'lib/strings/errors'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['50%']
 
 export const Component = observer(function Component({}: {}) {
   const pal = usePalette('default')
   const store = useStores()
+  const {closeModal} = useModalControls()
   const [date, setDate] = useState<Date>(
     store.preferences.birthDate || new Date(),
   )
@@ -33,7 +35,7 @@ export const Component = observer(function Component({}: {}) {
     setIsProcessing(true)
     try {
       await store.preferences.setBirthDate(date)
-      store.shell.closeModal()
+      closeModal()
     } catch (e) {
       setError(cleanError(String(e)))
     } finally {
diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx
index 01257055..ec37aeed 100644
--- a/src/view/com/modals/ChangeEmail.tsx
+++ b/src/view/com/modals/ChangeEmail.tsx
@@ -12,6 +12,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {cleanError} from 'lib/strings/errors'
+import {useModalControls} from '#/state/modals'
 
 enum Stages {
   InputEmail,
@@ -32,6 +33,7 @@ export const Component = observer(function Component({}: {}) {
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
   const [error, setError] = useState<string>('')
   const {isMobile} = useWebMediaQueries()
+  const {openModal, closeModal} = useModalControls()
 
   const onRequestChange = async () => {
     if (email === store.session.currentSession?.email) {
@@ -90,8 +92,8 @@ export const Component = observer(function Component({}: {}) {
   }
 
   const onVerify = async () => {
-    store.shell.closeModal()
-    store.shell.openModal({name: 'verify-email'})
+    closeModal()
+    openModal({name: 'verify-email'})
   }
 
   return (
@@ -207,7 +209,7 @@ export const Component = observer(function Component({}: {}) {
               <Button
                 testID="cancelBtn"
                 type="default"
-                onPress={() => store.shell.closeModal()}
+                onPress={() => closeModal()}
                 accessibilityLabel="Cancel"
                 accessibilityHint=""
                 label="Cancel"
diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx
index c54c1c04..6184cb3b 100644
--- a/src/view/com/modals/ChangeHandle.tsx
+++ b/src/view/com/modals/ChangeHandle.tsx
@@ -22,6 +22,7 @@ import {useTheme} from 'lib/ThemeContext'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {cleanError} from 'lib/strings/errors'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['100%']
 
@@ -30,6 +31,7 @@ export function Component({onChanged}: {onChanged: () => void}) {
   const [error, setError] = useState<string>('')
   const pal = usePalette('default')
   const {track} = useAnalytics()
+  const {closeModal} = useModalControls()
 
   const [isProcessing, setProcessing] = useState<boolean>(false)
   const [retryDescribeTrigger, setRetryDescribeTrigger] = React.useState<any>(
@@ -85,8 +87,8 @@ export function Component({onChanged}: {onChanged: () => void}) {
   // events
   // =
   const onPressCancel = React.useCallback(() => {
-    store.shell.closeModal()
-  }, [store])
+    closeModal()
+  }, [closeModal])
   const onPressRetryConnect = React.useCallback(
     () => setRetryDescribeTrigger({}),
     [setRetryDescribeTrigger],
@@ -110,7 +112,7 @@ export function Component({onChanged}: {onChanged: () => void}) {
       await store.agent.updateHandle({
         handle: newHandle,
       })
-      store.shell.closeModal()
+      closeModal()
       onChanged()
     } catch (err: any) {
       setError(cleanError(err))
@@ -127,6 +129,7 @@ export function Component({onChanged}: {onChanged: () => void}) {
     isCustom,
     onChanged,
     track,
+    closeModal,
   ])
 
   // rendering
diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx
index c1324b1c..6b942057 100644
--- a/src/view/com/modals/Confirm.tsx
+++ b/src/view/com/modals/Confirm.tsx
@@ -6,13 +6,13 @@ import {
   View,
 } from 'react-native'
 import {Text} from '../util/text/Text'
-import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {cleanError} from 'lib/strings/errors'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
-import type {ConfirmModal} from 'state/models/ui/shell'
+import type {ConfirmModal} from '#/state/modals'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['50%']
 
@@ -26,7 +26,7 @@ export function Component({
   cancelBtnText,
 }: ConfirmModal) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
   const [error, setError] = useState<string>('')
   const onPress = async () => {
@@ -34,7 +34,7 @@ export function Component({
     setIsProcessing(true)
     try {
       await onPressConfirm()
-      store.shell.closeModal()
+      closeModal()
       return
     } catch (e: any) {
       setError(cleanError(e))
diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx
index 9075d027..0891a647 100644
--- a/src/view/com/modals/ContentFilteringSettings.tsx
+++ b/src/view/com/modals/ContentFilteringSettings.tsx
@@ -16,6 +16,7 @@ import {isIOS} from 'platform/detection'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import * as Toast from '../util/Toast'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['90%']
 
@@ -24,14 +25,15 @@ export const Component = observer(
     const store = useStores()
     const {isMobile} = useWebMediaQueries()
     const pal = usePalette('default')
+    const {closeModal} = useModalControls()
 
     React.useEffect(() => {
       store.preferences.sync()
     }, [store])
 
     const onPressDone = React.useCallback(() => {
-      store.shell.closeModal()
-    }, [store])
+      closeModal()
+    }, [closeModal])
 
     return (
       <View testID="contentFilteringModal" style={[pal.view, styles.container]}>
@@ -89,8 +91,9 @@ const AdultContentEnabledPref = observer(
   function AdultContentEnabledPrefImpl() {
     const store = useStores()
     const pal = usePalette('default')
+    const {openModal} = useModalControls()
 
-    const onSetAge = () => store.shell.openModal({name: 'birth-date-settings'})
+    const onSetAge = () => openModal({name: 'birth-date-settings'})
 
     const onToggleAdultContent = async () => {
       if (isIOS) {
diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx
index 1ea12695..cdad3777 100644
--- a/src/view/com/modals/CreateOrEditList.tsx
+++ b/src/view/com/modals/CreateOrEditList.tsx
@@ -24,6 +24,7 @@ import {useTheme} from 'lib/ThemeContext'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {cleanError, isNetworkError} from 'lib/strings/errors'
+import {useModalControls} from '#/state/modals'
 
 const MAX_NAME = 64 // todo
 const MAX_DESCRIPTION = 300 // todo
@@ -40,6 +41,7 @@ export function Component({
   list?: ListModel
 }) {
   const store = useStores()
+  const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
   const [error, setError] = useState<string>('')
   const pal = usePalette('default')
@@ -67,8 +69,8 @@ export function Component({
   const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
 
   const onPressCancel = useCallback(() => {
-    store.shell.closeModal()
-  }, [store])
+    closeModal()
+  }, [closeModal])
 
   const onSelectNewAvatar = useCallback(
     async (img: RNImage | null) => {
@@ -123,7 +125,7 @@ export function Component({
         Toast.show(`${purposeLabel} list created`)
         onSave?.(res.uri)
       }
-      store.shell.closeModal()
+      closeModal()
     } catch (e: any) {
       if (isNetworkError(e)) {
         setError(
@@ -141,6 +143,7 @@ export function Component({
     error,
     onSave,
     store,
+    closeModal,
     activePurpose,
     isCurateList,
     purposeLabel,
diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx
index 50a4cd60..9a8a8b4b 100644
--- a/src/view/com/modals/DeleteAccount.tsx
+++ b/src/view/com/modals/DeleteAccount.tsx
@@ -17,6 +17,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {cleanError} from 'lib/strings/errors'
 import {resetToTab} from '../../../Navigation'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['60%']
 
@@ -24,6 +25,7 @@ export function Component({}: {}) {
   const pal = usePalette('default')
   const theme = useTheme()
   const store = useStores()
+  const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
   const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false)
   const [confirmCode, setConfirmCode] = React.useState<string>('')
@@ -55,14 +57,14 @@ export function Component({}: {}) {
       Toast.show('Your account has been deleted')
       resetToTab('HomeTab')
       store.session.clear()
-      store.shell.closeModal()
+      closeModal()
     } catch (e: any) {
       setError(cleanError(e))
     }
     setIsProcessing(false)
   }
   const onCancel = () => {
-    store.shell.closeModal()
+    closeModal()
   }
   return (
     <View style={[styles.container, pal.view]}>
diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx
index dcb6668c..a2a458f4 100644
--- a/src/view/com/modals/EditImage.tsx
+++ b/src/view/com/modals/EditImage.tsx
@@ -6,7 +6,6 @@ import {gradients, s} from 'lib/styles'
 import {useTheme} from 'lib/ThemeContext'
 import {Text} from '../util/text/Text'
 import LinearGradient from 'react-native-linear-gradient'
-import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import ImageEditor, {Position} from 'react-avatar-editor'
 import {TextInput} from './util'
@@ -19,6 +18,7 @@ import {Slider} from '@miblanchard/react-native-slider'
 import {MaterialIcons} from '@expo/vector-icons'
 import {observer} from 'mobx-react-lite'
 import {getKeys} from 'lib/type-assertions'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['80%']
 
@@ -52,9 +52,9 @@ export const Component = observer(function EditImageImpl({
 }: Props) {
   const pal = usePalette('default')
   const theme = useTheme()
-  const store = useStores()
   const windowDimensions = useWindowDimensions()
   const {isMobile} = useWebMediaQueries()
+  const {closeModal} = useModalControls()
 
   const {
     aspectRatio,
@@ -128,8 +128,8 @@ export const Component = observer(function EditImageImpl({
   }, [image])
 
   const onCloseModal = useCallback(() => {
-    store.shell.closeModal()
-  }, [store.shell])
+    closeModal()
+  }, [closeModal])
 
   const onPressCancel = useCallback(async () => {
     await gallery.previous(image)
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
index dfd5305f..f08bb232 100644
--- a/src/view/com/modals/EditProfile.tsx
+++ b/src/view/com/modals/EditProfile.tsx
@@ -13,7 +13,6 @@ import LinearGradient from 'react-native-linear-gradient'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {Text} from '../util/text/Text'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {useStores} from 'state/index'
 import {ProfileModel} from 'state/models/content/profile'
 import {s, colors, gradients} from 'lib/styles'
 import {enforceLen} from 'lib/strings/helpers'
@@ -27,6 +26,7 @@ import {useAnalytics} from 'lib/analytics/analytics'
 import {cleanError, isNetworkError} from 'lib/strings/errors'
 import Animated, {FadeOut} from 'react-native-reanimated'
 import {isWeb} from 'platform/detection'
+import {useModalControls} from '#/state/modals'
 
 const AnimatedTouchableOpacity =
   Animated.createAnimatedComponent(TouchableOpacity)
@@ -40,11 +40,11 @@ export function Component({
   profileView: ProfileModel
   onUpdate?: () => void
 }) {
-  const store = useStores()
   const [error, setError] = useState<string>('')
   const pal = usePalette('default')
   const theme = useTheme()
   const {track} = useAnalytics()
+  const {closeModal} = useModalControls()
 
   const [isProcessing, setProcessing] = useState<boolean>(false)
   const [displayName, setDisplayName] = useState<string>(
@@ -66,7 +66,7 @@ export function Component({
     RNImage | undefined | null
   >()
   const onPressCancel = () => {
-    store.shell.closeModal()
+    closeModal()
   }
   const onSelectNewAvatar = useCallback(
     async (img: RNImage | null) => {
@@ -123,7 +123,7 @@ export function Component({
       )
       Toast.show('Profile updated')
       onUpdate?.()
-      store.shell.closeModal()
+      closeModal()
     } catch (e: any) {
       if (isNetworkError(e)) {
         setError(
@@ -141,7 +141,7 @@ export function Component({
     error,
     profileView,
     onUpdate,
-    store,
+    closeModal,
     displayName,
     description,
     newUserAvatar,
diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx
index a8aa164c..227b2527 100644
--- a/src/view/com/modals/InviteCodes.tsx
+++ b/src/view/com/modals/InviteCodes.tsx
@@ -15,6 +15,7 @@ import {ScrollView} from './util'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useModalControls} from '#/state/modals'
 import {useInvitesState, useInvitesAPI} from '#/state/invites'
 import {UserInfoText} from '../util/UserInfoText'
 import {makeProfileLink} from '#/lib/routes/links'
@@ -25,11 +26,12 @@ export const snapPoints = ['70%']
 export function Component({}: {}) {
   const pal = usePalette('default')
   const store = useStores()
+  const {closeModal} = useModalControls()
   const {isTabletOrDesktop} = useWebMediaQueries()
 
   const onClose = React.useCallback(() => {
-    store.shell.closeModal()
-  }, [store])
+    closeModal()
+  }, [closeModal])
 
   if (store.me.invites.length === 0) {
     return (
diff --git a/src/view/com/modals/LinkWarning.tsx b/src/view/com/modals/LinkWarning.tsx
index 67a156af..751c69b3 100644
--- a/src/view/com/modals/LinkWarning.tsx
+++ b/src/view/com/modals/LinkWarning.tsx
@@ -5,12 +5,12 @@ import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Text} from '../util/text/Text'
 import {Button} from '../util/forms/Button'
-import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {isPossiblyAUrl, splitApexDomain} from 'lib/strings/url-helpers'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['50%']
 
@@ -22,12 +22,12 @@ export const Component = observer(function Component({
   href: string
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
   const potentiallyMisleading = isPossiblyAUrl(text)
 
   const onPressVisit = () => {
-    store.shell.closeModal()
+    closeModal()
     Linking.openURL(href)
   }
 
@@ -83,7 +83,7 @@ export const Component = observer(function Component({
           <Button
             testID="cancelBtn"
             type="default"
-            onPress={() => store.shell.closeModal()}
+            onPress={() => closeModal()}
             accessibilityLabel="Cancel"
             accessibilityHint=""
             label="Cancel"
diff --git a/src/view/com/modals/ListAddUser.tsx b/src/view/com/modals/ListAddUser.tsx
index a04e2d18..8864ebc7 100644
--- a/src/view/com/modals/ListAddUser.tsx
+++ b/src/view/com/modals/ListAddUser.tsx
@@ -26,6 +26,7 @@ import {cleanError} from 'lib/strings/errors'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {HITSLOP_20} from '#/lib/constants'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['90%']
 
@@ -38,6 +39,7 @@ export const Component = observer(function Component({
 }) {
   const pal = usePalette('default')
   const store = useStores()
+  const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
   const [query, setQuery] = useState('')
   const autocompleteView = useMemo<UserAutocompleteModel>(
@@ -146,7 +148,7 @@ export const Component = observer(function Component({
           <Button
             testID="doneBtn"
             type="default"
-            onPress={() => store.shell.closeModal()}
+            onPress={() => closeModal()}
             accessibilityLabel="Done"
             accessibilityHint=""
             label="Done"
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 5aaa09e8..c1999c5d 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -3,13 +3,13 @@ import {StyleSheet} from 'react-native'
 import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'
 import {observer} from 'mobx-react-lite'
 import BottomSheet from '@gorhom/bottom-sheet'
-import {useStores} from 'state/index'
 import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
 import {usePalette} from 'lib/hooks/usePalette'
 import {timeout} from 'lib/async/timeout'
 import {navigate} from '../../../Navigation'
 import once from 'lodash.once'
 
+import {useModals, useModalControls} from '#/state/modals'
 import * as ConfirmModal from './Confirm'
 import * as EditProfileModal from './EditProfile'
 import * as ProfilePreviewModal from './ProfilePreview'
@@ -41,17 +41,17 @@ const DEFAULT_SNAPPOINTS = ['90%']
 const HANDLE_HEIGHT = 24
 
 export const ModalsContainer = observer(function ModalsContainer() {
-  const store = useStores()
+  const {isModalActive, activeModals} = useModals()
+  const {closeModal} = useModalControls()
   const bottomSheetRef = useRef<BottomSheet>(null)
   const pal = usePalette('default')
   const safeAreaInsets = useSafeAreaInsets()
 
-  const activeModal =
-    store.shell.activeModals[store.shell.activeModals.length - 1]
+  const activeModal = activeModals[activeModals.length - 1]
 
   const navigateOnce = once(navigate)
 
-  const onBottomSheetAnimate = (fromIndex: number, toIndex: number) => {
+  const onBottomSheetAnimate = (_fromIndex: number, toIndex: number) => {
     if (activeModal?.name === 'profile-preview' && toIndex === 1) {
       // begin loading the profile screen behind the scenes
       navigateOnce('Profile', {name: activeModal.did})
@@ -59,7 +59,7 @@ export const ModalsContainer = observer(function ModalsContainer() {
   }
   const onBottomSheetChange = async (snapPoint: number) => {
     if (snapPoint === -1) {
-      store.shell.closeModal()
+      closeModal()
     } else if (activeModal?.name === 'profile-preview' && snapPoint === 1) {
       await navigateOnce('Profile', {name: activeModal.did})
       // There is no particular callback for when the view has actually been presented.
@@ -67,21 +67,21 @@ export const ModalsContainer = observer(function ModalsContainer() {
       // It's acceptable because the data is already being fetched + it usually takes longer anyway.
       // TODO: Figure out why avatar/cover don't always show instantly from cache.
       await timeout(200)
-      store.shell.closeModal()
+      closeModal()
     }
   }
   const onClose = () => {
     bottomSheetRef.current?.close()
-    store.shell.closeModal()
+    closeModal()
   }
 
   useEffect(() => {
-    if (store.shell.isModalActive) {
+    if (isModalActive) {
       bottomSheetRef.current?.expand()
     } else {
       bottomSheetRef.current?.close()
     }
-  }, [store.shell.isModalActive, bottomSheetRef, activeModal?.name])
+  }, [isModalActive, bottomSheetRef, activeModal?.name])
 
   let needsSafeTopInset = false
   let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
@@ -184,12 +184,12 @@ export const ModalsContainer = observer(function ModalsContainer() {
       snapPoints={snapPoints}
       topInset={topInset}
       handleHeight={HANDLE_HEIGHT}
-      index={store.shell.isModalActive ? 0 : -1}
+      index={isModalActive ? 0 : -1}
       enablePanDownToClose
       android_keyboardInputMode="adjustResize"
       keyboardBlurBehavior="restore"
       backdropComponent={
-        store.shell.isModalActive ? createCustomBackdrop(onClose) : undefined
+        isModalActive ? createCustomBackdrop(onClose) : undefined
       }
       handleIndicatorStyle={{backgroundColor: pal.text.color}}
       handleStyle={[styles.handle, pal.view]}
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index ede84537..65c4ee44 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -1,11 +1,11 @@
 import React from 'react'
 import {TouchableWithoutFeedback, StyleSheet, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import type {Modal as ModalIface} from 'state/models/ui/shell'
+import type {Modal as ModalIface} from '#/state/modals'
 
+import {useModals, useModalControls} from '#/state/modals'
 import * as ConfirmModal from './Confirm'
 import * as EditProfileModal from './EditProfile'
 import * as ProfilePreviewModal from './ProfilePreview'
@@ -34,15 +34,15 @@ import * as ChangeEmailModal from './ChangeEmail'
 import * as LinkWarningModal from './LinkWarning'
 
 export const ModalsContainer = observer(function ModalsContainer() {
-  const store = useStores()
+  const {isModalActive, activeModals} = useModals()
 
-  if (!store.shell.isModalActive) {
+  if (!isModalActive) {
     return null
   }
 
   return (
     <>
-      {store.shell.activeModals.map((modal, i) => (
+      {activeModals.map((modal, i) => (
         <Modal key={`modal-${i}`} modal={modal} />
       ))}
     </>
@@ -50,11 +50,12 @@ export const ModalsContainer = observer(function ModalsContainer() {
 })
 
 function Modal({modal}: {modal: ModalIface}) {
-  const store = useStores()
+  const {isModalActive} = useModals()
+  const {closeModal} = useModalControls()
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
 
-  if (!store.shell.isModalActive) {
+  if (!isModalActive) {
     return null
   }
 
@@ -62,7 +63,7 @@ function Modal({modal}: {modal: ModalIface}) {
     if (modal.name === 'crop-image' || modal.name === 'edit-image') {
       return // dont close on mask presses during crop
     }
-    store.shell.closeModal()
+    closeModal()
   }
   const onInnerPress = () => {
     // TODO: can we use prevent default?
diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx
index c01312d6..35ddfe2a 100644
--- a/src/view/com/modals/ModerationDetails.tsx
+++ b/src/view/com/modals/ModerationDetails.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {ModerationUI} from '@atproto/api'
-import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {s} from 'lib/styles'
 import {Text} from '../util/text/Text'
@@ -10,6 +9,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
 import {listUriToHref} from 'lib/strings/url-helpers'
 import {Button} from '../util/forms/Button'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = [300]
 
@@ -20,7 +20,7 @@ export function Component({
   context: 'account' | 'content'
   moderation: ModerationUI
 }) {
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
   const pal = usePalette('default')
 
@@ -99,10 +99,7 @@ export function Component({
         {description}
       </Text>
       <View style={s.flex1} />
-      <Button
-        type="primary"
-        style={styles.btn}
-        onPress={() => store.shell.closeModal()}>
+      <Button type="primary" style={styles.btn} onPress={() => closeModal()}>
         <Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}>
           Okay
         </Text>
diff --git a/src/view/com/modals/Repost.tsx b/src/view/com/modals/Repost.tsx
index b1862ecb..13728b62 100644
--- a/src/view/com/modals/Repost.tsx
+++ b/src/view/com/modals/Repost.tsx
@@ -1,12 +1,12 @@
 import React from 'react'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
-import {useStores} from 'state/index'
 import {s, colors, gradients} from 'lib/styles'
 import {Text} from '../util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {RepostIcon} from 'lib/icons'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = [250]
 
@@ -20,10 +20,10 @@ export function Component({
   isReposted: boolean
   // TODO: Add author into component
 }) {
-  const store = useStores()
   const pal = usePalette('default')
+  const {closeModal} = useModalControls()
   const onPress = async () => {
-    store.shell.closeModal()
+    closeModal()
   }
 
   return (
diff --git a/src/view/com/modals/SelfLabel.tsx b/src/view/com/modals/SelfLabel.tsx
index 820f2895..242b6a38 100644
--- a/src/view/com/modals/SelfLabel.tsx
+++ b/src/view/com/modals/SelfLabel.tsx
@@ -2,7 +2,6 @@ import React, {useState} from 'react'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {Text} from '../util/text/Text'
-import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@@ -10,6 +9,7 @@ import {isWeb} from 'platform/detection'
 import {Button} from '../util/forms/Button'
 import {SelectableBtn} from '../util/forms/SelectableBtn'
 import {ScrollView} from 'view/com/modals/util'
+import {useModalControls} from '#/state/modals'
 
 const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn']
 
@@ -25,7 +25,7 @@ export const Component = observer(function Component({
   onChange: (labels: string[]) => void
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
   const [selected, setSelected] = useState(labels)
 
@@ -143,7 +143,7 @@ export const Component = observer(function Component({
         <TouchableOpacity
           testID="confirmBtn"
           onPress={() => {
-            store.shell.closeModal()
+            closeModal()
           }}
           style={styles.btn}
           accessibilityRole="button"
diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx
index 13b21fe2..0f8db30b 100644
--- a/src/view/com/modals/ServerInput.tsx
+++ b/src/view/com/modals/ServerInput.tsx
@@ -6,26 +6,26 @@ import {
 } from '@fortawesome/react-native-fontawesome'
 import {ScrollView, TextInput} from './util'
 import {Text} from '../util/text/Text'
-import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
 import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index'
 import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['80%']
 
 export function Component({onSelect}: {onSelect: (url: string) => void}) {
   const theme = useTheme()
   const pal = usePalette('default')
-  const store = useStores()
   const [customUrl, setCustomUrl] = useState<string>('')
+  const {closeModal} = useModalControls()
 
   const doSelect = (url: string) => {
     if (!url.startsWith('http://') && !url.startsWith('https://')) {
       url = `https://${url}`
     }
-    store.shell.closeModal()
+    closeModal()
     onSelect(url)
   }
 
diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx
index aeec2e87..f86e8843 100644
--- a/src/view/com/modals/UserAddRemoveLists.tsx
+++ b/src/view/com/modals/UserAddRemoveLists.tsx
@@ -21,6 +21,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb, isAndroid} from 'platform/detection'
 import isEqual from 'lodash.isequal'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['fullscreen']
 
@@ -36,6 +37,7 @@ export const Component = observer(function UserAddRemoveListsImpl({
   onRemove?: (listUri: string) => void
 }) {
   const store = useStores()
+  const {closeModal} = useModalControls()
   const pal = usePalette('default')
   const palPrimary = usePalette('primary')
   const palInverted = usePalette('inverted')
@@ -69,8 +71,8 @@ export const Component = observer(function UserAddRemoveListsImpl({
   }, [memberships, listsList, store, setSelected, setMembershipsLoaded])
 
   const onPressCancel = useCallback(() => {
-    store.shell.closeModal()
-  }, [store])
+    closeModal()
+  }, [closeModal])
 
   const onPressSave = useCallback(async () => {
     let changes
@@ -87,8 +89,8 @@ export const Component = observer(function UserAddRemoveListsImpl({
     for (const uri of changes.removed) {
       onRemove?.(uri)
     }
-    store.shell.closeModal()
-  }, [store, selected, memberships, onAdd, onRemove])
+    closeModal()
+  }, [closeModal, selected, memberships, onAdd, onRemove])
 
   const onToggleSelected = useCallback(
     (uri: string) => {
diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx
index 9fe8811b..3adaffb1 100644
--- a/src/view/com/modals/VerifyEmail.tsx
+++ b/src/view/com/modals/VerifyEmail.tsx
@@ -20,6 +20,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {cleanError} from 'lib/strings/errors'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['90%']
 
@@ -43,6 +44,7 @@ export const Component = observer(function Component({
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
   const [error, setError] = useState<string>('')
   const {isMobile} = useWebMediaQueries()
+  const {openModal, closeModal} = useModalControls()
 
   const onSendEmail = async () => {
     setError('')
@@ -67,7 +69,7 @@ export const Component = observer(function Component({
       })
       store.session.updateLocalAccountData({emailConfirmed: true})
       Toast.show('Email verified')
-      store.shell.closeModal()
+      closeModal()
     } catch (e) {
       setError(cleanError(String(e)))
     } finally {
@@ -76,8 +78,8 @@ export const Component = observer(function Component({
   }
 
   const onEmailIncorrect = () => {
-    store.shell.closeModal()
-    store.shell.openModal({name: 'change-email'})
+    closeModal()
+    openModal({name: 'change-email'})
   }
 
   return (
@@ -224,7 +226,7 @@ export const Component = observer(function Component({
               <Button
                 testID="cancelBtn"
                 type="default"
-                onPress={() => store.shell.closeModal()}
+                onPress={() => closeModal()}
                 accessibilityLabel={
                   stage === Stages.Reminder ? 'Not right now' : 'Cancel'
                 }
diff --git a/src/view/com/modals/Waitlist.tsx b/src/view/com/modals/Waitlist.tsx
index 0fb371fe..219bdc58 100644
--- a/src/view/com/modals/Waitlist.tsx
+++ b/src/view/com/modals/Waitlist.tsx
@@ -12,19 +12,19 @@ import {
 } from '@fortawesome/react-native-fontawesome'
 import LinearGradient from 'react-native-linear-gradient'
 import {Text} from '../util/text/Text'
-import {useStores} from 'state/index'
 import {s, gradients} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {cleanError} from 'lib/strings/errors'
+import {useModalControls} from '#/state/modals'
 
 export const snapPoints = ['80%']
 
 export function Component({}: {}) {
   const pal = usePalette('default')
   const theme = useTheme()
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const [email, setEmail] = React.useState<string>('')
   const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false)
   const [isProcessing, setIsProcessing] = React.useState<boolean>(false)
@@ -54,7 +54,7 @@ export function Component({}: {}) {
     setIsProcessing(false)
   }
   const onCancel = () => {
-    store.shell.closeModal()
+    closeModal()
   }
 
   return (
diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx
index 8e35201d..c88d002a 100644
--- a/src/view/com/modals/crop-image/CropImage.web.tsx
+++ b/src/view/com/modals/crop-image/CropImage.web.tsx
@@ -7,10 +7,10 @@ import {Text} from 'view/com/util/text/Text'
 import {Dimensions} from 'lib/media/types'
 import {getDataUriSize} from 'lib/media/util'
 import {s, gradients} from 'lib/styles'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons'
 import {Image as RNImage} from 'react-native-image-crop-picker'
+import {useModalControls} from '#/state/modals'
 
 enum AspectRatio {
   Square = 'square',
@@ -33,7 +33,7 @@ export function Component({
   uri: string
   onSelect: (img?: RNImage) => void
 }) {
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const pal = usePalette('default')
   const [as, setAs] = React.useState<AspectRatio>(AspectRatio.Square)
   const [scale, setScale] = React.useState<number>(1)
@@ -43,7 +43,7 @@ export function Component({
 
   const onPressCancel = () => {
     onSelect(undefined)
-    store.shell.closeModal()
+    closeModal()
   }
   const onPressDone = () => {
     const canvas = editorRef.current?.getImageScaledToCanvas()
@@ -59,7 +59,7 @@ export function Component({
     } else {
       onSelect(undefined)
     }
-    store.shell.closeModal()
+    closeModal()
   }
 
   let cropperStyle
diff --git a/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx b/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx
index 65924561..d37d51e4 100644
--- a/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx
+++ b/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {ScrollView} from '../util'
-import {useStores} from 'state/index'
 import {Text} from '../../util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@@ -9,6 +8,7 @@ import {deviceLocales} from 'platform/detection'
 import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
 import {LanguageToggle} from './LanguageToggle'
 import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
+import {useModalControls} from '#/state/modals'
 import {
   useLanguagePrefs,
   useSetLanguagePrefs,
@@ -18,14 +18,14 @@ import {
 export const snapPoints = ['100%']
 
 export function Component({}: {}) {
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const langPrefs = useLanguagePrefs()
   const setLangPrefs = useSetLanguagePrefs()
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
   const onPressDone = React.useCallback(() => {
-    store.shell.closeModal()
-  }, [store])
+    closeModal()
+  }, [closeModal])
 
   const languages = React.useMemo(() => {
     const langs = LANGUAGES.filter(
diff --git a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
index 435fb9e1..4a39da75 100644
--- a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
+++ b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
@@ -2,7 +2,6 @@ import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {ScrollView} from '../util'
-import {useStores} from 'state/index'
 import {Text} from '../../util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@@ -10,6 +9,7 @@ import {deviceLocales} from 'platform/detection'
 import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
 import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
 import {ToggleButton} from 'view/com/util/forms/ToggleButton'
+import {useModalControls} from '#/state/modals'
 import {
   useLanguagePrefs,
   useSetLanguagePrefs,
@@ -20,14 +20,14 @@ import {
 export const snapPoints = ['100%']
 
 export const Component = observer(function PostLanguagesSettingsImpl() {
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const langPrefs = useLanguagePrefs()
   const setLangPrefs = useSetLanguagePrefs()
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
   const onPressDone = React.useCallback(() => {
-    store.shell.closeModal()
-  }, [store])
+    closeModal()
+  }, [closeModal])
 
   const languages = React.useMemo(() => {
     const langs = LANGUAGES.filter(
diff --git a/src/view/com/modals/report/Modal.tsx b/src/view/com/modals/report/Modal.tsx
index 98aa2d47..8dc3f53f 100644
--- a/src/view/com/modals/report/Modal.tsx
+++ b/src/view/com/modals/report/Modal.tsx
@@ -14,6 +14,7 @@ import {SendReportButton} from './SendReportButton'
 import {InputIssueDetails} from './InputIssueDetails'
 import {ReportReasonOptions} from './ReasonOptions'
 import {CollectionId} from './types'
+import {useModalControls} from '#/state/modals'
 
 const DMCA_LINK = 'https://blueskyweb.xyz/support/copyright'
 
@@ -37,6 +38,7 @@ type ReportComponentProps =
 
 export function Component(content: ReportComponentProps) {
   const store = useStores()
+  const {closeModal} = useModalControls()
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
   const [isProcessing, setIsProcessing] = useState(false)
@@ -60,7 +62,7 @@ export function Component(content: ReportComponentProps) {
     try {
       if (issue === '__copyright__') {
         Linking.openURL(DMCA_LINK)
-        store.shell.closeModal()
+        closeModal()
         return
       }
       const $type = !isAccountReport
@@ -76,7 +78,7 @@ export function Component(content: ReportComponentProps) {
       })
       Toast.show("Thank you for your report! We'll look into it promptly.")
 
-      store.shell.closeModal()
+      closeModal()
       return
     } catch (e: any) {
       setError(cleanError(e))
diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx
index 9e75d950..84e438fc 100644
--- a/src/view/com/posts/FeedErrorMessage.tsx
+++ b/src/view/com/posts/FeedErrorMessage.tsx
@@ -11,6 +11,7 @@ import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
 import {useStores} from 'state/index'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
 
 const MESSAGES = {
   [KnownError.Unknown]: '',
@@ -57,13 +58,14 @@ function FeedgenErrorMessage({
   const msg = MESSAGES[knownError]
   const uri = (feed.params as GetCustomFeed.QueryParams).feed
   const [ownerDid] = safeParseFeedgenUri(uri)
+  const {openModal, closeModal} = useModalControls()
 
   const onViewProfile = React.useCallback(() => {
     navigation.navigate('Profile', {name: ownerDid})
   }, [navigation, ownerDid])
 
   const onRemoveFeed = React.useCallback(async () => {
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
       title: 'Remove feed',
       message: 'Remove this feed from your saved feeds?',
@@ -78,10 +80,10 @@ function FeedgenErrorMessage({
         }
       },
       onPressCancel() {
-        store.shell.closeModal()
+        closeModal()
       },
     })
-  }, [store, uri])
+  }, [store, openModal, closeModal, uri])
 
   return (
     <View
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 1a1d38e4..1ee20978 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -40,6 +40,7 @@ import {makeProfileLink} from 'lib/routes/links'
 import {Link} from '../util/Link'
 import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
 
 interface Props {
   view: ProfileModel
@@ -113,6 +114,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
   const pal = usePalette('default')
   const palInverted = usePalette('inverted')
   const store = useStores()
+  const {openModal} = useModalControls()
   const navigation = useNavigation<NavigationProp>()
   const {track} = useAnalytics()
   const invalidHandle = isInvalidHandle(view.handle)
@@ -157,12 +159,12 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
 
   const onPressEditProfile = React.useCallback(() => {
     track('ProfileHeader:EditProfileButtonClicked')
-    store.shell.openModal({
+    openModal({
       name: 'edit-profile',
       profileView: view,
       onUpdate: onRefreshAll,
     })
-  }, [track, store, view, onRefreshAll])
+  }, [track, openModal, view, onRefreshAll])
 
   const trackPress = React.useCallback(
     (f: 'Followers' | 'Follows') => {
@@ -181,12 +183,12 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
 
   const onPressAddRemoveLists = React.useCallback(() => {
     track('ProfileHeader:AddToListsButtonClicked')
-    store.shell.openModal({
+    openModal({
       name: 'user-add-remove-lists',
       subject: view.did,
       displayName: view.displayName || view.handle,
     })
-  }, [track, view, store])
+  }, [track, view, openModal])
 
   const onPressMuteAccount = React.useCallback(async () => {
     track('ProfileHeader:MuteAccountButtonClicked')
@@ -212,7 +214,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
 
   const onPressBlockAccount = React.useCallback(async () => {
     track('ProfileHeader:BlockAccountButtonClicked')
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
       title: 'Block Account',
       message:
@@ -228,11 +230,11 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
         }
       },
     })
-  }, [track, view, store, onRefreshAll])
+  }, [track, view, openModal, onRefreshAll])
 
   const onPressUnblockAccount = React.useCallback(async () => {
     track('ProfileHeader:UnblockAccountButtonClicked')
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
       title: 'Unblock Account',
       message:
@@ -248,15 +250,15 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
         }
       },
     })
-  }, [track, view, store, onRefreshAll])
+  }, [track, view, openModal, onRefreshAll])
 
   const onPressReportAccount = React.useCallback(() => {
     track('ProfileHeader:ReportAccountButtonClicked')
-    store.shell.openModal({
+    openModal({
       name: 'report',
       did: view.did,
     })
-  }, [track, store, view])
+  }, [track, openModal, view])
 
   const isMe = React.useMemo(
     () => store.me.did === view.did,
diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx
index db9b6b4b..2f36609e 100644
--- a/src/view/com/testing/TestCtrls.e2e.tsx
+++ b/src/view/com/testing/TestCtrls.e2e.tsx
@@ -2,6 +2,7 @@ import React from 'react'
 import {Pressable, View} from 'react-native'
 import {useStores} from 'state/index'
 import {navigate} from '../../../Navigation'
+import {useModalControls} from '#/state/modals'
 
 /**
  * This utility component is only included in the test simulator
@@ -13,6 +14,7 @@ const BTN = {height: 1, width: 1, backgroundColor: 'red'}
 
 export function TestCtrls() {
   const store = useStores()
+  const {openModal} = useModalControls()
   const onPressSignInAlice = async () => {
     await store.session.login({
       service: 'http://localhost:3000',
@@ -85,7 +87,7 @@ export function TestCtrls() {
       />
       <Pressable
         testID="e2eOpenInviteCodesModal"
-        onPress={() => store.shell.openModal({name: 'invite-codes'})}
+        onPress={() => openModal({name: 'invite-codes'})}
         accessibilityRole="button"
         style={BTN}
       />
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 1777f665..074ab232 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -21,7 +21,6 @@ import {Text} from './text/Text'
 import {TypographyVariant} from 'lib/ThemeContext'
 import {NavigationProp} from 'lib/routes/types'
 import {router} from '../../../routes'
-import {useStores, RootStoreModel} from 'state/index'
 import {
   convertBskyAppUrlIfNeeded,
   isExternalUrl,
@@ -31,6 +30,7 @@ import {isAndroid, isWeb} from 'platform/detection'
 import {sanitizeUrl} from '@braintree/sanitize-url'
 import {PressableWithHover} from './PressableWithHover'
 import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
+import {useModalControls} from '#/state/modals'
 
 type Event =
   | React.MouseEvent<HTMLAnchorElement, MouseEvent>
@@ -60,17 +60,17 @@ export const Link = memo(function Link({
   anchorNoUnderline,
   ...props
 }: Props) {
-  const store = useStores()
+  const {closeModal} = useModalControls()
   const navigation = useNavigation<NavigationProp>()
   const anchorHref = asAnchor ? sanitizeUrl(href) : undefined
 
   const onPress = React.useCallback(
     (e?: Event) => {
       if (typeof href === 'string') {
-        return onPressInner(store, navigation, sanitizeUrl(href), e)
+        return onPressInner(closeModal, navigation, sanitizeUrl(href), e)
       }
     },
-    [store, navigation, href],
+    [closeModal, navigation, href],
   )
 
   if (noFeedback) {
@@ -160,8 +160,8 @@ export const TextLink = memo(function TextLink({
   warnOnMismatchingLabel?: boolean
 } & TextProps) {
   const {...props} = useLinkProps({to: sanitizeUrl(href)})
-  const store = useStores()
   const navigation = useNavigation<NavigationProp>()
+  const {openModal, closeModal} = useModalControls()
 
   if (warnOnMismatchingLabel && typeof text !== 'string') {
     console.error('Unable to detect mismatching label')
@@ -174,7 +174,7 @@ export const TextLink = memo(function TextLink({
         linkRequiresWarning(href, typeof text === 'string' ? text : '')
       if (requiresWarning) {
         e?.preventDefault?.()
-        store.shell.openModal({
+        openModal({
           name: 'link-warning',
           text: typeof text === 'string' ? text : '',
           href,
@@ -185,9 +185,17 @@ export const TextLink = memo(function TextLink({
         // @ts-ignore function signature differs by platform -prf
         return onPress()
       }
-      return onPressInner(store, navigation, sanitizeUrl(href), e)
+      return onPressInner(closeModal, navigation, sanitizeUrl(href), e)
     },
-    [onPress, store, navigation, href, text, warnOnMismatchingLabel],
+    [
+      onPress,
+      closeModal,
+      openModal,
+      navigation,
+      href,
+      text,
+      warnOnMismatchingLabel,
+    ],
   )
   const hrefAttrs = useMemo(() => {
     const isExternal = isExternalUrl(href)
@@ -285,7 +293,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
 // needed customizations
 // -prf
 function onPressInner(
-  store: RootStoreModel,
+  closeModal = () => {},
   navigation: NavigationProp,
   href: string,
   e?: Event,
@@ -318,7 +326,7 @@ function onPressInner(
     if (newTab || href.startsWith('http') || href.startsWith('mailto')) {
       Linking.openURL(href)
     } else {
-      store.shell.closeModal() // close any active modals
+      closeModal() // close any active modals
 
       // @ts-ignore we're not able to type check on this one -prf
       navigation.dispatch(StackActions.push(...router.matchPath(href)))
diff --git a/src/view/com/util/UserPreviewLink.tsx b/src/view/com/util/UserPreviewLink.tsx
index f43f9e80..9c5efe55 100644
--- a/src/view/com/util/UserPreviewLink.tsx
+++ b/src/view/com/util/UserPreviewLink.tsx
@@ -1,9 +1,9 @@
 import React from 'react'
 import {Pressable, StyleProp, ViewStyle} from 'react-native'
-import {useStores} from 'state/index'
 import {Link} from './Link'
 import {isWeb} from 'platform/detection'
 import {makeProfileLink} from 'lib/routes/links'
+import {useModalControls} from '#/state/modals'
 
 interface UserPreviewLinkProps {
   did: string
@@ -13,7 +13,7 @@ interface UserPreviewLinkProps {
 export function UserPreviewLink(
   props: React.PropsWithChildren<UserPreviewLinkProps>,
 ) {
-  const store = useStores()
+  const {openModal} = useModalControls()
 
   if (isWeb) {
     return (
@@ -29,7 +29,7 @@ export function UserPreviewLink(
   return (
     <Pressable
       onPress={() =>
-        store.shell.openModal({
+        openModal({
           name: 'profile-preview',
           did: props.did,
         })
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 1fffa312..45abed64 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -2,7 +2,6 @@ import React from 'react'
 import {StyleProp, View, ViewStyle} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {toShareUrl} from 'lib/strings/url-helpers'
-import {useStores} from 'state/index'
 import {useTheme} from 'lib/ThemeContext'
 import {shareUrl} from 'lib/sharing'
 import {
@@ -10,6 +9,7 @@ import {
   DropdownItem as NativeDropdownItem,
 } from './NativeDropdown'
 import {EventStopper} from '../EventStopper'
+import {useModalControls} from '#/state/modals'
 
 export function PostDropdownBtn({
   testID,
@@ -37,9 +37,9 @@ export function PostDropdownBtn({
   onDeletePost: () => void
   style?: StyleProp<ViewStyle>
 }) {
-  const store = useStores()
   const theme = useTheme()
   const defaultCtrlColor = theme.palette.default.postCtrl
+  const {openModal} = useModalControls()
 
   const dropdownItems: NativeDropdownItem[] = [
     {
@@ -108,7 +108,7 @@ export function PostDropdownBtn({
     !isAuthor && {
       label: 'Report post',
       onPress() {
-        store.shell.openModal({
+        openModal({
           name: 'report',
           uri: itemUri,
           cid: itemCid,
@@ -129,7 +129,7 @@ export function PostDropdownBtn({
     isAuthor && {
       label: 'Delete post',
       onPress() {
-        store.shell.openModal({
+        openModal({
           name: 'confirm',
           title: 'Delete this post?',
           message: 'Are you sure? This can not be undone.',
diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx
index 4f917844..b6fe0dd8 100644
--- a/src/view/com/util/moderation/ContentHider.tsx
+++ b/src/view/com/util/moderation/ContentHider.tsx
@@ -6,7 +6,7 @@ import {ModerationUI} from '@atproto/api'
 import {Text} from '../text/Text'
 import {ShieldExclamation} from 'lib/icons'
 import {describeModerationCause} from 'lib/moderation'
-import {useStores} from 'state/index'
+import {useModalControls} from '#/state/modals'
 
 export function ContentHider({
   testID,
@@ -22,10 +22,10 @@ export function ContentHider({
   style?: StyleProp<ViewStyle>
   childContainerStyle?: StyleProp<ViewStyle>
 }>) {
-  const store = useStores()
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
   const [override, setOverride] = React.useState(false)
+  const {openModal} = useModalControls()
 
   if (!moderation.blur || (ignoreMute && moderation.cause?.type === 'muted')) {
     return (
@@ -43,7 +43,7 @@ export function ContentHider({
           if (!moderation.noOverride) {
             setOverride(v => !v)
           } else {
-            store.shell.openModal({
+            openModal({
               name: 'moderation-details',
               context: 'content',
               moderation,
@@ -62,7 +62,7 @@ export function ContentHider({
         ]}>
         <Pressable
           onPress={() => {
-            store.shell.openModal({
+            openModal({
               name: 'moderation-details',
               context: 'content',
               moderation,
diff --git a/src/view/com/util/moderation/PostAlerts.tsx b/src/view/com/util/moderation/PostAlerts.tsx
index 0dba367f..2c9a7185 100644
--- a/src/view/com/util/moderation/PostAlerts.tsx
+++ b/src/view/com/util/moderation/PostAlerts.tsx
@@ -5,7 +5,7 @@ import {Text} from '../text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {ShieldExclamation} from 'lib/icons'
 import {describeModerationCause} from 'lib/moderation'
-import {useStores} from 'state/index'
+import {useModalControls} from '#/state/modals'
 
 export function PostAlerts({
   moderation,
@@ -15,8 +15,8 @@ export function PostAlerts({
   includeMute?: boolean
   style?: StyleProp<ViewStyle>
 }) {
-  const store = useStores()
   const pal = usePalette('default')
+  const {openModal} = useModalControls()
 
   const shouldAlert = !!moderation.cause && moderation.alert
   if (!shouldAlert) {
@@ -27,7 +27,7 @@ export function PostAlerts({
   return (
     <Pressable
       onPress={() => {
-        store.shell.openModal({
+        openModal({
           name: 'moderation-details',
           context: 'content',
           moderation,
diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx
index d224286b..a9ccf2eb 100644
--- a/src/view/com/util/moderation/PostHider.tsx
+++ b/src/view/com/util/moderation/PostHider.tsx
@@ -8,7 +8,7 @@ import {Text} from '../text/Text'
 import {addStyle} from 'lib/styles'
 import {describeModerationCause} from 'lib/moderation'
 import {ShieldExclamation} from 'lib/icons'
-import {useStores} from 'state/index'
+import {useModalControls} from '#/state/modals'
 
 interface Props extends ComponentProps<typeof Link> {
   // testID?: string
@@ -25,10 +25,10 @@ export function PostHider({
   children,
   ...props
 }: Props) {
-  const store = useStores()
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
   const [override, setOverride] = React.useState(false)
+  const {openModal} = useModalControls()
 
   if (!moderation.blur) {
     return (
@@ -63,7 +63,7 @@ export function PostHider({
         ]}>
         <Pressable
           onPress={() => {
-            store.shell.openModal({
+            openModal({
               name: 'moderation-details',
               context: 'content',
               moderation,
diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
index 6b7f4e7e..d2406e7a 100644
--- a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
+++ b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
@@ -8,7 +8,7 @@ import {
   describeModerationCause,
   getProfileModerationCauses,
 } from 'lib/moderation'
-import {useStores} from 'state/index'
+import {useModalControls} from '#/state/modals'
 
 export function ProfileHeaderAlerts({
   moderation,
@@ -17,8 +17,8 @@ export function ProfileHeaderAlerts({
   moderation: ProfileModeration
   style?: StyleProp<ViewStyle>
 }) {
-  const store = useStores()
   const pal = usePalette('default')
+  const {openModal} = useModalControls()
 
   const causes = getProfileModerationCauses(moderation)
   if (!causes.length) {
@@ -34,7 +34,7 @@ export function ProfileHeaderAlerts({
             testID="profileHeaderAlert"
             key={desc.name}
             onPress={() => {
-              store.shell.openModal({
+              openModal({
                 name: 'moderation-details',
                 context: 'content',
                 moderation: {cause},
diff --git a/src/view/com/util/moderation/ScreenHider.tsx b/src/view/com/util/moderation/ScreenHider.tsx
index 0224b9fe..c3d23b84 100644
--- a/src/view/com/util/moderation/ScreenHider.tsx
+++ b/src/view/com/util/moderation/ScreenHider.tsx
@@ -18,7 +18,7 @@ import {NavigationProp} from 'lib/routes/types'
 import {Text} from '../text/Text'
 import {Button} from '../forms/Button'
 import {describeModerationCause} from 'lib/moderation'
-import {useStores} from 'state/index'
+import {useModalControls} from '#/state/modals'
 
 export function ScreenHider({
   testID,
@@ -34,12 +34,12 @@ export function ScreenHider({
   style?: StyleProp<ViewStyle>
   containerStyle?: StyleProp<ViewStyle>
 }>) {
-  const store = useStores()
   const pal = usePalette('default')
   const palInverted = usePalette('inverted')
   const [override, setOverride] = React.useState(false)
   const navigation = useNavigation<NavigationProp>()
   const {isMobile} = useWebMediaQueries()
+  const {openModal} = useModalControls()
 
   if (!moderation.blur || override) {
     return (
@@ -72,7 +72,7 @@ export function ScreenHider({
         .{' '}
         <TouchableWithoutFeedback
           onPress={() => {
-            store.shell.openModal({
+            openModal({
               name: 'moderation-details',
               context: 'account',
               moderation,
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 5769a478..7bcea0e7 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -16,6 +16,7 @@ import {useStores} from 'state/index'
 import {RepostButton} from './RepostButton'
 import {Haptics} from 'lib/haptics'
 import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
+import {useModalControls} from '#/state/modals'
 
 interface PostCtrlsOpts {
   itemUri: string
@@ -51,6 +52,7 @@ interface PostCtrlsOpts {
 export function PostCtrls(opts: PostCtrlsOpts) {
   const store = useStores()
   const theme = useTheme()
+  const {closeModal} = useModalControls()
   const defaultCtrlColor = React.useMemo(
     () => ({
       color: theme.palette.default.postCtrl,
@@ -58,17 +60,17 @@ export function PostCtrls(opts: PostCtrlsOpts) {
     [theme],
   ) as StyleProp<ViewStyle>
   const onRepost = useCallback(() => {
-    store.shell.closeModal()
+    closeModal()
     if (!opts.isReposted) {
       Haptics.default()
       opts.onPressToggleRepost().catch(_e => undefined)
     } else {
       opts.onPressToggleRepost().catch(_e => undefined)
     }
-  }, [opts, store.shell])
+  }, [opts, closeModal])
 
   const onQuote = useCallback(() => {
-    store.shell.closeModal()
+    closeModal()
     store.shell.openComposer({
       quote: {
         uri: opts.itemUri,
@@ -86,6 +88,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
     opts.itemUri,
     opts.text,
     store.shell,
+    closeModal,
   ])
 
   const onPressToggleLikeWrapper = async () => {
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index 9c4ed8e5..0a763725 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -5,8 +5,8 @@ import {s, colors} from 'lib/styles'
 import {useTheme} from 'lib/ThemeContext'
 import {Text} from '../text/Text'
 import {pluralize} from 'lib/strings/helpers'
-import {useStores} from 'state/index'
 import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
+import {useModalControls} from '#/state/modals'
 
 interface Props {
   isReposted: boolean
@@ -23,8 +23,8 @@ export const RepostButton = ({
   onRepost,
   onQuote,
 }: Props) => {
-  const store = useStores()
   const theme = useTheme()
+  const {openModal} = useModalControls()
 
   const defaultControlColor = React.useMemo(
     () => ({
@@ -34,13 +34,13 @@ export const RepostButton = ({
   )
 
   const onPressToggleRepostWrapper = useCallback(() => {
-    store.shell.openModal({
+    openModal({
       name: 'repost',
       onRepost: onRepost,
       onQuote: onQuote,
       isReposted,
     })
-  }, [onRepost, onQuote, isReposted, store.shell])
+  }, [onRepost, onQuote, isReposted, openModal])
 
   return (
     <TouchableOpacity
diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx
index b654055c..338adfba 100644
--- a/src/view/screens/AppPasswords.tsx
+++ b/src/view/screens/AppPasswords.tsx
@@ -17,6 +17,7 @@ import {useFocusEffect} from '@react-navigation/native'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {CenteredView} from 'view/com/util/Views'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useModalControls} from '#/state/modals'
 import {useLanguagePrefs} from '#/state/preferences'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'>
@@ -27,6 +28,7 @@ export const AppPasswords = withAuthRequired(
     const setMinimalShellMode = useSetMinimalShellMode()
     const {screen} = useAnalytics()
     const {isTabletOrDesktop} = useWebMediaQueries()
+    const {openModal} = useModalControls()
 
     useFocusEffect(
       React.useCallback(() => {
@@ -36,8 +38,8 @@ export const AppPasswords = withAuthRequired(
     )
 
     const onAdd = React.useCallback(async () => {
-      store.shell.openModal({name: 'add-app-password'})
-    }, [store])
+      openModal({name: 'add-app-password'})
+    }, [openModal])
 
     // no app passwords (empty) state
     if (store.me.appPasswords.length === 0) {
@@ -162,10 +164,11 @@ function AppPassword({
 }) {
   const pal = usePalette('default')
   const store = useStores()
+  const {openModal} = useModalControls()
   const {contentLanguages} = useLanguagePrefs()
 
   const onDelete = React.useCallback(async () => {
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
       title: 'Delete App Password',
       message: `Are you sure you want to delete the app password "${name}"?`,
@@ -174,7 +177,7 @@ function AppPassword({
         Toast.show('App password deleted')
       },
     })
-  }, [store, name])
+  }, [store, openModal, name])
 
   const primaryLocale =
     contentLanguages.length > 0 ? contentLanguages[0] : 'en-US'
diff --git a/src/view/screens/LanguageSettings.tsx b/src/view/screens/LanguageSettings.tsx
index 4cf17894..c94364e9 100644
--- a/src/view/screens/LanguageSettings.tsx
+++ b/src/view/screens/LanguageSettings.tsx
@@ -2,7 +2,6 @@ import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {Text} from '../com/util/text/Text'
-import {useStores} from 'state/index'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@@ -19,6 +18,7 @@ import {useFocusEffect} from '@react-navigation/native'
 import {LANGUAGES} from 'lib/../locale/languages'
 import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useModalControls} from '#/state/modals'
 import {useLanguagePrefs, useSetLanguagePrefs} from '#/state/preferences'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'LanguageSettings'>
@@ -27,12 +27,12 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl(
   _: Props,
 ) {
   const pal = usePalette('default')
-  const store = useStores()
   const langPrefs = useLanguagePrefs()
   const setLangPrefs = useSetLanguagePrefs()
   const {isTabletOrDesktop} = useWebMediaQueries()
   const {screen, track} = useAnalytics()
   const setMinimalShellMode = useSetMinimalShellMode()
+  const {openModal} = useModalControls()
 
   useFocusEffect(
     React.useCallback(() => {
@@ -43,8 +43,8 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl(
 
   const onPressContentLanguages = React.useCallback(() => {
     track('Settings:ContentlanguagesButtonClicked')
-    store.shell.openModal({name: 'content-languages-settings'})
-  }, [track, store])
+    openModal({name: 'content-languages-settings'})
+  }, [track, openModal])
 
   const onChangePrimaryLanguage = React.useCallback(
     (value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx
index a64b0ca3..a29b0d6c 100644
--- a/src/view/screens/Lists.tsx
+++ b/src/view/screens/Lists.tsx
@@ -17,6 +17,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
 import {s} from 'lib/styles'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useModalControls} from '#/state/modals'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'>
 export const ListsScreen = withAuthRequired(
@@ -26,6 +27,7 @@ export const ListsScreen = withAuthRequired(
     const setMinimalShellMode = useSetMinimalShellMode()
     const {isMobile} = useWebMediaQueries()
     const navigation = useNavigation<NavigationProp>()
+    const {openModal} = useModalControls()
 
     const listsLists: ListsListModel = React.useMemo(
       () => new ListsListModel(store, 'my-curatelists'),
@@ -40,7 +42,7 @@ export const ListsScreen = withAuthRequired(
     )
 
     const onPressNewList = React.useCallback(() => {
-      store.shell.openModal({
+      openModal({
         name: 'create-or-edit-list',
         purpose: 'app.bsky.graph.defs#curatelist',
         onSave: (uri: string) => {
@@ -53,7 +55,7 @@ export const ListsScreen = withAuthRequired(
           } catch {}
         },
       })
-    }, [store, navigation])
+    }, [openModal, navigation])
 
     return (
       <View style={s.hContentRegion} testID="listsScreen">
diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx
index 142f3bce..eb952c06 100644
--- a/src/view/screens/Moderation.tsx
+++ b/src/view/screens/Moderation.tsx
@@ -8,7 +8,6 @@ import {
 import {observer} from 'mobx-react-lite'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {useStores} from 'state/index'
 import {s} from 'lib/styles'
 import {CenteredView} from '../com/util/Views'
 import {ViewHeader} from '../com/util/ViewHeader'
@@ -18,15 +17,16 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useModalControls} from '#/state/modals'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
 export const ModerationScreen = withAuthRequired(
   observer(function Moderation({}: Props) {
     const pal = usePalette('default')
-    const store = useStores()
     const setMinimalShellMode = useSetMinimalShellMode()
     const {screen, track} = useAnalytics()
     const {isTabletOrDesktop} = useWebMediaQueries()
+    const {openModal} = useModalControls()
 
     useFocusEffect(
       React.useCallback(() => {
@@ -37,8 +37,8 @@ export const ModerationScreen = withAuthRequired(
 
     const onPressContentFiltering = React.useCallback(() => {
       track('Moderation:ContentfilteringButtonClicked')
-      store.shell.openModal({name: 'content-filtering-settings'})
-    }, [track, store])
+      openModal({name: 'content-filtering-settings'})
+    }, [track, openModal])
 
     return (
       <CenteredView
diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx
index 8794c6d1..3892e47c 100644
--- a/src/view/screens/ModerationModlists.tsx
+++ b/src/view/screens/ModerationModlists.tsx
@@ -17,6 +17,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
 import {s} from 'lib/styles'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useModalControls} from '#/state/modals'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ModerationModlists'>
 export const ModerationModlistsScreen = withAuthRequired(
@@ -26,6 +27,7 @@ export const ModerationModlistsScreen = withAuthRequired(
     const setMinimalShellMode = useSetMinimalShellMode()
     const {isMobile} = useWebMediaQueries()
     const navigation = useNavigation<NavigationProp>()
+    const {openModal} = useModalControls()
 
     const mutelists: ListsListModel = React.useMemo(
       () => new ListsListModel(store, 'my-modlists'),
@@ -40,7 +42,7 @@ export const ModerationModlistsScreen = withAuthRequired(
     )
 
     const onPressNewList = React.useCallback(() => {
-      store.shell.openModal({
+      openModal({
         name: 'create-or-edit-list',
         purpose: 'app.bsky.graph.defs#modlist',
         onSave: (uri: string) => {
@@ -53,7 +55,7 @@ export const ModerationModlistsScreen = withAuthRequired(
           } catch {}
         },
       })
-    }, [store, navigation])
+    }, [openModal, navigation])
 
     return (
       <View style={s.hContentRegion} testID="moderationModlistsScreen">
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
index a4d146d6..3d108164 100644
--- a/src/view/screens/ProfileFeed.tsx
+++ b/src/view/screens/ProfileFeed.tsx
@@ -47,6 +47,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
 import {ComposeIcon2} from 'lib/icons'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
 
 const SECTION_TITLES = ['Posts', 'About']
 
@@ -137,6 +138,7 @@ export const ProfileFeedScreenInner = observer(
     route,
     feedOwnerDid,
   }: Props & {feedOwnerDid: string}) {
+    const {openModal} = useModalControls()
     const pal = usePalette('default')
     const store = useStores()
     const {track} = useAnalytics()
@@ -210,12 +212,12 @@ export const ProfileFeedScreenInner = observer(
 
     const onPressReport = React.useCallback(() => {
       if (!feedInfo) return
-      store.shell.openModal({
+      openModal({
         name: 'report',
         uri: feedInfo.uri,
         cid: feedInfo.cid,
       })
-    }, [store, feedInfo])
+    }, [openModal, feedInfo])
 
     const onCurrentPageSelected = React.useCallback(
       (index: number) => {
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index b84732d5..a165502b 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -46,6 +46,7 @@ import {ComposeIcon2} from 'lib/icons'
 import {ListItems} from 'view/com/lists/ListItems'
 import {logger} from '#/logger'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useModalControls} from '#/state/modals'
 
 const SECTION_TITLES_CURATE = ['Posts', 'About']
 const SECTION_TITLES_MOD = ['About']
@@ -110,6 +111,7 @@ export const ProfileListScreenInner = observer(
     const {rkey} = route.params
     const feedSectionRef = React.useRef<SectionRef>(null)
     const aboutSectionRef = React.useRef<SectionRef>(null)
+    const {openModal} = useModalControls()
 
     const list: ListModel = useMemo(() => {
       const model = new ListModel(
@@ -136,7 +138,7 @@ export const ProfileListScreenInner = observer(
     )
 
     const onPressAddUser = useCallback(() => {
-      store.shell.openModal({
+      openModal({
         name: 'list-add-user',
         list,
         onAdd() {
@@ -145,7 +147,7 @@ export const ProfileListScreenInner = observer(
           }
         },
       })
-    }, [store, list, feed])
+    }, [openModal, list, feed])
 
     const onCurrentPageSelected = React.useCallback(
       (index: number) => {
@@ -268,8 +270,8 @@ const Header = observer(function HeaderImpl({
 }) {
   const pal = usePalette('default')
   const palInverted = usePalette('inverted')
-  const store = useStores()
   const navigation = useNavigation<NavigationProp>()
+  const {openModal, closeModal} = useModalControls()
 
   const onTogglePinned = useCallback(async () => {
     Haptics.default()
@@ -280,7 +282,7 @@ const Header = observer(function HeaderImpl({
   }, [list])
 
   const onSubscribeMute = useCallback(() => {
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
       title: 'Mute these accounts?',
       message:
@@ -297,10 +299,10 @@ const Header = observer(function HeaderImpl({
         }
       },
       onPressCancel() {
-        store.shell.closeModal()
+        closeModal()
       },
     })
-  }, [store, list])
+  }, [openModal, closeModal, list])
 
   const onUnsubscribeMute = useCallback(async () => {
     try {
@@ -314,7 +316,7 @@ const Header = observer(function HeaderImpl({
   }, [list])
 
   const onSubscribeBlock = useCallback(() => {
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
       title: 'Block these accounts?',
       message:
@@ -331,10 +333,10 @@ const Header = observer(function HeaderImpl({
         }
       },
       onPressCancel() {
-        store.shell.closeModal()
+        closeModal()
       },
     })
-  }, [store, list])
+  }, [openModal, closeModal, list])
 
   const onUnsubscribeBlock = useCallback(async () => {
     try {
@@ -348,17 +350,17 @@ const Header = observer(function HeaderImpl({
   }, [list])
 
   const onPressEdit = useCallback(() => {
-    store.shell.openModal({
+    openModal({
       name: 'create-or-edit-list',
       list,
       onSave() {
         list.refresh()
       },
     })
-  }, [store, list])
+  }, [openModal, list])
 
   const onPressDelete = useCallback(() => {
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
       title: 'Delete List',
       message: 'Are you sure?',
@@ -372,16 +374,16 @@ const Header = observer(function HeaderImpl({
         }
       },
     })
-  }, [store, list, navigation])
+  }, [openModal, list, navigation])
 
   const onPressReport = useCallback(() => {
     if (!list.data) return
-    store.shell.openModal({
+    openModal({
       name: 'report',
       uri: list.uri,
       cid: list.data.cid,
     })
-  }, [store, list])
+  }, [openModal, list])
 
   const onPressShare = useCallback(() => {
     const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`)
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 570f8b7e..f912996e 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -46,6 +46,7 @@ import Clipboard from '@react-native-clipboard/clipboard'
 import {makeProfileLink} from 'lib/routes/links'
 import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
 import {
   useSetMinimalShellMode,
   useColorMode,
@@ -82,6 +83,7 @@ export const SettingsScreen = withAuthRequired(
     const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting(
       store.agent,
     )
+    const {openModal} = useModalControls()
 
     const primaryBg = useCustomPalette<ViewStyle>({
       light: {backgroundColor: colors.blue0},
@@ -117,7 +119,7 @@ export const SettingsScreen = withAuthRequired(
 
     const onPressChangeHandle = React.useCallback(() => {
       track('Settings:ChangeHandleButtonClicked')
-      store.shell.openModal({
+      openModal({
         name: 'change-handle',
         onChanged() {
           setIsSwitching(true)
@@ -135,12 +137,12 @@ export const SettingsScreen = withAuthRequired(
           )
         },
       })
-    }, [track, store, setIsSwitching])
+    }, [track, store, openModal, setIsSwitching])
 
     const onPressInviteCodes = React.useCallback(() => {
       track('Settings:InvitecodesButtonClicked')
-      store.shell.openModal({name: 'invite-codes'})
-    }, [track, store])
+      openModal({name: 'invite-codes'})
+    }, [track, openModal])
 
     const onPressLanguageSettings = React.useCallback(() => {
       navigation.navigate('LanguageSettings')
@@ -152,8 +154,8 @@ export const SettingsScreen = withAuthRequired(
     }, [track, store])
 
     const onPressDeleteAccount = React.useCallback(() => {
-      store.shell.openModal({name: 'delete-account'})
-    }, [store])
+      openModal({name: 'delete-account'})
+    }, [openModal])
 
     const onPressResetPreferences = React.useCallback(async () => {
       await store.preferences.reset()
@@ -229,8 +231,7 @@ export const SettingsScreen = withAuthRequired(
                 <Text type="lg" style={pal.text}>
                   {store.session.currentSession?.email}{' '}
                 </Text>
-                <Link
-                  onPress={() => store.shell.openModal({name: 'change-email'})}>
+                <Link onPress={() => openModal({name: 'change-email'})}>
                   <Text type="lg" style={pal.link}>
                     Change
                   </Text>
@@ -240,10 +241,7 @@ export const SettingsScreen = withAuthRequired(
                 <Text type="lg-medium" style={pal.text}>
                   Birthday:{' '}
                 </Text>
-                <Link
-                  onPress={() =>
-                    store.shell.openModal({name: 'birth-date-settings'})
-                  }>
+                <Link onPress={() => openModal({name: 'birth-date-settings'})}>
                   <Text type="lg" style={pal.link}>
                     Show
                   </Text>
@@ -649,6 +647,7 @@ const EmailConfirmationNotice = observer(
     const palInverted = usePalette('inverted')
     const store = useStores()
     const {isMobile} = useWebMediaQueries()
+    const {openModal} = useModalControls()
 
     if (!store.session.emailNeedsConfirmation) {
       return null
@@ -684,7 +683,7 @@ const EmailConfirmationNotice = observer(
               accessibilityRole="button"
               accessibilityLabel="Verify my email"
               accessibilityHint=""
-              onPress={() => store.shell.openModal({name: 'verify-email'})}>
+              onPress={() => openModal({name: 'verify-email'})}>
               <FontAwesomeIcon
                 icon="envelope"
                 color={palInverted.colors.text}
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index 7f5e6c5e..c8b3e091 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -44,6 +44,7 @@ import {useNavigationTabState} from 'lib/hooks/useNavigationTabState'
 import {isWeb} from 'platform/detection'
 import {formatCount, formatCountShortOnly} from 'view/com/util/numeric/format'
 import {useSetDrawerOpen} from '#/state/shell'
+import {useModalControls} from '#/state/modals'
 
 export const DrawerContent = observer(function DrawerContentImpl() {
   const theme = useTheme()
@@ -442,11 +443,12 @@ const InviteCodes = observer(function InviteCodesImpl({
   const setDrawerOpen = useSetDrawerOpen()
   const pal = usePalette('default')
   const {invitesAvailable} = store.me
+  const {openModal} = useModalControls()
   const onPress = React.useCallback(() => {
     track('Menu:ItemClicked', {url: '#invite-codes'})
     setDrawerOpen(false)
-    store.shell.openModal({name: 'invite-codes'})
-  }, [store, track, setDrawerOpen])
+    openModal({name: 'invite-codes'})
+  }, [openModal, track, setDrawerOpen])
   return (
     <TouchableOpacity
       testID="menuItemInviteCodes"
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index d360ceea..fedfcdfc 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -24,12 +24,14 @@ import {styles} from './BottomBarStyles'
 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
 import {useNavigationTabState} from 'lib/hooks/useNavigationTabState'
 import {UserAvatar} from 'view/com/util/UserAvatar'
+import {useModalControls} from '#/state/modals'
 
 type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds'
 
 export const BottomBar = observer(function BottomBarImpl({
   navigation,
 }: BottomTabBarProps) {
+  const {openModal} = useModalControls()
   const store = useStores()
   const pal = usePalette('default')
   const safeAreaInsets = useSafeAreaInsets()
@@ -72,8 +74,8 @@ export const BottomBar = observer(function BottomBarImpl({
     onPressTab('MyProfile')
   }, [onPressTab])
   const onLongPressProfile = React.useCallback(() => {
-    store.shell.openModal({name: 'switch-account'})
-  }, [store])
+    openModal({name: 'switch-account'})
+  }, [openModal])
 
   return (
     <Animated.View
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
index 84d7d785..a4b3e574 100644
--- a/src/view/shell/desktop/RightNav.tsx
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -13,6 +13,7 @@ import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {pluralize} from 'lib/strings/helpers'
 import {formatCount} from 'view/com/util/numeric/format'
+import {useModalControls} from '#/state/modals'
 
 export const DesktopRightNav = observer(function DesktopRightNavImpl() {
   const store = useStores()
@@ -83,12 +84,13 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() {
 const InviteCodes = observer(function InviteCodesImpl() {
   const store = useStores()
   const pal = usePalette('default')
+  const {openModal} = useModalControls()
 
   const {invitesAvailable} = store.me
 
   const onPress = React.useCallback(() => {
-    store.shell.openModal({name: 'invite-codes'})
-  }, [store])
+    openModal({name: 'invite-codes'})
+  }, [openModal])
   return (
     <TouchableOpacity
       style={[styles.inviteCodes, pal.border]}
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 703edf27..498bc11b 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -32,12 +32,14 @@ import {
   useIsDrawerSwipeDisabled,
 } from '#/state/shell'
 import {isAndroid} from 'platform/detection'
+import {useModalControls} from '#/state/modals'
 
 const ShellInner = observer(function ShellInnerImpl() {
   const store = useStores()
   const isDrawerOpen = useIsDrawerOpen()
   const isDrawerSwipeDisabled = useIsDrawerSwipeDisabled()
   const setIsDrawerOpen = useSetDrawerOpen()
+  const {closeModal} = useModalControls()
   useOTAUpdate() // this hook polls for OTA updates every few seconds
   const winDim = useWindowDimensions()
   const safeAreaInsets = useSafeAreaInsets()
@@ -60,13 +62,14 @@ const ShellInner = observer(function ShellInnerImpl() {
     if (isAndroid) {
       listener = BackHandler.addEventListener('hardwareBackPress', () => {
         setIsDrawerOpen(false)
+        closeModal()
         return store.shell.closeAnyActiveElement()
       })
     }
     return () => {
       listener.remove()
     }
-  }, [store, setIsDrawerOpen])
+  }, [store, setIsDrawerOpen, closeModal])
 
   return (
     <>
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 1731ea24..10489489 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -22,11 +22,13 @@ import {
   useSetDrawerOpen,
   useOnboardingState,
 } from '#/state/shell'
+import {useModalControls} from '#/state/modals'
 
 const ShellInner = observer(function ShellInnerImpl() {
   const store = useStores()
   const isDrawerOpen = useIsDrawerOpen()
   const setDrawerOpen = useSetDrawerOpen()
+  const {closeModal} = useModalControls()
   const onboardingState = useOnboardingState()
   const {isDesktop, isMobile} = useWebMediaQueries()
   const navigator = useNavigation<NavigationProp>()
@@ -35,9 +37,10 @@ const ShellInner = observer(function ShellInnerImpl() {
   useEffect(() => {
     navigator.addListener('state', () => {
       setDrawerOpen(false)
+      closeModal()
       store.shell.closeAnyActiveElement()
     })
-  }, [navigator, store.shell, setDrawerOpen])
+  }, [navigator, store.shell, setDrawerOpen, closeModal])
 
   const showBottomBar = isMobile && !onboardingState.isActive
   const showSideNavs =