Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Edit profile dialog ALF refresh (#5633)

authored by

Samuel Newman and committed by
GitHub
c3d0cc55 fe5eb507

+567 -405
+15 -6
modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt
··· 1 1 package expo.modules.bottomsheet 2 2 3 3 import android.content.Context 4 + import android.util.DisplayMetrics 4 5 import android.view.View 5 6 import android.view.ViewGroup 6 7 import android.view.ViewStructure ··· 17 18 import expo.modules.kotlin.AppContext 18 19 import expo.modules.kotlin.viewevent.EventDispatcher 19 20 import expo.modules.kotlin.views.ExpoView 20 - import java.util.ArrayList 21 + 21 22 22 23 class BottomSheetView( 23 24 context: Context, ··· 58 59 if (value < 0) { 59 60 0f 60 61 } else { 61 - value 62 + dpToPx(value) 62 63 } 63 64 } 64 65 65 66 var maxHeight = this.screenHeight 66 67 set(value) { 68 + val px = dpToPx(value) 67 69 field = 68 - if (value > this.screenHeight) { 69 - this.screenHeight.toFloat() 70 + if (px > this.screenHeight) { 71 + this.screenHeight 70 72 } else { 71 - value 73 + px 72 74 } 73 75 } 74 76 ··· 175 177 behavior.isDraggable = true 176 178 behavior.isHideable = true 177 179 178 - if (contentHeight > this.screenHeight) { 180 + if (contentHeight >= this.screenHeight || this.minHeight >= this.screenHeight) { 179 181 behavior.state = BottomSheetBehavior.STATE_EXPANDED 180 182 this.selectedSnapPoint = 2 181 183 } else { ··· 332 334 override fun addChildrenForAccessibility(outChildren: ArrayList<View>?) { } 333 335 334 336 override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent?): Boolean = false 337 + 338 + // https://stackoverflow.com/questions/11862391/getheight-px-or-dpi 339 + fun dpToPx(dp: Float): Float { 340 + val displayMetrics = context.resources.displayMetrics 341 + val px = dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT) 342 + return px 343 + } 335 344 }
+2 -1
package.json
··· 280 280 "**/zod": "3.23.8", 281 281 "**/expo-constants": "16.0.1", 282 282 "**/expo-device": "6.0.2", 283 - "@react-native/babel-preset": "0.74.1" 283 + "@react-native/babel-preset": "0.74.1", 284 + "@radix-ui/react-focus-scope": "1.1.0" 284 285 }, 285 286 "jest": { 286 287 "preset": "jest-expo/ios",
+29 -15
src/components/Dialog/index.tsx
··· 40 40 import {BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent' 41 41 42 42 export {useDialogContext, useDialogControl} from '#/components/Dialog/context' 43 + export * from '#/components/Dialog/shared' 43 44 export * from '#/components/Dialog/types' 44 45 export * from '#/components/Dialog/utils' 45 46 // @ts-ignore ··· 169 170 ) 170 171 } 171 172 172 - export function Inner({children, style}: DialogInnerProps) { 173 + export function Inner({children, style, header}: DialogInnerProps) { 173 174 const insets = useSafeAreaInsets() 174 175 return ( 175 - <View 176 - style={[ 177 - a.pt_2xl, 178 - a.px_xl, 179 - { 180 - paddingBottom: insets.bottom + insets.top, 181 - }, 182 - style, 183 - ]}> 184 - {children} 185 - </View> 176 + <> 177 + {header} 178 + <View 179 + style={[ 180 + a.pt_2xl, 181 + a.px_xl, 182 + { 183 + paddingBottom: insets.bottom + insets.top, 184 + }, 185 + style, 186 + ]}> 187 + {children} 188 + </View> 189 + </> 186 190 ) 187 191 } 188 192 189 193 export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>( 190 - function ScrollableInner({children, style, ...props}, ref) { 194 + function ScrollableInner( 195 + {children, style, contentContainerStyle, header, ...props}, 196 + ref, 197 + ) { 191 198 const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() 192 199 const insets = useSafeAreaInsets() 193 200 const {setEnabled} = useKeyboardController() ··· 232 239 return ( 233 240 <KeyboardAwareScrollView 234 241 style={[style]} 235 - contentContainerStyle={[a.pt_2xl, a.px_xl, {paddingBottom}]} 242 + contentContainerStyle={[ 243 + a.pt_2xl, 244 + a.px_xl, 245 + {paddingBottom}, 246 + contentContainerStyle, 247 + ]} 236 248 ref={ref} 237 249 {...props} 238 250 bounces={nativeSnapPoint === BottomSheetSnapPoint.Full} 239 251 bottomOffset={30} 240 252 scrollEventThrottle={50} 241 253 onScroll={isAndroid ? onScroll : undefined} 242 - keyboardShouldPersistTaps="handled"> 254 + keyboardShouldPersistTaps="handled" 255 + stickyHeaderIndices={header ? [0] : undefined}> 256 + {header} 243 257 {children} 244 258 </KeyboardAwareScrollView> 245 259 )
+7 -2
src/components/Dialog/index.web.tsx
··· 28 28 import {Portal} from '#/components/Portal' 29 29 30 30 export {useDialogContext, useDialogControl} from '#/components/Dialog/context' 31 + export * from '#/components/Dialog/shared' 31 32 export * from '#/components/Dialog/types' 32 33 export * from '#/components/Dialog/utils' 33 34 export {Input} from '#/components/forms/TextField' ··· 154 155 label, 155 156 accessibilityLabelledBy, 156 157 accessibilityDescribedBy, 158 + header, 159 + contentContainerStyle, 157 160 }: DialogInnerProps) { 158 161 const t = useTheme() 159 162 const {close} = React.useContext(Context) ··· 178 181 a.rounded_md, 179 182 a.w_full, 180 183 a.border, 181 - gtMobile ? a.p_2xl : a.p_xl, 182 184 t.atoms.bg, 183 185 { 184 186 maxWidth: 600, ··· 194 196 onFocusOutside={preventDefault} 195 197 onDismiss={close} 196 198 style={{display: 'flex', flexDirection: 'column'}}> 197 - {children} 199 + {header} 200 + <View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}> 201 + {children} 202 + </View> 198 203 </DismissableLayer> 199 204 </Animated.View> 200 205 </FocusScope>
+61
src/components/Dialog/shared.tsx
··· 1 + import React from 'react' 2 + import {StyleProp, TextStyle, View, ViewStyle} from 'react-native' 3 + 4 + import {atoms as a, useTheme, web} from '#/alf' 5 + import {Text} from '#/components/Typography' 6 + 7 + export function Header({ 8 + renderLeft, 9 + renderRight, 10 + children, 11 + style, 12 + }: { 13 + renderLeft?: () => React.ReactNode 14 + renderRight?: () => React.ReactNode 15 + children?: React.ReactNode 16 + style?: StyleProp<ViewStyle> 17 + }) { 18 + const t = useTheme() 19 + return ( 20 + <View 21 + style={[ 22 + a.relative, 23 + a.w_full, 24 + a.py_sm, 25 + a.flex_row, 26 + a.justify_center, 27 + a.align_center, 28 + {minHeight: 50}, 29 + a.border_b, 30 + t.atoms.border_contrast_medium, 31 + t.atoms.bg, 32 + web([ 33 + {borderRadiusTopLeft: a.rounded_md.borderRadius}, 34 + {borderRadiusTopRight: a.rounded_md.borderRadius}, 35 + ]), 36 + style, 37 + ]}> 38 + {renderLeft && ( 39 + <View style={[a.absolute, {left: 6}]}>{renderLeft()}</View> 40 + )} 41 + {children} 42 + {renderRight && ( 43 + <View style={[a.absolute, {right: 6}]}>{renderRight()}</View> 44 + )} 45 + </View> 46 + ) 47 + } 48 + 49 + export function HeaderText({ 50 + children, 51 + style, 52 + }: { 53 + children?: React.ReactNode 54 + style?: StyleProp<TextStyle> 55 + }) { 56 + return ( 57 + <Text style={[a.text_lg, a.text_center, a.font_bold, style]}> 58 + {children} 59 + </Text> 60 + ) 61 + }
+6
src/components/Dialog/types.ts
··· 4 4 GestureResponderEvent, 5 5 ScrollViewProps, 6 6 } from 'react-native' 7 + import {ViewStyle} from 'react-native' 8 + import {StyleProp} from 'react-native' 7 9 8 10 import {ViewStyleProp} from '#/alf' 9 11 import {BottomSheetViewProps} from '../../../modules/bottom-sheet' ··· 69 71 accessibilityLabelledBy: A11yProps['aria-labelledby'] 70 72 accessibilityDescribedBy: string 71 73 keyboardDismissMode?: ScrollViewProps['keyboardDismissMode'] 74 + contentContainerStyle?: StyleProp<ViewStyle> 75 + header?: React.ReactNode 72 76 }> 73 77 | DialogInnerPropsBase<{ 74 78 label: string 75 79 accessibilityLabelledBy?: undefined 76 80 accessibilityDescribedBy?: undefined 77 81 keyboardDismissMode?: ScrollViewProps['keyboardDismissMode'] 82 + contentContainerStyle?: StyleProp<ViewStyle> 83 + header?: React.ReactNode 78 84 }>
+14 -5
src/components/forms/TextField.tsx
··· 11 11 12 12 import {HITSLOP_20} from '#/lib/constants' 13 13 import {mergeRefs} from '#/lib/merge-refs' 14 - import {android, atoms as a, useTheme, web} from '#/alf' 14 + import {android, atoms as a, TextStyleProp, useTheme, web} from '#/alf' 15 15 import {useInteractionState} from '#/components/hooks/useInteractionState' 16 16 import {Props as SVGIconProps} from '#/components/icons/common' 17 17 import {Text} from '#/components/Typography' ··· 123 123 124 124 export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & { 125 125 label: string 126 + /** 127 + * @deprecated Controlled inputs are *strongly* discouraged. Use `defaultValue` instead where possible. 128 + * 129 + * See https://github.com/facebook/react-native-website/pull/4247 130 + */ 126 131 value?: string 127 132 onChangeText?: (value: string) => void 128 133 isInvalid?: boolean ··· 308 313 children, 309 314 label, 310 315 accessibilityHint, 311 - }: React.PropsWithChildren<{ 312 - label: string 313 - accessibilityHint?: AccessibilityProps['accessibilityHint'] 314 - }>) { 316 + style, 317 + }: React.PropsWithChildren< 318 + TextStyleProp & { 319 + label: string 320 + accessibilityHint?: AccessibilityProps['accessibilityHint'] 321 + } 322 + >) { 315 323 const t = useTheme() 316 324 const ctx = React.useContext(Context) 317 325 return ( ··· 334 342 color: t.palette.contrast_800, 335 343 } 336 344 : {}, 345 + style, 337 346 ]}> 338 347 {children} 339 348 </Text>
+14
src/lib/strings/helpers.ts
··· 41 41 ) 42 42 } 43 43 44 + export function useWarnMaxGraphemeCount({ 45 + text, 46 + maxCount, 47 + }: { 48 + text: string 49 + maxCount: number 50 + }) { 51 + const splitter = useMemo(() => new Graphemer(), []) 52 + 53 + return useMemo(() => { 54 + return splitter.countGraphemes(text) > maxCount 55 + }, [splitter, maxCount, text]) 56 + } 57 + 44 58 // https://stackoverflow.com/a/52171480 45 59 export function toHashCode(str: string, seed = 0): number { 46 60 let h1 = 0xdeadbeef ^ seed,
+370
src/screens/Profile/Header/EditProfileDialog.tsx
··· 1 + import React, {useCallback, useEffect, useState} from 'react' 2 + import {Dimensions, View} from 'react-native' 3 + import {Image as RNImage} from 'react-native-image-crop-picker' 4 + import {AppBskyActorDefs} from '@atproto/api' 5 + import {msg, Trans} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + 8 + import {compressIfNeeded} from '#/lib/media/manip' 9 + import {cleanError} from '#/lib/strings/errors' 10 + import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers' 11 + import {logger} from '#/logger' 12 + import {isWeb} from '#/platform/detection' 13 + import {useProfileUpdateMutation} from '#/state/queries/profile' 14 + import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 15 + import * as Toast from '#/view/com/util/Toast' 16 + import {EditableUserAvatar} from '#/view/com/util/UserAvatar' 17 + import {UserBanner} from '#/view/com/util/UserBanner' 18 + import {atoms as a, useTheme} from '#/alf' 19 + import {Button, ButtonText} from '#/components/Button' 20 + import * as Dialog from '#/components/Dialog' 21 + import * as TextField from '#/components/forms/TextField' 22 + import * as Prompt from '#/components/Prompt' 23 + 24 + const DISPLAY_NAME_MAX_GRAPHEMES = 64 25 + const DESCRIPTION_MAX_GRAPHEMES = 256 26 + 27 + const SCREEN_HEIGHT = Dimensions.get('window').height 28 + 29 + export function EditProfileDialog({ 30 + profile, 31 + control, 32 + onUpdate, 33 + }: { 34 + profile: AppBskyActorDefs.ProfileViewDetailed 35 + control: Dialog.DialogControlProps 36 + onUpdate?: () => void 37 + }) { 38 + const {_} = useLingui() 39 + const cancelControl = Dialog.useDialogControl() 40 + const [dirty, setDirty] = useState(false) 41 + 42 + // 'You might lose unsaved changes' warning 43 + useEffect(() => { 44 + if (isWeb && dirty) { 45 + const abortController = new AbortController() 46 + const {signal} = abortController 47 + window.addEventListener('beforeunload', evt => evt.preventDefault(), { 48 + signal, 49 + }) 50 + return () => { 51 + abortController.abort() 52 + } 53 + } 54 + }, [dirty]) 55 + 56 + const onPressCancel = useCallback(() => { 57 + if (dirty) { 58 + cancelControl.open() 59 + } else { 60 + control.close() 61 + } 62 + }, [dirty, control, cancelControl]) 63 + 64 + return ( 65 + <Dialog.Outer 66 + control={control} 67 + nativeOptions={{ 68 + preventDismiss: dirty, 69 + minHeight: SCREEN_HEIGHT, 70 + }}> 71 + <DialogInner 72 + profile={profile} 73 + onUpdate={onUpdate} 74 + setDirty={setDirty} 75 + onPressCancel={onPressCancel} 76 + /> 77 + 78 + <Prompt.Basic 79 + control={cancelControl} 80 + title={_(msg`Discard changes?`)} 81 + description={_(msg`Are you sure you want to discard your changes?`)} 82 + onConfirm={() => control.close()} 83 + confirmButtonCta={_(msg`Discard`)} 84 + confirmButtonColor="negative" 85 + /> 86 + </Dialog.Outer> 87 + ) 88 + } 89 + 90 + function DialogInner({ 91 + profile, 92 + onUpdate, 93 + setDirty, 94 + onPressCancel, 95 + }: { 96 + profile: AppBskyActorDefs.ProfileViewDetailed 97 + onUpdate?: () => void 98 + setDirty: (dirty: boolean) => void 99 + onPressCancel: () => void 100 + }) { 101 + const {_} = useLingui() 102 + const t = useTheme() 103 + const control = Dialog.useDialogContext() 104 + const { 105 + mutateAsync: updateProfileMutation, 106 + error: updateProfileError, 107 + isError: isUpdateProfileError, 108 + isPending: isUpdatingProfile, 109 + } = useProfileUpdateMutation() 110 + const [imageError, setImageError] = useState('') 111 + const initialDisplayName = profile.displayName || '' 112 + const [displayName, setDisplayName] = useState(initialDisplayName) 113 + const initialDescription = profile.description || '' 114 + const [description, setDescription] = useState(initialDescription) 115 + const [userBanner, setUserBanner] = useState<string | undefined | null>( 116 + profile.banner, 117 + ) 118 + const [userAvatar, setUserAvatar] = useState<string | undefined | null>( 119 + profile.avatar, 120 + ) 121 + const [newUserBanner, setNewUserBanner] = useState< 122 + RNImage | undefined | null 123 + >() 124 + const [newUserAvatar, setNewUserAvatar] = useState< 125 + RNImage | undefined | null 126 + >() 127 + 128 + const dirty = 129 + displayName !== initialDisplayName || 130 + description !== initialDescription || 131 + userAvatar !== profile.avatar || 132 + userBanner !== profile.banner 133 + 134 + useEffect(() => { 135 + setDirty(dirty) 136 + }, [dirty, setDirty]) 137 + 138 + const onSelectNewAvatar = useCallback( 139 + async (img: RNImage | null) => { 140 + setImageError('') 141 + if (img === null) { 142 + setNewUserAvatar(null) 143 + setUserAvatar(null) 144 + return 145 + } 146 + try { 147 + const finalImg = await compressIfNeeded(img, 1000000) 148 + setNewUserAvatar(finalImg) 149 + setUserAvatar(finalImg.path) 150 + } catch (e: any) { 151 + setImageError(cleanError(e)) 152 + } 153 + }, 154 + [setNewUserAvatar, setUserAvatar, setImageError], 155 + ) 156 + 157 + const onSelectNewBanner = useCallback( 158 + async (img: RNImage | null) => { 159 + setImageError('') 160 + if (!img) { 161 + setNewUserBanner(null) 162 + setUserBanner(null) 163 + return 164 + } 165 + try { 166 + const finalImg = await compressIfNeeded(img, 1000000) 167 + setNewUserBanner(finalImg) 168 + setUserBanner(finalImg.path) 169 + } catch (e: any) { 170 + setImageError(cleanError(e)) 171 + } 172 + }, 173 + [setNewUserBanner, setUserBanner, setImageError], 174 + ) 175 + 176 + const onPressSave = useCallback(async () => { 177 + setImageError('') 178 + try { 179 + await updateProfileMutation({ 180 + profile, 181 + updates: { 182 + displayName: displayName.trimEnd(), 183 + description: description.trimEnd(), 184 + }, 185 + newUserAvatar, 186 + newUserBanner, 187 + }) 188 + onUpdate?.() 189 + control.close() 190 + Toast.show(_(msg`Profile updated`)) 191 + } catch (e: any) { 192 + logger.error('Failed to update user profile', {message: String(e)}) 193 + } 194 + }, [ 195 + updateProfileMutation, 196 + profile, 197 + onUpdate, 198 + control, 199 + displayName, 200 + description, 201 + newUserAvatar, 202 + newUserBanner, 203 + setImageError, 204 + _, 205 + ]) 206 + 207 + const displayNameTooLong = useWarnMaxGraphemeCount({ 208 + text: displayName, 209 + maxCount: DISPLAY_NAME_MAX_GRAPHEMES, 210 + }) 211 + const descriptionTooLong = useWarnMaxGraphemeCount({ 212 + text: description, 213 + maxCount: DESCRIPTION_MAX_GRAPHEMES, 214 + }) 215 + 216 + const cancelButton = useCallback( 217 + () => ( 218 + <Button 219 + label={_(msg`Cancel`)} 220 + onPress={onPressCancel} 221 + size="small" 222 + color="primary" 223 + variant="ghost" 224 + style={[a.rounded_full]}> 225 + <ButtonText style={[a.text_md]}> 226 + <Trans>Cancel</Trans> 227 + </ButtonText> 228 + </Button> 229 + ), 230 + [onPressCancel, _], 231 + ) 232 + 233 + const saveButton = useCallback( 234 + () => ( 235 + <Button 236 + label={_(msg`Save`)} 237 + onPress={onPressSave} 238 + disabled={ 239 + !dirty || 240 + isUpdatingProfile || 241 + displayNameTooLong || 242 + descriptionTooLong 243 + } 244 + size="small" 245 + color="primary" 246 + variant="ghost" 247 + style={[a.rounded_full]}> 248 + <ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}> 249 + <Trans>Save</Trans> 250 + </ButtonText> 251 + </Button> 252 + ), 253 + [ 254 + _, 255 + t, 256 + dirty, 257 + onPressSave, 258 + isUpdatingProfile, 259 + displayNameTooLong, 260 + descriptionTooLong, 261 + ], 262 + ) 263 + 264 + return ( 265 + <Dialog.ScrollableInner 266 + label={_(msg`Edit profile`)} 267 + style={[a.overflow_hidden]} 268 + contentContainerStyle={[a.px_0, a.pt_0]} 269 + header={ 270 + <Dialog.Header renderLeft={cancelButton} renderRight={saveButton}> 271 + <Dialog.HeaderText> 272 + <Trans>Edit profile</Trans> 273 + </Dialog.HeaderText> 274 + </Dialog.Header> 275 + }> 276 + <View style={[a.relative]}> 277 + <UserBanner banner={userBanner} onSelectNewBanner={onSelectNewBanner} /> 278 + <View 279 + style={[ 280 + a.absolute, 281 + { 282 + top: 80, 283 + left: 20, 284 + width: 84, 285 + height: 84, 286 + borderWidth: 2, 287 + borderRadius: 42, 288 + borderColor: t.atoms.bg.backgroundColor, 289 + }, 290 + ]}> 291 + <EditableUserAvatar 292 + size={80} 293 + avatar={userAvatar} 294 + onSelectNewAvatar={onSelectNewAvatar} 295 + /> 296 + </View> 297 + </View> 298 + {isUpdateProfileError && ( 299 + <View style={[a.mt_xl]}> 300 + <ErrorMessage message={cleanError(updateProfileError)} /> 301 + </View> 302 + )} 303 + {imageError !== '' && ( 304 + <View style={[a.mt_xl]}> 305 + <ErrorMessage message={imageError} /> 306 + </View> 307 + )} 308 + <View style={[a.mt_4xl, a.px_xl, a.gap_xl]}> 309 + <View> 310 + <TextField.LabelText> 311 + <Trans>Display name</Trans> 312 + </TextField.LabelText> 313 + <TextField.Root isInvalid={displayNameTooLong}> 314 + <Dialog.Input 315 + defaultValue={displayName} 316 + onChangeText={setDisplayName} 317 + label={_(msg`Display name`)} 318 + placeholder={_(msg`e.g. Alice Lastname`)} 319 + /> 320 + </TextField.Root> 321 + {displayNameTooLong && ( 322 + <TextField.SuffixText 323 + style={[ 324 + a.text_sm, 325 + a.mt_xs, 326 + a.font_bold, 327 + {color: t.palette.negative_400}, 328 + ]} 329 + label={_(msg`Display name is too long`)}> 330 + <Trans> 331 + Display name is too long. The maximum number of characters is{' '} 332 + {DISPLAY_NAME_MAX_GRAPHEMES}. 333 + </Trans> 334 + </TextField.SuffixText> 335 + )} 336 + </View> 337 + 338 + <View> 339 + <TextField.LabelText> 340 + <Trans>Description</Trans> 341 + </TextField.LabelText> 342 + <TextField.Root isInvalid={descriptionTooLong}> 343 + <Dialog.Input 344 + defaultValue={description} 345 + onChangeText={setDescription} 346 + multiline 347 + label={_(msg`Display name`)} 348 + placeholder={_(msg`Tell us a bit about yourself`)} 349 + /> 350 + </TextField.Root> 351 + {descriptionTooLong && ( 352 + <TextField.SuffixText 353 + style={[ 354 + a.text_sm, 355 + a.mt_xs, 356 + a.font_bold, 357 + {color: t.palette.negative_400}, 358 + ]} 359 + label={_(msg`Description is too long`)}> 360 + <Trans> 361 + Description is too long. The maximum number of characters is{' '} 362 + {DESCRIPTION_MAX_GRAPHEMES}. 363 + </Trans> 364 + </TextField.SuffixText> 365 + )} 366 + </View> 367 + </View> 368 + </Dialog.ScrollableInner> 369 + ) 370 + }
+23 -20
src/screens/Profile/Header/ProfileHeaderLabeler.tsx
··· 18 18 import {isIOS} from '#/platform/detection' 19 19 import {useProfileShadow} from '#/state/cache/profile-shadow' 20 20 import {Shadow} from '#/state/cache/types' 21 - import {useModalControls} from '#/state/modals' 22 21 import {useLabelerSubscriptionMutation} from '#/state/queries/labeler' 23 22 import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' 24 23 import {usePreferencesQuery} from '#/state/queries/preferences' ··· 27 26 import * as Toast from '#/view/com/util/Toast' 28 27 import {atoms as a, tokens, useTheme} from '#/alf' 29 28 import {Button, ButtonText} from '#/components/Button' 30 - import {DialogOuterProps} from '#/components/Dialog' 29 + import {DialogOuterProps, useDialogControl} from '#/components/Dialog' 31 30 import { 32 31 Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, 33 32 Heart2_Stroke2_Corner0_Rounded as Heart, ··· 37 36 import {RichText} from '#/components/RichText' 38 37 import {Text} from '#/components/Typography' 39 38 import {ProfileHeaderDisplayName} from './DisplayName' 39 + import {EditProfileDialog} from './EditProfileDialog' 40 40 import {ProfileHeaderHandle} from './Handle' 41 41 import {ProfileHeaderMetrics} from './Metrics' 42 42 import {ProfileHeaderShell} from './Shell' ··· 63 63 const t = useTheme() 64 64 const {_} = useLingui() 65 65 const {currentAccount, hasSession} = useSession() 66 - const {openModal} = useModalControls() 67 66 const requireAuth = useRequireAuth() 68 67 const playHaptic = useHaptics() 69 68 const cantSubscribePrompt = Prompt.usePromptControl() ··· 117 116 } 118 117 }, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _]) 119 118 119 + const editProfileControl = useDialogControl() 120 120 const onPressEditProfile = React.useCallback(() => { 121 - openModal({ 122 - name: 'edit-profile', 123 - profile, 124 - }) 125 - }, [openModal, profile]) 121 + editProfileControl.open() 122 + }, [editProfileControl]) 126 123 127 124 const onPressSubscribe = React.useCallback( 128 125 () => ··· 169 166 style={[a.flex_row, a.justify_end, a.align_center, a.gap_xs, a.pb_lg]} 170 167 pointerEvents={isIOS ? 'auto' : 'box-none'}> 171 168 {isMe ? ( 172 - <Button 173 - testID="profileHeaderEditProfileButton" 174 - size="small" 175 - color="secondary" 176 - variant="solid" 177 - onPress={onPressEditProfile} 178 - label={_(msg`Edit profile`)} 179 - style={a.rounded_full}> 180 - <ButtonText> 181 - <Trans>Edit Profile</Trans> 182 - </ButtonText> 183 - </Button> 169 + <> 170 + <Button 171 + testID="profileHeaderEditProfileButton" 172 + size="small" 173 + color="secondary" 174 + variant="solid" 175 + onPress={onPressEditProfile} 176 + label={_(msg`Edit profile`)} 177 + style={a.rounded_full}> 178 + <ButtonText> 179 + <Trans>Edit Profile</Trans> 180 + </ButtonText> 181 + </Button> 182 + <EditProfileDialog 183 + profile={profile} 184 + control={editProfileControl} 185 + /> 186 + </> 184 187 ) : !isAppLabeler(profile.did) ? ( 185 188 <> 186 189 <Button
+23 -19
src/screens/Profile/Header/ProfileHeaderStandard.tsx
··· 14 14 import {isIOS} from '#/platform/detection' 15 15 import {useProfileShadow} from '#/state/cache/profile-shadow' 16 16 import {Shadow} from '#/state/cache/types' 17 - import {useModalControls} from '#/state/modals' 18 17 import { 19 18 useProfileBlockMutationQueue, 20 19 useProfileFollowMutationQueue, ··· 24 23 import * as Toast from '#/view/com/util/Toast' 25 24 import {atoms as a} from '#/alf' 26 25 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 26 + import {useDialogControl} from '#/components/Dialog' 27 27 import {MessageProfileButton} from '#/components/dms/MessageProfileButton' 28 28 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 29 29 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' ··· 34 34 import * as Prompt from '#/components/Prompt' 35 35 import {RichText} from '#/components/RichText' 36 36 import {ProfileHeaderDisplayName} from './DisplayName' 37 + import {EditProfileDialog} from './EditProfileDialog' 37 38 import {ProfileHeaderHandle} from './Handle' 38 39 import {ProfileHeaderMetrics} from './Metrics' 39 40 import {ProfileHeaderShell} from './Shell' ··· 57 58 useProfileShadow(profileUnshadowed) 58 59 const {currentAccount, hasSession} = useSession() 59 60 const {_} = useLingui() 60 - const {openModal} = useModalControls() 61 61 const moderation = useMemo( 62 62 () => moderateProfile(profile, moderationOpts), 63 63 [profile, moderationOpts], ··· 74 74 profile.viewer?.blockedBy || 75 75 profile.viewer?.blockingByList 76 76 77 + const editProfileControl = useDialogControl() 77 78 const onPressEditProfile = React.useCallback(() => { 78 - openModal({ 79 - name: 'edit-profile', 80 - profile, 81 - }) 82 - }, [openModal, profile]) 79 + editProfileControl.open() 80 + }, [editProfileControl]) 83 81 84 82 const onPressFollow = () => { 85 83 requireAuth(async () => { ··· 161 159 ]} 162 160 pointerEvents={isIOS ? 'auto' : 'box-none'}> 163 161 {isMe ? ( 164 - <Button 165 - testID="profileHeaderEditProfileButton" 166 - size="small" 167 - color="secondary" 168 - variant="solid" 169 - onPress={onPressEditProfile} 170 - label={_(msg`Edit profile`)} 171 - style={[a.rounded_full]}> 172 - <ButtonText> 173 - <Trans>Edit Profile</Trans> 174 - </ButtonText> 175 - </Button> 162 + <> 163 + <Button 164 + testID="profileHeaderEditProfileButton" 165 + size="small" 166 + color="secondary" 167 + variant="solid" 168 + onPress={onPressEditProfile} 169 + label={_(msg`Edit profile`)} 170 + style={[a.rounded_full]}> 171 + <ButtonText> 172 + <Trans>Edit Profile</Trans> 173 + </ButtonText> 174 + </Button> 175 + <EditProfileDialog 176 + profile={profile} 177 + control={editProfileControl} 178 + /> 179 + </> 176 180 ) : profile.viewer?.blocking ? ( 177 181 profile.viewer?.blockingByList ? null : ( 178 182 <Button
-7
src/state/modals/index.tsx
··· 4 4 5 5 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 6 6 7 - export interface EditProfileModal { 8 - name: 'edit-profile' 9 - profile: AppBskyActorDefs.ProfileViewDetailed 10 - onUpdate?: () => void 11 - } 12 - 13 7 export interface CreateOrEditListModal { 14 8 name: 'create-or-edit-list' 15 9 purpose?: string ··· 111 105 | AddAppPasswordModal 112 106 | ChangeHandleModal 113 107 | DeleteAccountModal 114 - | EditProfileModal 115 108 | VerifyEmailModal 116 109 | ChangeEmailModal 117 110 | ChangePasswordModal
-310
src/view/com/modals/EditProfile.tsx
··· 1 - import React, {useCallback, useState} from 'react' 2 - import { 3 - ActivityIndicator, 4 - KeyboardAvoidingView, 5 - ScrollView, 6 - StyleSheet, 7 - TextInput, 8 - TouchableOpacity, 9 - View, 10 - } from 'react-native' 11 - import {Image as RNImage} from 'react-native-image-crop-picker' 12 - import Animated, {FadeOut} from 'react-native-reanimated' 13 - import {LinearGradient} from 'expo-linear-gradient' 14 - import {AppBskyActorDefs} from '@atproto/api' 15 - import {msg, Trans} from '@lingui/macro' 16 - import {useLingui} from '@lingui/react' 17 - 18 - import {MAX_DESCRIPTION, MAX_DISPLAY_NAME} from '#/lib/constants' 19 - import {usePalette} from '#/lib/hooks/usePalette' 20 - import {compressIfNeeded} from '#/lib/media/manip' 21 - import {cleanError} from '#/lib/strings/errors' 22 - import {enforceLen} from '#/lib/strings/helpers' 23 - import {colors, gradients, s} from '#/lib/styles' 24 - import {useTheme} from '#/lib/ThemeContext' 25 - import {logger} from '#/logger' 26 - import {isWeb} from '#/platform/detection' 27 - import {useModalControls} from '#/state/modals' 28 - import {useProfileUpdateMutation} from '#/state/queries/profile' 29 - import {Text} from '#/view/com/util/text/Text' 30 - import * as Toast from '#/view/com/util/Toast' 31 - import {EditableUserAvatar} from '#/view/com/util/UserAvatar' 32 - import {UserBanner} from '#/view/com/util/UserBanner' 33 - import {ErrorMessage} from '../util/error/ErrorMessage' 34 - 35 - const AnimatedTouchableOpacity = 36 - Animated.createAnimatedComponent(TouchableOpacity) 37 - 38 - export const snapPoints = ['fullscreen'] 39 - 40 - export function Component({ 41 - profile, 42 - onUpdate, 43 - }: { 44 - profile: AppBskyActorDefs.ProfileViewDetailed 45 - onUpdate?: () => void 46 - }) { 47 - const pal = usePalette('default') 48 - const theme = useTheme() 49 - const {_} = useLingui() 50 - const {closeModal} = useModalControls() 51 - const updateMutation = useProfileUpdateMutation() 52 - const [imageError, setImageError] = useState<string>('') 53 - const [displayName, setDisplayName] = useState<string>( 54 - profile.displayName || '', 55 - ) 56 - const [description, setDescription] = useState<string>( 57 - profile.description || '', 58 - ) 59 - const [userBanner, setUserBanner] = useState<string | undefined | null>( 60 - profile.banner, 61 - ) 62 - const [userAvatar, setUserAvatar] = useState<string | undefined | null>( 63 - profile.avatar, 64 - ) 65 - const [newUserBanner, setNewUserBanner] = useState< 66 - RNImage | undefined | null 67 - >() 68 - const [newUserAvatar, setNewUserAvatar] = useState< 69 - RNImage | undefined | null 70 - >() 71 - const onPressCancel = () => { 72 - closeModal() 73 - } 74 - const onSelectNewAvatar = useCallback( 75 - async (img: RNImage | null) => { 76 - setImageError('') 77 - if (img === null) { 78 - setNewUserAvatar(null) 79 - setUserAvatar(null) 80 - return 81 - } 82 - try { 83 - const finalImg = await compressIfNeeded(img, 1000000) 84 - setNewUserAvatar(finalImg) 85 - setUserAvatar(finalImg.path) 86 - } catch (e: any) { 87 - setImageError(cleanError(e)) 88 - } 89 - }, 90 - [setNewUserAvatar, setUserAvatar, setImageError], 91 - ) 92 - 93 - const onSelectNewBanner = useCallback( 94 - async (img: RNImage | null) => { 95 - setImageError('') 96 - if (!img) { 97 - setNewUserBanner(null) 98 - setUserBanner(null) 99 - return 100 - } 101 - try { 102 - const finalImg = await compressIfNeeded(img, 1000000) 103 - setNewUserBanner(finalImg) 104 - setUserBanner(finalImg.path) 105 - } catch (e: any) { 106 - setImageError(cleanError(e)) 107 - } 108 - }, 109 - [setNewUserBanner, setUserBanner, setImageError], 110 - ) 111 - 112 - const onPressSave = useCallback(async () => { 113 - setImageError('') 114 - try { 115 - await updateMutation.mutateAsync({ 116 - profile, 117 - updates: { 118 - displayName, 119 - description, 120 - }, 121 - newUserAvatar, 122 - newUserBanner, 123 - }) 124 - Toast.show(_(msg`Profile updated`)) 125 - onUpdate?.() 126 - closeModal() 127 - } catch (e: any) { 128 - logger.error('Failed to update user profile', {message: String(e)}) 129 - } 130 - }, [ 131 - updateMutation, 132 - profile, 133 - onUpdate, 134 - closeModal, 135 - displayName, 136 - description, 137 - newUserAvatar, 138 - newUserBanner, 139 - setImageError, 140 - _, 141 - ]) 142 - 143 - return ( 144 - <KeyboardAvoidingView style={s.flex1} behavior="height"> 145 - <ScrollView style={[pal.view]} testID="editProfileModal"> 146 - <Text style={[styles.title, pal.text]}> 147 - <Trans>Edit my profile</Trans> 148 - </Text> 149 - <View style={styles.photos}> 150 - <UserBanner 151 - banner={userBanner} 152 - onSelectNewBanner={onSelectNewBanner} 153 - /> 154 - <View style={[styles.avi, {borderColor: pal.colors.background}]}> 155 - <EditableUserAvatar 156 - size={80} 157 - avatar={userAvatar} 158 - onSelectNewAvatar={onSelectNewAvatar} 159 - /> 160 - </View> 161 - </View> 162 - {updateMutation.isError && ( 163 - <View style={styles.errorContainer}> 164 - <ErrorMessage message={cleanError(updateMutation.error)} /> 165 - </View> 166 - )} 167 - {imageError !== '' && ( 168 - <View style={styles.errorContainer}> 169 - <ErrorMessage message={imageError} /> 170 - </View> 171 - )} 172 - <View style={styles.form}> 173 - <View> 174 - <Text style={[styles.label, pal.text]}> 175 - <Trans>Display Name</Trans> 176 - </Text> 177 - <TextInput 178 - testID="editProfileDisplayNameInput" 179 - style={[styles.textInput, pal.border, pal.text]} 180 - placeholder={_(msg`e.g. Alice Roberts`)} 181 - placeholderTextColor={colors.gray4} 182 - value={displayName} 183 - onChangeText={v => 184 - setDisplayName(enforceLen(v, MAX_DISPLAY_NAME)) 185 - } 186 - accessible={true} 187 - accessibilityLabel={_(msg`Display name`)} 188 - accessibilityHint={_(msg`Edit your display name`)} 189 - /> 190 - </View> 191 - <View style={s.pb10}> 192 - <Text style={[styles.label, pal.text]}> 193 - <Trans>Description</Trans> 194 - </Text> 195 - <TextInput 196 - testID="editProfileDescriptionInput" 197 - style={[styles.textArea, pal.border, pal.text]} 198 - placeholder={_(msg`e.g. Artist, dog-lover, and avid reader.`)} 199 - placeholderTextColor={colors.gray4} 200 - keyboardAppearance={theme.colorScheme} 201 - multiline 202 - value={description} 203 - onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} 204 - accessible={true} 205 - accessibilityLabel={_(msg`Description`)} 206 - accessibilityHint={_(msg`Edit your profile description`)} 207 - /> 208 - </View> 209 - {updateMutation.isPending ? ( 210 - <View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}> 211 - <ActivityIndicator /> 212 - </View> 213 - ) : ( 214 - <TouchableOpacity 215 - testID="editProfileSaveBtn" 216 - style={s.mt10} 217 - onPress={onPressSave} 218 - accessibilityRole="button" 219 - accessibilityLabel={_(msg`Save`)} 220 - accessibilityHint={_(msg`Saves any changes to your profile`)}> 221 - <LinearGradient 222 - colors={[gradients.blueLight.start, gradients.blueLight.end]} 223 - start={{x: 0, y: 0}} 224 - end={{x: 1, y: 1}} 225 - style={[styles.btn]}> 226 - <Text style={[s.white, s.bold]}> 227 - <Trans>Save Changes</Trans> 228 - </Text> 229 - </LinearGradient> 230 - </TouchableOpacity> 231 - )} 232 - {!updateMutation.isPending && ( 233 - <AnimatedTouchableOpacity 234 - exiting={!isWeb ? FadeOut : undefined} 235 - testID="editProfileCancelBtn" 236 - style={s.mt5} 237 - onPress={onPressCancel} 238 - accessibilityRole="button" 239 - accessibilityLabel={_(msg`Cancel profile editing`)} 240 - accessibilityHint="" 241 - onAccessibilityEscape={onPressCancel}> 242 - <View style={[styles.btn]}> 243 - <Text style={[s.black, s.bold, pal.text]}> 244 - <Trans>Cancel</Trans> 245 - </Text> 246 - </View> 247 - </AnimatedTouchableOpacity> 248 - )} 249 - </View> 250 - </ScrollView> 251 - </KeyboardAvoidingView> 252 - ) 253 - } 254 - 255 - const styles = StyleSheet.create({ 256 - title: { 257 - textAlign: 'center', 258 - fontWeight: '600', 259 - fontSize: 24, 260 - marginBottom: 18, 261 - }, 262 - label: { 263 - fontWeight: '600', 264 - paddingHorizontal: 4, 265 - paddingBottom: 4, 266 - marginTop: 20, 267 - }, 268 - form: { 269 - paddingHorizontal: 14, 270 - }, 271 - textInput: { 272 - borderWidth: 1, 273 - borderRadius: 6, 274 - paddingHorizontal: 14, 275 - paddingVertical: 10, 276 - fontSize: 16, 277 - }, 278 - textArea: { 279 - borderWidth: 1, 280 - borderRadius: 6, 281 - paddingHorizontal: 12, 282 - paddingTop: 10, 283 - fontSize: 16, 284 - height: 120, 285 - textAlignVertical: 'top', 286 - }, 287 - btn: { 288 - flexDirection: 'row', 289 - alignItems: 'center', 290 - justifyContent: 'center', 291 - width: '100%', 292 - borderRadius: 32, 293 - padding: 10, 294 - marginBottom: 10, 295 - }, 296 - avi: { 297 - position: 'absolute', 298 - top: 80, 299 - left: 24, 300 - width: 84, 301 - height: 84, 302 - borderWidth: 2, 303 - borderRadius: 42, 304 - }, 305 - photos: { 306 - marginBottom: 36, 307 - marginHorizontal: -14, 308 - }, 309 - errorContainer: {marginTop: 20}, 310 - })
+1 -5
src/view/com/modals/Modal.tsx
··· 13 13 import * as ChangePasswordModal from './ChangePassword' 14 14 import * as CreateOrEditListModal from './CreateOrEditList' 15 15 import * as DeleteAccountModal from './DeleteAccount' 16 - import * as EditProfileModal from './EditProfile' 17 16 import * as InAppBrowserConsentModal from './InAppBrowserConsent' 18 17 import * as InviteCodesModal from './InviteCodes' 19 18 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' ··· 55 54 56 55 let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS 57 56 let element 58 - if (activeModal?.name === 'edit-profile') { 59 - snapPoints = EditProfileModal.snapPoints 60 - element = <EditProfileModal.Component {...activeModal} /> 61 - } else if (activeModal?.name === 'create-or-edit-list') { 57 + if (activeModal?.name === 'create-or-edit-list') { 62 58 snapPoints = CreateOrEditListModal.snapPoints 63 59 element = <CreateOrEditListModal.Component {...activeModal} /> 64 60 } else if (activeModal?.name === 'user-add-remove-lists') {
+1 -4
src/view/com/modals/Modal.web.tsx
··· 14 14 import * as CreateOrEditListModal from './CreateOrEditList' 15 15 import * as CropImageModal from './CropImage.web' 16 16 import * as DeleteAccountModal from './DeleteAccount' 17 - import * as EditProfileModal from './EditProfile' 18 17 import * as InviteCodesModal from './InviteCodes' 19 18 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' 20 19 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' ··· 63 62 } 64 63 65 64 let element 66 - if (modal.name === 'edit-profile') { 67 - element = <EditProfileModal.Component {...modal} /> 68 - } else if (modal.name === 'create-or-edit-list') { 65 + if (modal.name === 'create-or-edit-list') { 69 66 element = <CreateOrEditListModal.Component {...modal} /> 70 67 } else if (modal.name === 'user-add-remove-lists') { 71 68 element = <UserAddRemoveLists.Component {...modal} />
+1 -11
yarn.lock
··· 5197 5197 resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz#8635edd346304f8b42cae86b05912b61aef27afe" 5198 5198 integrity sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg== 5199 5199 5200 - "@radix-ui/react-focus-scope@1.0.1": 5201 - version "1.0.1" 5202 - resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.1.tgz#faea8c25f537c5a5c38c50914b63722db0e7f951" 5203 - integrity sha512-Ej2MQTit8IWJiS2uuujGUmxXjF/y5xZptIIQnyd2JHLwtV0R2j9NRVoRj/1j/gJ7e3REdaBw4Hjf4a1ImhkZcQ== 5204 - dependencies: 5205 - "@babel/runtime" "^7.13.10" 5206 - "@radix-ui/react-compose-refs" "1.0.0" 5207 - "@radix-ui/react-primitive" "1.0.1" 5208 - "@radix-ui/react-use-callback-ref" "1.0.0" 5209 - 5210 - "@radix-ui/react-focus-scope@1.1.0", "@radix-ui/react-focus-scope@^1.1.0": 5200 + "@radix-ui/react-focus-scope@1.0.1", "@radix-ui/react-focus-scope@1.1.0", "@radix-ui/react-focus-scope@^1.1.0": 5211 5201 version "1.1.0" 5212 5202 resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz#ebe2891a298e0a33ad34daab2aad8dea31caf0b2" 5213 5203 integrity sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==