From eeba0387b75d3583bfded8e5ed4366609375c767 Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
Date: Tue, 12 Mar 2024 14:33:02 -0500
Subject: Small fixes (#3184)

* Fix alignment of MutedWords close button

* Add cancel button to dropdowns

* Revert "Add cancel button to dropdowns"

This reverts commit b8f5ddce924311e439aeaa844a80d38f6e7da051.
---
 src/components/dialogs/MutedWords.tsx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

(limited to 'src/components/dialogs/MutedWords.tsx')

diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx
index 658ba2aae..5f23a7883 100644
--- a/src/components/dialogs/MutedWords.tsx
+++ b/src/components/dialogs/MutedWords.tsx
@@ -254,9 +254,9 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
         </View>
 
         {isNative && <View style={{height: 20}} />}
-
-        <Dialog.Close />
       </View>
+
+      <Dialog.Close />
     </Dialog.ScrollableInner>
   )
 }
-- 
cgit 1.4.1


From 9f2f7f221c10e89916e6e8761623fbf1281eec77 Mon Sep 17 00:00:00 2001
From: Hailey <me@haileyok.com>
Date: Tue, 12 Mar 2024 16:56:14 -0700
Subject: ALF confirmation dialogs (Dialogs Pt. 3) (#3143)

* Improve a11y on ios

* Format

* Remove android

* Fix android

* ALF confirmation dialog

* Use ALF for Delete Post confirmation

organize

diff

fix text

minimize

change copy

alternative confirm prompt

revert type changes

add ButtonColor param

* small adjustment to buttons in prompt

* full width below gtmobile

* update hide post dialog

* space out dialogs

* update dialogs for lists

* add example

* add to app passwords

* Revert some changes

* use sharedvalue for `importantForAccessibility`

* add back `isOpen`

* fix some more types

* small adjustment to buttons in prompt

* full width below gtmobile

* update the rest of the prompts

rm old confirm modal

rm update prompt

feed error prompt

feed source card and profile block/unblock

composer discard

* Update src/view/screens/AppPasswords.tsx

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

* lint

* How about a default

* Reverse reverse

* Port over confirm dialogs

* Add some comments

* Remove unused file

* complete merge

* add testID where needed

---------

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
---
 src/alf/atoms.ts                            |   6 +
 src/components/Dialog/index.tsx             |   4 +-
 src/components/Dialog/types.ts              |   1 +
 src/components/Prompt.tsx                   |  91 ++++++++--
 src/components/dialogs/MutedWords.tsx       |  33 ++--
 src/lib/hooks/useOTAUpdate.ts               |  52 ++----
 src/state/modals/index.tsx                  |  13 --
 src/view/com/composer/Composer.tsx          |  43 +++--
 src/view/com/feeds/FeedSourceCard.tsx       | 254 ++++++++++++++--------------
 src/view/com/modals/Confirm.tsx             | 132 ---------------
 src/view/com/modals/Modal.tsx               |   6 +-
 src/view/com/modals/Modal.web.tsx           |   5 +-
 src/view/com/posts/FeedErrorMessage.tsx     |  89 +++++-----
 src/view/com/profile/ProfileHeader.tsx      |  47 ++---
 src/view/com/profile/ProfileMenu.tsx        |  86 +++++-----
 src/view/com/util/forms/PostDropdownBtn.tsx |  43 +++--
 src/view/screens/AppPasswords.tsx           |  36 ++--
 src/view/screens/ProfileList.tsx            | 198 +++++++++++-----------
 src/view/screens/Storybook/Dialogs.tsx      |   2 +-
 19 files changed, 538 insertions(+), 603 deletions(-)
 delete mode 100644 src/view/com/modals/Confirm.tsx

(limited to 'src/components/dialogs/MutedWords.tsx')

diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index fff3a4d8b..6d73cd307 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -111,6 +111,12 @@ export const atoms = {
   flex_row: {
     flexDirection: 'row',
   },
+  flex_col_reverse: {
+    flexDirection: 'column-reverse',
+  },
+  flex_row_reverse: {
+    flexDirection: 'row-reverse',
+  },
   flex_wrap: {
     flexWrap: 'wrap',
   },
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index f0e7b7e82..5f6edeac7 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -75,6 +75,7 @@ export function Outer({
   control,
   onClose,
   nativeOptions,
+  testID,
 }: React.PropsWithChildren<DialogOuterProps>) {
   const t = useTheme()
   const sheet = React.useRef<BottomSheet>(null)
@@ -145,7 +146,8 @@ export function Outer({
           accessibilityViewIsModal
           // Android
           importantForAccessibility="yes"
-          style={[a.absolute, a.inset_0]}>
+          style={[a.absolute, a.inset_0]}
+          testID={testID}>
           <BottomSheet
             enableDynamicSizing={!hasSnapPoints}
             enablePanDownToClose
diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts
index 4fc60ec39..9e7ad3c04 100644
--- a/src/components/Dialog/types.ts
+++ b/src/components/Dialog/types.ts
@@ -46,6 +46,7 @@ export type DialogOuterProps = {
     sheet?: Omit<BottomSheetProps, 'children'>
   }
   webOptions?: {}
+  testID?: string
 }
 
 type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 3b245c440..9ffa9e7d8 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -1,11 +1,11 @@
 import React from 'react'
-import {View, PressableProps} from 'react-native'
+import {View} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {useTheme, atoms as a, useBreakpoints} from '#/alf'
 import {Text} from '#/components/Typography'
-import {Button} from '#/components/Button'
+import {Button, ButtonColor, ButtonText} from '#/components/Button'
 
 import * as Dialog from '#/components/Dialog'
 
@@ -22,8 +22,10 @@ const Context = React.createContext<{
 export function Outer({
   children,
   control,
+  testID,
 }: React.PropsWithChildren<{
   control: Dialog.DialogOuterProps['control']
+  testID?: string
 }>) {
   const {gtMobile} = useBreakpoints()
   const titleId = React.useId()
@@ -35,7 +37,7 @@ export function Outer({
   )
 
   return (
-    <Dialog.Outer control={control}>
+    <Dialog.Outer control={control} testID={testID}>
       <Context.Provider value={context}>
         <Dialog.Handle />
 
@@ -80,7 +82,9 @@ export function Actions({children}: React.PropsWithChildren<{}>) {
         a.w_full,
         a.gap_sm,
         a.justify_end,
-        gtMobile ? [a.flex_row] : [a.flex_col, a.pt_md, a.pb_4xl],
+        gtMobile
+          ? [a.flex_row, a.flex_row_reverse, a.justify_start]
+          : [a.flex_col, a.pt_md, a.pb_4xl],
       ]}>
       {children}
     </View>
@@ -89,18 +93,29 @@ export function Actions({children}: React.PropsWithChildren<{}>) {
 
 export function Cancel({
   children,
-}: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) {
+  cta,
+}: React.PropsWithChildren<{
+  /**
+   * Optional i18n string, used in lieu of `children` for simple buttons. If
+   * undefined (and `children` is undefined), it will default to "Cancel".
+   */
+  cta?: string
+}>) {
   const {_} = useLingui()
   const {gtMobile} = useBreakpoints()
   const {close} = Dialog.useDialogContext()
+  const onPress = React.useCallback(() => {
+    close()
+  }, [close])
+
   return (
     <Button
       variant="solid"
       color="secondary"
       size={gtMobile ? 'small' : 'medium'}
-      label={_(msg`Cancel`)}
-      onPress={() => close()}>
-      {children}
+      label={cta || _(msg`Cancel`)}
+      onPress={onPress}>
+      {children ? children : <ButtonText>{cta || _(msg`Cancel`)}</ButtonText>}
     </Button>
   )
 }
@@ -108,22 +123,70 @@ export function Cancel({
 export function Action({
   children,
   onPress,
-}: React.PropsWithChildren<{onPress?: () => void}>) {
+  color = 'primary',
+  cta,
+  testID,
+}: React.PropsWithChildren<{
+  onPress: () => void
+  color?: ButtonColor
+  /**
+   * Optional i18n string, used in lieu of `children` for simple buttons. If
+   * undefined (and `children` is undefined), it will default to "Confirm".
+   */
+  cta?: string
+  testID?: string
+}>) {
   const {_} = useLingui()
   const {gtMobile} = useBreakpoints()
   const {close} = Dialog.useDialogContext()
   const handleOnPress = React.useCallback(() => {
     close()
-    onPress?.()
+    onPress()
   }, [close, onPress])
+
   return (
     <Button
       variant="solid"
-      color="primary"
+      color={color}
       size={gtMobile ? 'small' : 'medium'}
-      label={_(msg`Confirm`)}
-      onPress={handleOnPress}>
-      {children}
+      label={cta || _(msg`Confirm`)}
+      onPress={handleOnPress}
+      testID={testID}>
+      {children ? children : <ButtonText>{cta || _(msg`Confirm`)}</ButtonText>}
     </Button>
   )
 }
+
+export function Basic({
+  control,
+  title,
+  description,
+  cancelButtonCta,
+  confirmButtonCta,
+  onConfirm,
+  confirmButtonColor,
+}: React.PropsWithChildren<{
+  control: Dialog.DialogOuterProps['control']
+  title: string
+  description: string
+  cancelButtonCta?: string
+  confirmButtonCta?: string
+  onConfirm: () => void
+  confirmButtonColor?: ButtonColor
+}>) {
+  return (
+    <Outer control={control} testID="confirmModal">
+      <Title>{title}</Title>
+      <Description>{description}</Description>
+      <Actions>
+        <Action
+          cta={confirmButtonCta}
+          onPress={onConfirm}
+          color={confirmButtonColor}
+          testID="confirmBtn"
+        />
+        <Cancel cta={cancelButtonCta} />
+      </Actions>
+    </Outer>
+  )
+}
diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx
index 5f23a7883..dcdaccea3 100644
--- a/src/components/dialogs/MutedWords.tsx
+++ b/src/components/dialogs/MutedWords.tsx
@@ -277,29 +277,16 @@ function MutedWordRow({
 
   return (
     <>
-      <Prompt.Outer control={control}>
-        <Prompt.Title>
-          <Trans>Are you sure?</Trans>
-        </Prompt.Title>
-        <Prompt.Description>
-          <Trans>
-            This will delete {word.value} from your muted words. You can always
-            add it back later.
-          </Trans>
-        </Prompt.Description>
-        <Prompt.Actions>
-          <Prompt.Cancel>
-            <ButtonText>
-              <Trans>Nevermind</Trans>
-            </ButtonText>
-          </Prompt.Cancel>
-          <Prompt.Action onPress={remove}>
-            <ButtonText>
-              <Trans>Remove</Trans>
-            </ButtonText>
-          </Prompt.Action>
-        </Prompt.Actions>
-      </Prompt.Outer>
+      <Prompt.Basic
+        control={control}
+        title={_(msg`Are you sure?`)}
+        description={_(
+          msg`This will delete ${word.value} from your muted words. You can always add it back later.`,
+        )}
+        onConfirm={remove}
+        confirmButtonCta={_(msg`Remove`)}
+        confirmButtonColor="negative"
+      />
 
       <View
         style={[
diff --git a/src/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts
index 53eab300e..d35179256 100644
--- a/src/lib/hooks/useOTAUpdate.ts
+++ b/src/lib/hooks/useOTAUpdate.ts
@@ -2,25 +2,9 @@ import * as Updates from 'expo-updates'
 import {useCallback, useEffect} from 'react'
 import {AppState} from 'react-native'
 import {logger} from '#/logger'
-import {useModalControls} from '#/state/modals'
-import {t} from '@lingui/macro'
 
 export function useOTAUpdate() {
-  const {openModal} = useModalControls()
-
   // HELPER FUNCTIONS
-  const showUpdatePopup = useCallback(() => {
-    openModal({
-      name: 'confirm',
-      title: t`Update Available`,
-      message: t`A new version of the app is available. Please update to continue using the app.`,
-      onPressConfirm: async () => {
-        Updates.reloadAsync().catch(err => {
-          throw err
-        })
-      },
-    })
-  }, [openModal])
   const checkForUpdate = useCallback(async () => {
     logger.debug('useOTAUpdate: Checking for update...')
     try {
@@ -32,32 +16,26 @@ export function useOTAUpdate() {
       }
       // Otherwise fetch the update in the background, so even if the user rejects switching to latest version it will be done automatically on next relaunch.
       await Updates.fetchUpdateAsync()
-      // show a popup modal
-      showUpdatePopup()
     } catch (e) {
       logger.error('useOTAUpdate: Error while checking for update', {
         message: e,
       })
     }
-  }, [showUpdatePopup])
-  const updateEventListener = useCallback(
-    (event: Updates.UpdateEvent) => {
-      logger.debug('useOTAUpdate: Listening for update...')
-      if (event.type === Updates.UpdateEventType.ERROR) {
-        logger.error('useOTAUpdate: Error while listening for update', {
-          message: event.message,
-        })
-      } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
-        // Handle no update available
-        // do nothing
-      } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
-        // Handle update available
-        // open modal, ask for user confirmation, and reload the app
-        showUpdatePopup()
-      }
-    },
-    [showUpdatePopup],
-  )
+  }, [])
+  const updateEventListener = useCallback((event: Updates.UpdateEvent) => {
+    logger.debug('useOTAUpdate: Listening for update...')
+    if (event.type === Updates.UpdateEventType.ERROR) {
+      logger.error('useOTAUpdate: Error while listening for update', {
+        message: event.message,
+      })
+    } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
+      // Handle no update available
+      // do nothing
+    } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
+      // Handle update available
+      // open modal, ask for user confirmation, and reload the app
+    }
+  }, [])
 
   useEffect(() => {
     // ADD EVENT LISTENERS
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 691add005..a18f6c87c 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -1,6 +1,5 @@
 import React from 'react'
 import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api'
-import {StyleProp, ViewStyle} from 'react-native'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 
 import {ImageModel} from '#/state/models/media/image'
@@ -9,17 +8,6 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 import {EmbedPlayerSource} from '#/lib/strings/embed-player'
 import {ThreadgateSetting} from '../queries/threadgate'
 
-export interface ConfirmModal {
-  name: 'confirm'
-  title: string
-  message: string | (() => JSX.Element)
-  onPressConfirm: () => void | Promise<void>
-  onPressCancel?: () => void | Promise<void>
-  confirmBtnText?: string
-  confirmBtnStyle?: StyleProp<ViewStyle>
-  cancelBtnText?: string
-}
-
 export interface EditProfileModal {
   name: 'edit-profile'
   profile: AppBskyActorDefs.ProfileViewDetailed
@@ -225,7 +213,6 @@ export type Modal =
   | InviteCodesModal
 
   // Generic
-  | ConfirmModal
   | LinkWarningModal
   | EmbedConsentModal
   | InAppBrowserConsentModal
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 2855d4232..ef965b271 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -49,7 +49,7 @@ import {SuggestedLanguage} from './select-language/SuggestedLanguage'
 import {insertMentionAt} from 'lib/strings/mention-manip'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useModals, useModalControls} from '#/state/modals'
+import {useModals} from '#/state/modals'
 import {useRequireAltTextEnabled} from '#/state/preferences'
 import {
   useLanguagePrefs,
@@ -63,6 +63,8 @@ import {emitPostCreated} from '#/state/events'
 import {ThreadgateSetting} from '#/state/queries/threadgate'
 import {logger} from '#/logger'
 import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
+import * as Prompt from '#/components/Prompt'
+import {useDialogStateControlContext} from 'state/dialogs'
 
 type Props = ComposerOpts
 export const ComposePost = observer(function ComposePost({
@@ -76,8 +78,7 @@ export const ComposePost = observer(function ComposePost({
 }: Props) {
   const {currentAccount} = useSession()
   const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
-  const {isModalActive, activeModals} = useModals()
-  const {openModal, closeModal} = useModalControls()
+  const {isModalActive} = useModals()
   const {closeComposer} = useComposerControls()
   const {track} = useAnalytics()
   const pal = usePalette('default')
@@ -87,6 +88,9 @@ export const ComposePost = observer(function ComposePost({
   const langPrefs = useLanguagePrefs()
   const setLangPrefs = useLanguagePrefsApi()
   const textInput = useRef<TextInputRef>(null)
+  const discardPromptControl = Prompt.usePromptControl()
+  const {closeAllDialogs} = useDialogStateControlContext()
+
   const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
   const [isProcessing, setIsProcessing] = useState(false)
   const [processingState, setProcessingState] = useState('')
@@ -134,27 +138,21 @@ export const ComposePost = observer(function ComposePost({
 
   const onPressCancel = useCallback(() => {
     if (graphemeLength > 0 || !gallery.isEmpty) {
-      if (activeModals.some(modal => modal.name === 'confirm')) {
-        closeModal()
-      }
+      closeAllDialogs()
       if (Keyboard) {
         Keyboard.dismiss()
       }
-      openModal({
-        name: 'confirm',
-        title: _(msg`Discard draft`),
-        onPressConfirm: onClose,
-        onPressCancel: () => {
-          closeModal()
-        },
-        message: _(msg`Are you sure you'd like to discard this draft?`),
-        confirmBtnText: _(msg`Discard`),
-        confirmBtnStyle: {backgroundColor: colors.red4},
-      })
+      discardPromptControl.open()
     } else {
       onClose()
     }
-  }, [openModal, closeModal, activeModals, onClose, graphemeLength, gallery, _])
+  }, [
+    graphemeLength,
+    gallery.isEmpty,
+    closeAllDialogs,
+    discardPromptControl,
+    onClose,
+  ])
   // android back button
   useEffect(() => {
     if (!isAndroid) {
@@ -488,6 +486,15 @@ export const ComposePost = observer(function ComposePost({
           <CharProgress count={graphemeLength} />
         </View>
       </View>
+
+      <Prompt.Basic
+        control={discardPromptControl}
+        title={_(msg`Discard draft?`)}
+        description={_(msg`Are you sure you'd like to discard this draft?`)}
+        onConfirm={onClose}
+        confirmButtonCta={_(msg`Discard`)}
+        confirmButtonColor="negative"
+      />
     </KeyboardAvoidingView>
   )
 })
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index 4af62f6f6..9300b4159 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -11,7 +11,6 @@ import {AtUri} from '@atproto/api'
 import * as Toast from 'view/com/util/Toast'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {logger} from '#/logger'
-import {useModalControls} from '#/state/modals'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {
@@ -24,6 +23,7 @@ import {
 import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
 import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 import {useTheme} from '#/alf'
+import * as Prompt from '#/components/Prompt'
 import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
 
 export function FeedSourceCard({
@@ -85,8 +85,8 @@ export function FeedSourceCardLoaded({
   const t = useTheme()
   const pal = usePalette('default')
   const {_} = useLingui()
+  const removePromptControl = Prompt.usePromptControl()
   const navigation = useNavigationDeduped()
-  const {openModal} = useModalControls()
 
   const {isPending: isSavePending, mutateAsync: saveFeed} =
     useSaveFeedMutation()
@@ -96,40 +96,45 @@ export function FeedSourceCardLoaded({
 
   const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed?.uri || ''))
 
+  const onSave = React.useCallback(async () => {
+    if (!feed) return
+
+    try {
+      if (pinOnSave) {
+        await pinFeed({uri: feed.uri})
+      } else {
+        await saveFeed({uri: feed.uri})
+      }
+      Toast.show(_(msg`Added to my feeds`))
+    } catch (e) {
+      Toast.show(_(msg`There was an issue contacting your server`))
+      logger.error('Failed to save feed', {message: e})
+    }
+  }, [_, feed, pinFeed, pinOnSave, saveFeed])
+
+  const onUnsave = React.useCallback(async () => {
+    if (!feed) return
+
+    try {
+      await removeFeed({uri: feed.uri})
+      // await item.unsave()
+      Toast.show(_(msg`Removed from my feeds`))
+    } catch (e) {
+      Toast.show(_(msg`There was an issue contacting your server`))
+      logger.error('Failed to unsave feed', {message: e})
+    }
+  }, [_, feed, removeFeed])
+
   const onToggleSaved = React.useCallback(async () => {
     // Only feeds can be un/saved, lists are handled elsewhere
     if (feed?.type !== 'feed') return
 
     if (isSaved) {
-      openModal({
-        name: 'confirm',
-        title: _(msg`Remove from my feeds`),
-        message: _(msg`Remove ${feed?.displayName} from my feeds?`),
-        onPressConfirm: async () => {
-          try {
-            await removeFeed({uri: feed.uri})
-            // await item.unsave()
-            Toast.show(_(msg`Removed from my feeds`))
-          } catch (e) {
-            Toast.show(_(msg`There was an issue contacting your server`))
-            logger.error('Failed to unsave feed', {message: e})
-          }
-        },
-      })
+      removePromptControl.open()
     } else {
-      try {
-        if (pinOnSave) {
-          await pinFeed({uri: feed.uri})
-        } else {
-          await saveFeed({uri: feed.uri})
-        }
-        Toast.show(_(msg`Added to my feeds`))
-      } catch (e) {
-        Toast.show(_(msg`There was an issue contacting your server`))
-        logger.error('Failed to save feed', {message: e})
-      }
+      await onSave()
     }
-  }, [isSaved, openModal, feed, removeFeed, saveFeed, _, pinOnSave, pinFeed])
+  }, [feed?.type, isSaved, removePromptControl, onSave])
 
   /*
    * LOAD STATE
@@ -167,25 +172,7 @@ export function FeedSourceCardLoaded({
             accessibilityRole="button"
             accessibilityLabel={_(msg`Remove from my feeds`)}
             accessibilityHint=""
-            onPress={() => {
-              openModal({
-                name: 'confirm',
-                title: _(msg`Remove from my feeds`),
-                message: _(msg`Remove this feed from my feeds?`),
-                onPressConfirm: async () => {
-                  try {
-                    await removeFeed({uri: feedUri})
-                    // await item.unsave()
-                    Toast.show(_(msg`Removed from my feeds`))
-                  } catch (e) {
-                    Toast.show(
-                      _(msg`There was an issue contacting your server`),
-                    )
-                    logger.error('Failed to unsave feed', {message: e})
-                  }
-                },
-              })
-            }}
+            onPress={onToggleSaved}
             hitSlop={15}
             style={styles.btn}>
             <FontAwesomeIcon
@@ -199,89 +186,104 @@ export function FeedSourceCardLoaded({
     )
 
   return (
-    <Pressable
-      testID={`feed-${feed.displayName}`}
-      accessibilityRole="button"
-      style={[styles.container, pal.border, style]}
-      onPress={() => {
-        if (feed.type === 'feed') {
-          navigation.push('ProfileFeed', {
-            name: feed.creatorDid,
-            rkey: new AtUri(feed.uri).rkey,
-          })
-        } else if (feed.type === 'list') {
-          navigation.push('ProfileList', {
-            name: feed.creatorDid,
-            rkey: new AtUri(feed.uri).rkey,
-          })
-        }
-      }}
-      key={feed.uri}>
-      <View style={[styles.headerContainer]}>
-        <View style={[s.mr10]}>
-          <UserAvatar type="algo" size={36} avatar={feed.avatar} />
-        </View>
-        <View style={[styles.headerTextContainer]}>
-          <Text style={[pal.text, s.bold]} numberOfLines={3}>
-            {feed.displayName}
-          </Text>
-          <Text style={[pal.textLight]} numberOfLines={3}>
-            {feed.type === 'feed' ? (
-              <Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
-            ) : (
-              <Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
-            )}
-          </Text>
-        </View>
-
-        {showSaveBtn && feed.type === 'feed' && (
-          <View style={[s.justifyCenter]}>
-            <Pressable
-              testID={`feed-${feed.displayName}-toggleSave`}
-              disabled={isSavePending || isPinPending || isRemovePending}
-              accessibilityRole="button"
-              accessibilityLabel={
-                isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`)
-              }
-              accessibilityHint=""
-              onPress={onToggleSaved}
-              hitSlop={15}
-              style={styles.btn}>
-              {isSaved ? (
-                <FontAwesomeIcon
-                  icon={['far', 'trash-can']}
-                  size={19}
-                  color={pal.colors.icon}
-                />
+    <>
+      <Pressable
+        testID={`feed-${feed.displayName}`}
+        accessibilityRole="button"
+        style={[styles.container, pal.border, style]}
+        onPress={() => {
+          if (feed.type === 'feed') {
+            navigation.push('ProfileFeed', {
+              name: feed.creatorDid,
+              rkey: new AtUri(feed.uri).rkey,
+            })
+          } else if (feed.type === 'list') {
+            navigation.push('ProfileList', {
+              name: feed.creatorDid,
+              rkey: new AtUri(feed.uri).rkey,
+            })
+          }
+        }}
+        key={feed.uri}>
+        <View style={[styles.headerContainer]}>
+          <View style={[s.mr10]}>
+            <UserAvatar type="algo" size={36} avatar={feed.avatar} />
+          </View>
+          <View style={[styles.headerTextContainer]}>
+            <Text style={[pal.text, s.bold]} numberOfLines={3}>
+              {feed.displayName}
+            </Text>
+            <Text style={[pal.textLight]} numberOfLines={3}>
+              {feed.type === 'feed' ? (
+                <Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
               ) : (
-                <FontAwesomeIcon
-                  icon="plus"
-                  size={18}
-                  color={pal.colors.link}
-                />
+                <Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
               )}
-            </Pressable>
+            </Text>
           </View>
-        )}
-      </View>
 
-      {showDescription && feed.description ? (
-        <RichText
-          style={[t.atoms.text_contrast_high, styles.description]}
-          value={feed.description}
-          numberOfLines={3}
-        />
-      ) : null}
+          {showSaveBtn && feed.type === 'feed' && (
+            <View style={[s.justifyCenter]}>
+              <Pressable
+                testID={`feed-${feed.displayName}-toggleSave`}
+                disabled={isSavePending || isPinPending || isRemovePending}
+                accessibilityRole="button"
+                accessibilityLabel={
+                  isSaved
+                    ? _(msg`Remove from my feeds`)
+                    : _(msg`Add to my feeds`)
+                }
+                accessibilityHint=""
+                onPress={onToggleSaved}
+                hitSlop={15}
+                style={styles.btn}>
+                {isSaved ? (
+                  <FontAwesomeIcon
+                    icon={['far', 'trash-can']}
+                    size={19}
+                    color={pal.colors.icon}
+                  />
+                ) : (
+                  <FontAwesomeIcon
+                    icon="plus"
+                    size={18}
+                    color={pal.colors.link}
+                  />
+                )}
+              </Pressable>
+            </View>
+          )}
+        </View>
+
+        {showDescription && feed.description ? (
+          <RichText
+            style={[t.atoms.text_contrast_high, styles.description]}
+            value={feed.description}
+            numberOfLines={3}
+          />
+        ) : null}
 
-      {showLikes && feed.type === 'feed' ? (
-        <Text type="sm-medium" style={[pal.text, pal.textLight]}>
-          <Trans>
-            Liked by {feed.likeCount || 0}{' '}
-            {pluralize(feed.likeCount || 0, 'user')}
-          </Trans>
-        </Text>
-      ) : null}
-    </Pressable>
+        {showLikes && feed.type === 'feed' ? (
+          <Text type="sm-medium" style={[pal.text, pal.textLight]}>
+            <Trans>
+              Liked by {feed.likeCount || 0}{' '}
+              {pluralize(feed.likeCount || 0, 'user')}
+            </Trans>
+          </Text>
+        ) : null}
+      </Pressable>
+
+      <Prompt.Basic
+        control={removePromptControl}
+        title={_(msg`Remove from my feeds?`)}
+        description={_(
+          msg`Are you sure you want to remove ${feed.displayName} from your feeds?`,
+        )}
+        onConfirm={onUnsave}
+        confirmButtonCta={_(msg`Remove`)}
+        confirmButtonColor="negative"
+      />
+    </>
   )
 }
 
diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx
deleted file mode 100644
index 307897fb8..000000000
--- a/src/view/com/modals/Confirm.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-import React, {useState} from 'react'
-import {
-  ActivityIndicator,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-} from 'react-native'
-import {Text} from '../util/text/Text'
-import {s, colors} from 'lib/styles'
-import {ErrorMessage} from '../util/error/ErrorMessage'
-import {cleanError} from 'lib/strings/errors'
-import {usePalette} from 'lib/hooks/usePalette'
-import {isWeb} from 'platform/detection'
-import {useLingui} from '@lingui/react'
-import {Trans, msg} from '@lingui/macro'
-import type {ConfirmModal} from '#/state/modals'
-import {useModalControls} from '#/state/modals'
-
-export const snapPoints = ['50%']
-
-export function Component({
-  title,
-  message,
-  onPressConfirm,
-  onPressCancel,
-  confirmBtnText,
-  confirmBtnStyle,
-  cancelBtnText,
-}: ConfirmModal) {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const {closeModal} = useModalControls()
-  const [isProcessing, setIsProcessing] = useState<boolean>(false)
-  const [error, setError] = useState<string>('')
-  const onPress = async () => {
-    setError('')
-    setIsProcessing(true)
-    try {
-      await onPressConfirm()
-      closeModal()
-      return
-    } catch (e: any) {
-      setError(cleanError(e))
-      setIsProcessing(false)
-    }
-  }
-  return (
-    <View testID="confirmModal" style={[pal.view, styles.container]}>
-      <Text type="title-xl" style={[pal.text, styles.title]}>
-        {title}
-      </Text>
-      {typeof message === 'string' ? (
-        <Text type="xl" style={[pal.textLight, styles.description]}>
-          {message}
-        </Text>
-      ) : (
-        message()
-      )}
-      {error ? (
-        <View style={s.mt10}>
-          <ErrorMessage message={error} />
-        </View>
-      ) : undefined}
-      <View style={s.flex1} />
-      {isProcessing ? (
-        <View style={[styles.btn, s.mt10]}>
-          <ActivityIndicator />
-        </View>
-      ) : (
-        <TouchableOpacity
-          testID="confirmBtn"
-          onPress={onPress}
-          style={[styles.btn, confirmBtnStyle]}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg({message: 'Confirm', context: 'action'}))}
-          accessibilityHint="">
-          <Text style={[s.white, s.bold, s.f18]}>
-            {confirmBtnText ?? <Trans context="action">Confirm</Trans>}
-          </Text>
-        </TouchableOpacity>
-      )}
-      {onPressCancel === undefined ? null : (
-        <TouchableOpacity
-          testID="cancelBtn"
-          onPress={onPressCancel}
-          style={[styles.btnCancel, s.mt10]}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg({message: 'Cancel', context: 'action'}))}
-          accessibilityHint="">
-          <Text type="button-lg" style={pal.textLight}>
-            {cancelBtnText ?? <Trans context="action">Cancel</Trans>}
-          </Text>
-        </TouchableOpacity>
-      )}
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-    padding: 10,
-    paddingBottom: isWeb ? 0 : 60,
-  },
-  title: {
-    textAlign: 'center',
-    marginBottom: 12,
-  },
-  description: {
-    textAlign: 'center',
-    paddingHorizontal: 22,
-    marginBottom: 10,
-  },
-  btn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    borderRadius: 32,
-    padding: 14,
-    marginTop: 22,
-    marginHorizontal: 44,
-    backgroundColor: colors.blue3,
-  },
-  btnCancel: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    borderRadius: 32,
-    padding: 14,
-    marginHorizontal: 20,
-  },
-})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 100444ff5..e03879c1d 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -6,7 +6,6 @@ import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
 import {usePalette} from 'lib/hooks/usePalette'
 
 import {useModals, useModalControls} from '#/state/modals'
-import * as ConfirmModal from './Confirm'
 import * as EditProfileModal from './EditProfile'
 import * as RepostModal from './Repost'
 import * as SelfLabelModal from './SelfLabel'
@@ -66,10 +65,7 @@ export function ModalsContainer() {
 
   let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
   let element
-  if (activeModal?.name === 'confirm') {
-    snapPoints = ConfirmModal.snapPoints
-    element = <ConfirmModal.Component {...activeModal} />
-  } else if (activeModal?.name === 'edit-profile') {
+  if (activeModal?.name === 'edit-profile') {
     snapPoints = EditProfileModal.snapPoints
     element = <EditProfileModal.Component {...activeModal} />
   } else if (activeModal?.name === 'report') {
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 0ced894a1..d72b7e485 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -7,7 +7,6 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
 
 import {useModals, useModalControls} from '#/state/modals'
 import type {Modal as ModalIface} from '#/state/modals'
-import * as ConfirmModal from './Confirm'
 import * as EditProfileModal from './EditProfile'
 import * as ReportModal from './report/Modal'
 import * as AppealLabelModal from './AppealLabel'
@@ -78,9 +77,7 @@ function Modal({modal}: {modal: ModalIface}) {
   }
 
   let element
-  if (modal.name === 'confirm') {
-    element = <ConfirmModal.Component {...modal} />
-  } else if (modal.name === 'edit-profile') {
+  if (modal.name === 'edit-profile') {
     element = <EditProfileModal.Component {...modal} />
   } else if (modal.name === 'report') {
     element = <ReportModal.Component {...modal} />
diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx
index 6d99c32f1..c52090f97 100644
--- a/src/view/com/posts/FeedErrorMessage.tsx
+++ b/src/view/com/posts/FeedErrorMessage.tsx
@@ -9,13 +9,13 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
 import {logger} from '#/logger'
-import {useModalControls} from '#/state/modals'
 import {msg as msgLingui, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {FeedDescriptor} from '#/state/queries/post-feed'
 import {EmptyState} from '../util/EmptyState'
 import {cleanError} from '#/lib/strings/errors'
 import {useRemoveFeedMutation} from '#/state/queries/preferences'
+import * as Prompt from '#/components/Prompt'
 
 export enum KnownError {
   Block = 'Block',
@@ -118,35 +118,29 @@ function FeedgenErrorMessage({
   )
   const [_, uri] = feedDesc.split('|')
   const [ownerDid] = safeParseFeedgenUri(uri)
-  const {openModal, closeModal} = useModalControls()
+  const removePromptControl = Prompt.usePromptControl()
   const {mutateAsync: removeFeed} = useRemoveFeedMutation()
 
   const onViewProfile = React.useCallback(() => {
     navigation.navigate('Profile', {name: ownerDid})
   }, [navigation, ownerDid])
 
+  const onPressRemoveFeed = React.useCallback(() => {
+    removePromptControl.open()
+  }, [removePromptControl])
+
   const onRemoveFeed = React.useCallback(async () => {
-    openModal({
-      name: 'confirm',
-      title: _l(msgLingui`Remove feed`),
-      message: _l(msgLingui`Remove this feed from your saved feeds?`),
-      async onPressConfirm() {
-        try {
-          await removeFeed({uri})
-        } catch (err) {
-          Toast.show(
-            _l(
-              msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`,
-            ),
-          )
-          logger.error('Failed to remove feed', {message: err})
-        }
-      },
-      onPressCancel() {
-        closeModal()
-      },
-    })
-  }, [openModal, closeModal, uri, removeFeed, _l])
+    try {
+      await removeFeed({uri})
+    } catch (err) {
+      Toast.show(
+        _l(
+          msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`,
+        ),
+      )
+      logger.error('Failed to remove feed', {message: err})
+    }
+  }, [uri, removeFeed, _l])
 
   const cta = React.useMemo(() => {
     switch (knownError) {
@@ -179,27 +173,38 @@ function FeedgenErrorMessage({
   }, [knownError, onViewProfile, onRemoveFeed, _l])
 
   return (
-    <View
-      style={[
-        pal.border,
-        pal.viewLight,
-        {
-          borderTopWidth: 1,
-          paddingHorizontal: 20,
-          paddingVertical: 18,
-          gap: 12,
-        },
-      ]}>
-      <Text style={pal.text}>{msg}</Text>
+    <>
+      <View
+        style={[
+          pal.border,
+          pal.viewLight,
+          {
+            borderTopWidth: 1,
+            paddingHorizontal: 20,
+            paddingVertical: 18,
+            gap: 12,
+          },
+        ]}>
+        <Text style={pal.text}>{msg}</Text>
 
-      {rawError?.message && (
-        <Text style={pal.textLight}>
-          <Trans>Message from server: {rawError.message}</Trans>
-        </Text>
-      )}
+        {rawError?.message && (
+          <Text style={pal.textLight}>
+            <Trans>Message from server: {rawError.message}</Trans>
+          </Text>
+        )}
 
-      {cta}
-    </View>
+        {cta}
+      </View>
+
+      <Prompt.Basic
+        control={removePromptControl}
+        title={_l(msgLingui`Remove feed?`)}
+        description={_l(msgLingui`Remove this feed from your saved feeds`)}
+        onConfirm={onPressRemoveFeed}
+        confirmButtonCta={_l(msgLingui`Remove`)}
+        confirmButtonColor="negative"
+      />
+    </>
   )
 }
 
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index a11fe8374..75e06eb9b 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -52,6 +52,7 @@ import {LabelInfo} from '../util/moderation/LabelInfo'
 import {useProfileShadow} from 'state/cache/profile-shadow'
 import {atoms as a} from '#/alf'
 import {ProfileMenu} from 'view/com/profile/ProfileMenu'
+import * as Prompt from '#/components/Prompt'
 
 let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
   const pal = usePalette('default')
@@ -104,6 +105,7 @@ let ProfileHeader = ({
   const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
   const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
   const [__, queueUnblock] = useProfileBlockMutationQueue(profile)
+  const unblockPromptControl = Prompt.usePromptControl()
   const moderation = useMemo(
     () => moderateProfile(profile, moderationOpts),
     [profile, moderationOpts],
@@ -176,27 +178,18 @@ let ProfileHeader = ({
     })
   }, [track, openModal, profile])
 
-  const onPressUnblockAccount = React.useCallback(() => {
+  const unblockAccount = React.useCallback(async () => {
     track('ProfileHeader:UnblockAccountButtonClicked')
-    openModal({
-      name: 'confirm',
-      title: _(msg`Unblock Account`),
-      message: _(
-        msg`The account will be able to interact with you after unblocking.`,
-      ),
-      onPressConfirm: async () => {
-        try {
-          await queueUnblock()
-          Toast.show(_(msg`Account unblocked`))
-        } catch (e: any) {
-          if (e?.name !== 'AbortError') {
-            logger.error('Failed to unblock account', {message: e})
-            Toast.show(_(msg`There was an issue! ${e.toString()}`))
-          }
-        }
-      },
-    })
-  }, [_, openModal, queueUnblock, track])
+    try {
+      await queueUnblock()
+      Toast.show(_(msg`Account unblocked`))
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        logger.error('Failed to unblock account', {message: e})
+        Toast.show(_(msg`There was an issue! ${e.toString()}`))
+      }
+    }
+  }, [_, queueUnblock, track])
 
   const isMe = React.useMemo(
     () => currentAccount?.did === profile.did,
@@ -242,7 +235,7 @@ let ProfileHeader = ({
             profile.viewer?.blockingByList ? null : (
               <TouchableOpacity
                 testID="unblockBtn"
-                onPress={onPressUnblockAccount}
+                onPress={() => unblockPromptControl.open()}
                 style={[styles.btn, styles.mainBtn, pal.btn]}
                 accessibilityRole="button"
                 accessibilityLabel={_(msg`Unblock`)}
@@ -475,6 +468,18 @@ let ProfileHeader = ({
           />
         </View>
       </TouchableWithoutFeedback>
+      <Prompt.Basic
+        control={unblockPromptControl}
+        title={_(msg`Unblock Account?`)}
+        description={_(
+          msg`The account will be able to interact with you after unblocking.`,
+        )}
+        onConfirm={unblockAccount}
+        confirmButtonCta={
+          profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
+        }
+        confirmButtonColor="negative"
+      />
     </View>
   )
 }
diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx
index d79e1891d..ef102daf9 100644
--- a/src/view/com/profile/ProfileMenu.tsx
+++ b/src/view/com/profile/ProfileMenu.tsx
@@ -33,6 +33,7 @@ import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Per
 import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
 import {logger} from '#/logger'
 import {Shadow} from 'state/cache/types'
+import * as Prompt from '#/components/Prompt'
 
 let ProfileMenu = ({
   profile,
@@ -53,6 +54,8 @@ let ProfileMenu = ({
   const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
   const [, queueUnfollow] = useProfileFollowMutationQueue(profile)
 
+  const blockPromptControl = Prompt.usePromptControl()
+
   const invalidateProfileQuery = React.useCallback(() => {
     queryClient.invalidateQueries({
       queryKey: profileQueryKey(profile.did),
@@ -102,49 +105,31 @@ let ProfileMenu = ({
     }
   }, [profile.viewer?.muted, track, queueUnmute, _, queueMute])
 
-  const onPressBlockAccount = React.useCallback(async () => {
+  const blockAccount = React.useCallback(async () => {
     if (profile.viewer?.blocking) {
       track('ProfileHeader:UnblockAccountButtonClicked')
-      openModal({
-        name: 'confirm',
-        title: _(msg`Unblock Account`),
-        message: _(
-          msg`The account will be able to interact with you after unblocking.`,
-        ),
-        onPressConfirm: async () => {
-          try {
-            await queueUnblock()
-            Toast.show(_(msg`Account unblocked`))
-          } catch (e: any) {
-            if (e?.name !== 'AbortError') {
-              logger.error('Failed to unblock account', {message: e})
-              Toast.show(_(msg`There was an issue! ${e.toString()}`))
-            }
-          }
-        },
-      })
+      try {
+        await queueUnblock()
+        Toast.show(_(msg`Account unblocked`))
+      } catch (e: any) {
+        if (e?.name !== 'AbortError') {
+          logger.error('Failed to unblock account', {message: e})
+          Toast.show(_(msg`There was an issue! ${e.toString()}`))
+        }
+      }
     } else {
       track('ProfileHeader:BlockAccountButtonClicked')
-      openModal({
-        name: 'confirm',
-        title: _(msg`Block Account`),
-        message: _(
-          msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
-        ),
-        onPressConfirm: async () => {
-          try {
-            await queueBlock()
-            Toast.show(_(msg`Account blocked`))
-          } catch (e: any) {
-            if (e?.name !== 'AbortError') {
-              logger.error('Failed to block account', {message: e})
-              Toast.show(_(msg`There was an issue! ${e.toString()}`))
-            }
-          }
-        },
-      })
+      try {
+        await queueBlock()
+        Toast.show(_(msg`Account blocked`))
+      } catch (e: any) {
+        if (e?.name !== 'AbortError') {
+          logger.error('Failed to block account', {message: e})
+          Toast.show(_(msg`There was an issue! ${e.toString()}`))
+        }
+      }
     }
-  }, [profile.viewer?.blocking, track, openModal, _, queueUnblock, queueBlock])
+  }, [profile.viewer?.blocking, track, _, queueUnblock, queueBlock])
 
   const onPressUnfollowAccount = React.useCallback(async () => {
     track('ProfileHeader:UnfollowButtonClicked')
@@ -268,7 +253,7 @@ let ProfileMenu = ({
                             ? _(msg`Unblock Account`)
                             : _(msg`Block Account`)
                         }
-                        onPress={onPressBlockAccount}>
+                        onPress={() => blockPromptControl.open()}>
                         <Menu.ItemText>
                           {profile.viewer?.blocking ? (
                             <Trans>Unblock Account</Trans>
@@ -299,6 +284,29 @@ let ProfileMenu = ({
           )}
         </Menu.Outer>
       </Menu.Root>
+
+      <Prompt.Basic
+        control={blockPromptControl}
+        title={
+          profile.viewer?.blocking
+            ? _(msg`Unblock Account?`)
+            : _(msg`Block Account?`)
+        }
+        description={
+          profile.viewer?.blocking
+            ? _(
+                msg`The account will be able to interact with you after unblocking.`,
+              )
+            : _(
+                msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
+              )
+        }
+        onConfirm={blockAccount}
+        confirmButtonCta={
+          profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
+        }
+        confirmButtonColor="negative"
+      />
     </EventStopper>
   )
 }
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 3c1a736f3..84a047c40 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -14,6 +14,8 @@ import {useTheme} from 'lib/ThemeContext'
 import {shareUrl} from 'lib/sharing'
 import * as Toast from '../Toast'
 import {EventStopper} from '../EventStopper'
+import {useDialogControl} from '#/components/Dialog'
+import * as Prompt from '#/components/Prompt'
 import {useModalControls} from '#/state/modals'
 import {makeProfileLink} from '#/lib/routes/links'
 import {CommonNavigatorParams} from '#/lib/routes/types'
@@ -81,6 +83,8 @@ let PostDropdownBtn = ({
   const openLink = useOpenLink()
   const navigation = useNavigation()
   const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
+  const deletePromptControl = useDialogControl()
+  const hidePromptControl = useDialogControl()
 
   const rootUri = record.reply?.root?.uri || postUri
   const isThreadMuted = mutedThreads.includes(rootUri)
@@ -257,16 +261,7 @@ let PostDropdownBtn = ({
                   <Menu.Item
                     testID="postDropdownHideBtn"
                     label={_(msg`Hide post`)}
-                    onPress={() => {
-                      openModal({
-                        name: 'confirm',
-                        title: _(msg`Hide this post?`),
-                        message: _(
-                          msg`This will hide this post from your feeds.`,
-                        ),
-                        onPressConfirm: onHidePost,
-                      })
-                    }}>
+                    onPress={hidePromptControl.open}>
                     <Menu.ItemText>{_(msg`Hide post`)}</Menu.ItemText>
                     <Menu.ItemIcon icon={EyeSlash} position="right" />
                   </Menu.Item>
@@ -298,14 +293,7 @@ let PostDropdownBtn = ({
               <Menu.Item
                 testID="postDropdownDeleteBtn"
                 label={_(msg`Delete post`)}
-                onPress={() => {
-                  openModal({
-                    name: 'confirm',
-                    title: _(msg`Delete this post?`),
-                    message: _(msg`Are you sure? This cannot be undone.`),
-                    onPressConfirm: onDeletePost,
-                  })
-                }}>
+                onPress={deletePromptControl.open}>
                 <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
                 <Menu.ItemIcon icon={Trash} position="right" />
               </Menu.Item>
@@ -335,6 +323,25 @@ let PostDropdownBtn = ({
           </Menu.Group>
         </Menu.Outer>
       </Menu.Root>
+
+      <Prompt.Basic
+        control={deletePromptControl}
+        title={_(msg`Delete this post?`)}
+        description={_(
+          msg`If you remove this post, you won't be able to recover it.`,
+        )}
+        onConfirm={onDeletePost}
+        confirmButtonCta={_(msg`Delete`)}
+        confirmButtonColor="negative"
+      />
+
+      <Prompt.Basic
+        control={hidePromptControl}
+        title={_(msg`Hide this post?`)}
+        description={_(msg`This post will be hidden from feeds.`)}
+        onConfirm={onHidePost}
+        confirmButtonCta={_(msg`Hide`)}
+      />
     </EventStopper>
   )
 }
diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx
index dc439c367..800216169 100644
--- a/src/view/screens/AppPasswords.tsx
+++ b/src/view/screens/AppPasswords.tsx
@@ -29,6 +29,8 @@ import {
 } from '#/state/queries/app-passwords'
 import {ErrorScreen} from '../com/util/error/ErrorScreen'
 import {cleanError} from '#/lib/strings/errors'
+import * as Prompt from '#/components/Prompt'
+import {useDialogControl} from '#/components/Dialog'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'>
 export function AppPasswords({}: Props) {
@@ -212,23 +214,18 @@ function AppPassword({
 }) {
   const pal = usePalette('default')
   const {_} = useLingui()
-  const {openModal} = useModalControls()
+  const control = useDialogControl()
   const {contentLanguages} = useLanguagePrefs()
   const deleteMutation = useAppPasswordDeleteMutation()
 
   const onDelete = React.useCallback(async () => {
-    openModal({
-      name: 'confirm',
-      title: _(msg`Delete app password`),
-      message: _(
-        msg`Are you sure you want to delete the app password "${name}"?`,
-      ),
-      async onPressConfirm() {
-        await deleteMutation.mutateAsync({name})
-        Toast.show(_(msg`App password deleted`))
-      },
-    })
-  }, [deleteMutation, openModal, name, _])
+    await deleteMutation.mutateAsync({name})
+    Toast.show(_(msg`App password deleted`))
+  }, [deleteMutation, name, _])
+
+  const onPress = React.useCallback(() => {
+    control.open()
+  }, [control])
 
   const primaryLocale =
     contentLanguages.length > 0 ? contentLanguages[0] : 'en-US'
@@ -237,7 +234,7 @@ function AppPassword({
     <TouchableOpacity
       testID={testID}
       style={[styles.item, pal.border]}
-      onPress={onDelete}
+      onPress={onPress}
       accessibilityRole="button"
       accessibilityLabel={_(msg`Delete app password`)}
       accessibilityHint="">
@@ -260,6 +257,17 @@ function AppPassword({
         </Text>
       </View>
       <FontAwesomeIcon icon={['far', 'trash-can']} style={styles.trashIcon} />
+
+      <Prompt.Basic
+        control={control}
+        title={_(msg`Delete app password?`)}
+        description={_(
+          msg`Are you sure you want to delete the app password "${name}"?`,
+        )}
+        onConfirm={onDelete}
+        confirmButtonCta={_(msg`Delete`)}
+        confirmButtonColor="negative"
+      />
     </TouchableOpacity>
   )
 }
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 9e98757ef..95046e5b4 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -61,6 +61,8 @@ import {logger} from '#/logger'
 import {useAnalytics} from '#/lib/analytics/analytics'
 import {listenSoftReset} from '#/state/events'
 import {atoms as a, useTheme} from '#/alf'
+import * as Prompt from '#/components/Prompt'
+import {useDialogControl} from '#/components/Dialog'
 
 const SECTION_TITLES_CURATE = ['Posts', 'About']
 const SECTION_TITLES_MOD = ['About']
@@ -234,7 +236,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
   const {_} = useLingui()
   const navigation = useNavigation<NavigationProp>()
   const {currentAccount} = useSession()
-  const {openModal, closeModal} = useModalControls()
+  const {openModal} = useModalControls()
   const listMuteMutation = useListMuteMutation()
   const listBlockMutation = useListBlockMutation()
   const listDeleteMutation = useListDeleteMutation()
@@ -251,6 +253,10 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
   const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
   const {track} = useAnalytics()
 
+  const deleteListPromptControl = useDialogControl()
+  const subscribeMutePromptControl = useDialogControl()
+  const subscribeBlockPromptControl = useDialogControl()
+
   const isPinned = preferences?.feeds?.pinned?.includes(list.uri)
   const isSaved = preferences?.feeds?.saved?.includes(list.uri)
 
@@ -269,32 +275,19 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
     }
   }, [list.uri, isPinned, pinFeed, unpinFeed, _])
 
-  const onSubscribeMute = useCallback(() => {
-    openModal({
-      name: 'confirm',
-      title: _(msg`Mute these accounts?`),
-      message: _(
-        msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`,
-      ),
-      confirmBtnText: _(msg`Mute this List`),
-      async onPressConfirm() {
-        try {
-          await listMuteMutation.mutateAsync({uri: list.uri, mute: true})
-          Toast.show(_(msg`List muted`))
-          track('Lists:Mute')
-        } catch {
-          Toast.show(
-            _(
-              msg`There was an issue. Please check your internet connection and try again.`,
-            ),
-          )
-        }
-      },
-      onPressCancel() {
-        closeModal()
-      },
-    })
-  }, [openModal, closeModal, list, listMuteMutation, track, _])
+  const onSubscribeMute = useCallback(async () => {
+    try {
+      await listMuteMutation.mutateAsync({uri: list.uri, mute: true})
+      Toast.show(_(msg`List muted`))
+      track('Lists:Mute')
+    } catch {
+      Toast.show(
+        _(
+          msg`There was an issue. Please check your internet connection and try again.`,
+        ),
+      )
+    }
+  }, [list, listMuteMutation, track, _])
 
   const onUnsubscribeMute = useCallback(async () => {
     try {
@@ -310,32 +303,19 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
     }
   }, [list, listMuteMutation, track, _])
 
-  const onSubscribeBlock = useCallback(() => {
-    openModal({
-      name: 'confirm',
-      title: _(msg`Block these accounts?`),
-      message: _(
-        msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
-      ),
-      confirmBtnText: _(msg`Block this List`),
-      async onPressConfirm() {
-        try {
-          await listBlockMutation.mutateAsync({uri: list.uri, block: true})
-          Toast.show(_(msg`List blocked`))
-          track('Lists:Block')
-        } catch {
-          Toast.show(
-            _(
-              msg`There was an issue. Please check your internet connection and try again.`,
-            ),
-          )
-        }
-      },
-      onPressCancel() {
-        closeModal()
-      },
-    })
-  }, [openModal, closeModal, list, listBlockMutation, track, _])
+  const onSubscribeBlock = useCallback(async () => {
+    try {
+      await listBlockMutation.mutateAsync({uri: list.uri, block: true})
+      Toast.show(_(msg`List blocked`))
+      track('Lists:Block')
+    } catch {
+      Toast.show(
+        _(
+          msg`There was an issue. Please check your internet connection and try again.`,
+        ),
+      )
+    }
+  }, [list, listBlockMutation, track, _])
 
   const onUnsubscribeBlock = useCallback(async () => {
     try {
@@ -358,34 +338,26 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
     })
   }, [openModal, list])
 
-  const onPressDelete = useCallback(() => {
-    openModal({
-      name: 'confirm',
-      title: _(msg`Delete List`),
-      message: _(msg`Are you sure?`),
-      async onPressConfirm() {
-        await listDeleteMutation.mutateAsync({uri: list.uri})
-
-        if (isSaved || isPinned) {
-          const {saved, pinned} = preferences!.feeds
-
-          setSavedFeeds({
-            saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved,
-            pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned,
-          })
-        }
+  const onPressDelete = useCallback(async () => {
+    await listDeleteMutation.mutateAsync({uri: list.uri})
 
-        Toast.show(_(msg`List deleted`))
-        track('Lists:Delete')
-        if (navigation.canGoBack()) {
-          navigation.goBack()
-        } else {
-          navigation.navigate('Home')
-        }
-      },
-    })
+    if (isSaved || isPinned) {
+      const {saved, pinned} = preferences!.feeds
+
+      setSavedFeeds({
+        saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved,
+        pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned,
+      })
+    }
+
+    Toast.show(_(msg`List deleted`))
+    track('Lists:Delete')
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
+    }
   }, [
-    openModal,
     list,
     listDeleteMutation,
     navigation,
@@ -443,7 +415,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
       items.push({
         testID: 'listHeaderDropdownDeleteBtn',
         label: _(msg`Delete List`),
-        onPress: onPressDelete,
+        onPress: deleteListPromptControl.open,
         icon: {
           ios: {
             name: 'trash',
@@ -489,7 +461,9 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
         items.push({
           testID: 'listHeaderDropdownMuteBtn',
           label: isMuting ? _(msg`Un-mute list`) : _(msg`Mute list`),
-          onPress: isMuting ? onUnsubscribeMute : onSubscribeMute,
+          onPress: isMuting
+            ? onUnsubscribeMute
+            : subscribeMutePromptControl.open,
           icon: {
             ios: {
               name: isMuting ? 'eye' : 'eye.slash',
@@ -504,7 +478,9 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
         items.push({
           testID: 'listHeaderDropdownBlockBtn',
           label: isBlocking ? _(msg`Un-block list`) : _(msg`Block list`),
-          onPress: isBlocking ? onUnsubscribeBlock : onSubscribeBlock,
+          onPress: isBlocking
+            ? onUnsubscribeBlock
+            : subscribeBlockPromptControl.open,
           icon: {
             ios: {
               name: 'person.fill.xmark',
@@ -517,24 +493,24 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
     }
     return items
   }, [
-    isOwner,
-    onPressShare,
-    onPressEdit,
-    onPressDelete,
-    onPressReport,
     _,
+    onPressShare,
+    isOwner,
     isModList,
     isPinned,
-    unpinFeed,
+    isCurateList,
+    onPressEdit,
+    deleteListPromptControl.open,
+    onPressReport,
     isPending,
+    unpinFeed,
     list.uri,
-    isCurateList,
-    isMuting,
     isBlocking,
+    isMuting,
     onUnsubscribeMute,
-    onSubscribeMute,
+    subscribeMutePromptControl.open,
     onUnsubscribeBlock,
-    onSubscribeBlock,
+    subscribeBlockPromptControl.open,
   ])
 
   const subscribeDropdownItems: DropdownItem[] = useMemo(() => {
@@ -542,7 +518,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
       {
         testID: 'subscribeDropdownMuteBtn',
         label: _(msg`Mute accounts`),
-        onPress: onSubscribeMute,
+        onPress: subscribeMutePromptControl.open,
         icon: {
           ios: {
             name: 'speaker.slash',
@@ -554,7 +530,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
       {
         testID: 'subscribeDropdownBlockBtn',
         label: _(msg`Block accounts`),
-        onPress: onSubscribeBlock,
+        onPress: subscribeBlockPromptControl.open,
         icon: {
           ios: {
             name: 'person.fill.xmark',
@@ -564,7 +540,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
         },
       },
     ]
-  }, [onSubscribeMute, onSubscribeBlock, _])
+  }, [_, subscribeMutePromptControl.open, subscribeBlockPromptControl.open])
 
   return (
     <ProfileSubpageHeader
@@ -620,6 +596,38 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
           <FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} />
         </View>
       </NativeDropdown>
+
+      <Prompt.Basic
+        control={deleteListPromptControl}
+        title={_(msg`Delete this list?`)}
+        description={_(
+          msg`If you delete this list, you won't be able to recover it.`,
+        )}
+        onConfirm={onPressDelete}
+        confirmButtonCta={_(msg`Delete`)}
+        confirmButtonColor="negative"
+      />
+
+      <Prompt.Basic
+        control={subscribeMutePromptControl}
+        title={_(msg`Mute these accounts?`)}
+        description={_(
+          msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`,
+        )}
+        onConfirm={onSubscribeMute}
+        confirmButtonCta={_(msg`Mute list`)}
+      />
+
+      <Prompt.Basic
+        control={subscribeBlockPromptControl}
+        title={_(msg`Block these accounts?`)}
+        description={_(
+          msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
+        )}
+        onConfirm={onSubscribeBlock}
+        confirmButtonCta={_(msg`Block list`)}
+        confirmButtonColor="negative"
+      />
     </ProfileSubpageHeader>
   )
 }
diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx
index 09be124db..c2eaf19ac 100644
--- a/src/view/screens/Storybook/Dialogs.tsx
+++ b/src/view/screens/Storybook/Dialogs.tsx
@@ -68,7 +68,7 @@ export function Dialogs() {
         </Prompt.Description>
         <Prompt.Actions>
           <Prompt.Cancel>Cancel</Prompt.Cancel>
-          <Prompt.Action>Confirm</Prompt.Action>
+          <Prompt.Action onPress={() => {}}>Confirm</Prompt.Action>
         </Prompt.Actions>
       </Prompt.Outer>
 
-- 
cgit 1.4.1

