about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-05-07 10:23:33 -0500
committerGitHub <noreply@github.com>2025-05-07 10:23:33 -0500
commit0f96669f8c0d578d888c06496d97929130d34a1f (patch)
treecd053b6062fc5045eb14411135dc6ea46d5018f3 /src
parent0edd3bd3b4445275ea3f9ddfc5f91ad4950acdd8 (diff)
downloadvoidsky-0f96669f8c0d578d888c06496d97929130d34a1f.tar.zst
[APP-1158] Refactor email-related dialogs (#8296)
* WIP

* Update email

* Fire off confirmation email after change

* Verify step, integrate stateful control

* Remove tentative EnterCode step

* Handle token step

* Handle instructions and integrate into 2FA setting

* Fix load state when reusing same email

* Add new state

* Add 2FA screens

* Clean up state in Update step

* Clean up verify state, handle normal callback

* Normalize convetions

* Add verification reminder screen

* Improve session refresh

* Handle verification requirements for composer and convo

* Fix lint

* Do better

* Couple missing translations

* Format

* Use listeners for easier to grok logic

* Clean errors

* Move to global context

* [APP-1158] Gate features by email verification state (#8305)

* Use new hook in all locations

* Format

* Seems to work, not great duplication

* Wrap all open composer calls

* Remove unneeded spans

* Missed one

* Fix handler on Conversation

* Gate new chat in header

* Add comment

* Remove whoopsie

* Format

* add hackfix for dialog not showing

* add prompt to accept chat btn

* navigation not necessary

* send back one screen, rather than home

* Update comment

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* Clear dialog state

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* Update icon

* Check color

* Add 2FA warning

* Update instructions

* Fix X button

* Use an effect silly goose

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/dialaUpdate copyogs/EmailDialog/screens/Update.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

* Update copy

* Update copy

* Update copy

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

* Add link back to update email from verify email dialog

* Handle token field validation

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Diffstat (limited to 'src')
-rw-r--r--src/Navigation.tsx13
-rw-r--r--src/components/Dialog/index.web.tsx8
-rw-r--r--src/components/StarterPack/ProfileStarterPacks.tsx50
-rw-r--r--src/components/dialogs/Context.tsx15
-rw-r--r--src/components/dialogs/EmailDialog/components/ResendEmailText.tsx56
-rw-r--r--src/components/dialogs/EmailDialog/components/TokenField.tsx45
-rw-r--r--src/components/dialogs/EmailDialog/data/useAccountEmailState.ts79
-rw-r--r--src/components/dialogs/EmailDialog/data/useConfirmEmail.ts29
-rw-r--r--src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts35
-rw-r--r--src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts13
-rw-r--r--src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts13
-rw-r--r--src/components/dialogs/EmailDialog/data/useUpdateEmail.ts45
-rw-r--r--src/components/dialogs/EmailDialog/events.ts23
-rw-r--r--src/components/dialogs/EmailDialog/index.tsx71
-rw-r--r--src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx254
-rw-r--r--src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx137
-rw-r--r--src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx70
-rw-r--r--src/components/dialogs/EmailDialog/screens/Update.tsx319
-rw-r--r--src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx99
-rw-r--r--src/components/dialogs/EmailDialog/screens/Verify.tsx386
-rw-r--r--src/components/dialogs/EmailDialog/types.ts38
-rw-r--r--src/components/dms/MessageProfileButton.tsx43
-rw-r--r--src/components/dms/dialogs/NewChatDialog.tsx35
-rw-r--r--src/lib/hooks/useCleanError.ts91
-rw-r--r--src/lib/hooks/useIntentHandler.ts4
-rw-r--r--src/lib/hooks/useOpenComposer.tsx22
-rw-r--r--src/lib/hooks/useRequireEmailVerification.tsx53
-rw-r--r--src/screens/Messages/ChatList.tsx15
-rw-r--r--src/screens/Messages/Conversation.tsx61
-rw-r--r--src/screens/Messages/components/RequestButtons.tsx34
-rw-r--r--src/screens/Messages/components/RequestListItem.tsx2
-rw-r--r--src/screens/Profile/ProfileFeed/index.tsx21
-rw-r--r--src/screens/Settings/AccountSettings.tsx33
-rw-r--r--src/screens/Settings/components/Email2FAToggle.tsx65
-rw-r--r--src/screens/VideoFeed/index.tsx40
-rw-r--r--src/state/shell/composer/index.tsx14
-rw-r--r--src/state/shell/composer/useComposerKeyboardShortcut.tsx4
-rw-r--r--src/state/shell/index.tsx3
-rw-r--r--src/view/com/composer/Composer.tsx21
-rw-r--r--src/view/com/composer/videos/SelectVideoBtn.tsx112
-rw-r--r--src/view/com/feeds/FeedPage.tsx16
-rw-r--r--src/view/com/post-thread/PostThread.tsx18
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx4
-rw-r--r--src/view/com/post/Post.tsx16
-rw-r--r--src/view/com/posts/PostFeedItem.tsx4
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx4
-rw-r--r--src/view/screens/Feeds.tsx15
-rw-r--r--src/view/screens/Lists.tsx37
-rw-r--r--src/view/screens/ModerationModlists.tsx37
-rw-r--r--src/view/screens/Notifications.tsx10
-rw-r--r--src/view/screens/Profile.tsx15
-rw-r--r--src/view/screens/ProfileList.tsx4
-rw-r--r--src/view/shell/desktop/LeftNav.tsx4
-rw-r--r--src/view/shell/index.tsx2
-rw-r--r--src/view/shell/index.web.tsx2
55 files changed, 2257 insertions, 402 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 424d73290..c98359387 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -104,8 +104,10 @@ import {Wizard} from '#/screens/StarterPack/Wizard'
 import TopicScreen from '#/screens/Topic'
 import {VideoFeed} from '#/screens/VideoFeed'
 import {useTheme} from '#/alf'
-import {useDialogControl} from '#/components/Dialog'
-import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
+import {
+  EmailDialogScreenID,
+  useEmailDialogControl,
+} from '#/components/dialogs/EmailDialog'
 import {router} from '#/routes'
 import {Referrer} from '../modules/expo-bluesky-swiss-army'
 
@@ -738,12 +740,14 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
   const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
   const {currentAccount} = useSession()
   const prevLoggedRouteName = React.useRef<string | undefined>(undefined)
-  const verifyEmailDialogControl = useDialogControl()
+  const emailDialogControl = useEmailDialogControl()
 
   function onReady() {
     prevLoggedRouteName.current = getCurrentRouteName()
     if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
-      verifyEmailDialogControl.open()
+      emailDialogControl.open({
+        id: EmailDialogScreenID.VerificationReminder,
+      })
       snoozeEmailConfirmationPrompt()
     }
   }
@@ -768,7 +772,6 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
         }}>
         {children}
       </NavigationContainer>
-      <VerifyEmailDialog control={verifyEmailDialogControl} reminder />
     </>
   )
 }
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index 12bd8819b..c43e9c5c0 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -195,7 +195,13 @@ export function Inner({
           onDismiss={close}
           style={{display: 'flex', flexDirection: 'column'}}>
           {header}
-          <View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
+          <View
+            style={[
+              gtMobile ? a.p_2xl : a.p_xl,
+              a.overflow_hidden,
+              a.rounded_md,
+              contentContainerStyle,
+            ]}>
             {children}
           </View>
         </DismissableLayer.DismissableLayer>
diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx
index d7afa37d2..de19b0bce 100644
--- a/src/components/StarterPack/ProfileStarterPacks.tsx
+++ b/src/components/StarterPack/ProfileStarterPacks.tsx
@@ -18,7 +18,7 @@ import {useNavigation} from '@react-navigation/native'
 
 import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack'
 import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
-import {useEmail} from '#/lib/hooks/useEmail'
+import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {type NavigationProp} from '#/lib/routes/types'
 import {parseStarterPackUri} from '#/lib/strings/starter-pack'
@@ -30,7 +30,6 @@ import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 import {atoms as a, ios, useTheme} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import {useDialogControl} from '#/components/Dialog'
-import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
 import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
 import {LinearGradientBackground} from '#/components/LinearGradientBackground'
 import {Loader} from '#/components/Loader'
@@ -197,9 +196,7 @@ function Empty() {
   const confirmDialogControl = useDialogControl()
   const followersDialogControl = useDialogControl()
   const errorDialogControl = useDialogControl()
-
-  const {needsEmailVerification} = useEmail()
-  const verifyEmailControl = useDialogControl()
+  const requireEmailVerification = useRequireEmailVerification()
 
   const [isGenerating, setIsGenerating] = useState(false)
 
@@ -230,6 +227,27 @@ function Empty() {
     generateStarterPack()
   }
 
+  const openConfirmDialog = useCallback(() => {
+    confirmDialogControl.open()
+  }, [confirmDialogControl])
+  const wrappedOpenConfirmDialog = requireEmailVerification(openConfirmDialog, {
+    instructions: [
+      <Trans key="confirm">
+        Before creating a starter pack, you must first verify your email.
+      </Trans>,
+    ],
+  })
+  const navToWizard = useCallback(() => {
+    navigation.navigate('StarterPackWizard')
+  }, [navigation])
+  const wrappedNavToWizard = requireEmailVerification(navToWizard, {
+    instructions: [
+      <Trans key="nav">
+        Before creating a starter pack, you must first verify your email.
+      </Trans>,
+    ],
+  })
+
   return (
     <LinearGradientBackground
       style={[
@@ -258,13 +276,7 @@ function Empty() {
           color="primary"
           size="small"
           disabled={isGenerating}
-          onPress={() => {
-            if (needsEmailVerification) {
-              verifyEmailControl.open()
-            } else {
-              confirmDialogControl.open()
-            }
-          }}
+          onPress={wrappedOpenConfirmDialog}
           style={{backgroundColor: 'transparent'}}>
           <ButtonText style={{color: 'white'}}>
             <Trans>Make one for me</Trans>
@@ -277,13 +289,7 @@ function Empty() {
           color="primary"
           size="small"
           disabled={isGenerating}
-          onPress={() => {
-            if (needsEmailVerification) {
-              verifyEmailControl.open()
-            } else {
-              navigation.navigate('StarterPackWizard')
-            }
-          }}
+          onPress={wrappedNavToWizard}
           style={{
             backgroundColor: 'white',
             borderColor: 'white',
@@ -339,12 +345,6 @@ function Empty() {
         onConfirm={generate}
         confirmButtonCta={_(msg`Retry`)}
       />
-      <VerifyEmailDialog
-        reasonText={_(
-          msg`Before creating a starter pack, you must first verify your email.`,
-        )}
-        control={verifyEmailControl}
-      />
     </LinearGradientBackground>
   )
 }
diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx
index fda904b8b..728044325 100644
--- a/src/components/dialogs/Context.tsx
+++ b/src/components/dialogs/Context.tsx
@@ -1,6 +1,7 @@
 import {createContext, useContext, useMemo, useState} from 'react'
 
 import * as Dialog from '#/components/Dialog'
+import {type Screen} from '#/components/dialogs/EmailDialog/types'
 
 type Control = Dialog.DialogControlProps
 
@@ -15,6 +16,7 @@ type ControlsContext = {
   mutedWordsDialogControl: Control
   signinDialogControl: Control
   inAppBrowserConsentControl: StatefulControl<string>
+  emailDialogControl: StatefulControl<Screen>
 }
 
 const ControlsContext = createContext<ControlsContext | null>(null)
@@ -33,14 +35,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   const mutedWordsDialogControl = Dialog.useDialogControl()
   const signinDialogControl = Dialog.useDialogControl()
   const inAppBrowserConsentControl = useStatefulDialogControl<string>()
+  const emailDialogControl = useStatefulDialogControl<Screen>()
 
   const ctx = useMemo<ControlsContext>(
     () => ({
       mutedWordsDialogControl,
       signinDialogControl,
       inAppBrowserConsentControl,
+      emailDialogControl,
     }),
-    [mutedWordsDialogControl, signinDialogControl, inAppBrowserConsentControl],
+    [
+      mutedWordsDialogControl,
+      signinDialogControl,
+      inAppBrowserConsentControl,
+      emailDialogControl,
+    ],
   )
 
   return (
@@ -48,7 +57,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   )
 }
 
-function useStatefulDialogControl<T>(initialValue?: T): StatefulControl<T> {
+export function useStatefulDialogControl<T>(
+  initialValue?: T,
+): StatefulControl<T> {
   const [value, setValue] = useState(initialValue)
   const control = Dialog.useDialogControl()
   return useMemo(
diff --git a/src/components/dialogs/EmailDialog/components/ResendEmailText.tsx b/src/components/dialogs/EmailDialog/components/ResendEmailText.tsx
new file mode 100644
index 000000000..0fc9a5469
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/components/ResendEmailText.tsx
@@ -0,0 +1,56 @@
+import {useState} from 'react'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {wait} from '#/lib/async/wait'
+import {atoms as a, type TextStyleProp, useTheme} from '#/alf'
+import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {createStaticClick, InlineLinkText} from '#/components/Link'
+import {Loader} from '#/components/Loader'
+import {Span, Text} from '#/components/Typography'
+
+export function ResendEmailText({
+  onPress,
+  style,
+}: TextStyleProp & {
+  onPress: () => Promise<any>
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const [status, setStatus] = useState<'sending' | 'success' | null>(null)
+
+  const handleOnPress = async () => {
+    setStatus('sending')
+    try {
+      await wait(1000, onPress())
+      setStatus('success')
+    } finally {
+      setTimeout(() => {
+        setStatus(null)
+      }, 1000)
+    }
+  }
+
+  return (
+    <Text
+      style={[a.italic, a.leading_snug, t.atoms.text_contrast_medium, style]}>
+      <Trans>
+        Don't see an email?{' '}
+        <InlineLinkText
+          label={_(msg`Resend`)}
+          {...createStaticClick(() => {
+            handleOnPress()
+          })}>
+          Click here to resend.
+        </InlineLinkText>
+      </Trans>{' '}
+      <Span style={{top: 1}}>
+        {status === 'sending' ? (
+          <Loader size="xs" />
+        ) : status === 'success' ? (
+          <Check size="xs" fill={t.palette.positive_500} />
+        ) : null}
+      </Span>
+    </Text>
+  )
+}
diff --git a/src/components/dialogs/EmailDialog/components/TokenField.tsx b/src/components/dialogs/EmailDialog/components/TokenField.tsx
new file mode 100644
index 000000000..26a51e485
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/components/TokenField.tsx
@@ -0,0 +1,45 @@
+import {type TextInputProps, View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import * as TextField from '#/components/forms/TextField'
+import {Shield_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
+
+export function normalizeCode(value: string) {
+  const normalized = value.toUpperCase().replace(/[^A-Z2-7]/g, '')
+  if (normalized.length <= 5) return normalized
+  return `${normalized.slice(0, 5)}-${normalized.slice(5)}`
+}
+
+export function isValidCode(value?: string) {
+  return Boolean(value && /^[A-Z2-7]{5}-[A-Z2-7]{5}$/.test(value))
+}
+
+export function TokenField({
+  value,
+  onChangeText,
+  onSubmitEditing,
+}: Pick<TextInputProps, 'value' | 'onChangeText' | 'onSubmitEditing'>) {
+  const {_} = useLingui()
+  const isInvalid = Boolean(value && value.length > 10 && !isValidCode(value))
+
+  const handleOnChangeText = (v: string) => {
+    onChangeText?.(normalizeCode(v))
+  }
+
+  return (
+    <View>
+      <TextField.Root>
+        <TextField.Icon icon={Shield} />
+        <TextField.Input
+          isInvalid={isInvalid}
+          label={_(msg`Confirmation code`)}
+          placeholder="XXXXX-XXXXX"
+          value={value}
+          onChangeText={handleOnChangeText}
+          onSubmitEditing={onSubmitEditing}
+        />
+      </TextField.Root>
+    </View>
+  )
+}
diff --git a/src/components/dialogs/EmailDialog/data/useAccountEmailState.ts b/src/components/dialogs/EmailDialog/data/useAccountEmailState.ts
new file mode 100644
index 000000000..377411107
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/data/useAccountEmailState.ts
@@ -0,0 +1,79 @@
+import {useCallback, useEffect, useState} from 'react'
+import {useQuery, useQueryClient} from '@tanstack/react-query'
+
+import {useAgent} from '#/state/session'
+import {emitEmailVerified} from '#/components/dialogs/EmailDialog/events'
+
+export type AccountEmailState = {
+  isEmailVerified: boolean
+  email2FAEnabled: boolean
+}
+
+export const accountEmailStateQueryKey = ['accountEmailState'] as const
+
+export function useInvalidateAccountEmailState() {
+  const qc = useQueryClient()
+
+  return useCallback(() => {
+    return qc.invalidateQueries({
+      queryKey: accountEmailStateQueryKey,
+    })
+  }, [qc])
+}
+
+export function useUpdateAccountEmailStateQueryCache() {
+  const qc = useQueryClient()
+
+  return useCallback(
+    (data: AccountEmailState) => {
+      return qc.setQueriesData(
+        {
+          queryKey: accountEmailStateQueryKey,
+        },
+        data,
+      )
+    },
+    [qc],
+  )
+}
+
+export function useAccountEmailState() {
+  const agent = useAgent()
+  const [prevIsEmailVerified, setPrevEmailIsVerified] = useState(
+    !!agent.session?.emailConfirmed,
+  )
+  const fallbackData: AccountEmailState = {
+    isEmailVerified: !!agent.session?.emailConfirmed,
+    email2FAEnabled: !!agent.session?.emailAuthFactor,
+  }
+  const query = useQuery<AccountEmailState>({
+    enabled: !!agent.session,
+    refetchOnWindowFocus: true,
+    queryKey: accountEmailStateQueryKey,
+    queryFn: async () => {
+      // will also trigger updates to `#/state/session` data
+      const {data} = await agent.resumeSession(agent.session!)
+      return {
+        isEmailVerified: !!data.emailConfirmed,
+        email2FAEnabled: !!data.emailAuthFactor,
+      }
+    },
+  })
+
+  const state = query.data ?? fallbackData
+
+  /*
+   * This will emit `n` times for each instance of this hook. So the listeners
+   * all use `once` to prevent multiple handlers firing.
+   */
+  useEffect(() => {
+    if (state.isEmailVerified && !prevIsEmailVerified) {
+      setPrevEmailIsVerified(true)
+      emitEmailVerified()
+    } else if (!state.isEmailVerified && prevIsEmailVerified) {
+      setPrevEmailIsVerified(false)
+    }
+  }, [state, prevIsEmailVerified])
+
+  return state
+}
diff --git a/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts
new file mode 100644
index 000000000..73f824fcc
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts
@@ -0,0 +1,29 @@
+import {useMutation} from '@tanstack/react-query'
+
+import {useAgent, useSession} from '#/state/session'
+import {useUpdateAccountEmailStateQueryCache} from '#/components/dialogs/EmailDialog/data/useAccountEmailState'
+
+export function useConfirmEmail() {
+  const agent = useAgent()
+  const {currentAccount} = useSession()
+  const updateAccountEmailStateQueryCache =
+    useUpdateAccountEmailStateQueryCache()
+
+  return useMutation({
+    mutationFn: async ({token}: {token: string}) => {
+      if (!currentAccount?.email) {
+        throw new Error('No email found for the current account')
+      }
+
+      await agent.com.atproto.server.confirmEmail({
+        email: currentAccount.email,
+        token: token.trim(),
+      })
+      const {data} = await agent.resumeSession(agent.session!)
+      updateAccountEmailStateQueryCache({
+        isEmailVerified: !!data.emailConfirmed,
+        email2FAEnabled: !!data.emailAuthFactor,
+      })
+    },
+  })
+}
diff --git a/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts
new file mode 100644
index 000000000..39f5fd2d9
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts
@@ -0,0 +1,35 @@
+import {useMutation} from '@tanstack/react-query'
+
+import {useAgent, useSession} from '#/state/session'
+import {useUpdateAccountEmailStateQueryCache} from '#/components/dialogs/EmailDialog/data/useAccountEmailState'
+
+export function useManageEmail2FA() {
+  const agent = useAgent()
+  const {currentAccount} = useSession()
+  const updateAccountEmailStateQueryCache =
+    useUpdateAccountEmailStateQueryCache()
+
+  return useMutation({
+    mutationFn: async ({
+      enabled,
+      token,
+    }:
+      | {enabled: true; token?: undefined}
+      | {enabled: false; token: string}) => {
+      if (!currentAccount?.email) {
+        throw new Error('No email found for the current account')
+      }
+
+      await agent.com.atproto.server.updateEmail({
+        email: currentAccount.email,
+        emailAuthFactor: enabled,
+        token,
+      })
+      const {data} = await agent.resumeSession(agent.session!)
+      updateAccountEmailStateQueryCache({
+        isEmailVerified: !!data.emailConfirmed,
+        email2FAEnabled: !!data.emailAuthFactor,
+      })
+    },
+  })
+}
diff --git a/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts b/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts
new file mode 100644
index 000000000..a442662fc
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts
@@ -0,0 +1,13 @@
+import {useMutation} from '@tanstack/react-query'
+
+import {useAgent} from '#/state/session'
+
+export function useRequestEmailUpdate() {
+  const agent = useAgent()
+
+  return useMutation({
+    mutationFn: async () => {
+      return (await agent.com.atproto.server.requestEmailUpdate()).data
+    },
+  })
+}
diff --git a/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts b/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts
new file mode 100644
index 000000000..ae308c7af
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts
@@ -0,0 +1,13 @@
+import {useMutation} from '@tanstack/react-query'
+
+import {useAgent} from '#/state/session'
+
+export function useRequestEmailVerification() {
+  const agent = useAgent()
+
+  return useMutation({
+    mutationFn: async () => {
+      await agent.com.atproto.server.requestEmailConfirmation()
+    },
+  })
+}
diff --git a/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts
new file mode 100644
index 000000000..2ec1eb6dc
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts
@@ -0,0 +1,45 @@
+import {useMutation} from '@tanstack/react-query'
+
+import {useAgent} from '#/state/session'
+import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate'
+
+async function updateEmailAndRefreshSession(
+  agent: ReturnType<typeof useAgent>,
+  email: string,
+  token?: string,
+) {
+  await agent.com.atproto.server.updateEmail({email: email.trim(), token})
+  await agent.resumeSession(agent.session!)
+}
+
+export function useUpdateEmail() {
+  const agent = useAgent()
+  const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate()
+
+  return useMutation<
+    {status: 'tokenRequired' | 'success'},
+    Error,
+    {email: string; token?: string}
+  >({
+    mutationFn: async ({email, token}: {email: string; token?: string}) => {
+      if (token) {
+        await updateEmailAndRefreshSession(agent, email, token)
+        return {
+          status: 'success',
+        }
+      } else {
+        const {tokenRequired} = await requestEmailUpdate()
+        if (tokenRequired) {
+          return {
+            status: 'tokenRequired',
+          }
+        } else {
+          await updateEmailAndRefreshSession(agent, email, token)
+          return {
+            status: 'success',
+          }
+        }
+      }
+    },
+  })
+}
diff --git a/src/components/dialogs/EmailDialog/events.ts b/src/components/dialogs/EmailDialog/events.ts
new file mode 100644
index 000000000..4fa171cad
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/events.ts
@@ -0,0 +1,23 @@
+import {useEffect} from 'react'
+import EventEmitter from 'eventemitter3'
+
+const events = new EventEmitter<{
+  emailVerified: void
+}>()
+
+export function emitEmailVerified() {
+  events.emit('emailVerified')
+}
+
+export function useOnEmailVerified(cb: () => void) {
+  useEffect(() => {
+    /*
+     * N.B. Use `once` here, since the event can fire multiple times for each
+     * instance of `useAccountEmailState`
+     */
+    events.once('emailVerified', cb)
+    return () => {
+      events.off('emailVerified', cb)
+    }
+  }, [cb])
+}
diff --git a/src/components/dialogs/EmailDialog/index.tsx b/src/components/dialogs/EmailDialog/index.tsx
new file mode 100644
index 000000000..4bac7295a
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/index.tsx
@@ -0,0 +1,71 @@
+import {useCallback, useState} from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import * as Dialog from '#/components/Dialog'
+import {type StatefulControl} from '#/components/dialogs/Context'
+import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
+import {useAccountEmailState} from '#/components/dialogs/EmailDialog/data/useAccountEmailState'
+import {Manage2FA} from '#/components/dialogs/EmailDialog/screens/Manage2FA'
+import {Update} from '#/components/dialogs/EmailDialog/screens/Update'
+import {VerificationReminder} from '#/components/dialogs/EmailDialog/screens/VerificationReminder'
+import {Verify} from '#/components/dialogs/EmailDialog/screens/Verify'
+import {type Screen, ScreenID} from '#/components/dialogs/EmailDialog/types'
+
+export type {Screen} from '#/components/dialogs/EmailDialog/types'
+export {ScreenID as EmailDialogScreenID} from '#/components/dialogs/EmailDialog/types'
+
+export function useEmailDialogControl() {
+  return useGlobalDialogsControlContext().emailDialogControl
+}
+
+export function EmailDialog() {
+  const {_} = useLingui()
+  const emailDialogControl = useEmailDialogControl()
+  const {isEmailVerified} = useAccountEmailState()
+  const onClose = useCallback(() => {
+    if (!isEmailVerified) {
+      if (emailDialogControl.value?.id === ScreenID.Verify) {
+        emailDialogControl.value.onCloseWithoutVerifying?.()
+      }
+    }
+    emailDialogControl.clear()
+  }, [isEmailVerified, emailDialogControl])
+
+  return (
+    <Dialog.Outer control={emailDialogControl.control} onClose={onClose}>
+      <Dialog.Handle />
+
+      <Dialog.ScrollableInner
+        label={_(msg`Make adjustments to email settings for your account`)}
+        style={[{maxWidth: 400}]}>
+        <Inner control={emailDialogControl} />
+        <Dialog.Close />
+      </Dialog.ScrollableInner>
+    </Dialog.Outer>
+  )
+}
+
+function Inner({control}: {control: StatefulControl<Screen>}) {
+  const [screen, showScreen] = useState(() => control.value)
+
+  if (!screen) return null
+
+  switch (screen.id) {
+    case ScreenID.Update: {
+      return <Update config={screen} showScreen={showScreen} />
+    }
+    case ScreenID.Verify: {
+      return <Verify config={screen} showScreen={showScreen} />
+    }
+    case ScreenID.VerificationReminder: {
+      return <VerificationReminder config={screen} showScreen={showScreen} />
+    }
+    case ScreenID.Manage2FA: {
+      return <Manage2FA config={screen} showScreen={showScreen} />
+    }
+    default: {
+      return null
+    }
+  }
+}
diff --git a/src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx b/src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx
new file mode 100644
index 000000000..1896ff27d
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx
@@ -0,0 +1,254 @@
+import {useReducer, useState} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {wait} from '#/lib/async/wait'
+import {useCleanError} from '#/lib/hooks/useCleanError'
+import {logger} from '#/logger'
+import {useSession} from '#/state/session'
+import {atoms as a, useTheme} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogContext} from '#/components/Dialog'
+import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText'
+import {
+  isValidCode,
+  TokenField,
+} from '#/components/dialogs/EmailDialog/components/TokenField'
+import {useManageEmail2FA} from '#/components/dialogs/EmailDialog/data/useManageEmail2FA'
+import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate'
+import {Divider} from '#/components/Divider'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
+import {createStaticClick, InlineLinkText} from '#/components/Link'
+import {Loader} from '#/components/Loader'
+import {Span, Text} from '#/components/Typography'
+
+type State = {
+  error: string
+  step: 'email' | 'token'
+  emailStatus: 'pending' | 'success' | 'error' | 'default'
+  tokenStatus: 'pending' | 'success' | 'error' | 'default'
+}
+
+type Action =
+  | {
+      type: 'setError'
+      error: string
+    }
+  | {
+      type: 'setStep'
+      step: 'email' | 'token'
+    }
+  | {
+      type: 'setEmailStatus'
+      status: State['emailStatus']
+    }
+  | {
+      type: 'setTokenStatus'
+      status: State['tokenStatus']
+    }
+
+function reducer(state: State, action: Action): State {
+  switch (action.type) {
+    case 'setError': {
+      return {
+        ...state,
+        error: action.error,
+        emailStatus: 'error',
+        tokenStatus: 'error',
+      }
+    }
+    case 'setStep': {
+      return {
+        ...state,
+        error: '',
+        step: action.step,
+      }
+    }
+    case 'setEmailStatus': {
+      return {
+        ...state,
+        error: '',
+        emailStatus: action.status,
+      }
+    }
+    case 'setTokenStatus': {
+      return {
+        ...state,
+        error: '',
+        tokenStatus: action.status,
+      }
+    }
+    default: {
+      return state
+    }
+  }
+}
+
+export function Disable() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const cleanError = useCleanError()
+  const {currentAccount} = useSession()
+  const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate()
+  const {mutateAsync: manageEmail2FA} = useManageEmail2FA()
+  const control = useDialogContext()
+
+  const [token, setToken] = useState('')
+  const [state, dispatch] = useReducer(reducer, {
+    error: '',
+    step: 'email',
+    emailStatus: 'default',
+    tokenStatus: 'default',
+  })
+
+  const handleSendEmail = async () => {
+    dispatch({type: 'setEmailStatus', status: 'pending'})
+    try {
+      await wait(1000, requestEmailUpdate())
+      dispatch({type: 'setEmailStatus', status: 'success'})
+      setTimeout(() => {
+        dispatch({type: 'setStep', step: 'token'})
+      }, 1000)
+    } catch (e) {
+      logger.error('Manage2FA: email update code request failed', {
+        safeMessage: e,
+      })
+      const {clean} = cleanError(e)
+      dispatch({
+        type: 'setError',
+        error: clean || _(msg`Failed to send email, please try again.`),
+      })
+    }
+  }
+
+  const handleManageEmail2FA = async () => {
+    if (!isValidCode(token)) {
+      dispatch({
+        type: 'setError',
+        error: _(msg`Please enter a valid code.`),
+      })
+      return
+    }
+
+    dispatch({type: 'setTokenStatus', status: 'pending'})
+
+    try {
+      await wait(1000, manageEmail2FA({enabled: false, token}))
+      dispatch({type: 'setTokenStatus', status: 'success'})
+      setTimeout(() => {
+        control.close()
+      }, 1000)
+    } catch (e) {
+      logger.error('Manage2FA: disable email 2FA failed', {safeMessage: e})
+      const {clean} = cleanError(e)
+      dispatch({
+        type: 'setError',
+        error: clean || _(msg`Failed to update email 2FA settings`),
+      })
+    }
+  }
+
+  return (
+    <View style={[a.gap_sm]}>
+      <Text style={[a.text_xl, a.font_heavy, a.leading_snug]}>
+        <Trans>Disable email 2FA</Trans>
+      </Text>
+
+      {state.step === 'email' ? (
+        <>
+          <Text
+            style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>
+              To disable your email 2FA method, please verify your access to{' '}
+              <Span style={[a.font_bold]}>{currentAccount?.email}</Span>
+            </Trans>
+          </Text>
+
+          <View style={[a.gap_lg, a.pt_sm]}>
+            {state.error && <Admonition type="error">{state.error}</Admonition>}
+
+            <Button
+              label={_(msg`Send email`)}
+              size="large"
+              variant="solid"
+              color="primary"
+              onPress={handleSendEmail}
+              disabled={state.emailStatus === 'pending'}>
+              <ButtonText>
+                <Trans>Send email</Trans>
+              </ButtonText>
+              <ButtonIcon
+                icon={
+                  state.emailStatus === 'pending'
+                    ? Loader
+                    : state.emailStatus === 'success'
+                    ? Check
+                    : Envelope
+                }
+              />
+            </Button>
+
+            <Divider />
+
+            <Text
+              style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+              <Trans>
+                Have a code?{' '}
+                <InlineLinkText
+                  label={_(msg`Enter code`)}
+                  {...createStaticClick(() => {
+                    dispatch({type: 'setStep', step: 'token'})
+                  })}>
+                  Click here.
+                </InlineLinkText>
+              </Trans>
+            </Text>
+          </View>
+        </>
+      ) : (
+        <>
+          <Text
+            style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>
+              To disable your email 2FA method, please verify your access to{' '}
+              <Span style={[a.font_bold]}>{currentAccount?.email}</Span>
+            </Trans>
+          </Text>
+
+          <View style={[a.gap_sm, a.py_sm]}>
+            <TokenField
+              value={token}
+              onChangeText={setToken}
+              onSubmitEditing={handleManageEmail2FA}
+            />
+            <ResendEmailText onPress={handleSendEmail} />
+          </View>
+
+          {state.error && <Admonition type="error">{state.error}</Admonition>}
+
+          <Button
+            label={_(msg`Disable 2FA`)}
+            size="large"
+            variant="solid"
+            color="primary"
+            onPress={handleManageEmail2FA}
+            disabled={
+              !token || token.length !== 11 || state.tokenStatus === 'pending'
+            }>
+            <ButtonText>
+              <Trans>Disable 2FA</Trans>
+            </ButtonText>
+            {state.tokenStatus === 'pending' ? (
+              <ButtonIcon icon={Loader} />
+            ) : state.tokenStatus === 'success' ? (
+              <ButtonIcon icon={Check} />
+            ) : null}
+          </Button>
+        </>
+      )}
+    </View>
+  )
+}
diff --git a/src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx b/src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx
new file mode 100644
index 000000000..7a126792a
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx
@@ -0,0 +1,137 @@
+import {useReducer} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {wait} from '#/lib/async/wait'
+import {useCleanError} from '#/lib/hooks/useCleanError'
+import {logger} from '#/logger'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogContext} from '#/components/Dialog'
+import {useManageEmail2FA} from '#/components/dialogs/EmailDialog/data/useManageEmail2FA'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+type State = {
+  error: string
+  status: 'pending' | 'success' | 'error' | 'default'
+}
+
+type Action =
+  | {
+      type: 'setError'
+      error: string
+    }
+  | {
+      type: 'setStatus'
+      status: State['status']
+    }
+
+function reducer(state: State, action: Action): State {
+  switch (action.type) {
+    case 'setError': {
+      return {
+        ...state,
+        error: action.error,
+        status: 'error',
+      }
+    }
+    case 'setStatus': {
+      return {
+        ...state,
+        error: '',
+        status: action.status,
+      }
+    }
+    default: {
+      return state
+    }
+  }
+}
+
+export function Enable() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const cleanError = useCleanError()
+  const {gtPhone} = useBreakpoints()
+  const {mutateAsync: manageEmail2FA} = useManageEmail2FA()
+  const control = useDialogContext()
+
+  const [state, dispatch] = useReducer(reducer, {
+    error: '',
+    status: 'default',
+  })
+
+  const handleManageEmail2FA = async () => {
+    dispatch({type: 'setStatus', status: 'pending'})
+
+    try {
+      await wait(1000, manageEmail2FA({enabled: true}))
+      dispatch({type: 'setStatus', status: 'success'})
+      setTimeout(() => {
+        control.close()
+      }, 1000)
+    } catch (e) {
+      logger.error('Manage2FA: enable email 2FA failed', {safeMessage: e})
+      const {clean} = cleanError(e)
+      dispatch({
+        type: 'setError',
+        error: clean || _(msg`Failed to update email 2FA settings`),
+      })
+    }
+  }
+
+  return (
+    <View style={[a.gap_lg]}>
+      <View style={[a.gap_sm]}>
+        <Text style={[a.text_xl, a.font_heavy, a.leading_snug]}>
+          <Trans>Enable email 2FA</Trans>
+        </Text>
+
+        <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+          <Trans>Require an email code to sign in to your account.</Trans>
+        </Text>
+      </View>
+
+      {state.error && <Admonition type="error">{state.error}</Admonition>}
+
+      <View style={[a.gap_sm, gtPhone && [a.flex_row_reverse]]}>
+        <Button
+          label={_(msg`Enable`)}
+          size="large"
+          variant="solid"
+          color="primary"
+          onPress={handleManageEmail2FA}
+          disabled={state.status === 'pending'}>
+          <ButtonText>
+            <Trans>Enable</Trans>
+          </ButtonText>
+          <ButtonIcon
+            position="right"
+            icon={
+              state.status === 'pending'
+                ? Loader
+                : state.status === 'success'
+                ? Check
+                : ShieldIcon
+            }
+          />
+        </Button>
+        <Button
+          label={_(msg`Cancel`)}
+          size="large"
+          variant="solid"
+          color="secondary"
+          onPress={() => control.close()}>
+          <ButtonText>
+            <Trans>Cancel</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx b/src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx
new file mode 100644
index 000000000..427a42b1f
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx
@@ -0,0 +1,70 @@
+import {useEffect, useState} from 'react'
+import {Trans} from '@lingui/macro'
+
+import {useAccountEmailState} from '#/components/dialogs/EmailDialog/data/useAccountEmailState'
+import {Disable} from '#/components/dialogs/EmailDialog/screens/Manage2FA/Disable'
+import {Enable} from '#/components/dialogs/EmailDialog/screens/Manage2FA/Enable'
+import {
+  ScreenID,
+  type ScreenProps,
+} from '#/components/dialogs/EmailDialog/types'
+
+export function Manage2FA({showScreen}: ScreenProps<ScreenID.Manage2FA>) {
+  const {isEmailVerified, email2FAEnabled} = useAccountEmailState()
+  const [requestedAction, setRequestedAction] = useState<
+    'enable' | 'disable' | null
+  >(null)
+
+  useEffect(() => {
+    if (!isEmailVerified) {
+      showScreen({
+        id: ScreenID.Verify,
+        instructions: [
+          <Trans key="2fa">
+            You need to verify your email address before you can enable email
+            2FA.
+          </Trans>,
+        ],
+        onVerify: () => {
+          showScreen({
+            id: ScreenID.Manage2FA,
+          })
+        },
+      })
+    }
+  }, [isEmailVerified, showScreen])
+
+  /*
+   * Wacky state handling so that once 2FA settings change, we don't show the
+   * wrong step of this form - esb
+   */
+
+  if (email2FAEnabled) {
+    if (!requestedAction) {
+      setRequestedAction('disable')
+      return <Disable />
+    }
+
+    if (requestedAction === 'disable') {
+      return <Disable />
+    }
+    if (requestedAction === 'enable') {
+      return <Enable />
+    }
+  } else {
+    if (!requestedAction) {
+      setRequestedAction('enable')
+      return <Enable />
+    }
+
+    if (requestedAction === 'disable') {
+      return <Disable />
+    }
+    if (requestedAction === 'enable') {
+      return <Enable />
+    }
+  }
+
+  // should never happen
+  return null
+}
diff --git a/src/components/dialogs/EmailDialog/screens/Update.tsx b/src/components/dialogs/EmailDialog/screens/Update.tsx
new file mode 100644
index 000000000..be0af8807
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/screens/Update.tsx
@@ -0,0 +1,319 @@
+import {useReducer} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {validate as validateEmail} from 'email-validator'
+
+import {wait} from '#/lib/async/wait'
+import {useCleanError} from '#/lib/hooks/useCleanError'
+import {logger} from '#/logger'
+import {useSession} from '#/state/session'
+import {atoms as a, useTheme} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText'
+import {
+  isValidCode,
+  TokenField,
+} from '#/components/dialogs/EmailDialog/components/TokenField'
+import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate'
+import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification'
+import {useUpdateEmail} from '#/components/dialogs/EmailDialog/data/useUpdateEmail'
+import {
+  type ScreenID,
+  type ScreenProps,
+} from '#/components/dialogs/EmailDialog/types'
+import {Divider} from '#/components/Divider'
+import * as TextField from '#/components/forms/TextField'
+import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+type State = {
+  step: 'email' | 'token'
+  mutationStatus: 'pending' | 'success' | 'error' | 'default'
+  error: string
+  emailValid: boolean
+  email: string
+  token: string
+}
+
+type Action =
+  | {
+      type: 'setStep'
+      step: State['step']
+    }
+  | {
+      type: 'setError'
+      error: string
+    }
+  | {
+      type: 'setMutationStatus'
+      status: State['mutationStatus']
+    }
+  | {
+      type: 'setEmail'
+      value: string
+    }
+  | {
+      type: 'setToken'
+      value: string
+    }
+
+function reducer(state: State, action: Action): State {
+  switch (action.type) {
+    case 'setStep': {
+      return {
+        ...state,
+        step: action.step,
+      }
+    }
+    case 'setError': {
+      return {
+        ...state,
+        error: action.error,
+        mutationStatus: 'error',
+      }
+    }
+    case 'setMutationStatus': {
+      return {
+        ...state,
+        error: '',
+        mutationStatus: action.status,
+      }
+    }
+    case 'setEmail': {
+      const emailValid = validateEmail(action.value)
+      return {
+        ...state,
+        step: 'email',
+        token: '',
+        email: action.value,
+        emailValid,
+      }
+    }
+    case 'setToken': {
+      return {
+        ...state,
+        error: '',
+        token: action.value,
+      }
+    }
+  }
+}
+
+export function Update(_props: ScreenProps<ScreenID.Update>) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const cleanError = useCleanError()
+  const {currentAccount} = useSession()
+  const [state, dispatch] = useReducer(reducer, {
+    step: 'email',
+    mutationStatus: 'default',
+    error: '',
+    email: '',
+    emailValid: true,
+    token: '',
+  })
+
+  const {mutateAsync: updateEmail} = useUpdateEmail()
+  const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate()
+  const {mutateAsync: requestEmailVerification} = useRequestEmailVerification()
+
+  const handleEmailChange = (email: string) => {
+    dispatch({
+      type: 'setEmail',
+      value: email,
+    })
+  }
+
+  const handleUpdateEmail = async () => {
+    if (state.step === 'token' && !isValidCode(state.token)) {
+      dispatch({
+        type: 'setError',
+        error: _(msg`Please enter a valid code.`),
+      })
+      return
+    }
+
+    dispatch({
+      type: 'setMutationStatus',
+      status: 'pending',
+    })
+
+    if (state.emailValid === false) {
+      dispatch({
+        type: 'setError',
+        error: _(msg`Please enter a valid email address.`),
+      })
+      return
+    }
+
+    if (state.email === currentAccount!.email) {
+      dispatch({
+        type: 'setError',
+        error: _(msg`This email is already associated with your account.`),
+      })
+      return
+    }
+
+    try {
+      const {status} = await wait(
+        1000,
+        updateEmail({
+          email: state.email,
+          token: state.token,
+        }),
+      )
+
+      if (status === 'tokenRequired') {
+        dispatch({
+          type: 'setStep',
+          step: 'token',
+        })
+        dispatch({
+          type: 'setMutationStatus',
+          status: 'default',
+        })
+      } else if (status === 'success') {
+        dispatch({
+          type: 'setMutationStatus',
+          status: 'success',
+        })
+
+        try {
+          // fire off a confirmation email immediately
+          await requestEmailVerification()
+        } catch {}
+      }
+    } catch (e) {
+      logger.error('EmailDialog: update email failed', {safeMessage: e})
+      const {clean} = cleanError(e)
+      dispatch({
+        type: 'setError',
+        error: clean || _(msg`Failed to update email, please try again.`),
+      })
+    }
+  }
+
+  return (
+    <View style={[a.gap_lg]}>
+      <Text style={[a.text_xl, a.font_heavy]}>
+        <Trans>Update your email</Trans>
+      </Text>
+
+      {currentAccount?.emailAuthFactor && (
+        <Admonition type="warning">
+          <Trans>
+            If you update your email address, email 2FA will be disabled.
+          </Trans>
+        </Admonition>
+      )}
+
+      <View style={[a.gap_md]}>
+        <View>
+          <Text style={[a.pb_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>Please enter your new email address.</Trans>
+          </Text>
+          <TextField.Root>
+            <TextField.Icon icon={Envelope} />
+            <TextField.Input
+              label={_(msg`New email address`)}
+              placeholder={_(msg`alice@example.com`)}
+              defaultValue={state.email}
+              onChangeText={
+                state.mutationStatus === 'success'
+                  ? undefined
+                  : handleEmailChange
+              }
+              keyboardType="email-address"
+              autoComplete="email"
+              autoCapitalize="none"
+              onSubmitEditing={handleUpdateEmail}
+            />
+          </TextField.Root>
+        </View>
+
+        {state.step === 'token' && (
+          <>
+            <Divider />
+            <View>
+              <Text style={[a.text_md, a.pb_sm, a.font_bold]}>
+                <Trans>Security step required</Trans>
+              </Text>
+              <Text
+                style={[a.pb_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+                <Trans>
+                  Please enter the security code we sent to your previous email
+                  address.
+                </Trans>
+              </Text>
+              <TokenField
+                value={state.token}
+                onChangeText={
+                  state.mutationStatus === 'success'
+                    ? undefined
+                    : token => {
+                        dispatch({
+                          type: 'setToken',
+                          value: token,
+                        })
+                      }
+                }
+                onSubmitEditing={handleUpdateEmail}
+              />
+              {state.mutationStatus !== 'success' && (
+                <ResendEmailText
+                  onPress={requestEmailUpdate}
+                  style={[a.pt_sm]}
+                />
+              )}
+            </View>
+          </>
+        )}
+
+        {state.error && <Admonition type="error">{state.error}</Admonition>}
+      </View>
+
+      {state.mutationStatus === 'success' ? (
+        <>
+          <Divider />
+          <View style={[a.gap_sm]}>
+            <View style={[a.flex_row, a.gap_sm, a.align_center]}>
+              <Check fill={t.palette.positive_600} size="xs" />
+              <Text style={[a.text_md, a.font_heavy]}>
+                <Trans>Success!</Trans>
+              </Text>
+            </View>
+            <Text style={[a.leading_snug]}>
+              <Trans>
+                Please click on the link in the email we just sent you to verify
+                your new email address. This is an important step to allow you
+                to continue enjoying all the features of Bluesky.
+              </Trans>
+            </Text>
+          </View>
+        </>
+      ) : (
+        <Button
+          label={_(msg`Update email`)}
+          size="large"
+          variant="solid"
+          color="primary"
+          onPress={handleUpdateEmail}
+          disabled={
+            !state.email ||
+            (state.step === 'token' &&
+              (!state.token || state.token.length !== 11)) ||
+            state.mutationStatus === 'pending'
+          }>
+          <ButtonText>
+            <Trans>Update email</Trans>
+          </ButtonText>
+          {state.mutationStatus === 'pending' && <ButtonIcon icon={Loader} />}
+        </Button>
+      )}
+    </View>
+  )
+}
diff --git a/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx b/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx
new file mode 100644
index 000000000..267b784b0
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx
@@ -0,0 +1,99 @@
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {useDialogContext} from '#/components/Dialog'
+import {
+  ScreenID,
+  type ScreenProps,
+} from '#/components/dialogs/EmailDialog/types'
+import {Divider} from '#/components/Divider'
+import {GradientFill} from '#/components/GradientFill'
+import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
+import {Text} from '#/components/Typography'
+
+export function VerificationReminder({
+  showScreen,
+}: ScreenProps<ScreenID.VerificationReminder>) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtPhone, gtMobile} = useBreakpoints()
+  const control = useDialogContext()
+
+  const dialogPadding = gtMobile ? a.p_2xl.padding : a.p_xl.padding
+
+  return (
+    <View style={[a.gap_lg]}>
+      <View
+        style={[
+          a.absolute,
+          {
+            top: platform({web: dialogPadding, default: a.p_2xl.padding}) * -1,
+            left: dialogPadding * -1,
+            right: dialogPadding * -1,
+            height: 150,
+          },
+        ]}>
+        <View
+          style={[
+            a.absolute,
+            a.inset_0,
+            a.align_center,
+            a.justify_center,
+            a.overflow_hidden,
+            a.pt_md,
+            t.atoms.bg_contrast_100,
+          ]}>
+          <GradientFill gradient={tokens.gradients.primary} />
+          <ShieldIcon width={64} fill="white" style={[a.z_10]} />
+        </View>
+      </View>
+
+      <View style={[a.mb_xs, {height: 150 - dialogPadding}]} />
+
+      <View style={[a.gap_sm]}>
+        <Text style={[a.text_xl, a.font_heavy]}>
+          <Trans>Please verify your email</Trans>
+        </Text>
+        <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+          <Trans>
+            Your email has not yet been verified. Please verify your email in
+            order to enjoy all the features of Bluesky.
+          </Trans>
+        </Text>
+      </View>
+
+      <Divider />
+
+      <View style={[a.gap_sm, gtPhone && [a.flex_row_reverse]]}>
+        <Button
+          label={_(msg`Get started`)}
+          variant="solid"
+          color="primary"
+          size="large"
+          onPress={() =>
+            showScreen({
+              id: ScreenID.Verify,
+            })
+          }>
+          <ButtonText>
+            <Trans>Get started</Trans>
+          </ButtonText>
+        </Button>
+        <Button
+          label={_(msg`Maybe later`)}
+          accessibilityHint={_(msg`Snoozes the reminder`)}
+          variant="ghost"
+          color="secondary"
+          size="large"
+          onPress={() => control.close()}>
+          <ButtonText>
+            <Trans>Maybe later</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/dialogs/EmailDialog/screens/Verify.tsx b/src/components/dialogs/EmailDialog/screens/Verify.tsx
new file mode 100644
index 000000000..dabd0d2f2
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/screens/Verify.tsx
@@ -0,0 +1,386 @@
+import {useReducer} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {wait} from '#/lib/async/wait'
+import {useCleanError} from '#/lib/hooks/useCleanError'
+import {logger} from '#/logger'
+import {useSession} from '#/state/session'
+import {atoms as a, useTheme} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText'
+import {
+  isValidCode,
+  TokenField,
+} from '#/components/dialogs/EmailDialog/components/TokenField'
+import {useConfirmEmail} from '#/components/dialogs/EmailDialog/data/useConfirmEmail'
+import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification'
+import {useOnEmailVerified} from '#/components/dialogs/EmailDialog/events'
+import {
+  ScreenID,
+  type ScreenProps,
+} from '#/components/dialogs/EmailDialog/types'
+import {Divider} from '#/components/Divider'
+import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
+import {createStaticClick, InlineLinkText} from '#/components/Link'
+import {Loader} from '#/components/Loader'
+import {Span, Text} from '#/components/Typography'
+
+type State = {
+  step: 'email' | 'token' | 'success'
+  mutationStatus: 'pending' | 'success' | 'error' | 'default'
+  error: string
+  token: string
+}
+
+type Action =
+  | {
+      type: 'setStep'
+      step: State['step']
+    }
+  | {
+      type: 'setError'
+      error: string
+    }
+  | {
+      type: 'setMutationStatus'
+      status: State['mutationStatus']
+    }
+  | {
+      type: 'setToken'
+      value: string
+    }
+
+function reducer(state: State, action: Action): State {
+  switch (action.type) {
+    case 'setStep': {
+      return {
+        ...state,
+        error: '',
+        mutationStatus: 'default',
+        step: action.step,
+      }
+    }
+    case 'setError': {
+      return {
+        ...state,
+        error: action.error,
+        mutationStatus: 'error',
+      }
+    }
+    case 'setMutationStatus': {
+      return {
+        ...state,
+        error: '',
+        mutationStatus: action.status,
+      }
+    }
+    case 'setToken': {
+      return {
+        ...state,
+        error: '',
+        token: action.value,
+      }
+    }
+  }
+}
+
+export function Verify({config, showScreen}: ScreenProps<ScreenID.Verify>) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const cleanError = useCleanError()
+  const {currentAccount} = useSession()
+  const [state, dispatch] = useReducer(reducer, {
+    step: 'email',
+    mutationStatus: 'default',
+    error: '',
+    token: '',
+  })
+
+  const {mutateAsync: requestEmailVerification} = useRequestEmailVerification()
+  const {mutateAsync: confirmEmail} = useConfirmEmail()
+
+  useOnEmailVerified(() => {
+    if (config.onVerify) {
+      config.onVerify()
+    } else {
+      dispatch({
+        type: 'setStep',
+        step: 'success',
+      })
+    }
+  })
+
+  const handleRequestEmailVerification = async () => {
+    dispatch({
+      type: 'setMutationStatus',
+      status: 'pending',
+    })
+
+    try {
+      await wait(1000, requestEmailVerification())
+      dispatch({
+        type: 'setMutationStatus',
+        status: 'success',
+      })
+    } catch (e) {
+      logger.error('EmailDialog: sending verification email failed', {
+        safeMessage: e,
+      })
+      const {clean} = cleanError(e)
+      dispatch({
+        type: 'setError',
+        error: clean || _(msg`Failed to send email, please try again.`),
+      })
+    }
+  }
+
+  const handleConfirmEmail = async () => {
+    if (!isValidCode(state.token)) {
+      dispatch({
+        type: 'setError',
+        error: _(msg`Please enter a valid code.`),
+      })
+      return
+    }
+
+    dispatch({
+      type: 'setMutationStatus',
+      status: 'pending',
+    })
+
+    try {
+      await wait(1000, confirmEmail({token: state.token}))
+      dispatch({
+        type: 'setStep',
+        step: 'success',
+      })
+    } catch (e) {
+      logger.error('EmailDialog: confirming email failed', {
+        safeMessage: e,
+      })
+      const {clean} = cleanError(e)
+      dispatch({
+        type: 'setError',
+        error: clean || _(msg`Failed to verify email, please try again.`),
+      })
+    }
+  }
+
+  if (state.step === 'success') {
+    return (
+      <View style={[a.gap_lg]}>
+        <View style={[a.gap_sm]}>
+          <Text style={[a.text_xl, a.font_heavy]}>
+            <Span style={{top: 1}}>
+              <Check size="sm" fill={t.palette.positive_600} />
+            </Span>
+            {'  '}
+            <Trans>Email verification complete!</Trans>
+          </Text>
+
+          <Text
+            style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>
+              You have successfully verified your email address. You can close
+              this dialog.
+            </Trans>
+          </Text>
+        </View>
+      </View>
+    )
+  }
+
+  return (
+    <View style={[a.gap_lg]}>
+      <View style={[a.gap_sm]}>
+        <Text style={[a.text_xl, a.font_heavy]}>
+          {state.step === 'email' ? (
+            state.mutationStatus === 'success' ? (
+              <>
+                <Span style={{top: 1}}>
+                  <Check size="sm" fill={t.palette.positive_600} />
+                </Span>
+                {'  '}
+                <Trans>Email sent!</Trans>
+              </>
+            ) : (
+              <Trans>Verify your email</Trans>
+            )
+          ) : (
+            <Trans>Verify email code</Trans>
+          )}
+        </Text>
+
+        {state.step === 'email' && state.mutationStatus !== 'success' && (
+          <>
+            {config.instructions?.map((int, i) => (
+              <Text
+                key={i}
+                style={[
+                  a.italic,
+                  a.text_sm,
+                  a.leading_snug,
+                  t.atoms.text_contrast_medium,
+                ]}>
+                {int}
+              </Text>
+            ))}
+          </>
+        )}
+
+        <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+          {state.step === 'email' ? (
+            state.mutationStatus === 'success' ? (
+              <Trans>
+                We sent an email to{' '}
+                <Span style={[a.font_bold, t.atoms.text]}>
+                  {currentAccount!.email}
+                </Span>{' '}
+                containing a link. Please click on it to complete the email
+                verification process.
+              </Trans>
+            ) : (
+              <Trans>
+                We'll send an email to{' '}
+                <Span style={[a.font_bold, t.atoms.text]}>
+                  {currentAccount!.email}
+                </Span>{' '}
+                containing a link. Please click on it to complete the email
+                verification process.
+              </Trans>
+            )
+          ) : (
+            <Trans>
+              Please enter the code we sent to{' '}
+              <Span style={[a.font_bold, t.atoms.text]}>
+                {currentAccount!.email}
+              </Span>{' '}
+              below.
+            </Trans>
+          )}
+        </Text>
+
+        {state.step === 'email' && state.mutationStatus !== 'success' && (
+          <Text
+            style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>
+              If you need to update your email,{' '}
+              <InlineLinkText
+                label={_(msg`Click here to update your email`)}
+                {...createStaticClick(() => {
+                  showScreen({id: ScreenID.Update})
+                })}>
+                click here
+              </InlineLinkText>
+              .
+            </Trans>
+          </Text>
+        )}
+
+        {state.step === 'email' && state.mutationStatus === 'success' && (
+          <ResendEmailText onPress={requestEmailVerification} />
+        )}
+      </View>
+
+      {state.step === 'email' && state.mutationStatus !== 'success' ? (
+        <>
+          {state.error && <Admonition type="error">{state.error}</Admonition>}
+          <Button
+            label={_(msg`Send verification email`)}
+            size="large"
+            variant="solid"
+            color="primary"
+            onPress={handleRequestEmailVerification}
+            disabled={state.mutationStatus === 'pending'}>
+            <ButtonText>
+              <Trans>Send email</Trans>
+            </ButtonText>
+            <ButtonIcon
+              icon={state.mutationStatus === 'pending' ? Loader : Envelope}
+            />
+          </Button>
+        </>
+      ) : null}
+
+      {state.step === 'email' && (
+        <>
+          <Divider />
+
+          <Text
+            style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>
+              Have a code?{' '}
+              <InlineLinkText
+                label={_(msg`Enter code`)}
+                {...createStaticClick(() => {
+                  dispatch({
+                    type: 'setStep',
+                    step: 'token',
+                  })
+                })}>
+                Click here.
+              </InlineLinkText>
+            </Trans>
+          </Text>
+        </>
+      )}
+
+      {state.step === 'token' ? (
+        <>
+          <TokenField
+            value={state.token}
+            onChangeText={token => {
+              dispatch({
+                type: 'setToken',
+                value: token,
+              })
+            }}
+            onSubmitEditing={handleConfirmEmail}
+          />
+
+          {state.error && <Admonition type="error">{state.error}</Admonition>}
+
+          <Button
+            label={_(msg`Verify code`)}
+            size="large"
+            variant="solid"
+            color="primary"
+            onPress={handleConfirmEmail}
+            disabled={
+              !state.token ||
+              state.token.length !== 11 ||
+              state.mutationStatus === 'pending'
+            }>
+            <ButtonText>
+              <Trans>Verify code</Trans>
+            </ButtonText>
+            {state.mutationStatus === 'pending' && <ButtonIcon icon={Loader} />}
+          </Button>
+
+          <Divider />
+
+          <Text
+            style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>
+              Don't have a code or need a new one?{' '}
+              <InlineLinkText
+                label={_(msg`Click here to restart the verification process.`)}
+                {...createStaticClick(() => {
+                  dispatch({
+                    type: 'setStep',
+                    step: 'email',
+                  })
+                })}>
+                Click here.
+              </InlineLinkText>
+            </Trans>
+          </Text>
+        </>
+      ) : null}
+    </View>
+  )
+}
diff --git a/src/components/dialogs/EmailDialog/types.ts b/src/components/dialogs/EmailDialog/types.ts
new file mode 100644
index 000000000..7edc3facc
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/types.ts
@@ -0,0 +1,38 @@
+import {type ReactNode} from 'react'
+
+import {type DialogControlProps} from '#/components/Dialog'
+
+export type EmailDialogProps = {
+  control: DialogControlProps
+}
+
+export type EmailDialogInnerProps = EmailDialogProps & {}
+
+export type Screen =
+  | {
+      id: ScreenID.Update
+    }
+  | {
+      id: ScreenID.Verify
+      instructions?: ReactNode[]
+      onVerify?: () => void
+      onCloseWithoutVerifying?: () => void
+    }
+  | {
+      id: ScreenID.VerificationReminder
+    }
+  | {
+      id: ScreenID.Manage2FA
+    }
+
+export enum ScreenID {
+  Update = 'Update',
+  Verify = 'Verify',
+  VerificationReminder = 'VerificationReminder',
+  Manage2FA = 'Manage2FA',
+}
+
+export type ScreenProps<T extends ScreenID> = {
+  config: Extract<Screen, {id: T}>
+  showScreen: (screen: Screen) => void
+}
diff --git a/src/components/dms/MessageProfileButton.tsx b/src/components/dms/MessageProfileButton.tsx
index 7f31f550c..07b3a0c04 100644
--- a/src/components/dms/MessageProfileButton.tsx
+++ b/src/components/dms/MessageProfileButton.tsx
@@ -1,20 +1,18 @@
 import React from 'react'
 import {View} from 'react-native'
-import {AppBskyActorDefs} from '@atproto/api'
-import {msg} from '@lingui/macro'
+import {type AppBskyActorDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 
-import {useEmail} from '#/lib/hooks/useEmail'
-import {NavigationProp} from '#/lib/routes/types'
+import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
+import {type NavigationProp} from '#/lib/routes/types'
 import {logEvent} from '#/lib/statsig/statsig'
 import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability'
 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
 import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonIcon} from '#/components/Button'
-import {useDialogControl} from '#/components/Dialog'
-import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
 import {canBeMessaged} from '#/components/dms/util'
 import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message'
 
@@ -26,8 +24,7 @@ export function MessageProfileButton({
   const {_} = useLingui()
   const t = useTheme()
   const navigation = useNavigation<NavigationProp>()
-  const {needsEmailVerification} = useEmail()
-  const verifyEmailControl = useDialogControl()
+  const requireEmailVerification = useRequireEmailVerification()
 
   const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did)
   const {mutate: initiateConvo} = useGetConvoForMembers({
@@ -45,11 +42,6 @@ export function MessageProfileButton({
       return
     }
 
-    if (needsEmailVerification) {
-      verifyEmailControl.open()
-      return
-    }
-
     if (convoAvailability.convo) {
       logEvent('chat:open', {logContext: 'ProfileHeader'})
       navigation.navigate('MessagesConversation', {
@@ -59,14 +51,15 @@ export function MessageProfileButton({
       logEvent('chat:create', {logContext: 'ProfileHeader'})
       initiateConvo([profile.did])
     }
-  }, [
-    needsEmailVerification,
-    verifyEmailControl,
-    navigation,
-    profile.did,
-    initiateConvo,
-    convoAvailability,
-  ])
+  }, [navigation, profile.did, initiateConvo, convoAvailability])
+
+  const wrappedOnPress = requireEmailVerification(onPress, {
+    instructions: [
+      <Trans key="message">
+        Before you can message another user, you must first verify your email.
+      </Trans>,
+    ],
+  })
 
   if (!convoAvailability) {
     // show pending state based on declaration
@@ -102,15 +95,9 @@ export function MessageProfileButton({
           shape="round"
           label={_(msg`Message ${profile.handle}`)}
           style={[a.justify_center]}
-          onPress={onPress}>
+          onPress={wrappedOnPress}>
           <ButtonIcon icon={Message} size="md" />
         </Button>
-        <VerifyEmailDialog
-          reasonText={_(
-            msg`Before you may message another user, you must first verify your email.`,
-          )}
-          control={verifyEmailControl}
-        />
       </>
     )
   } else {
diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx
index a5ba793fb..192e36b5d 100644
--- a/src/components/dms/dialogs/NewChatDialog.tsx
+++ b/src/components/dms/dialogs/NewChatDialog.tsx
@@ -1,8 +1,8 @@
 import {useCallback} from 'react'
-import {msg} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {useEmail} from '#/lib/hooks/useEmail'
+import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
 import {logEvent} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
@@ -10,9 +10,7 @@ import {FAB} from '#/view/com/util/fab/FAB'
 import * as Toast from '#/view/com/util/Toast'
 import {useTheme} from '#/alf'
 import * as Dialog from '#/components/Dialog'
-import {useDialogControl} from '#/components/Dialog'
 import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
-import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
 
 export function NewChat({
@@ -24,8 +22,7 @@ export function NewChat({
 }) {
   const t = useTheme()
   const {_} = useLingui()
-  const {needsEmailVerification} = useEmail()
-  const verifyEmailControl = useDialogControl()
+  const requireEmailVerification = useRequireEmailVerification()
 
   const {mutate: createChat} = useGetConvoForMembers({
     onSuccess: data => {
@@ -49,17 +46,22 @@ export function NewChat({
     [control, createChat],
   )
 
+  const onPress = useCallback(() => {
+    control.open()
+  }, [control])
+  const wrappedOnPress = requireEmailVerification(onPress, {
+    instructions: [
+      <Trans key="new-chat">
+        Before you can message another user, you must first verify your email.
+      </Trans>,
+    ],
+  })
+
   return (
     <>
       <FAB
         testID="newChatFAB"
-        onPress={() => {
-          if (needsEmailVerification) {
-            verifyEmailControl.open()
-          } else {
-            control.open()
-          }
-        }}
+        onPress={wrappedOnPress}
         icon={<Plus size="lg" fill={t.palette.white} />}
         accessibilityRole="button"
         accessibilityLabel={_(msg`New chat`)}
@@ -74,13 +76,6 @@ export function NewChat({
           sortByMessageDeclaration
         />
       </Dialog.Outer>
-
-      <VerifyEmailDialog
-        reasonText={_(
-          msg`Before you may message another user, you must first verify your email.`,
-        )}
-        control={verifyEmailControl}
-      />
     </>
   )
 }
diff --git a/src/lib/hooks/useCleanError.ts b/src/lib/hooks/useCleanError.ts
new file mode 100644
index 000000000..dc9284e90
--- /dev/null
+++ b/src/lib/hooks/useCleanError.ts
@@ -0,0 +1,91 @@
+import {useCallback} from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+type CleanedError = {
+  raw: string | undefined
+  clean: string | undefined
+}
+
+export function useCleanError() {
+  const {_} = useLingui()
+
+  return useCallback<(error?: any) => CleanedError>(
+    error => {
+      if (!error)
+        return {
+          raw: undefined,
+          clean: undefined,
+        }
+
+      let raw = error.toString()
+
+      if (isNetworkError(raw)) {
+        return {
+          raw,
+          clean: _(
+            msg`Unable to connect. Please check your internet connection and try again.`,
+          ),
+        }
+      }
+
+      if (
+        raw.includes('Upstream Failure') ||
+        raw.includes('NotEnoughResources') ||
+        raw.includes('pipethrough network error')
+      ) {
+        return {
+          raw,
+          clean: _(
+            msg`The server appears to be experiencing issues. Please try again in a few moments.`,
+          ),
+        }
+      }
+
+      if (raw.includes('Bad token scope')) {
+        return {
+          raw,
+          clean: _(
+            msg`This feature is not available while using an app password. Please sign in with your main password.`,
+          ),
+        }
+      }
+
+      if (raw.includes('Rate Limit Exceeded')) {
+        return {
+          raw,
+          clean: _(
+            msg`You've reached the maximum number of requests allowed. Please try again later.`,
+          ),
+        }
+      }
+
+      if (raw.startsWith('Error: ')) {
+        raw = raw.slice('Error: '.length)
+      }
+
+      return {
+        raw,
+        clean: undefined,
+      }
+    },
+    [_],
+  )
+}
+
+const NETWORK_ERRORS = [
+  'Abort',
+  'Network request failed',
+  'Failed to fetch',
+  'Load failed',
+]
+
+export function isNetworkError(e: unknown) {
+  const str = String(e)
+  for (const err of NETWORK_ERRORS) {
+    if (str.includes(err)) {
+      return true
+    }
+  }
+  return false
+}
diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts
index a33aff237..4a5653750 100644
--- a/src/lib/hooks/useIntentHandler.ts
+++ b/src/lib/hooks/useIntentHandler.ts
@@ -1,10 +1,10 @@
 import React from 'react'
 import * as Linking from 'expo-linking'
 
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
 import {logEvent} from '#/lib/statsig/statsig'
 import {isNative} from '#/platform/detection'
 import {useSession} from '#/state/session'
-import {useComposerControls} from '#/state/shell'
 import {useCloseAllActiveElements} from '#/state/util'
 import {useIntentDialogs} from '#/components/intents/IntentDialogs'
 import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
@@ -83,7 +83,7 @@ export function useIntentHandler() {
 
 export function useComposeIntent() {
   const closeAllActiveElements = useCloseAllActiveElements()
-  const {openComposer} = useComposerControls()
+  const {openComposer} = useOpenComposer()
   const {hasSession} = useSession()
 
   return React.useCallback(
diff --git a/src/lib/hooks/useOpenComposer.tsx b/src/lib/hooks/useOpenComposer.tsx
new file mode 100644
index 000000000..50c04d1e1
--- /dev/null
+++ b/src/lib/hooks/useOpenComposer.tsx
@@ -0,0 +1,22 @@
+import {useMemo} from 'react'
+import {Trans} from '@lingui/macro'
+
+import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
+import {useOpenComposer as rootUseOpenComposer} from '#/state/shell/composer'
+
+export function useOpenComposer() {
+  const {openComposer} = rootUseOpenComposer()
+  const requireEmailVerification = useRequireEmailVerification()
+  return useMemo(() => {
+    return {
+      openComposer: requireEmailVerification(openComposer, {
+        instructions: [
+          <Trans key="pre-compose">
+            Before creating a post or replying, you must first verify your
+            email.
+          </Trans>,
+        ],
+      }),
+    }
+  }, [openComposer, requireEmailVerification])
+}
diff --git a/src/lib/hooks/useRequireEmailVerification.tsx b/src/lib/hooks/useRequireEmailVerification.tsx
new file mode 100644
index 000000000..26045847e
--- /dev/null
+++ b/src/lib/hooks/useRequireEmailVerification.tsx
@@ -0,0 +1,53 @@
+import {useCallback} from 'react'
+import {Keyboard} from 'react-native'
+
+import {useEmail} from '#/lib/hooks/useEmail'
+import {useRequireAuth, useSession} from '#/state/session'
+import {useCloseAllActiveElements} from '#/state/util'
+import {
+  EmailDialogScreenID,
+  type Screen,
+  useEmailDialogControl,
+} from '#/components/dialogs/EmailDialog'
+
+export function useRequireEmailVerification() {
+  const {currentAccount} = useSession()
+  const {needsEmailVerification} = useEmail()
+  const requireAuth = useRequireAuth()
+  const emailDialogControl = useEmailDialogControl()
+  const closeAll = useCloseAllActiveElements()
+
+  return useCallback(
+    <T extends (...args: any[]) => any>(
+      cb: T,
+      config: Omit<
+        Extract<Screen, {id: EmailDialogScreenID.Verify}>,
+        'id'
+      > = {},
+    ): ((...args: Parameters<T>) => ReturnType<T>) => {
+      return (...args: Parameters<T>): ReturnType<T> => {
+        if (!currentAccount) {
+          return requireAuth(() => cb(...args)) as ReturnType<T>
+        }
+        if (needsEmailVerification) {
+          Keyboard.dismiss()
+          closeAll()
+          emailDialogControl.open({
+            id: EmailDialogScreenID.Verify,
+            ...config,
+          })
+          return undefined as ReturnType<T>
+        } else {
+          return cb(...args)
+        }
+      }
+    },
+    [
+      needsEmailVerification,
+      currentAccount,
+      emailDialogControl,
+      closeAll,
+      requireAuth,
+    ],
+  )
+}
diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx
index 2846ed828..9fd54f1b0 100644
--- a/src/screens/Messages/ChatList.tsx
+++ b/src/screens/Messages/ChatList.tsx
@@ -9,6 +9,7 @@ import {type NativeStackScreenProps} from '@react-navigation/native-stack'
 
 import {useAppState} from '#/lib/hooks/useAppState'
 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
+import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
 import {type MessagesTabNavigatorParams} from '#/lib/routes/types'
 import {cleanError} from '#/lib/strings/errors'
 import {logger} from '#/logger'
@@ -321,6 +322,18 @@ export function MessagesScreen({navigation, route}: Props) {
 function Header({newChatControl}: {newChatControl: DialogControlProps}) {
   const {_} = useLingui()
   const {gtMobile} = useBreakpoints()
+  const requireEmailVerification = useRequireEmailVerification()
+
+  const openChatControl = useCallback(() => {
+    newChatControl.open()
+  }, [newChatControl])
+  const wrappedOpenChatControl = requireEmailVerification(openChatControl, {
+    instructions: [
+      <Trans key="new-chat">
+        Before you can message another user, you must first verify your email.
+      </Trans>,
+    ],
+  })
 
   const settingsLink = (
     <Link
@@ -352,7 +365,7 @@ function Header({newChatControl}: {newChatControl: DialogControlProps}) {
               color="primary"
               size="small"
               variant="solid"
-              onPress={newChatControl.open}>
+              onPress={wrappedOpenChatControl}>
               <ButtonIcon icon={PlusIcon} position="left" />
               <ButtonText>
                 <Trans>New chat</Trans>
diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx
index 2222084ce..90547a8d4 100644
--- a/src/screens/Messages/Conversation.tsx
+++ b/src/screens/Messages/Conversation.tsx
@@ -1,11 +1,11 @@
-import React, {useCallback} from 'react'
+import React, {useCallback, useEffect} from 'react'
 import {View} from 'react-native'
 import {
   type AppBskyActorDefs,
   moderateProfile,
   type ModerationDecision,
 } from '@atproto/api'
-import {msg} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {
   type RouteProp,
@@ -17,6 +17,7 @@ import {type NativeStackScreenProps} from '@react-navigation/native-stack'
 
 import {useEmail} from '#/lib/hooks/useEmail'
 import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 import {
   type CommonNavigatorParams,
   type NavigationProp,
@@ -31,8 +32,10 @@ import {useProfileQuery} from '#/state/queries/profile'
 import {useSetMinimalShellMode} from '#/state/shell'
 import {MessagesList} from '#/screens/Messages/components/MessagesList'
 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
-import {useDialogControl} from '#/components/Dialog'
-import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
+import {
+  EmailDialogScreenID,
+  useEmailDialogControl,
+} from '#/components/dialogs/EmailDialog'
 import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter'
 import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
 import {Error} from '#/components/Error'
@@ -183,19 +186,50 @@ function InnerReady({
   hasScrolled: boolean
   setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
 }) {
-  const {_} = useLingui()
   const convoState = useConvo()
   const navigation = useNavigation<NavigationProp>()
   const {params} =
     useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
-  const verifyEmailControl = useDialogControl()
   const {needsEmailVerification} = useEmail()
+  const emailDialogControl = useEmailDialogControl()
 
-  React.useEffect(() => {
+  /**
+   * Must be non-reactive, otherwise the update to open the global dialog will
+   * cause a re-render loop.
+   */
+  const maybeBlockForEmailVerification = useNonReactiveCallback(() => {
     if (needsEmailVerification) {
-      verifyEmailControl.open()
+      /*
+       * HACKFIX
+       *
+       * Load bearing timeout, to bump this state update until the after the
+       * `navigator.addListener('state')` handler closes elements from
+       * `shell/index.*.tsx`  - sfn & esb
+       */
+      setTimeout(() =>
+        emailDialogControl.open({
+          id: EmailDialogScreenID.Verify,
+          instructions: [
+            <Trans key="pre-compose">
+              Before you can message another user, you must first verify your
+              email.
+            </Trans>,
+          ],
+          onCloseWithoutVerifying: () => {
+            if (navigation.canGoBack()) {
+              navigation.goBack()
+            } else {
+              navigation.navigate('Messages', {animation: 'pop'})
+            }
+          },
+        }),
+      )
     }
-  }, [needsEmailVerification, verifyEmailControl])
+  })
+
+  useEffect(() => {
+    maybeBlockForEmailVerification()
+  }, [maybeBlockForEmailVerification])
 
   return (
     <>
@@ -216,15 +250,6 @@ function InnerReady({
           }
         />
       )}
-      <VerifyEmailDialog
-        reasonText={_(
-          msg`Before you may message another user, you must first verify your email.`,
-        )}
-        control={verifyEmailControl}
-        onCloseWithoutVerifying={() => {
-          navigation.navigate('Home')
-        }}
-      />
     </>
   )
 }
diff --git a/src/screens/Messages/components/RequestButtons.tsx b/src/screens/Messages/components/RequestButtons.tsx
index 62db09600..3490bec0d 100644
--- a/src/screens/Messages/components/RequestButtons.tsx
+++ b/src/screens/Messages/components/RequestButtons.tsx
@@ -1,11 +1,12 @@
 import {useCallback} from 'react'
-import {ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
+import {type ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {StackActions, useNavigation} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 
-import {NavigationProp} from '#/lib/routes/types'
+import {useEmail} from '#/lib/hooks/useEmail'
+import {type NavigationProp} from '#/lib/routes/types'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {useAcceptConversation} from '#/state/queries/messages/accept-conversation'
 import {precacheConvoQuery} from '#/state/queries/messages/conversation'
@@ -13,8 +14,17 @@ import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
 import {useProfileBlockMutationQueue} from '#/state/queries/profile'
 import * as Toast from '#/view/com/util/Toast'
 import {atoms as a} from '#/alf'
-import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button'
+import {
+  Button,
+  ButtonIcon,
+  type ButtonProps,
+  ButtonText,
+} from '#/components/Button'
 import {useDialogControl} from '#/components/Dialog'
+import {
+  EmailDialogScreenID,
+  useEmailDialogControl,
+} from '#/components/dialogs/EmailDialog'
 import {ReportDialog} from '#/components/dms/ReportDialog'
 import {CircleX_Stroke2_Corner0_Rounded} from '#/components/icons/CircleX'
 import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
@@ -186,6 +196,8 @@ export function AcceptChatButton({
   const {_} = useLingui()
   const queryClient = useQueryClient()
   const navigation = useNavigation<NavigationProp>()
+  const {needsEmailVerification} = useEmail()
+  const emailDialogControl = useEmailDialogControl()
 
   const {mutate: acceptConvo, isPending} = useAcceptConversation(convo.id, {
     onMutate: () => {
@@ -216,8 +228,20 @@ export function AcceptChatButton({
   })
 
   const onPressAccept = useCallback(() => {
-    acceptConvo()
-  }, [acceptConvo])
+    if (needsEmailVerification) {
+      emailDialogControl.open({
+        id: EmailDialogScreenID.Verify,
+        instructions: [
+          <Trans key="request-btn">
+            Before you can accept this chat request, you must first verify your
+            email.
+          </Trans>,
+        ],
+      })
+    } else {
+      acceptConvo()
+    }
+  }, [acceptConvo, needsEmailVerification, emailDialogControl])
 
   return (
     <Button
diff --git a/src/screens/Messages/components/RequestListItem.tsx b/src/screens/Messages/components/RequestListItem.tsx
index 654691a01..5e09dd2e3 100644
--- a/src/screens/Messages/components/RequestListItem.tsx
+++ b/src/screens/Messages/components/RequestListItem.tsx
@@ -1,5 +1,5 @@
 import {View} from 'react-native'
-import {ChatBskyConvoDefs} from '@atproto/api'
+import {type ChatBskyConvoDefs} from '@atproto/api'
 import {Trans} from '@lingui/macro'
 
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
diff --git a/src/screens/Profile/ProfileFeed/index.tsx b/src/screens/Profile/ProfileFeed/index.tsx
index 3f517d334..2f4b87015 100644
--- a/src/screens/Profile/ProfileFeed/index.tsx
+++ b/src/screens/Profile/ProfileFeed/index.tsx
@@ -5,36 +5,39 @@ import {AppBskyFeedDefs} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useIsFocused, useNavigation} from '@react-navigation/native'
-import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {type NativeStackScreenProps} from '@react-navigation/native-stack'
 import {useQueryClient} from '@tanstack/react-query'
 
 import {VIDEO_FEED_URIS} from '#/lib/constants'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useSetTitle} from '#/lib/hooks/useSetTitle'
 import {ComposeIcon2} from '#/lib/icons'
-import {CommonNavigatorParams} from '#/lib/routes/types'
-import {NavigationProp} from '#/lib/routes/types'
+import {type CommonNavigatorParams} from '#/lib/routes/types'
+import {type NavigationProp} from '#/lib/routes/types'
 import {makeRecordUri} from '#/lib/strings/url-helpers'
 import {s} from '#/lib/styles'
 import {isNative} from '#/platform/detection'
 import {listenSoftReset} from '#/state/events'
 import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
-import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
-import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
+import {
+  type FeedSourceFeedInfo,
+  useFeedSourceInfoQuery,
+} from '#/state/queries/feed'
+import {type FeedDescriptor, type FeedParams} from '#/state/queries/post-feed'
 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
 import {
   usePreferencesQuery,
-  UsePreferencesQueryResponse,
+  type UsePreferencesQueryResponse,
 } from '#/state/queries/preferences'
 import {useResolveUriQuery} from '#/state/queries/resolve-uri'
 import {truncateAndInvalidate} from '#/state/queries/util'
 import {useSession} from '#/state/session'
-import {useComposerControls} from '#/state/shell/composer'
 import {PostFeed} from '#/view/com/posts/PostFeed'
 import {EmptyState} from '#/view/com/util/EmptyState'
 import {FAB} from '#/view/com/util/fab/FAB'
 import {Button} from '#/view/com/util/forms/Button'
-import {ListRef} from '#/view/com/util/List'
+import {type ListRef} from '#/view/com/util/List'
 import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
 import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 import {Text} from '#/view/com/util/text/Text'
@@ -156,7 +159,7 @@ export function ProfileFeedScreenInner({
 }) {
   const {_} = useLingui()
   const {hasSession} = useSession()
-  const {openComposer} = useComposerControls()
+  const {openComposer} = useOpenComposer()
   const isScreenFocused = useIsFocused()
 
   useSetTitle(feedInfo?.displayName)
diff --git a/src/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx
index 7c50bd8df..393bad2f8 100644
--- a/src/screens/Settings/AccountSettings.tsx
+++ b/src/screens/Settings/AccountSettings.tsx
@@ -9,8 +9,10 @@ import * as SettingsList from '#/screens/Settings/components/SettingsList'
 import {atoms as a, useTheme} from '#/alf'
 import {useDialogControl} from '#/components/Dialog'
 import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
-import {ChangeEmailDialog} from '#/components/dialogs/ChangeEmailDialog'
-import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
+import {
+  EmailDialogScreenID,
+  useEmailDialogControl,
+} from '#/components/dialogs/EmailDialog'
 import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At'
 import {BirthdayCake_Stroke2_Corner2_Rounded as BirthdayCakeIcon} from '#/components/icons/BirthdayCake'
 import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car'
@@ -31,8 +33,7 @@ export function AccountSettingsScreen({}: Props) {
   const {_} = useLingui()
   const {currentAccount} = useSession()
   const {openModal} = useModalControls()
-  const verifyEmailControl = useDialogControl()
-  const changeEmailControl = useDialogControl()
+  const emailDialogControl = useEmailDialogControl()
   const birthdayControl = useDialogControl()
   const changeHandleControl = useDialogControl()
   const exportCarControl = useDialogControl()
@@ -75,7 +76,11 @@ export function AccountSettingsScreen({}: Props) {
           {currentAccount && !currentAccount.emailConfirmed && (
             <SettingsList.PressableItem
               label={_(msg`Verify your email`)}
-              onPress={() => verifyEmailControl.open()}
+              onPress={() =>
+                emailDialogControl.open({
+                  id: EmailDialogScreenID.Verify,
+                })
+              }
               style={[
                 a.my_xs,
                 a.mx_lg,
@@ -96,11 +101,15 @@ export function AccountSettingsScreen({}: Props) {
             </SettingsList.PressableItem>
           )}
           <SettingsList.PressableItem
-            label={_(msg`Change email`)}
-            onPress={() => changeEmailControl.open()}>
+            label={_(msg`Update email`)}
+            onPress={() =>
+              emailDialogControl.open({
+                id: EmailDialogScreenID.Update,
+              })
+            }>
             <SettingsList.ItemIcon icon={PencilIcon} />
             <SettingsList.ItemText>
-              <Trans>Change email</Trans>
+              <Trans>Update email</Trans>
             </SettingsList.ItemText>
             <SettingsList.Chevron />
           </SettingsList.PressableItem>
@@ -167,14 +176,6 @@ export function AccountSettingsScreen({}: Props) {
         </SettingsList.Container>
       </Layout.Content>
 
-      <ChangeEmailDialog
-        control={changeEmailControl}
-        verifyEmailControl={verifyEmailControl}
-      />
-      <VerifyEmailDialog
-        control={verifyEmailControl}
-        changeEmailControl={changeEmailControl}
-      />
       <BirthDateSettingsDialog control={birthdayControl} />
       <ChangeHandleDialog control={changeHandleControl} />
       <ExportCarDialog control={exportCarControl} />
diff --git a/src/screens/Settings/components/Email2FAToggle.tsx b/src/screens/Settings/components/Email2FAToggle.tsx
index 3e341cd73..584026298 100644
--- a/src/screens/Settings/components/Email2FAToggle.tsx
+++ b/src/screens/Settings/components/Email2FAToggle.tsx
@@ -2,11 +2,12 @@ import React from 'react'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {useAgent, useSession} from '#/state/session'
+import {useSession} from '#/state/session'
 import {useDialogControl} from '#/components/Dialog'
-import {ChangeEmailDialog} from '#/components/dialogs/ChangeEmailDialog'
-import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
-import * as Prompt from '#/components/Prompt'
+import {
+  EmailDialogScreenID,
+  useEmailDialogControl,
+} from '#/components/dialogs/EmailDialog'
 import {DisableEmail2FADialog} from './DisableEmail2FADialog'
 import * as SettingsList from './SettingsList'
 
@@ -14,63 +15,17 @@ export function Email2FAToggle() {
   const {_} = useLingui()
   const {currentAccount} = useSession()
   const disableDialogControl = useDialogControl()
-  const enableDialogControl = useDialogControl()
-  const verifyEmailDialogControl = useDialogControl()
-  const changeEmailDialogControl = useDialogControl()
-  const agent = useAgent()
-
-  const enableEmailAuthFactor = React.useCallback(async () => {
-    if (currentAccount?.email) {
-      await agent.com.atproto.server.updateEmail({
-        email: currentAccount.email,
-        emailAuthFactor: true,
-      })
-      await agent.resumeSession(agent.session!)
-    }
-  }, [currentAccount, agent])
+  const emailDialogControl = useEmailDialogControl()
 
   const onToggle = React.useCallback(() => {
-    if (!currentAccount) {
-      return
-    }
-    if (currentAccount.emailAuthFactor) {
-      disableDialogControl.open()
-    } else {
-      if (!currentAccount.emailConfirmed) {
-        verifyEmailDialogControl.open()
-        return
-      }
-      enableDialogControl.open()
-    }
-  }, [
-    currentAccount,
-    enableDialogControl,
-    verifyEmailDialogControl,
-    disableDialogControl,
-  ])
+    emailDialogControl.open({
+      id: EmailDialogScreenID.Manage2FA,
+    })
+  }, [emailDialogControl])
 
   return (
     <>
       <DisableEmail2FADialog control={disableDialogControl} />
-      <Prompt.Basic
-        control={enableDialogControl}
-        title={_(msg`Enable Email 2FA`)}
-        description={_(msg`Require an email code to sign in to your account.`)}
-        onConfirm={enableEmailAuthFactor}
-        confirmButtonCta={_(msg`Enable`)}
-      />
-      <VerifyEmailDialog
-        control={verifyEmailDialogControl}
-        changeEmailControl={changeEmailDialogControl}
-        onCloseAfterVerifying={enableDialogControl.open}
-        reasonText={_(
-          msg`You need to verify your email address before you can enable email 2FA.`,
-        )}
-      />
-      <ChangeEmailDialog
-        control={changeEmailDialogControl}
-        verifyEmailControl={verifyEmailDialogControl}
-      />
       <SettingsList.BadgeButton
         label={
           currentAccount?.emailAuthFactor ? _(msg`Change`) : _(msg`Enable`)
diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx
index 344b93429..aabfe4b20 100644
--- a/src/screens/VideoFeed/index.tsx
+++ b/src/screens/VideoFeed/index.tsx
@@ -1,18 +1,18 @@
 import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
 import {
   LayoutAnimation,
-  ListRenderItem,
+  type ListRenderItem,
   Pressable,
   ScrollView,
   View,
-  ViewabilityConfig,
-  ViewToken,
+  type ViewabilityConfig,
+  type ViewToken,
 } from 'react-native'
 import {SystemBars} from 'react-native-edge-to-edge'
 import {
   Gesture,
   GestureDetector,
-  NativeGesture,
+  type NativeGesture,
 } from 'react-native-gesture-handler'
 import Animated, {
   useAnimatedStyle,
@@ -24,38 +24,46 @@ import {
 } from 'react-native-safe-area-context'
 import {useEvent} from 'expo'
 import {useEventListener} from 'expo'
-import {Image, ImageStyle} from 'expo-image'
+import {Image, type ImageStyle} from 'expo-image'
 import {LinearGradient} from 'expo-linear-gradient'
-import {createVideoPlayer, VideoPlayer, VideoView} from 'expo-video'
+import {createVideoPlayer, type VideoPlayer, VideoView} from 'expo-video'
 import {
   AppBskyEmbedVideo,
-  AppBskyFeedDefs,
+  type AppBskyFeedDefs,
   AppBskyFeedPost,
   AtUri,
-  ModerationDecision,
+  type ModerationDecision,
   RichText as RichTextAPI,
 } from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {
-  RouteProp,
+  type RouteProp,
   useFocusEffect,
   useIsFocused,
   useNavigation,
   useRoute,
 } from '@react-navigation/native'
-import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {type NativeStackScreenProps} from '@react-navigation/native-stack'
 
 import {HITSLOP_20} from '#/lib/constants'
 import {useHaptics} from '#/lib/haptics'
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
-import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
+import {
+  type CommonNavigatorParams,
+  type NavigationProp,
+} from '#/lib/routes/types'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {cleanError} from '#/lib/strings/errors'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {isAndroid} from '#/platform/detection'
 import {useA11y} from '#/state/a11y'
-import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
+import {
+  POST_TOMBSTONE,
+  type Shadow,
+  usePostShadow,
+} from '#/state/cache/post-shadow'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {
   FeedFeedbackProvider,
@@ -64,13 +72,13 @@ import {
 import {useFeedFeedback} from '#/state/feed-feedback'
 import {usePostLikeMutationQueue} from '#/state/queries/post'
 import {
-  AuthorFilter,
-  FeedPostSliceItem,
+  type AuthorFilter,
+  type FeedPostSliceItem,
   usePostFeedQuery,
 } from '#/state/queries/post-feed'
 import {useProfileFollowMutationQueue} from '#/state/queries/profile'
 import {useSession} from '#/state/session'
-import {useComposerControls, useSetMinimalShellMode} from '#/state/shell'
+import {useSetMinimalShellMode} from '#/state/shell'
 import {useSetLightStatusBar} from '#/state/shell/light-status-bar'
 import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
 import {List} from '#/view/com/util/List'
@@ -685,7 +693,7 @@ function Overlay({
 }) {
   const {_} = useLingui()
   const t = useTheme()
-  const {openComposer} = useComposerControls()
+  const {openComposer} = useOpenComposer()
   const {currentAccount} = useSession()
   const navigation = useNavigation<NavigationProp>()
   const seekingAnimationSV = useSharedValue(0)
diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx
index b425873fc..ad07333be 100644
--- a/src/state/shell/composer/index.tsx
+++ b/src/state/shell/composer/index.tsx
@@ -125,5 +125,17 @@ export function useComposerState() {
 }
 
 export function useComposerControls() {
-  return React.useContext(controlsContext)
+  const {closeComposer} = React.useContext(controlsContext)
+  return React.useMemo(() => ({closeComposer}), [closeComposer])
+}
+
+/**
+ * DO NOT USE DIRECTLY. The deprecation notice as a warning only, it's not
+ * actually deprecated.
+ *
+ * @deprecated use `#/lib/hooks/useOpenComposer` instead
+ */
+export function useOpenComposer() {
+  const {openComposer} = React.useContext(controlsContext)
+  return React.useMemo(() => ({openComposer}), [openComposer])
 }
diff --git a/src/state/shell/composer/useComposerKeyboardShortcut.tsx b/src/state/shell/composer/useComposerKeyboardShortcut.tsx
index cfec5c445..4a48f5e45 100644
--- a/src/state/shell/composer/useComposerKeyboardShortcut.tsx
+++ b/src/state/shell/composer/useComposerKeyboardShortcut.tsx
@@ -1,11 +1,11 @@
 import React from 'react'
 
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
 import {useDialogStateContext} from '#/state/dialogs'
 import {useLightbox} from '#/state/lightbox'
 import {useModals} from '#/state/modals'
 import {useSession} from '#/state/session'
 import {useIsDrawerOpen} from '#/state/shell/drawer-open'
-import {useComposerControls} from './'
 
 /**
  * Based on {@link https://github.com/jaywcjlove/hotkeys-js/blob/b0038773f3b902574f22af747f3bb003a850f1da/src/index.js#L51C1-L64C2}
@@ -39,7 +39,7 @@ function shouldIgnore(event: KeyboardEvent) {
 }
 
 export function useComposerKeyboardShortcut() {
-  const {openComposer} = useComposerControls()
+  const {openComposer} = useOpenComposer()
   const {openDialogs} = useDialogStateContext()
   const {isModalActive} = useModals()
   const {activeLightbox} = useLightbox()
diff --git a/src/state/shell/index.tsx b/src/state/shell/index.tsx
index f61dc3c41..92cf520a3 100644
--- a/src/state/shell/index.tsx
+++ b/src/state/shell/index.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import type React from 'react'
 
 import {Provider as ColorModeProvider} from './color-mode'
 import {Provider as DrawerOpenProvider} from './drawer-open'
@@ -9,7 +9,6 @@ import {Provider as ShellLayoutProvder} from './shell-layout'
 import {Provider as TickEveryMinuteProvider} from './tick-every-minute'
 
 export {useSetThemePrefs, useThemePrefs} from './color-mode'
-export {useComposerControls, useComposerState} from './composer'
 export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open'
 export {
   useIsDrawerSwipeDisabled,
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index b6d269d28..e690e7256 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -62,7 +62,6 @@ import {
   type SupportedMimeTypes,
 } from '#/lib/constants'
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
-import {useEmail} from '#/lib/hooks/useEmail'
 import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 import {usePalette} from '#/lib/hooks/usePalette'
@@ -120,8 +119,6 @@ import * as Toast from '#/view/com/util/Toast'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, native, useTheme, web} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import {useDialogControl} from '#/components/Dialog'
-import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
@@ -331,15 +328,6 @@ export const ComposePost = ({
     }
   }, [onPressCancel, closeAllDialogs, closeAllModals])
 
-  const {needsEmailVerification} = useEmail()
-  const emailVerificationControl = useDialogControl()
-
-  useEffect(() => {
-    if (needsEmailVerification) {
-      emailVerificationControl.open()
-    }
-  }, [needsEmailVerification, emailVerificationControl])
-
   const missingAltError = useMemo(() => {
     if (!requireAltTextEnabled) {
       return
@@ -620,15 +608,6 @@ export const ComposePost = ({
   const isWebFooterSticky = !isNative && thread.posts.length > 1
   return (
     <BottomSheetPortalProvider>
-      <VerifyEmailDialog
-        control={emailVerificationControl}
-        onCloseWithoutVerifying={() => {
-          onClose()
-        }}
-        reasonText={_(
-          msg`Before creating a post, you must first verify your email.`,
-        )}
-      />
       <KeyboardAvoidingView
         testID="composePostView"
         behavior={isIOS ? 'padding' : 'height'}
diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx
index 8d9371f0d..96715955f 100644
--- a/src/view/com/composer/videos/SelectVideoBtn.tsx
+++ b/src/view/com/composer/videos/SelectVideoBtn.tsx
@@ -1,26 +1,19 @@
 import {useCallback} from 'react'
-import {Keyboard} from 'react-native'
-import {ImagePickerAsset} from 'expo-image-picker'
+import {type ImagePickerAsset} from 'expo-image-picker'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {
   SUPPORTED_MIME_TYPES,
-  SupportedMimeTypes,
+  type SupportedMimeTypes,
   VIDEO_MAX_DURATION_MS,
 } from '#/lib/constants'
-import {BSKY_SERVICE} from '#/lib/constants'
 import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
-import {getHostnameFromUrl} from '#/lib/strings/url-helpers'
 import {isWeb} from '#/platform/detection'
 import {isNative} from '#/platform/detection'
-import {useSession} from '#/state/session'
 import {atoms as a, useTheme} from '#/alf'
 import {Button} from '#/components/Button'
-import {useDialogControl} from '#/components/Dialog'
-import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
 import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
-import * as Prompt from '#/components/Prompt'
 import {pickVideo} from './pickVideo'
 
 type Props = {
@@ -33,66 +26,45 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
   const {_} = useLingui()
   const t = useTheme()
   const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
-  const control = Prompt.usePromptControl()
-  const {currentAccount} = useSession()
 
   const onPressSelectVideo = useCallback(async () => {
     if (isNative && !(await requestVideoAccessIfNeeded())) {
       return
     }
 
-    if (
-      currentAccount &&
-      !currentAccount.emailConfirmed &&
-      getHostnameFromUrl(currentAccount.service) ===
-        getHostnameFromUrl(BSKY_SERVICE)
-    ) {
-      Keyboard.dismiss()
-      control.open()
-    } else {
-      const response = await pickVideo()
-      if (response.assets && response.assets.length > 0) {
-        const asset = response.assets[0]
-        try {
-          if (isWeb) {
-            // asset.duration is null for gifs (see the TODO in pickVideo.web.ts)
-            if (asset.duration && asset.duration > VIDEO_MAX_DURATION_MS) {
-              throw Error(_(msg`Videos must be less than 3 minutes long`))
-            }
-            // compression step on native converts to mp4, so no need to check there
-            if (
-              !SUPPORTED_MIME_TYPES.includes(
-                asset.mimeType as SupportedMimeTypes,
-              )
-            ) {
-              throw Error(_(msg`Unsupported video type: ${asset.mimeType}`))
-            }
-          } else {
-            if (typeof asset.duration !== 'number') {
-              throw Error('Asset is not a video')
-            }
-            if (asset.duration > VIDEO_MAX_DURATION_MS) {
-              throw Error(_(msg`Videos must be less than 3 minutes long`))
-            }
+    const response = await pickVideo()
+    if (response.assets && response.assets.length > 0) {
+      const asset = response.assets[0]
+      try {
+        if (isWeb) {
+          // asset.duration is null for gifs (see the TODO in pickVideo.web.ts)
+          if (asset.duration && asset.duration > VIDEO_MAX_DURATION_MS) {
+            throw Error(_(msg`Videos must be less than 3 minutes long`))
           }
-          onSelectVideo(asset)
-        } catch (err) {
-          if (err instanceof Error) {
-            setError(err.message)
-          } else {
-            setError(_(msg`An error occurred while selecting the video`))
+          // compression step on native converts to mp4, so no need to check there
+          if (
+            !SUPPORTED_MIME_TYPES.includes(asset.mimeType as SupportedMimeTypes)
+          ) {
+            throw Error(_(msg`Unsupported video type: ${asset.mimeType}`))
           }
+        } else {
+          if (typeof asset.duration !== 'number') {
+            throw Error('Asset is not a video')
+          }
+          if (asset.duration > VIDEO_MAX_DURATION_MS) {
+            throw Error(_(msg`Videos must be less than 3 minutes long`))
+          }
+        }
+        onSelectVideo(asset)
+      } catch (err) {
+        if (err instanceof Error) {
+          setError(err.message)
+        } else {
+          setError(_(msg`An error occurred while selecting the video`))
         }
       }
     }
-  }, [
-    requestVideoAccessIfNeeded,
-    currentAccount,
-    control,
-    setError,
-    _,
-    onSelectVideo,
-  ])
+  }, [requestVideoAccessIfNeeded, setError, _, onSelectVideo])
 
   return (
     <>
@@ -111,30 +83,6 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
           style={disabled && t.atoms.text_contrast_low}
         />
       </Button>
-      <VerifyEmailPrompt control={control} />
-    </>
-  )
-}
-
-function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
-  const {_} = useLingui()
-  const verifyEmailDialogControl = useDialogControl()
-
-  return (
-    <>
-      <Prompt.Basic
-        control={control}
-        title={_(msg`Verified email required`)}
-        description={_(
-          msg`To upload videos to Bluesky, you must first verify your email.`,
-        )}
-        confirmButtonCta={_(msg`Verify now`)}
-        confirmButtonColor="primary"
-        onConfirm={() => {
-          verifyEmailDialogControl.open()
-        }}
-      />
-      <VerifyEmailDialog control={verifyEmailDialogControl} />
     </>
   )
 }
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index f643adaf9..604533b0f 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -1,32 +1,32 @@
 import React from 'react'
 import {View} from 'react-native'
-import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
+import {type AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {NavigationProp, useNavigation} from '@react-navigation/native'
+import {type NavigationProp, useNavigation} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 
 import {VIDEO_FEED_URIS} from '#/lib/constants'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
 import {ComposeIcon2} from '#/lib/icons'
 import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers'
-import {AllNavigatorParams} from '#/lib/routes/types'
+import {type AllNavigatorParams} from '#/lib/routes/types'
 import {logEvent} from '#/lib/statsig/statsig'
 import {s} from '#/lib/styles'
 import {isNative} from '#/platform/detection'
 import {listenSoftReset} from '#/state/events'
 import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
 import {useSetHomeBadge} from '#/state/home-badge'
-import {SavedFeedSourceInfo} from '#/state/queries/feed'
+import {type SavedFeedSourceInfo} from '#/state/queries/feed'
 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
-import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
+import {type FeedDescriptor, type FeedParams} from '#/state/queries/post-feed'
 import {truncateAndInvalidate} from '#/state/queries/util'
 import {useSession} from '#/state/session'
 import {useSetMinimalShellMode} from '#/state/shell'
-import {useComposerControls} from '#/state/shell/composer'
 import {useHeaderOffset} from '#/components/hooks/useHeaderOffset'
 import {PostFeed} from '../posts/PostFeed'
 import {FAB} from '../util/fab/FAB'
-import {ListMethods} from '../util/List'
+import {type ListMethods} from '../util/List'
 import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
 import {MainScrollProvider} from '../util/MainScrollProvider'
 
@@ -57,7 +57,7 @@ export function FeedPage({
   const {_} = useLingui()
   const navigation = useNavigation<NavigationProp<AllNavigatorParams>>()
   const queryClient = useQueryClient()
-  const {openComposer} = useComposerControls()
+  const {openComposer} = useOpenComposer()
   const [isScrolledDown, setIsScrolledDown] = React.useState(false)
   const setMinimalShellMode = useSetMinimalShellMode()
   const headerOffset = useHeaderOffset()
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 83dbdb553..d974ce6b5 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -5,7 +5,7 @@ import Animated from 'react-native-reanimated'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {
   AppBskyFeedDefs,
-  AppBskyFeedThreadgate,
+  type AppBskyFeedThreadgate,
   moderatePost,
 } from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
@@ -14,6 +14,7 @@ import {useLingui} from '@lingui/react'
 import {HITSLOP_10} from '#/lib/constants'
 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
 import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
 import {useSetTitle} from '#/lib/hooks/useSetTitle'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {clamp} from '#/lib/numbers'
@@ -25,19 +26,18 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {
   fillThreadModerationCache,
   sortThread,
-  ThreadBlocked,
-  ThreadModerationCache,
-  ThreadNode,
-  ThreadNotFound,
-  ThreadPost,
+  type ThreadBlocked,
+  type ThreadModerationCache,
+  type ThreadNode,
+  type ThreadNotFound,
+  type ThreadPost,
   usePostThreadQuery,
 } from '#/state/queries/post-thread'
 import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences'
 import {usePreferencesQuery} from '#/state/queries/preferences'
 import {useSession} from '#/state/session'
-import {useComposerControls} from '#/state/shell'
 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
-import {List, ListMethods} from '#/view/com/util/List'
+import {List, type ListMethods} from '#/view/com/util/List'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonIcon} from '#/components/Button'
 import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
@@ -394,7 +394,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
     [refetch],
   )
 
-  const {openComposer} = useComposerControls()
+  const {openComposer} = useOpenComposer()
   const onPressReply = React.useCallback(() => {
     if (thread?.type !== 'post') {
       return
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index dfd641f66..10c3e6b4d 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -17,6 +17,7 @@ import {msg, Plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {MAX_POST_LINES} from '#/lib/constants'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
 import {useOpenLink} from '#/lib/hooks/useOpenLink'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {makeProfileLink} from '#/lib/routes/links'
@@ -36,7 +37,6 @@ import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {useLanguagePrefs} from '#/state/preferences'
 import {type ThreadPost} from '#/state/queries/post-thread'
 import {useSession} from '#/state/session'
-import {useComposerControls} from '#/state/shell/composer'
 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
 import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
@@ -204,7 +204,7 @@ let PostThreadItemLoaded = ({
   const pal = usePalette('default')
   const {_, i18n} = useLingui()
   const langPrefs = useLanguagePrefs()
-  const {openComposer} = useComposerControls()
+  const {openComposer} = useOpenComposer()
   const [limitLines, setLimitLines] = React.useState(
     () => countLines(richText?.text) >= MAX_POST_LINES,
   )
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 600cee428..c6cf254f3 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -1,11 +1,11 @@
 import React, {useMemo, useState} from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
 import {
-  AppBskyFeedDefs,
+  type AppBskyFeedDefs,
   AppBskyFeedPost,
   AtUri,
   moderatePost,
-  ModerationDecision,
+  type ModerationDecision,
   RichText as RichTextAPI,
 } from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@@ -14,15 +14,19 @@ import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
 
 import {MAX_POST_LINES} from '#/lib/constants'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {makeProfileLink} from '#/lib/routes/links'
 import {countLines} from '#/lib/strings/helpers'
 import {colors, s} from '#/lib/styles'
-import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
+import {
+  POST_TOMBSTONE,
+  type Shadow,
+  usePostShadow,
+} from '#/state/cache/post-shadow'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {precacheProfile} from '#/state/queries/profile'
 import {useSession} from '#/state/session'
-import {useComposerControls} from '#/state/shell/composer'
 import {AviFollowButton} from '#/view/com/posts/AviFollowButton'
 import {atoms as a} from '#/alf'
 import {ProfileHoverCard} from '#/components/ProfileHoverCard'
@@ -113,7 +117,7 @@ function PostInner({
   const queryClient = useQueryClient()
   const pal = usePalette('default')
   const {_} = useLingui()
-  const {openComposer} = useComposerControls()
+  const {openComposer} = useOpenComposer()
   const [limitLines, setLimitLines] = useState(
     () => countLines(richText?.text) >= MAX_POST_LINES,
   )
diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx
index facd31e5f..123a8b0c2 100644
--- a/src/view/com/posts/PostFeedItem.tsx
+++ b/src/view/com/posts/PostFeedItem.tsx
@@ -19,6 +19,7 @@ import {useQueryClient} from '@tanstack/react-query'
 
 import {isReasonFeedSource, type ReasonFeedSource} from '#/lib/api/feed/types'
 import {MAX_POST_LINES} from '#/lib/constants'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {makeProfileLink} from '#/lib/routes/links'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
@@ -33,7 +34,6 @@ import {
 import {useFeedFeedbackContext} from '#/state/feed-feedback'
 import {precacheProfile} from '#/state/queries/profile'
 import {useSession} from '#/state/session'
-import {useComposerControls} from '#/state/shell/composer'
 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
 import {FeedNameText} from '#/view/com/util/FeedInfoText'
 import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls'
@@ -159,7 +159,7 @@ let FeedItemInner = ({
   onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
 }): React.ReactNode => {
   const queryClient = useQueryClient()
-  const {openComposer} = useComposerControls()
+  const {openComposer} = useOpenComposer()
   const pal = usePalette('default')
   const {_} = useLingui()
 
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index d97654a63..a9cae8886 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -22,6 +22,7 @@ import {DISCOVER_DEBUG_DIDS, POST_CTRL_HITSLOP} from '#/lib/constants'
 import {CountWheel} from '#/lib/custom-animations/CountWheel'
 import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
 import {useHaptics} from '#/lib/haptics'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
 import {makeProfileLink} from '#/lib/routes/links'
 import {shareUrl} from '#/lib/sharing'
 import {useGate} from '#/lib/statsig/statsig'
@@ -33,7 +34,6 @@ import {
   usePostRepostMutationQueue,
 } from '#/state/queries/post'
 import {useRequireAuth, useSession} from '#/state/session'
-import {useComposerControls} from '#/state/shell/composer'
 import {
   ProgressGuideAction,
   useProgressGuideControls,
@@ -76,7 +76,7 @@ let PostCtrls = ({
 }): React.ReactNode => {
   const t = useTheme()
   const {_, i18n} = useLingui()
-  const {openComposer} = useComposerControls()
+  const {openComposer} = useOpenComposer()
   const {currentAccount} = useSession()
   const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext)
   const [queueRepost, queueUnrepost] = usePostRepostMutationQueue(
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
index 520400fd1..ca0280116 100644
--- a/src/view/screens/Feeds.tsx
+++ b/src/view/screens/Feeds.tsx
@@ -1,30 +1,33 @@
 import React from 'react'
 import {ActivityIndicator, StyleSheet, View} from 'react-native'
-import {AppBskyFeedDefs} from '@atproto/api'
+import {type AppBskyFeedDefs} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useFocusEffect} from '@react-navigation/native'
 import debounce from 'lodash.debounce'
 
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {ComposeIcon2} from '#/lib/icons'
-import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {
+  type CommonNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
 import {cleanError} from '#/lib/strings/errors'
 import {s} from '#/lib/styles'
 import {isNative, isWeb} from '#/platform/detection'
 import {
-  SavedFeedItem,
+  type SavedFeedItem,
   useGetPopularFeedsQuery,
   useSavedFeeds,
   useSearchPopularFeedsMutation,
 } from '#/state/queries/feed'
 import {useSession} from '#/state/session'
 import {useSetMinimalShellMode} from '#/state/shell'
-import {useComposerControls} from '#/state/shell/composer'
 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
 import {FAB} from '#/view/com/util/fab/FAB'
-import {List, ListMethods} from '#/view/com/util/List'
+import {List, type ListMethods} from '#/view/com/util/List'
 import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 import {Text} from '#/view/com/util/text/Text'
 import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
@@ -102,7 +105,7 @@ type FlatlistSlice =
 
 export function FeedsScreen(_props: Props) {
   const pal = usePalette('default')
-  const {openComposer} = useComposerControls()
+  const {openComposer} = useOpenComposer()
   const {isMobile} = useWebMediaQueries()
   const [query, setQuery] = React.useState('')
   const [isPTR, setIsPTR] = React.useState(false)
diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx
index 300153966..bcda97dc5 100644
--- a/src/view/screens/Lists.tsx
+++ b/src/view/screens/Lists.tsx
@@ -4,16 +4,17 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useFocusEffect, useNavigation} from '@react-navigation/native'
 
-import {useEmail} from '#/lib/hooks/useEmail'
-import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
-import {NavigationProp} from '#/lib/routes/types'
+import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
+import {
+  type CommonNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {type NavigationProp} from '#/lib/routes/types'
 import {useModalControls} from '#/state/modals'
 import {useSetMinimalShellMode} from '#/state/shell'
 import {MyLists} from '#/view/com/lists/MyLists'
 import {atoms as a} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import {useDialogControl} from '#/components/Dialog'
-import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
 import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
 import * as Layout from '#/components/Layout'
 
@@ -23,8 +24,7 @@ export function ListsScreen({}: Props) {
   const setMinimalShellMode = useSetMinimalShellMode()
   const navigation = useNavigation<NavigationProp>()
   const {openModal} = useModalControls()
-  const {needsEmailVerification} = useEmail()
-  const control = useDialogControl()
+  const requireEmailVerification = useRequireEmailVerification()
 
   useFocusEffect(
     React.useCallback(() => {
@@ -33,11 +33,6 @@ export function ListsScreen({}: Props) {
   )
 
   const onPressNewList = React.useCallback(() => {
-    if (needsEmailVerification) {
-      control.open()
-      return
-    }
-
     openModal({
       name: 'create-or-edit-list',
       purpose: 'app.bsky.graph.defs#curatelist',
@@ -51,7 +46,15 @@ export function ListsScreen({}: Props) {
         } catch {}
       },
     })
-  }, [needsEmailVerification, control, openModal, navigation])
+  }, [openModal, navigation])
+
+  const wrappedOnPressNewList = requireEmailVerification(onPressNewList, {
+    instructions: [
+      <Trans key="lists">
+        Before creating a list, you must first verify your email.
+      </Trans>,
+    ],
+  })
 
   return (
     <Layout.Screen testID="listsScreen">
@@ -68,7 +71,7 @@ export function ListsScreen({}: Props) {
           color="secondary"
           variant="solid"
           size="small"
-          onPress={onPressNewList}>
+          onPress={wrappedOnPressNewList}>
           <ButtonIcon icon={PlusIcon} />
           <ButtonText>
             <Trans context="action">New</Trans>
@@ -76,12 +79,6 @@ export function ListsScreen({}: Props) {
         </Button>
       </Layout.Header.Outer>
       <MyLists filter="curate" style={a.flex_grow} />
-      <VerifyEmailDialog
-        reasonText={_(
-          msg`Before creating a list, you must first verify your email.`,
-        )}
-        control={control}
-      />
     </Layout.Screen>
   )
 }
diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx
index 0b5090e3d..23ed492f6 100644
--- a/src/view/screens/ModerationModlists.tsx
+++ b/src/view/screens/ModerationModlists.tsx
@@ -4,16 +4,17 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useFocusEffect, useNavigation} from '@react-navigation/native'
 
-import {useEmail} from '#/lib/hooks/useEmail'
-import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
-import {NavigationProp} from '#/lib/routes/types'
+import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
+import {
+  type CommonNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {type NavigationProp} from '#/lib/routes/types'
 import {useModalControls} from '#/state/modals'
 import {useSetMinimalShellMode} from '#/state/shell'
 import {MyLists} from '#/view/com/lists/MyLists'
 import {atoms as a} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import {useDialogControl} from '#/components/Dialog'
-import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
 import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
 import * as Layout from '#/components/Layout'
 
@@ -23,8 +24,7 @@ export function ModerationModlistsScreen({}: Props) {
   const setMinimalShellMode = useSetMinimalShellMode()
   const navigation = useNavigation<NavigationProp>()
   const {openModal} = useModalControls()
-  const {needsEmailVerification} = useEmail()
-  const control = useDialogControl()
+  const requireEmailVerification = useRequireEmailVerification()
 
   useFocusEffect(
     React.useCallback(() => {
@@ -33,11 +33,6 @@ export function ModerationModlistsScreen({}: Props) {
   )
 
   const onPressNewList = React.useCallback(() => {
-    if (needsEmailVerification) {
-      control.open()
-      return
-    }
-
     openModal({
       name: 'create-or-edit-list',
       purpose: 'app.bsky.graph.defs#modlist',
@@ -51,7 +46,15 @@ export function ModerationModlistsScreen({}: Props) {
         } catch {}
       },
     })
-  }, [needsEmailVerification, control, openModal, navigation])
+  }, [openModal, navigation])
+
+  const wrappedOnPressNewList = requireEmailVerification(onPressNewList, {
+    instructions: [
+      <Trans key="modlist">
+        Before creating a list, you must first verify your email.
+      </Trans>,
+    ],
+  })
 
   return (
     <Layout.Screen testID="moderationModlistsScreen">
@@ -68,7 +71,7 @@ export function ModerationModlistsScreen({}: Props) {
           color="secondary"
           variant="solid"
           size="small"
-          onPress={onPressNewList}>
+          onPress={wrappedOnPressNewList}>
           <ButtonIcon icon={PlusIcon} />
           <ButtonText>
             <Trans context="action">New</Trans>
@@ -76,12 +79,6 @@ export function ModerationModlistsScreen({}: Props) {
         </Button>
       </Layout.Header.Outer>
       <MyLists filter="mod" style={a.flex_grow} />
-      <VerifyEmailDialog
-        reasonText={_(
-          msg`Before creating a list, you must first verify your email.`,
-        )}
-        control={control}
-      />
     </Layout.Screen>
   )
 }
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index 1880fb816..ace0de2ae 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -6,10 +6,11 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
 import {ComposeIcon2} from '#/lib/icons'
 import {
-  NativeStackScreenProps,
-  NotificationsTabNavigatorParams,
+  type NativeStackScreenProps,
+  type NotificationsTabNavigatorParams,
 } from '#/lib/routes/types'
 import {s} from '#/lib/styles'
 import {logger} from '#/logger'
@@ -22,12 +23,11 @@ import {
 } from '#/state/queries/notifications/unread'
 import {truncateAndInvalidate} from '#/state/queries/util'
 import {useSetMinimalShellMode} from '#/state/shell'
-import {useComposerControls} from '#/state/shell/composer'
 import {NotificationFeed} from '#/view/com/notifications/NotificationFeed'
 import {Pager} from '#/view/com/pager/Pager'
 import {TabBar} from '#/view/com/pager/TabBar'
 import {FAB} from '#/view/com/util/fab/FAB'
-import {ListMethods} from '#/view/com/util/List'
+import {type ListMethods} from '#/view/com/util/List'
 import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
 import {MainScrollProvider} from '#/view/com/util/MainScrollProvider'
 import {atoms as a} from '#/alf'
@@ -49,7 +49,7 @@ type Props = NativeStackScreenProps<
 >
 export function NotificationsScreen({}: Props) {
   const {_} = useLingui()
-  const {openComposer} = useComposerControls()
+  const {openComposer} = useOpenComposer()
   const unreadNotifs = useUnreadNotifications()
   const hasNew = !!unreadNotifs
   const {checkUnread: checkUnreadAll} = useUnreadNotificationsApi()
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 425d55656..cc339bb03 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -2,9 +2,9 @@ import React, {useCallback, useMemo} from 'react'
 import {StyleSheet} from 'react-native'
 import {SafeAreaView} from 'react-native-safe-area-context'
 import {
-  AppBskyActorDefs,
+  type AppBskyActorDefs,
   moderateProfile,
-  ModerationOpts,
+  type ModerationOpts,
   RichText as RichTextAPI,
 } from '@atproto/api'
 import {msg} from '@lingui/macro'
@@ -12,9 +12,13 @@ import {useLingui} from '@lingui/react'
 import {useFocusEffect} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
 import {useSetTitle} from '#/lib/hooks/useSetTitle'
 import {ComposeIcon2} from '#/lib/icons'
-import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {
+  type CommonNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
 import {combinedDisplayName} from '#/lib/strings/display-names'
 import {cleanError} from '#/lib/strings/errors'
 import {isInvalidHandle} from '#/lib/strings/handles'
@@ -28,13 +32,12 @@ import {useProfileQuery} from '#/state/queries/profile'
 import {useResolveDidQuery} from '#/state/queries/resolve-uri'
 import {useAgent, useSession} from '#/state/session'
 import {useSetMinimalShellMode} from '#/state/shell'
-import {useComposerControls} from '#/state/shell/composer'
 import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens'
 import {ProfileLists} from '#/view/com/lists/ProfileLists'
 import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
 import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
 import {FAB} from '#/view/com/util/fab/FAB'
-import {ListRef} from '#/view/com/util/List'
+import {type ListRef} from '#/view/com/util/List'
 import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header'
 import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
 import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
@@ -165,7 +168,7 @@ function ProfileScreenLoaded({
   const profile = useProfileShadow(profileUnshadowed)
   const {hasSession, currentAccount} = useSession()
   const setMinimalShellMode = useSetMinimalShellMode()
-  const {openComposer} = useComposerControls()
+  const {openComposer} = useOpenComposer()
   const {
     data: labelerInfo,
     error: labelerError,
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 61f1eb745..78cf5d11e 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -16,6 +16,7 @@ import {useNavigation} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 
 import {useHaptics} from '#/lib/haptics'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useSetTitle} from '#/lib/hooks/useSetTitle'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
@@ -54,7 +55,6 @@ import {useResolveUriQuery} from '#/state/queries/resolve-uri'
 import {truncateAndInvalidate} from '#/state/queries/util'
 import {useSession} from '#/state/session'
 import {useSetMinimalShellMode} from '#/state/shell'
-import {useComposerControls} from '#/state/shell/composer'
 import {ListMembers} from '#/view/com/lists/ListMembers'
 import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
 import {PostFeed} from '#/view/com/posts/PostFeed'
@@ -155,7 +155,7 @@ function ProfileListScreenLoaded({
 }) {
   const {_} = useLingui()
   const queryClient = useQueryClient()
-  const {openComposer} = useComposerControls()
+  const {openComposer} = useOpenComposer()
   const setMinimalShellMode = useSetMinimalShellMode()
   const {currentAccount} = useSession()
   const {rkey} = route.params
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index 0688fb5dc..7d34a3d14 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -10,6 +10,7 @@ import {
 } from '@react-navigation/native'
 
 import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {getCurrentRoute, isTab} from '#/lib/routes/helpers'
@@ -25,7 +26,6 @@ import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations
 import {useUnreadNotifications} from '#/state/queries/notifications/unread'
 import {useProfilesQuery} from '#/state/queries/profile'
 import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
-import {useComposerControls} from '#/state/shell/composer'
 import {useLoggedOutViewControls} from '#/state/shell/logged-out'
 import {useCloseAllActiveElements} from '#/state/util'
 import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
@@ -447,7 +447,7 @@ function NavItem({count, hasNew, href, icon, iconFilled, label}: NavItemProps) {
 function ComposeBtn() {
   const {currentAccount} = useSession()
   const {getState} = useNavigation()
-  const {openComposer} = useComposerControls()
+  const {openComposer} = useOpenComposer()
   const {_} = useLingui()
   const {leftNavMinimal} = useLayoutBreakpoints()
   const [isFetchingHandle, setIsFetchingHandle] = React.useState(false)
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 1e34f6da5..cd328c457 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -25,6 +25,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal'
 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
 import {atoms as a, select, useTheme} from '#/alf'
 import {setSystemUITheme} from '#/alf/util/systemUI'
+import {EmailDialog} from '#/components/dialogs/EmailDialog'
 import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent'
 import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
 import {SigninDialog} from '#/components/dialogs/Signin'
@@ -152,6 +153,7 @@ function ShellInner() {
       <ModalsContainer />
       <MutedWordsDialog />
       <SigninDialog />
+      <EmailDialog />
       <InAppBrowserConsentDialog />
       <Lightbox />
       <PortalOutlet />
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 898ff8fa8..a7ff76d61 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -17,6 +17,7 @@ import {Lightbox} from '#/view/com/lightbox/Lightbox'
 import {ModalsContainer} from '#/view/com/modals/Modal'
 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
 import {atoms as a, select, useTheme} from '#/alf'
+import {EmailDialog} from '#/components/dialogs/EmailDialog'
 import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
 import {SigninDialog} from '#/components/dialogs/Signin'
 import {Outlet as PortalOutlet} from '#/components/Portal'
@@ -67,6 +68,7 @@ function ShellInner() {
       <ModalsContainer />
       <MutedWordsDialog />
       <SigninDialog />
+      <EmailDialog />
       <Lightbox />
       <PortalOutlet />