diff options
-rw-r--r-- | src/screens/Profile/Header/ProfileHeaderLabeler.tsx | 16 | ||||
-rw-r--r-- | src/screens/Profile/Header/ProfileHeaderStandard.tsx | 16 | ||||
-rw-r--r-- | src/state/modals/index.tsx | 9 | ||||
-rw-r--r-- | src/view/com/modals/EditProfile.tsx | 310 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 6 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 5 |
6 files changed, 354 insertions, 8 deletions
diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx index 37a5985cf..ca0cb1e62 100644 --- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -15,9 +15,10 @@ import {MAX_LABELERS} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import {isAppLabeler} from '#/lib/moderation' import {logger} from '#/logger' -import {isIOS} from '#/platform/detection' +import {isIOS, isWeb} 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' @@ -116,10 +117,19 @@ let ProfileHeaderLabeler = ({ } }, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _]) + const {openModal} = useModalControls() const editProfileControl = useDialogControl() const onPressEditProfile = React.useCallback(() => { - editProfileControl.open() - }, [editProfileControl]) + if (isWeb) { + // temp, while we figure out the nested dialog bug + openModal({ + name: 'edit-profile', + profile, + }) + } else { + editProfileControl.open() + } + }, [editProfileControl, openModal, profile]) const onPressSubscribe = React.useCallback( () => diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 81aadcc64..340621398 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -11,9 +11,10 @@ import {useLingui} from '@lingui/react' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {logger} from '#/logger' -import {isIOS} from '#/platform/detection' +import {isIOS, isWeb} from '#/platform/detection' import {useProfileShadow} from '#/state/cache/profile-shadow' import {Shadow} from '#/state/cache/types' +import {useModalControls} from '#/state/modals' import { useProfileBlockMutationQueue, useProfileFollowMutationQueue, @@ -74,10 +75,19 @@ let ProfileHeaderStandard = ({ profile.viewer?.blockedBy || profile.viewer?.blockingByList + const {openModal} = useModalControls() const editProfileControl = useDialogControl() const onPressEditProfile = React.useCallback(() => { - editProfileControl.open() - }, [editProfileControl]) + if (isWeb) { + // temp, while we figure out the nested dialog bug + openModal({ + name: 'edit-profile', + profile, + }) + } else { + editProfileControl.open() + } + }, [editProfileControl, openModal, profile]) const onPressFollow = () => { requireAuth(async () => { diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 05e0c53f6..78f476d52 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -4,6 +4,12 @@ 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 @@ -102,6 +108,9 @@ export type Modal = | ChangeEmailModal | ChangePasswordModal + // Temp + | EditProfileModal + // Curation | ContentLanguagesSettingsModal | PostLanguagesSettingsModal diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx new file mode 100644 index 000000000..1e94f483e --- /dev/null +++ b/src/view/com/modals/EditProfile.tsx @@ -0,0 +1,310 @@ +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 c2360742e..becb39ff3 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -13,6 +13,7 @@ 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' @@ -53,7 +54,10 @@ export function ModalsContainer() { let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS let element - if (activeModal?.name === 'create-or-edit-list') { + if (activeModal?.name === 'edit-profile') { + snapPoints = EditProfileModal.snapPoints + element = <EditProfileModal.Component {...activeModal} /> + } else 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 76b2811b1..46ced58d9 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -14,6 +14,7 @@ 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' @@ -61,7 +62,9 @@ function Modal({modal}: {modal: ModalIface}) { } let element - if (modal.name === 'create-or-edit-list') { + if (modal.name === 'edit-profile') { + element = <EditProfileModal.Component {...modal} /> + } else if (modal.name === 'create-or-edit-list') { element = <CreateOrEditListModal.Component {...modal} /> } else if (modal.name === 'user-add-remove-lists') { element = <UserAddRemoveLists.Component {...modal} /> |