about summary refs log tree commit diff
path: root/src/screens/Profile/Header
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Profile/Header')
-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
3 files changed, 416 insertions, 39 deletions
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