From c3d0cc55d98fb32b25cd2164cfa1c399985e7c84 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 15 Oct 2024 21:57:28 +0300 Subject: Edit profile dialog ALF refresh (#5633) --- .../expo/modules/bottomsheet/BottomSheetView.kt | 21 +- package.json | 3 +- src/components/Dialog/index.tsx | 44 ++- src/components/Dialog/index.web.tsx | 9 +- src/components/Dialog/shared.tsx | 61 ++++ src/components/Dialog/types.ts | 6 + src/components/forms/TextField.tsx | 19 +- src/lib/strings/helpers.ts | 14 + src/screens/Profile/Header/EditProfileDialog.tsx | 370 +++++++++++++++++++++ .../Profile/Header/ProfileHeaderLabeler.tsx | 43 +-- .../Profile/Header/ProfileHeaderStandard.tsx | 42 +-- src/state/modals/index.tsx | 7 - src/view/com/modals/EditProfile.tsx | 310 ----------------- src/view/com/modals/Modal.tsx | 6 +- src/view/com/modals/Modal.web.tsx | 5 +- yarn.lock | 12 +- 16 files changed, 567 insertions(+), 405 deletions(-) create mode 100644 src/components/Dialog/shared.tsx create mode 100644 src/screens/Profile/Header/EditProfileDialog.tsx delete mode 100644 src/view/com/modals/EditProfile.tsx 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?) { } 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 ( - - {children} - + <> + {header} + + {children} + + ) } export const ScrollableInner = React.forwardRef( - 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( return ( + keyboardShouldPersistTaps="handled" + stickyHeaderIndices={header ? [0] : undefined}> + {header} {children} ) 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} + + {children} + 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 +}) { + const t = useTheme() + return ( + + {renderLeft && ( + {renderLeft()} + )} + {children} + {renderRight && ( + {renderRight()} + )} + + ) +} + +export function HeaderText({ + children, + style, +}: { + children?: React.ReactNode + style?: StyleProp +}) { + return ( + + {children} + + ) +} 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 + header?: React.ReactNode }> | DialogInnerPropsBase<{ label: string accessibilityLabelledBy?: undefined accessibilityDescribedBy?: undefined keyboardDismissMode?: ScrollViewProps['keyboardDismissMode'] + contentContainerStyle?: StyleProp + 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 & { 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} 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 ( + + + + control.close()} + confirmButtonCta={_(msg`Discard`)} + confirmButtonColor="negative" + /> + + ) +} + +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( + profile.banner, + ) + const [userAvatar, setUserAvatar] = useState( + 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( + () => ( + + ), + [onPressCancel, _], + ) + + const saveButton = useCallback( + () => ( + + ), + [ + _, + t, + dirty, + onPressSave, + isUpdatingProfile, + displayNameTooLong, + descriptionTooLong, + ], + ) + + return ( + + + Edit profile + + + }> + + + + + + + {isUpdateProfileError && ( + + + + )} + {imageError !== '' && ( + + + + )} + + + + Display name + + + + + {displayNameTooLong && ( + + + Display name is too long. The maximum number of characters is{' '} + {DISPLAY_NAME_MAX_GRAPHEMES}. + + + )} + + + + + Description + + + + + {descriptionTooLong && ( + + + Description is too long. The maximum number of characters is{' '} + {DESCRIPTION_MAX_GRAPHEMES}. + + + )} + + + + ) +} 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 ? ( - + <> + + + ) : !isAppLabeler(profile.did) ? ( <> + <> + + + ) : profile.viewer?.blocking ? ( profile.viewer?.blockingByList ? null : (