diff options
Diffstat (limited to 'src/screens/Profile/Header')
-rw-r--r-- | src/screens/Profile/Header/EditProfileDialog.tsx | 370 | ||||
-rw-r--r-- | src/screens/Profile/Header/ProfileHeaderLabeler.tsx | 43 | ||||
-rw-r--r-- | src/screens/Profile/Header/ProfileHeaderStandard.tsx | 42 |
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 |