Bluesky app fork with some witchin' additions 馃挮
at linkat-integration 399 lines 12 kB view raw
1import {useCallback, useEffect, useState} from 'react' 2import {useWindowDimensions, View} from 'react-native' 3import {type AppBskyActorDefs} from '@atproto/api' 4import {msg, Plural, Trans} from '@lingui/macro' 5import {useLingui} from '@lingui/react' 6 7import {urls} from '#/lib/constants' 8import {cleanError} from '#/lib/strings/errors' 9import {isOverMaxGraphemeCount} from '#/lib/strings/helpers' 10import {logger} from '#/logger' 11import {type ImageMeta} from '#/state/gallery' 12import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' 13import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 14import {useProfileUpdateMutation} from '#/state/queries/profile' 15import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 16import * as Toast from '#/view/com/util/Toast' 17import {EditableUserAvatar} from '#/view/com/util/UserAvatar' 18import {UserBanner} from '#/view/com/util/UserBanner' 19import {atoms as a, useTheme} from '#/alf' 20import {Admonition} from '#/components/Admonition' 21import {Button, ButtonIcon, ButtonText} from '#/components/Button' 22import * as Dialog from '#/components/Dialog' 23import * as TextField from '#/components/forms/TextField' 24import {InlineLinkText} from '#/components/Link' 25import {Loader} from '#/components/Loader' 26import * as Prompt from '#/components/Prompt' 27import {Text} from '#/components/Typography' 28import {useSimpleVerificationState} from '#/components/verification' 29 30const DISPLAY_NAME_MAX_GRAPHEMES = 64 31const DESCRIPTION_MAX_GRAPHEMES = 256 32 33export function EditProfileDialog({ 34 profile, 35 control, 36 onUpdate, 37}: { 38 profile: AppBskyActorDefs.ProfileViewDetailed 39 control: Dialog.DialogControlProps 40 onUpdate?: () => void 41}) { 42 const {_} = useLingui() 43 const cancelControl = Dialog.useDialogControl() 44 const [dirty, setDirty] = useState(false) 45 const {height} = useWindowDimensions() 46 47 const onPressCancel = useCallback(() => { 48 if (dirty) { 49 cancelControl.open() 50 } else { 51 control.close() 52 } 53 }, [dirty, control, cancelControl]) 54 55 return ( 56 <Dialog.Outer 57 control={control} 58 nativeOptions={{ 59 preventDismiss: dirty, 60 minHeight: height, 61 }} 62 webOptions={{ 63 onBackgroundPress: () => { 64 if (dirty) { 65 cancelControl.open() 66 } else { 67 control.close() 68 } 69 }, 70 }} 71 testID="editProfileModal"> 72 <DialogInner 73 profile={profile} 74 onUpdate={onUpdate} 75 setDirty={setDirty} 76 onPressCancel={onPressCancel} 77 /> 78 79 <Prompt.Basic 80 control={cancelControl} 81 title={_(msg`Discard changes?`)} 82 description={_(msg`Are you sure you want to discard your changes?`)} 83 onConfirm={() => control.close()} 84 confirmButtonCta={_(msg`Discard`)} 85 confirmButtonColor="negative" 86 /> 87 </Dialog.Outer> 88 ) 89} 90 91function DialogInner({ 92 profile, 93 onUpdate, 94 setDirty, 95 onPressCancel, 96}: { 97 profile: AppBskyActorDefs.ProfileViewDetailed 98 onUpdate?: () => void 99 setDirty: (dirty: boolean) => void 100 onPressCancel: () => void 101}) { 102 const {_} = useLingui() 103 const t = useTheme() 104 const control = Dialog.useDialogContext() 105 const enableSquareButtons = useEnableSquareButtons() 106 const verification = useSimpleVerificationState({ 107 profile, 108 }) 109 const { 110 mutateAsync: updateProfileMutation, 111 error: updateProfileError, 112 isError: isUpdateProfileError, 113 isPending: isUpdatingProfile, 114 } = useProfileUpdateMutation() 115 const [imageError, setImageError] = useState('') 116 const initialDisplayName = profile.displayName || '' 117 const [displayName, setDisplayName] = useState(initialDisplayName) 118 const initialDescription = profile.description || '' 119 const [description, setDescription] = useState(initialDescription) 120 const [userBanner, setUserBanner] = useState<string | undefined | null>( 121 profile.banner, 122 ) 123 const [userAvatar, setUserAvatar] = useState<string | undefined | null>( 124 profile.avatar, 125 ) 126 const [newUserBanner, setNewUserBanner] = useState< 127 ImageMeta | undefined | null 128 >() 129 const [newUserAvatar, setNewUserAvatar] = useState< 130 ImageMeta | undefined | null 131 >() 132 133 const dirty = 134 displayName !== initialDisplayName || 135 description !== initialDescription || 136 userAvatar !== profile.avatar || 137 userBanner !== profile.banner 138 139 const enableSquareAvatars = useEnableSquareAvatars() 140 141 useEffect(() => { 142 setDirty(dirty) 143 }, [dirty, setDirty]) 144 145 const onSelectNewAvatar = useCallback( 146 (img: ImageMeta | null) => { 147 setImageError('') 148 if (img === null) { 149 setNewUserAvatar(null) 150 setUserAvatar(null) 151 return 152 } 153 try { 154 setNewUserAvatar(img) 155 setUserAvatar(img.path) 156 } catch (e: any) { 157 setImageError(cleanError(e)) 158 } 159 }, 160 [setNewUserAvatar, setUserAvatar, setImageError], 161 ) 162 163 const onSelectNewBanner = useCallback( 164 (img: ImageMeta | null) => { 165 setImageError('') 166 if (!img) { 167 setNewUserBanner(null) 168 setUserBanner(null) 169 return 170 } 171 try { 172 setNewUserBanner(img) 173 setUserBanner(img.path) 174 } catch (e: any) { 175 setImageError(cleanError(e)) 176 } 177 }, 178 [setNewUserBanner, setUserBanner, setImageError], 179 ) 180 181 const onPressSave = useCallback(async () => { 182 setImageError('') 183 try { 184 await updateProfileMutation({ 185 profile, 186 updates: { 187 displayName: displayName.trimEnd(), 188 description: description.trimEnd(), 189 }, 190 newUserAvatar, 191 newUserBanner, 192 }) 193 control.close(() => onUpdate?.()) 194 Toast.show(_(msg({message: 'Profile updated', context: 'toast'}))) 195 } catch (e: any) { 196 logger.error('Failed to update user profile', {message: String(e)}) 197 } 198 }, [ 199 updateProfileMutation, 200 profile, 201 onUpdate, 202 control, 203 displayName, 204 description, 205 newUserAvatar, 206 newUserBanner, 207 setImageError, 208 _, 209 ]) 210 211 const displayNameTooLong = isOverMaxGraphemeCount({ 212 text: displayName, 213 maxCount: DISPLAY_NAME_MAX_GRAPHEMES, 214 }) 215 const descriptionTooLong = isOverMaxGraphemeCount({ 216 text: description, 217 maxCount: DESCRIPTION_MAX_GRAPHEMES, 218 }) 219 220 const cancelButton = useCallback( 221 () => ( 222 <Button 223 label={_(msg`Cancel`)} 224 onPress={onPressCancel} 225 size="small" 226 color="primary" 227 variant="ghost" 228 style={[enableSquareButtons ? a.rounded_sm : a.rounded_full]} 229 testID="editProfileCancelBtn"> 230 <ButtonText style={[a.text_md]}> 231 <Trans>Cancel</Trans> 232 </ButtonText> 233 </Button> 234 ), 235 [onPressCancel, _, enableSquareButtons], 236 ) 237 238 const saveButton = useCallback( 239 () => ( 240 <Button 241 label={_(msg`Save`)} 242 onPress={onPressSave} 243 disabled={ 244 !dirty || 245 isUpdatingProfile || 246 displayNameTooLong || 247 descriptionTooLong 248 } 249 size="small" 250 color="primary" 251 variant="ghost" 252 style={[enableSquareButtons ? a.rounded_sm : a.rounded_full]} 253 testID="editProfileSaveBtn"> 254 <ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}> 255 <Trans>Save</Trans> 256 </ButtonText> 257 {isUpdatingProfile && <ButtonIcon icon={Loader} />} 258 </Button> 259 ), 260 [ 261 _, 262 t, 263 dirty, 264 onPressSave, 265 isUpdatingProfile, 266 displayNameTooLong, 267 descriptionTooLong, 268 enableSquareButtons, 269 ], 270 ) 271 272 return ( 273 <Dialog.ScrollableInner 274 label={_(msg`Edit profile`)} 275 style={[a.overflow_hidden]} 276 contentContainerStyle={[a.px_0, a.pt_0]} 277 header={ 278 <Dialog.Header renderLeft={cancelButton} renderRight={saveButton}> 279 <Dialog.HeaderText> 280 <Trans>Edit profile</Trans> 281 </Dialog.HeaderText> 282 </Dialog.Header> 283 }> 284 <View style={[a.relative]}> 285 <UserBanner banner={userBanner} onSelectNewBanner={onSelectNewBanner} /> 286 <View 287 style={[ 288 a.absolute, 289 { 290 top: 80, 291 left: 20, 292 width: 84, 293 height: 84, 294 borderWidth: 2, 295 borderRadius: enableSquareAvatars ? 11 : 42, 296 borderColor: t.atoms.bg.backgroundColor, 297 }, 298 ]}> 299 <EditableUserAvatar 300 size={80} 301 avatar={userAvatar} 302 onSelectNewAvatar={onSelectNewAvatar} 303 /> 304 </View> 305 </View> 306 {isUpdateProfileError && ( 307 <View style={[a.mt_xl]}> 308 <ErrorMessage message={cleanError(updateProfileError)} /> 309 </View> 310 )} 311 {imageError !== '' && ( 312 <View style={[a.mt_xl]}> 313 <ErrorMessage message={imageError} /> 314 </View> 315 )} 316 <View style={[a.mt_4xl, a.px_xl, a.gap_xl]}> 317 <View> 318 <TextField.LabelText> 319 <Trans>Display name</Trans> 320 </TextField.LabelText> 321 <TextField.Root isInvalid={displayNameTooLong}> 322 <Dialog.Input 323 defaultValue={displayName} 324 onChangeText={setDisplayName} 325 label={_(msg`Display name`)} 326 placeholder={_(msg`e.g. Alice Lastname`)} 327 testID="editProfileDisplayNameInput" 328 /> 329 </TextField.Root> 330 {displayNameTooLong && ( 331 <Text 332 style={[ 333 a.text_sm, 334 a.mt_xs, 335 a.font_semi_bold, 336 {color: t.palette.negative_400}, 337 ]}> 338 <Plural 339 value={DISPLAY_NAME_MAX_GRAPHEMES} 340 other="Display name is too long. The maximum number of characters is #." 341 /> 342 </Text> 343 )} 344 </View> 345 346 {verification.isVerified && 347 verification.role === 'default' && 348 displayName !== initialDisplayName && ( 349 <Admonition type="error"> 350 <Trans> 351 You are verified. You will lose your verification status if you 352 change your display name.{' '} 353 <InlineLinkText 354 label={_( 355 msg({ 356 message: `Learn more`, 357 context: `english-only-resource`, 358 }), 359 )} 360 to={urls.website.blog.initialVerificationAnnouncement}> 361 <Trans context="english-only-resource">Learn more.</Trans> 362 </InlineLinkText> 363 </Trans> 364 </Admonition> 365 )} 366 367 <View> 368 <TextField.LabelText> 369 <Trans>Description</Trans> 370 </TextField.LabelText> 371 <TextField.Root isInvalid={descriptionTooLong}> 372 <Dialog.Input 373 defaultValue={description} 374 onChangeText={setDescription} 375 multiline 376 label={_(msg`Description`)} 377 placeholder={_(msg`Tell us a bit about yourself`)} 378 testID="editProfileDescriptionInput" 379 /> 380 </TextField.Root> 381 {descriptionTooLong && ( 382 <Text 383 style={[ 384 a.text_sm, 385 a.mt_xs, 386 a.font_semi_bold, 387 {color: t.palette.negative_400}, 388 ]}> 389 <Plural 390 value={DESCRIPTION_MAX_GRAPHEMES} 391 other="Description is too long. The maximum number of characters is #." 392 /> 393 </Text> 394 )} 395 </View> 396 </View> 397 </Dialog.ScrollableInner> 398 ) 399}