diff options
-rw-r--r-- | modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt | 21 | ||||
-rw-r--r-- | package.json | 3 | ||||
-rw-r--r-- | src/components/Dialog/index.tsx | 44 | ||||
-rw-r--r-- | src/components/Dialog/index.web.tsx | 9 | ||||
-rw-r--r-- | src/components/Dialog/shared.tsx | 61 | ||||
-rw-r--r-- | src/components/Dialog/types.ts | 6 | ||||
-rw-r--r-- | src/components/forms/TextField.tsx | 19 | ||||
-rw-r--r-- | src/lib/strings/helpers.ts | 14 | ||||
-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 | ||||
-rw-r--r-- | src/state/modals/index.tsx | 7 | ||||
-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 | ||||
-rw-r--r-- | yarn.lock | 12 |
16 files changed, 567 insertions, 405 deletions
diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt index cfc89f5b3..56b5b3f05 100644 --- a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt @@ -1,6 +1,7 @@ package expo.modules.bottomsheet import android.content.Context +import android.util.DisplayMetrics import android.view.View import android.view.ViewGroup import android.view.ViewStructure @@ -17,7 +18,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialog import expo.modules.kotlin.AppContext import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView -import java.util.ArrayList + class BottomSheetView( context: Context, @@ -58,17 +59,18 @@ class BottomSheetView( if (value < 0) { 0f } else { - value + dpToPx(value) } } var maxHeight = this.screenHeight set(value) { + val px = dpToPx(value) field = - if (value > this.screenHeight) { - this.screenHeight.toFloat() + if (px > this.screenHeight) { + this.screenHeight } else { - value + px } } @@ -175,7 +177,7 @@ class BottomSheetView( behavior.isDraggable = true behavior.isHideable = true - if (contentHeight > this.screenHeight) { + if (contentHeight >= this.screenHeight || this.minHeight >= this.screenHeight) { behavior.state = BottomSheetBehavior.STATE_EXPANDED this.selectedSnapPoint = 2 } else { @@ -332,4 +334,11 @@ class BottomSheetView( override fun addChildrenForAccessibility(outChildren: ArrayList<View>?) { } override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent?): Boolean = false + + // https://stackoverflow.com/questions/11862391/getheight-px-or-dpi + fun dpToPx(dp: Float): Float { + val displayMetrics = context.resources.displayMetrics + val px = dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT) + return px + } } diff --git a/package.json b/package.json index 1d4d0d460..720af2e36 100644 --- a/package.json +++ b/package.json @@ -280,7 +280,8 @@ "**/zod": "3.23.8", "**/expo-constants": "16.0.1", "**/expo-device": "6.0.2", - "@react-native/babel-preset": "0.74.1" + "@react-native/babel-preset": "0.74.1", + "@radix-ui/react-focus-scope": "1.1.0" }, "jest": { "preset": "jest-expo/ios", 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} /> diff --git a/yarn.lock b/yarn.lock index df0c6c3ea..8d72b9d84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5197,17 +5197,7 @@ resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz#8635edd346304f8b42cae86b05912b61aef27afe" integrity sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg== -"@radix-ui/react-focus-scope@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.1.tgz#faea8c25f537c5a5c38c50914b63722db0e7f951" - integrity sha512-Ej2MQTit8IWJiS2uuujGUmxXjF/y5xZptIIQnyd2JHLwtV0R2j9NRVoRj/1j/gJ7e3REdaBw4Hjf4a1ImhkZcQ== - dependencies: - "@babel/runtime" "^7.13.10" - "@radix-ui/react-compose-refs" "1.0.0" - "@radix-ui/react-primitive" "1.0.1" - "@radix-ui/react-use-callback-ref" "1.0.0" - -"@radix-ui/react-focus-scope@1.1.0", "@radix-ui/react-focus-scope@^1.1.0": +"@radix-ui/react-focus-scope@1.0.1", "@radix-ui/react-focus-scope@1.1.0", "@radix-ui/react-focus-scope@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz#ebe2891a298e0a33ad34daab2aad8dea31caf0b2" integrity sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA== |