about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-10-15 21:57:28 +0300
committerGitHub <noreply@github.com>2024-10-15 21:57:28 +0300
commitc3d0cc55d98fb32b25cd2164cfa1c399985e7c84 (patch)
tree6a06ca7ec00e6c7143002fa1762bc5e08f858d4e /src
parentfe5eb507ca693e4db9ca1317b522765a513fea8c (diff)
downloadvoidsky-c3d0cc55d98fb32b25cd2164cfa1c399985e7c84.tar.zst
Edit profile dialog ALF refresh (#5633)
Diffstat (limited to 'src')
-rw-r--r--src/components/Dialog/index.tsx44
-rw-r--r--src/components/Dialog/index.web.tsx9
-rw-r--r--src/components/Dialog/shared.tsx61
-rw-r--r--src/components/Dialog/types.ts6
-rw-r--r--src/components/forms/TextField.tsx19
-rw-r--r--src/lib/strings/helpers.ts14
-rw-r--r--src/screens/Profile/Header/EditProfileDialog.tsx370
-rw-r--r--src/screens/Profile/Header/ProfileHeaderLabeler.tsx43
-rw-r--r--src/screens/Profile/Header/ProfileHeaderStandard.tsx42
-rw-r--r--src/state/modals/index.tsx7
-rw-r--r--src/view/com/modals/EditProfile.tsx310
-rw-r--r--src/view/com/modals/Modal.tsx6
-rw-r--r--src/view/com/modals/Modal.web.tsx5
13 files changed, 549 insertions, 387 deletions
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index 93acad438..46c072ce4 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -40,6 +40,7 @@ import {
 import {BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
 
 export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
+export * from '#/components/Dialog/shared'
 export * from '#/components/Dialog/types'
 export * from '#/components/Dialog/utils'
 // @ts-ignore
@@ -169,25 +170,31 @@ export function Outer({
   )
 }
 
-export function Inner({children, style}: DialogInnerProps) {
+export function Inner({children, style, header}: DialogInnerProps) {
   const insets = useSafeAreaInsets()
   return (
-    <View
-      style={[
-        a.pt_2xl,
-        a.px_xl,
-        {
-          paddingBottom: insets.bottom + insets.top,
-        },
-        style,
-      ]}>
-      {children}
-    </View>
+    <>
+      {header}
+      <View
+        style={[
+          a.pt_2xl,
+          a.px_xl,
+          {
+            paddingBottom: insets.bottom + insets.top,
+          },
+          style,
+        ]}>
+        {children}
+      </View>
+    </>
   )
 }
 
 export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
-  function ScrollableInner({children, style, ...props}, ref) {
+  function ScrollableInner(
+    {children, style, contentContainerStyle, header, ...props},
+    ref,
+  ) {
     const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
     const insets = useSafeAreaInsets()
     const {setEnabled} = useKeyboardController()
@@ -232,14 +239,21 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
     return (
       <KeyboardAwareScrollView
         style={[style]}
-        contentContainerStyle={[a.pt_2xl, a.px_xl, {paddingBottom}]}
+        contentContainerStyle={[
+          a.pt_2xl,
+          a.px_xl,
+          {paddingBottom},
+          contentContainerStyle,
+        ]}
         ref={ref}
         {...props}
         bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
         bottomOffset={30}
         scrollEventThrottle={50}
         onScroll={isAndroid ? onScroll : undefined}
-        keyboardShouldPersistTaps="handled">
+        keyboardShouldPersistTaps="handled"
+        stickyHeaderIndices={header ? [0] : undefined}>
+        {header}
         {children}
       </KeyboardAwareScrollView>
     )
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index 1a20311d3..43cb95b03 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -28,6 +28,7 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 import {Portal} from '#/components/Portal'
 
 export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
+export * from '#/components/Dialog/shared'
 export * from '#/components/Dialog/types'
 export * from '#/components/Dialog/utils'
 export {Input} from '#/components/forms/TextField'
@@ -154,6 +155,8 @@ export function Inner({
   label,
   accessibilityLabelledBy,
   accessibilityDescribedBy,
+  header,
+  contentContainerStyle,
 }: DialogInnerProps) {
   const t = useTheme()
   const {close} = React.useContext(Context)
@@ -178,7 +181,6 @@ export function Inner({
           a.rounded_md,
           a.w_full,
           a.border,
-          gtMobile ? a.p_2xl : a.p_xl,
           t.atoms.bg,
           {
             maxWidth: 600,
@@ -194,7 +196,10 @@ export function Inner({
           onFocusOutside={preventDefault}
           onDismiss={close}
           style={{display: 'flex', flexDirection: 'column'}}>
-          {children}
+          {header}
+          <View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
+            {children}
+          </View>
         </DismissableLayer>
       </Animated.View>
     </FocusScope>
diff --git a/src/components/Dialog/shared.tsx b/src/components/Dialog/shared.tsx
new file mode 100644
index 000000000..6f9bc2678
--- /dev/null
+++ b/src/components/Dialog/shared.tsx
@@ -0,0 +1,61 @@
+import React from 'react'
+import {StyleProp, TextStyle, View, ViewStyle} from 'react-native'
+
+import {atoms as a, useTheme, web} from '#/alf'
+import {Text} from '#/components/Typography'
+
+export function Header({
+  renderLeft,
+  renderRight,
+  children,
+  style,
+}: {
+  renderLeft?: () => React.ReactNode
+  renderRight?: () => React.ReactNode
+  children?: React.ReactNode
+  style?: StyleProp<ViewStyle>
+}) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.relative,
+        a.w_full,
+        a.py_sm,
+        a.flex_row,
+        a.justify_center,
+        a.align_center,
+        {minHeight: 50},
+        a.border_b,
+        t.atoms.border_contrast_medium,
+        t.atoms.bg,
+        web([
+          {borderRadiusTopLeft: a.rounded_md.borderRadius},
+          {borderRadiusTopRight: a.rounded_md.borderRadius},
+        ]),
+        style,
+      ]}>
+      {renderLeft && (
+        <View style={[a.absolute, {left: 6}]}>{renderLeft()}</View>
+      )}
+      {children}
+      {renderRight && (
+        <View style={[a.absolute, {right: 6}]}>{renderRight()}</View>
+      )}
+    </View>
+  )
+}
+
+export function HeaderText({
+  children,
+  style,
+}: {
+  children?: React.ReactNode
+  style?: StyleProp<TextStyle>
+}) {
+  return (
+    <Text style={[a.text_lg, a.text_center, a.font_bold, style]}>
+      {children}
+    </Text>
+  )
+}
diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts
index b1388a817..526784baa 100644
--- a/src/components/Dialog/types.ts
+++ b/src/components/Dialog/types.ts
@@ -4,6 +4,8 @@ import type {
   GestureResponderEvent,
   ScrollViewProps,
 } from 'react-native'
+import {ViewStyle} from 'react-native'
+import {StyleProp} from 'react-native'
 
 import {ViewStyleProp} from '#/alf'
 import {BottomSheetViewProps} from '../../../modules/bottom-sheet'
@@ -69,10 +71,14 @@ export type DialogInnerProps =
       accessibilityLabelledBy: A11yProps['aria-labelledby']
       accessibilityDescribedBy: string
       keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
+      contentContainerStyle?: StyleProp<ViewStyle>
+      header?: React.ReactNode
     }>
   | DialogInnerPropsBase<{
       label: string
       accessibilityLabelledBy?: undefined
       accessibilityDescribedBy?: undefined
       keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
+      contentContainerStyle?: StyleProp<ViewStyle>
+      header?: React.ReactNode
     }>
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index 96d3481cd..3c224ba68 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -11,7 +11,7 @@ import {
 
 import {HITSLOP_20} from '#/lib/constants'
 import {mergeRefs} from '#/lib/merge-refs'
-import {android, atoms as a, useTheme, web} from '#/alf'
+import {android, atoms as a, TextStyleProp, useTheme, web} from '#/alf'
 import {useInteractionState} from '#/components/hooks/useInteractionState'
 import {Props as SVGIconProps} from '#/components/icons/common'
 import {Text} from '#/components/Typography'
@@ -123,6 +123,11 @@ export function useSharedInputStyles() {
 
 export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
   label: string
+  /**
+   * @deprecated Controlled inputs are *strongly* discouraged. Use `defaultValue` instead where possible.
+   *
+   * See https://github.com/facebook/react-native-website/pull/4247
+   */
   value?: string
   onChangeText?: (value: string) => void
   isInvalid?: boolean
@@ -308,10 +313,13 @@ export function SuffixText({
   children,
   label,
   accessibilityHint,
-}: React.PropsWithChildren<{
-  label: string
-  accessibilityHint?: AccessibilityProps['accessibilityHint']
-}>) {
+  style,
+}: React.PropsWithChildren<
+  TextStyleProp & {
+    label: string
+    accessibilityHint?: AccessibilityProps['accessibilityHint']
+  }
+>) {
   const t = useTheme()
   const ctx = React.useContext(Context)
   return (
@@ -334,6 +342,7 @@ export function SuffixText({
               color: t.palette.contrast_800,
             }
           : {},
+        style,
       ]}>
       {children}
     </Text>
diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts
index acd55da2d..ca77c4666 100644
--- a/src/lib/strings/helpers.ts
+++ b/src/lib/strings/helpers.ts
@@ -41,6 +41,20 @@ export function useEnforceMaxGraphemeCount() {
   )
 }
 
+export function useWarnMaxGraphemeCount({
+  text,
+  maxCount,
+}: {
+  text: string
+  maxCount: number
+}) {
+  const splitter = useMemo(() => new Graphemer(), [])
+
+  return useMemo(() => {
+    return splitter.countGraphemes(text) > maxCount
+  }, [splitter, maxCount, text])
+}
+
 // https://stackoverflow.com/a/52171480
 export function toHashCode(str: string, seed = 0): number {
   let h1 = 0xdeadbeef ^ seed,
diff --git a/src/screens/Profile/Header/EditProfileDialog.tsx b/src/screens/Profile/Header/EditProfileDialog.tsx
new file mode 100644
index 000000000..3cbae2a60
--- /dev/null
+++ b/src/screens/Profile/Header/EditProfileDialog.tsx
@@ -0,0 +1,370 @@
+import React, {useCallback, useEffect, useState} from 'react'
+import {Dimensions, View} from 'react-native'
+import {Image as RNImage} from 'react-native-image-crop-picker'
+import {AppBskyActorDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {compressIfNeeded} from '#/lib/media/manip'
+import {cleanError} from '#/lib/strings/errors'
+import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers'
+import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
+import {useProfileUpdateMutation} from '#/state/queries/profile'
+import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
+import * as Toast from '#/view/com/util/Toast'
+import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
+import {UserBanner} from '#/view/com/util/UserBanner'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import * as TextField from '#/components/forms/TextField'
+import * as Prompt from '#/components/Prompt'
+
+const DISPLAY_NAME_MAX_GRAPHEMES = 64
+const DESCRIPTION_MAX_GRAPHEMES = 256
+
+const SCREEN_HEIGHT = Dimensions.get('window').height
+
+export function EditProfileDialog({
+  profile,
+  control,
+  onUpdate,
+}: {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  control: Dialog.DialogControlProps
+  onUpdate?: () => void
+}) {
+  const {_} = useLingui()
+  const cancelControl = Dialog.useDialogControl()
+  const [dirty, setDirty] = useState(false)
+
+  // 'You might lose unsaved changes' warning
+  useEffect(() => {
+    if (isWeb && dirty) {
+      const abortController = new AbortController()
+      const {signal} = abortController
+      window.addEventListener('beforeunload', evt => evt.preventDefault(), {
+        signal,
+      })
+      return () => {
+        abortController.abort()
+      }
+    }
+  }, [dirty])
+
+  const onPressCancel = useCallback(() => {
+    if (dirty) {
+      cancelControl.open()
+    } else {
+      control.close()
+    }
+  }, [dirty, control, cancelControl])
+
+  return (
+    <Dialog.Outer
+      control={control}
+      nativeOptions={{
+        preventDismiss: dirty,
+        minHeight: SCREEN_HEIGHT,
+      }}>
+      <DialogInner
+        profile={profile}
+        onUpdate={onUpdate}
+        setDirty={setDirty}
+        onPressCancel={onPressCancel}
+      />
+
+      <Prompt.Basic
+        control={cancelControl}
+        title={_(msg`Discard changes?`)}
+        description={_(msg`Are you sure you want to discard your changes?`)}
+        onConfirm={() => control.close()}
+        confirmButtonCta={_(msg`Discard`)}
+        confirmButtonColor="negative"
+      />
+    </Dialog.Outer>
+  )
+}
+
+function DialogInner({
+  profile,
+  onUpdate,
+  setDirty,
+  onPressCancel,
+}: {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  onUpdate?: () => void
+  setDirty: (dirty: boolean) => void
+  onPressCancel: () => void
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const control = Dialog.useDialogContext()
+  const {
+    mutateAsync: updateProfileMutation,
+    error: updateProfileError,
+    isError: isUpdateProfileError,
+    isPending: isUpdatingProfile,
+  } = useProfileUpdateMutation()
+  const [imageError, setImageError] = useState('')
+  const initialDisplayName = profile.displayName || ''
+  const [displayName, setDisplayName] = useState(initialDisplayName)
+  const initialDescription = profile.description || ''
+  const [description, setDescription] = useState(initialDescription)
+  const [userBanner, setUserBanner] = useState<string | undefined | null>(
+    profile.banner,
+  )
+  const [userAvatar, setUserAvatar] = useState<string | undefined | null>(
+    profile.avatar,
+  )
+  const [newUserBanner, setNewUserBanner] = useState<
+    RNImage | undefined | null
+  >()
+  const [newUserAvatar, setNewUserAvatar] = useState<
+    RNImage | undefined | null
+  >()
+
+  const dirty =
+    displayName !== initialDisplayName ||
+    description !== initialDescription ||
+    userAvatar !== profile.avatar ||
+    userBanner !== profile.banner
+
+  useEffect(() => {
+    setDirty(dirty)
+  }, [dirty, setDirty])
+
+  const onSelectNewAvatar = useCallback(
+    async (img: RNImage | null) => {
+      setImageError('')
+      if (img === null) {
+        setNewUserAvatar(null)
+        setUserAvatar(null)
+        return
+      }
+      try {
+        const finalImg = await compressIfNeeded(img, 1000000)
+        setNewUserAvatar(finalImg)
+        setUserAvatar(finalImg.path)
+      } catch (e: any) {
+        setImageError(cleanError(e))
+      }
+    },
+    [setNewUserAvatar, setUserAvatar, setImageError],
+  )
+
+  const onSelectNewBanner = useCallback(
+    async (img: RNImage | null) => {
+      setImageError('')
+      if (!img) {
+        setNewUserBanner(null)
+        setUserBanner(null)
+        return
+      }
+      try {
+        const finalImg = await compressIfNeeded(img, 1000000)
+        setNewUserBanner(finalImg)
+        setUserBanner(finalImg.path)
+      } catch (e: any) {
+        setImageError(cleanError(e))
+      }
+    },
+    [setNewUserBanner, setUserBanner, setImageError],
+  )
+
+  const onPressSave = useCallback(async () => {
+    setImageError('')
+    try {
+      await updateProfileMutation({
+        profile,
+        updates: {
+          displayName: displayName.trimEnd(),
+          description: description.trimEnd(),
+        },
+        newUserAvatar,
+        newUserBanner,
+      })
+      onUpdate?.()
+      control.close()
+      Toast.show(_(msg`Profile updated`))
+    } catch (e: any) {
+      logger.error('Failed to update user profile', {message: String(e)})
+    }
+  }, [
+    updateProfileMutation,
+    profile,
+    onUpdate,
+    control,
+    displayName,
+    description,
+    newUserAvatar,
+    newUserBanner,
+    setImageError,
+    _,
+  ])
+
+  const displayNameTooLong = useWarnMaxGraphemeCount({
+    text: displayName,
+    maxCount: DISPLAY_NAME_MAX_GRAPHEMES,
+  })
+  const descriptionTooLong = useWarnMaxGraphemeCount({
+    text: description,
+    maxCount: DESCRIPTION_MAX_GRAPHEMES,
+  })
+
+  const cancelButton = useCallback(
+    () => (
+      <Button
+        label={_(msg`Cancel`)}
+        onPress={onPressCancel}
+        size="small"
+        color="primary"
+        variant="ghost"
+        style={[a.rounded_full]}>
+        <ButtonText style={[a.text_md]}>
+          <Trans>Cancel</Trans>
+        </ButtonText>
+      </Button>
+    ),
+    [onPressCancel, _],
+  )
+
+  const saveButton = useCallback(
+    () => (
+      <Button
+        label={_(msg`Save`)}
+        onPress={onPressSave}
+        disabled={
+          !dirty ||
+          isUpdatingProfile ||
+          displayNameTooLong ||
+          descriptionTooLong
+        }
+        size="small"
+        color="primary"
+        variant="ghost"
+        style={[a.rounded_full]}>
+        <ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}>
+          <Trans>Save</Trans>
+        </ButtonText>
+      </Button>
+    ),
+    [
+      _,
+      t,
+      dirty,
+      onPressSave,
+      isUpdatingProfile,
+      displayNameTooLong,
+      descriptionTooLong,
+    ],
+  )
+
+  return (
+    <Dialog.ScrollableInner
+      label={_(msg`Edit profile`)}
+      style={[a.overflow_hidden]}
+      contentContainerStyle={[a.px_0, a.pt_0]}
+      header={
+        <Dialog.Header renderLeft={cancelButton} renderRight={saveButton}>
+          <Dialog.HeaderText>
+            <Trans>Edit profile</Trans>
+          </Dialog.HeaderText>
+        </Dialog.Header>
+      }>
+      <View style={[a.relative]}>
+        <UserBanner banner={userBanner} onSelectNewBanner={onSelectNewBanner} />
+        <View
+          style={[
+            a.absolute,
+            {
+              top: 80,
+              left: 20,
+              width: 84,
+              height: 84,
+              borderWidth: 2,
+              borderRadius: 42,
+              borderColor: t.atoms.bg.backgroundColor,
+            },
+          ]}>
+          <EditableUserAvatar
+            size={80}
+            avatar={userAvatar}
+            onSelectNewAvatar={onSelectNewAvatar}
+          />
+        </View>
+      </View>
+      {isUpdateProfileError && (
+        <View style={[a.mt_xl]}>
+          <ErrorMessage message={cleanError(updateProfileError)} />
+        </View>
+      )}
+      {imageError !== '' && (
+        <View style={[a.mt_xl]}>
+          <ErrorMessage message={imageError} />
+        </View>
+      )}
+      <View style={[a.mt_4xl, a.px_xl, a.gap_xl]}>
+        <View>
+          <TextField.LabelText>
+            <Trans>Display name</Trans>
+          </TextField.LabelText>
+          <TextField.Root isInvalid={displayNameTooLong}>
+            <Dialog.Input
+              defaultValue={displayName}
+              onChangeText={setDisplayName}
+              label={_(msg`Display name`)}
+              placeholder={_(msg`e.g. Alice Lastname`)}
+            />
+          </TextField.Root>
+          {displayNameTooLong && (
+            <TextField.SuffixText
+              style={[
+                a.text_sm,
+                a.mt_xs,
+                a.font_bold,
+                {color: t.palette.negative_400},
+              ]}
+              label={_(msg`Display name is too long`)}>
+              <Trans>
+                Display name is too long. The maximum number of characters is{' '}
+                {DISPLAY_NAME_MAX_GRAPHEMES}.
+              </Trans>
+            </TextField.SuffixText>
+          )}
+        </View>
+
+        <View>
+          <TextField.LabelText>
+            <Trans>Description</Trans>
+          </TextField.LabelText>
+          <TextField.Root isInvalid={descriptionTooLong}>
+            <Dialog.Input
+              defaultValue={description}
+              onChangeText={setDescription}
+              multiline
+              label={_(msg`Display name`)}
+              placeholder={_(msg`Tell us a bit about yourself`)}
+            />
+          </TextField.Root>
+          {descriptionTooLong && (
+            <TextField.SuffixText
+              style={[
+                a.text_sm,
+                a.mt_xs,
+                a.font_bold,
+                {color: t.palette.negative_400},
+              ]}
+              label={_(msg`Description is too long`)}>
+              <Trans>
+                Description is too long. The maximum number of characters is{' '}
+                {DESCRIPTION_MAX_GRAPHEMES}.
+              </Trans>
+            </TextField.SuffixText>
+          )}
+        </View>
+      </View>
+    </Dialog.ScrollableInner>
+  )
+}
diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
index b0d954a92..8710de0b7 100644
--- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
@@ -18,7 +18,6 @@ import {logger} from '#/logger'
 import {isIOS} from '#/platform/detection'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {Shadow} from '#/state/cache/types'
-import {useModalControls} from '#/state/modals'
 import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
 import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
 import {usePreferencesQuery} from '#/state/queries/preferences'
@@ -27,7 +26,7 @@ import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
 import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, tokens, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
-import {DialogOuterProps} from '#/components/Dialog'
+import {DialogOuterProps, useDialogControl} from '#/components/Dialog'
 import {
   Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
   Heart2_Stroke2_Corner0_Rounded as Heart,
@@ -37,6 +36,7 @@ import * as Prompt from '#/components/Prompt'
 import {RichText} from '#/components/RichText'
 import {Text} from '#/components/Typography'
 import {ProfileHeaderDisplayName} from './DisplayName'
+import {EditProfileDialog} from './EditProfileDialog'
 import {ProfileHeaderHandle} from './Handle'
 import {ProfileHeaderMetrics} from './Metrics'
 import {ProfileHeaderShell} from './Shell'
@@ -63,7 +63,6 @@ let ProfileHeaderLabeler = ({
   const t = useTheme()
   const {_} = useLingui()
   const {currentAccount, hasSession} = useSession()
-  const {openModal} = useModalControls()
   const requireAuth = useRequireAuth()
   const playHaptic = useHaptics()
   const cantSubscribePrompt = Prompt.usePromptControl()
@@ -117,12 +116,10 @@ let ProfileHeaderLabeler = ({
     }
   }, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _])
 
+  const editProfileControl = useDialogControl()
   const onPressEditProfile = React.useCallback(() => {
-    openModal({
-      name: 'edit-profile',
-      profile,
-    })
-  }, [openModal, profile])
+    editProfileControl.open()
+  }, [editProfileControl])
 
   const onPressSubscribe = React.useCallback(
     () =>
@@ -169,18 +166,24 @@ let ProfileHeaderLabeler = ({
           style={[a.flex_row, a.justify_end, a.align_center, a.gap_xs, a.pb_lg]}
           pointerEvents={isIOS ? 'auto' : 'box-none'}>
           {isMe ? (
-            <Button
-              testID="profileHeaderEditProfileButton"
-              size="small"
-              color="secondary"
-              variant="solid"
-              onPress={onPressEditProfile}
-              label={_(msg`Edit profile`)}
-              style={a.rounded_full}>
-              <ButtonText>
-                <Trans>Edit Profile</Trans>
-              </ButtonText>
-            </Button>
+            <>
+              <Button
+                testID="profileHeaderEditProfileButton"
+                size="small"
+                color="secondary"
+                variant="solid"
+                onPress={onPressEditProfile}
+                label={_(msg`Edit profile`)}
+                style={a.rounded_full}>
+                <ButtonText>
+                  <Trans>Edit Profile</Trans>
+                </ButtonText>
+              </Button>
+              <EditProfileDialog
+                profile={profile}
+                control={editProfileControl}
+              />
+            </>
           ) : !isAppLabeler(profile.did) ? (
             <>
               <Button
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index 4a2929a6e..81aadcc64 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -14,7 +14,6 @@ import {logger} from '#/logger'
 import {isIOS} from '#/platform/detection'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {Shadow} from '#/state/cache/types'
-import {useModalControls} from '#/state/modals'
 import {
   useProfileBlockMutationQueue,
   useProfileFollowMutationQueue,
@@ -24,6 +23,7 @@ import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
 import * as Toast from '#/view/com/util/Toast'
 import {atoms as a} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
 import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
@@ -34,6 +34,7 @@ import {
 import * as Prompt from '#/components/Prompt'
 import {RichText} from '#/components/RichText'
 import {ProfileHeaderDisplayName} from './DisplayName'
+import {EditProfileDialog} from './EditProfileDialog'
 import {ProfileHeaderHandle} from './Handle'
 import {ProfileHeaderMetrics} from './Metrics'
 import {ProfileHeaderShell} from './Shell'
@@ -57,7 +58,6 @@ let ProfileHeaderStandard = ({
     useProfileShadow(profileUnshadowed)
   const {currentAccount, hasSession} = useSession()
   const {_} = useLingui()
-  const {openModal} = useModalControls()
   const moderation = useMemo(
     () => moderateProfile(profile, moderationOpts),
     [profile, moderationOpts],
@@ -74,12 +74,10 @@ let ProfileHeaderStandard = ({
     profile.viewer?.blockedBy ||
     profile.viewer?.blockingByList
 
+  const editProfileControl = useDialogControl()
   const onPressEditProfile = React.useCallback(() => {
-    openModal({
-      name: 'edit-profile',
-      profile,
-    })
-  }, [openModal, profile])
+    editProfileControl.open()
+  }, [editProfileControl])
 
   const onPressFollow = () => {
     requireAuth(async () => {
@@ -161,18 +159,24 @@ let ProfileHeaderStandard = ({
           ]}
           pointerEvents={isIOS ? 'auto' : 'box-none'}>
           {isMe ? (
-            <Button
-              testID="profileHeaderEditProfileButton"
-              size="small"
-              color="secondary"
-              variant="solid"
-              onPress={onPressEditProfile}
-              label={_(msg`Edit profile`)}
-              style={[a.rounded_full]}>
-              <ButtonText>
-                <Trans>Edit Profile</Trans>
-              </ButtonText>
-            </Button>
+            <>
+              <Button
+                testID="profileHeaderEditProfileButton"
+                size="small"
+                color="secondary"
+                variant="solid"
+                onPress={onPressEditProfile}
+                label={_(msg`Edit profile`)}
+                style={[a.rounded_full]}>
+                <ButtonText>
+                  <Trans>Edit Profile</Trans>
+                </ButtonText>
+              </Button>
+              <EditProfileDialog
+                profile={profile}
+                control={editProfileControl}
+              />
+            </>
           ) : profile.viewer?.blocking ? (
             profile.viewer?.blockingByList ? null : (
               <Button
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 5be21dfd3..03ab73f43 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -4,12 +4,6 @@ import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
 
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 
-export interface EditProfileModal {
-  name: 'edit-profile'
-  profile: AppBskyActorDefs.ProfileViewDetailed
-  onUpdate?: () => void
-}
-
 export interface CreateOrEditListModal {
   name: 'create-or-edit-list'
   purpose?: string
@@ -111,7 +105,6 @@ export type Modal =
   | AddAppPasswordModal
   | ChangeHandleModal
   | DeleteAccountModal
-  | EditProfileModal
   | VerifyEmailModal
   | ChangeEmailModal
   | ChangePasswordModal
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
deleted file mode 100644
index 1e94f483e..000000000
--- a/src/view/com/modals/EditProfile.tsx
+++ /dev/null
@@ -1,310 +0,0 @@
-import React, {useCallback, useState} from 'react'
-import {
-  ActivityIndicator,
-  KeyboardAvoidingView,
-  ScrollView,
-  StyleSheet,
-  TextInput,
-  TouchableOpacity,
-  View,
-} from 'react-native'
-import {Image as RNImage} from 'react-native-image-crop-picker'
-import Animated, {FadeOut} from 'react-native-reanimated'
-import {LinearGradient} from 'expo-linear-gradient'
-import {AppBskyActorDefs} from '@atproto/api'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {MAX_DESCRIPTION, MAX_DISPLAY_NAME} from '#/lib/constants'
-import {usePalette} from '#/lib/hooks/usePalette'
-import {compressIfNeeded} from '#/lib/media/manip'
-import {cleanError} from '#/lib/strings/errors'
-import {enforceLen} from '#/lib/strings/helpers'
-import {colors, gradients, s} from '#/lib/styles'
-import {useTheme} from '#/lib/ThemeContext'
-import {logger} from '#/logger'
-import {isWeb} from '#/platform/detection'
-import {useModalControls} from '#/state/modals'
-import {useProfileUpdateMutation} from '#/state/queries/profile'
-import {Text} from '#/view/com/util/text/Text'
-import * as Toast from '#/view/com/util/Toast'
-import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
-import {UserBanner} from '#/view/com/util/UserBanner'
-import {ErrorMessage} from '../util/error/ErrorMessage'
-
-const AnimatedTouchableOpacity =
-  Animated.createAnimatedComponent(TouchableOpacity)
-
-export const snapPoints = ['fullscreen']
-
-export function Component({
-  profile,
-  onUpdate,
-}: {
-  profile: AppBskyActorDefs.ProfileViewDetailed
-  onUpdate?: () => void
-}) {
-  const pal = usePalette('default')
-  const theme = useTheme()
-  const {_} = useLingui()
-  const {closeModal} = useModalControls()
-  const updateMutation = useProfileUpdateMutation()
-  const [imageError, setImageError] = useState<string>('')
-  const [displayName, setDisplayName] = useState<string>(
-    profile.displayName || '',
-  )
-  const [description, setDescription] = useState<string>(
-    profile.description || '',
-  )
-  const [userBanner, setUserBanner] = useState<string | undefined | null>(
-    profile.banner,
-  )
-  const [userAvatar, setUserAvatar] = useState<string | undefined | null>(
-    profile.avatar,
-  )
-  const [newUserBanner, setNewUserBanner] = useState<
-    RNImage | undefined | null
-  >()
-  const [newUserAvatar, setNewUserAvatar] = useState<
-    RNImage | undefined | null
-  >()
-  const onPressCancel = () => {
-    closeModal()
-  }
-  const onSelectNewAvatar = useCallback(
-    async (img: RNImage | null) => {
-      setImageError('')
-      if (img === null) {
-        setNewUserAvatar(null)
-        setUserAvatar(null)
-        return
-      }
-      try {
-        const finalImg = await compressIfNeeded(img, 1000000)
-        setNewUserAvatar(finalImg)
-        setUserAvatar(finalImg.path)
-      } catch (e: any) {
-        setImageError(cleanError(e))
-      }
-    },
-    [setNewUserAvatar, setUserAvatar, setImageError],
-  )
-
-  const onSelectNewBanner = useCallback(
-    async (img: RNImage | null) => {
-      setImageError('')
-      if (!img) {
-        setNewUserBanner(null)
-        setUserBanner(null)
-        return
-      }
-      try {
-        const finalImg = await compressIfNeeded(img, 1000000)
-        setNewUserBanner(finalImg)
-        setUserBanner(finalImg.path)
-      } catch (e: any) {
-        setImageError(cleanError(e))
-      }
-    },
-    [setNewUserBanner, setUserBanner, setImageError],
-  )
-
-  const onPressSave = useCallback(async () => {
-    setImageError('')
-    try {
-      await updateMutation.mutateAsync({
-        profile,
-        updates: {
-          displayName,
-          description,
-        },
-        newUserAvatar,
-        newUserBanner,
-      })
-      Toast.show(_(msg`Profile updated`))
-      onUpdate?.()
-      closeModal()
-    } catch (e: any) {
-      logger.error('Failed to update user profile', {message: String(e)})
-    }
-  }, [
-    updateMutation,
-    profile,
-    onUpdate,
-    closeModal,
-    displayName,
-    description,
-    newUserAvatar,
-    newUserBanner,
-    setImageError,
-    _,
-  ])
-
-  return (
-    <KeyboardAvoidingView style={s.flex1} behavior="height">
-      <ScrollView style={[pal.view]} testID="editProfileModal">
-        <Text style={[styles.title, pal.text]}>
-          <Trans>Edit my profile</Trans>
-        </Text>
-        <View style={styles.photos}>
-          <UserBanner
-            banner={userBanner}
-            onSelectNewBanner={onSelectNewBanner}
-          />
-          <View style={[styles.avi, {borderColor: pal.colors.background}]}>
-            <EditableUserAvatar
-              size={80}
-              avatar={userAvatar}
-              onSelectNewAvatar={onSelectNewAvatar}
-            />
-          </View>
-        </View>
-        {updateMutation.isError && (
-          <View style={styles.errorContainer}>
-            <ErrorMessage message={cleanError(updateMutation.error)} />
-          </View>
-        )}
-        {imageError !== '' && (
-          <View style={styles.errorContainer}>
-            <ErrorMessage message={imageError} />
-          </View>
-        )}
-        <View style={styles.form}>
-          <View>
-            <Text style={[styles.label, pal.text]}>
-              <Trans>Display Name</Trans>
-            </Text>
-            <TextInput
-              testID="editProfileDisplayNameInput"
-              style={[styles.textInput, pal.border, pal.text]}
-              placeholder={_(msg`e.g. Alice Roberts`)}
-              placeholderTextColor={colors.gray4}
-              value={displayName}
-              onChangeText={v =>
-                setDisplayName(enforceLen(v, MAX_DISPLAY_NAME))
-              }
-              accessible={true}
-              accessibilityLabel={_(msg`Display name`)}
-              accessibilityHint={_(msg`Edit your display name`)}
-            />
-          </View>
-          <View style={s.pb10}>
-            <Text style={[styles.label, pal.text]}>
-              <Trans>Description</Trans>
-            </Text>
-            <TextInput
-              testID="editProfileDescriptionInput"
-              style={[styles.textArea, pal.border, pal.text]}
-              placeholder={_(msg`e.g. Artist, dog-lover, and avid reader.`)}
-              placeholderTextColor={colors.gray4}
-              keyboardAppearance={theme.colorScheme}
-              multiline
-              value={description}
-              onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
-              accessible={true}
-              accessibilityLabel={_(msg`Description`)}
-              accessibilityHint={_(msg`Edit your profile description`)}
-            />
-          </View>
-          {updateMutation.isPending ? (
-            <View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}>
-              <ActivityIndicator />
-            </View>
-          ) : (
-            <TouchableOpacity
-              testID="editProfileSaveBtn"
-              style={s.mt10}
-              onPress={onPressSave}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Save`)}
-              accessibilityHint={_(msg`Saves any changes to your profile`)}>
-              <LinearGradient
-                colors={[gradients.blueLight.start, gradients.blueLight.end]}
-                start={{x: 0, y: 0}}
-                end={{x: 1, y: 1}}
-                style={[styles.btn]}>
-                <Text style={[s.white, s.bold]}>
-                  <Trans>Save Changes</Trans>
-                </Text>
-              </LinearGradient>
-            </TouchableOpacity>
-          )}
-          {!updateMutation.isPending && (
-            <AnimatedTouchableOpacity
-              exiting={!isWeb ? FadeOut : undefined}
-              testID="editProfileCancelBtn"
-              style={s.mt5}
-              onPress={onPressCancel}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Cancel profile editing`)}
-              accessibilityHint=""
-              onAccessibilityEscape={onPressCancel}>
-              <View style={[styles.btn]}>
-                <Text style={[s.black, s.bold, pal.text]}>
-                  <Trans>Cancel</Trans>
-                </Text>
-              </View>
-            </AnimatedTouchableOpacity>
-          )}
-        </View>
-      </ScrollView>
-    </KeyboardAvoidingView>
-  )
-}
-
-const styles = StyleSheet.create({
-  title: {
-    textAlign: 'center',
-    fontWeight: '600',
-    fontSize: 24,
-    marginBottom: 18,
-  },
-  label: {
-    fontWeight: '600',
-    paddingHorizontal: 4,
-    paddingBottom: 4,
-    marginTop: 20,
-  },
-  form: {
-    paddingHorizontal: 14,
-  },
-  textInput: {
-    borderWidth: 1,
-    borderRadius: 6,
-    paddingHorizontal: 14,
-    paddingVertical: 10,
-    fontSize: 16,
-  },
-  textArea: {
-    borderWidth: 1,
-    borderRadius: 6,
-    paddingHorizontal: 12,
-    paddingTop: 10,
-    fontSize: 16,
-    height: 120,
-    textAlignVertical: 'top',
-  },
-  btn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    width: '100%',
-    borderRadius: 32,
-    padding: 10,
-    marginBottom: 10,
-  },
-  avi: {
-    position: 'absolute',
-    top: 80,
-    left: 24,
-    width: 84,
-    height: 84,
-    borderWidth: 2,
-    borderRadius: 42,
-  },
-  photos: {
-    marginBottom: 36,
-    marginHorizontal: -14,
-  },
-  errorContainer: {marginTop: 20},
-})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 90e93821c..8cb6ddfef 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -13,7 +13,6 @@ import * as ChangeHandleModal from './ChangeHandle'
 import * as ChangePasswordModal from './ChangePassword'
 import * as CreateOrEditListModal from './CreateOrEditList'
 import * as DeleteAccountModal from './DeleteAccount'
-import * as EditProfileModal from './EditProfile'
 import * as InAppBrowserConsentModal from './InAppBrowserConsent'
 import * as InviteCodesModal from './InviteCodes'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
@@ -55,10 +54,7 @@ export function ModalsContainer() {
 
   let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
   let element
-  if (activeModal?.name === 'edit-profile') {
-    snapPoints = EditProfileModal.snapPoints
-    element = <EditProfileModal.Component {...activeModal} />
-  } else if (activeModal?.name === 'create-or-edit-list') {
+  if (activeModal?.name === 'create-or-edit-list') {
     snapPoints = CreateOrEditListModal.snapPoints
     element = <CreateOrEditListModal.Component {...activeModal} />
   } else if (activeModal?.name === 'user-add-remove-lists') {
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index a2acc23bb..013028944 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -14,7 +14,6 @@ import * as ChangePasswordModal from './ChangePassword'
 import * as CreateOrEditListModal from './CreateOrEditList'
 import * as CropImageModal from './CropImage.web'
 import * as DeleteAccountModal from './DeleteAccount'
-import * as EditProfileModal from './EditProfile'
 import * as InviteCodesModal from './InviteCodes'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
@@ -63,9 +62,7 @@ function Modal({modal}: {modal: ModalIface}) {
   }
 
   let element
-  if (modal.name === 'edit-profile') {
-    element = <EditProfileModal.Component {...modal} />
-  } else if (modal.name === 'create-or-edit-list') {
+  if (modal.name === 'create-or-edit-list') {
     element = <CreateOrEditListModal.Component {...modal} />
   } else if (modal.name === 'user-add-remove-lists') {
     element = <UserAddRemoveLists.Component {...modal} />