diff options
author | Samuel Newman <mozzius@protonmail.com> | 2024-10-15 21:57:28 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-10-15 21:57:28 +0300 |
commit | c3d0cc55d98fb32b25cd2164cfa1c399985e7c84 (patch) | |
tree | 6a06ca7ec00e6c7143002fa1762bc5e08f858d4e /src/screens/Profile/Header/EditProfileDialog.tsx | |
parent | fe5eb507ca693e4db9ca1317b522765a513fea8c (diff) | |
download | voidsky-c3d0cc55d98fb32b25cd2164cfa1c399985e7c84.tar.zst |
Edit profile dialog ALF refresh (#5633)
Diffstat (limited to 'src/screens/Profile/Header/EditProfileDialog.tsx')
-rw-r--r-- | src/screens/Profile/Header/EditProfileDialog.tsx | 370 |
1 files changed, 370 insertions, 0 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> + ) +} |