Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at main 554 lines 17 kB view raw
1import {useCallback, useEffect, useRef, useState} from 'react' 2import {Pressable, View} from 'react-native' 3import {type AppBskyActorDefs} from '@atproto/api' 4import {msg} from '@lingui/core/macro' 5import {useLingui} from '@lingui/react' 6import {Plural, Trans} from '@lingui/react/macro' 7 8import { 9 HITSLOP_10, 10 MAX_DESCRIPTION, 11 MAX_DISPLAY_NAME, 12 urls, 13} from '#/lib/constants' 14import {cleanError} from '#/lib/strings/errors' 15import {isOverMaxGraphemeCount} from '#/lib/strings/helpers' 16import {isValidWebsiteFormat} from '#/lib/strings/website' 17import {logger} from '#/logger' 18import {type ImageMeta} from '#/state/gallery' 19import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' 20import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 21import {useProfileUpdateMutation} from '#/state/queries/profile' 22import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 23import {EditableUserAvatar} from '#/view/com/util/UserAvatar' 24import {UserBanner} from '#/view/com/util/UserBanner' 25import {atoms as a, useTheme} from '#/alf' 26import * as tokens from '#/alf/tokens' 27import {Admonition} from '#/components/Admonition' 28import {Button, ButtonIcon, ButtonText} from '#/components/Button' 29import * as Dialog from '#/components/Dialog' 30import * as TextField from '#/components/forms/TextField' 31import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX' 32import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 33import {InlineLinkText} from '#/components/Link' 34import {Loader} from '#/components/Loader' 35import * as Prompt from '#/components/Prompt' 36import * as Toast from '#/components/Toast' 37import {Text} from '#/components/Typography' 38import {useSimpleVerificationState} from '#/components/verification' 39 40const PRONOUNS_MAX_GRAPHEMES = 20 41const WEBSITE_MAX_GRAPHEMES = 2048 42 43export function EditProfileDialog({ 44 profile, 45 control, 46 onUpdate, 47}: { 48 profile: AppBskyActorDefs.ProfileViewDetailed 49 control: Dialog.DialogControlProps 50 onUpdate?: () => void 51}) { 52 const {_} = useLingui() 53 const cancelControl = Dialog.useDialogControl() 54 const [dirty, setDirty] = useState(false) 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 fullHeight: true, 70 }} 71 webOptions={{ 72 onBackgroundPress: () => { 73 if (dirty) { 74 cancelControl.open() 75 } else { 76 control.close() 77 } 78 }, 79 }} 80 testID="editProfileModal"> 81 <DialogInner 82 profile={profile} 83 onUpdate={onUpdate} 84 setDirty={setDirty} 85 onPressCancel={onPressCancel} 86 /> 87 88 <Prompt.Basic 89 control={cancelControl} 90 title={_(msg`Discard changes?`)} 91 description={_(msg`Are you sure you want to discard your changes?`)} 92 onConfirm={() => control.close()} 93 confirmButtonCta={_(msg`Discard`)} 94 confirmButtonColor="negative" 95 /> 96 </Dialog.Outer> 97 ) 98} 99 100function DialogInner({ 101 profile, 102 onUpdate, 103 setDirty, 104 onPressCancel, 105}: { 106 profile: AppBskyActorDefs.ProfileViewDetailed 107 onUpdate?: () => void 108 setDirty: (dirty: boolean) => void 109 onPressCancel: () => void 110}) { 111 const {_} = useLingui() 112 const t = useTheme() 113 const control = Dialog.useDialogContext() 114 const enableSquareButtons = useEnableSquareButtons() 115 const verification = useSimpleVerificationState({ 116 profile, 117 }) 118 const { 119 mutateAsync: updateProfileMutation, 120 error: updateProfileError, 121 isError: isUpdateProfileError, 122 isPending: isUpdatingProfile, 123 } = useProfileUpdateMutation() 124 const [imageError, setImageError] = useState('') 125 const initialDisplayName = profile.displayName || '' 126 const [displayName, setDisplayName] = useState(initialDisplayName) 127 const initialDescription = profile.description || '' 128 const [description, setDescription] = useState(initialDescription) 129 const initialPronouns = profile.pronouns || '' 130 const [pronouns, setPronouns] = useState(initialPronouns) 131 const initialWebsite = profile.website || '' 132 const [website, setWebsite] = useState(initialWebsite) 133 const websiteInputRef = useRef<any>(null) 134 const [userBanner, setUserBanner] = useState<string | undefined | null>( 135 profile.banner, 136 ) 137 const [userAvatar, setUserAvatar] = useState<string | undefined | null>( 138 profile.avatar, 139 ) 140 const [newUserBanner, setNewUserBanner] = useState< 141 ImageMeta | undefined | null 142 >() 143 const [newUserAvatar, setNewUserAvatar] = useState< 144 ImageMeta | undefined | null 145 >() 146 147 const dirty = 148 displayName !== initialDisplayName || 149 description !== initialDescription || 150 userAvatar !== profile.avatar || 151 userBanner !== profile.banner || 152 pronouns !== initialPronouns || 153 website !== initialWebsite 154 155 const enableSquareAvatars = useEnableSquareAvatars() 156 157 useEffect(() => { 158 setDirty(dirty) 159 }, [dirty, setDirty]) 160 161 const onSelectNewAvatar = useCallback( 162 (img: ImageMeta | null) => { 163 setImageError('') 164 if (img === null) { 165 setNewUserAvatar(null) 166 setUserAvatar(null) 167 return 168 } 169 try { 170 setNewUserAvatar(img) 171 setUserAvatar(img.path) 172 } catch (e: any) { 173 setImageError(cleanError(e)) 174 } 175 }, 176 [setNewUserAvatar, setUserAvatar, setImageError], 177 ) 178 179 const onSelectNewBanner = useCallback( 180 (img: ImageMeta | null) => { 181 setImageError('') 182 if (!img) { 183 setNewUserBanner(null) 184 setUserBanner(null) 185 return 186 } 187 try { 188 setNewUserBanner(img) 189 setUserBanner(img.path) 190 } catch (e: any) { 191 setImageError(cleanError(e)) 192 } 193 }, 194 [setNewUserBanner, setUserBanner, setImageError], 195 ) 196 197 const onClearWebsite = useCallback(() => { 198 setWebsite('') 199 if (websiteInputRef.current) { 200 websiteInputRef.current.clear() 201 } 202 }, [setWebsite]) 203 204 const onPressSave = useCallback(async () => { 205 setImageError('') 206 try { 207 await updateProfileMutation({ 208 profile, 209 updates: { 210 displayName: displayName.trimEnd(), 211 description: description.trimEnd(), 212 pronouns: pronouns.trimEnd().toLowerCase(), 213 website: website.trimEnd(), 214 }, 215 newUserAvatar, 216 newUserBanner, 217 }) 218 control.close(() => onUpdate?.()) 219 Toast.show(_(msg({message: 'Profile updated', context: 'toast'}))) 220 } catch (e: any) { 221 logger.error('Failed to update user profile', {message: String(e)}) 222 } 223 }, [ 224 updateProfileMutation, 225 profile, 226 onUpdate, 227 control, 228 displayName, 229 description, 230 pronouns, 231 website, 232 newUserAvatar, 233 newUserBanner, 234 setImageError, 235 _, 236 ]) 237 238 const displayNameTooLong = isOverMaxGraphemeCount({ 239 text: displayName, 240 maxCount: MAX_DISPLAY_NAME, 241 }) 242 const pronounsTooLong = isOverMaxGraphemeCount({ 243 text: pronouns, 244 maxCount: PRONOUNS_MAX_GRAPHEMES, 245 }) 246 const websiteTooLong = isOverMaxGraphemeCount({ 247 text: website, 248 maxCount: WEBSITE_MAX_GRAPHEMES, 249 }) 250 const websiteInvalidFormat = !isValidWebsiteFormat(website) 251 const descriptionTooLong = isOverMaxGraphemeCount({ 252 text: description, 253 maxCount: MAX_DESCRIPTION, 254 }) 255 256 const cancelButton = useCallback( 257 () => ( 258 <Button 259 label={_(msg`Cancel`)} 260 onPress={onPressCancel} 261 size="small" 262 color="primary" 263 variant="ghost" 264 style={[enableSquareButtons ? a.rounded_sm : a.rounded_full]} 265 testID="editProfileCancelBtn"> 266 <ButtonText style={[a.text_md]}> 267 <Trans>Cancel</Trans> 268 </ButtonText> 269 </Button> 270 ), 271 [onPressCancel, _, enableSquareButtons], 272 ) 273 274 const saveButton = useCallback( 275 () => ( 276 <Button 277 label={_(msg`Save`)} 278 onPress={onPressSave} 279 disabled={ 280 !dirty || 281 isUpdatingProfile || 282 displayNameTooLong || 283 descriptionTooLong 284 } 285 size="small" 286 color="primary" 287 variant="ghost" 288 style={[enableSquareButtons ? a.rounded_sm : a.rounded_full]} 289 testID="editProfileSaveBtn"> 290 <ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}> 291 <Trans>Save</Trans> 292 </ButtonText> 293 {isUpdatingProfile && <ButtonIcon icon={Loader} />} 294 </Button> 295 ), 296 [ 297 _, 298 t, 299 dirty, 300 onPressSave, 301 isUpdatingProfile, 302 displayNameTooLong, 303 descriptionTooLong, 304 enableSquareButtons, 305 ], 306 ) 307 308 return ( 309 <Dialog.ScrollableInner 310 label={_(msg`Edit profile`)} 311 style={[a.overflow_hidden]} 312 contentContainerStyle={[a.px_0, a.pt_0]} 313 header={ 314 <Dialog.Header renderLeft={cancelButton} renderRight={saveButton}> 315 <Dialog.HeaderText> 316 <Trans>Edit profile</Trans> 317 </Dialog.HeaderText> 318 </Dialog.Header> 319 }> 320 <View style={[a.relative]}> 321 <UserBanner banner={userBanner} onSelectNewBanner={onSelectNewBanner} /> 322 <View 323 style={[ 324 a.absolute, 325 { 326 top: 80, 327 left: 20, 328 width: 84, 329 height: 84, 330 borderWidth: 2, 331 borderRadius: enableSquareAvatars ? 11 : 42, 332 borderColor: t.atoms.bg.backgroundColor, 333 }, 334 ]}> 335 <EditableUserAvatar 336 size={80} 337 avatar={userAvatar} 338 onSelectNewAvatar={onSelectNewAvatar} 339 /> 340 </View> 341 </View> 342 {isUpdateProfileError && ( 343 <View style={[a.mt_xl]}> 344 <ErrorMessage message={cleanError(updateProfileError)} /> 345 </View> 346 )} 347 {imageError !== '' && ( 348 <View style={[a.mt_xl]}> 349 <ErrorMessage message={imageError} /> 350 </View> 351 )} 352 <View style={[a.mt_4xl, a.px_xl, a.gap_xl]}> 353 <View> 354 <TextField.LabelText> 355 <Trans>Display name</Trans> 356 </TextField.LabelText> 357 <TextField.Root isInvalid={displayNameTooLong}> 358 <Dialog.Input 359 defaultValue={displayName} 360 onChangeText={setDisplayName} 361 label={_(msg`Display name`)} 362 placeholder={_(msg`e.g. Alice Lastname`)} 363 testID="editProfileDisplayNameInput" 364 /> 365 </TextField.Root> 366 {displayNameTooLong && ( 367 <Text 368 style={[ 369 a.text_sm, 370 a.mt_xs, 371 a.font_semi_bold, 372 {color: t.palette.negative_400}, 373 ]}> 374 <Plural 375 value={MAX_DISPLAY_NAME} 376 other="Display name is too long. The maximum number of characters is #." 377 /> 378 </Text> 379 )} 380 </View> 381 382 {verification.isVerified && 383 verification.role === 'default' && 384 displayName !== initialDisplayName && ( 385 <Admonition type="error"> 386 <Trans> 387 You are verified. You will lose your verification status if you 388 change your display name.{' '} 389 <InlineLinkText 390 label={_( 391 msg({ 392 message: `Learn more`, 393 context: `english-only-resource`, 394 }), 395 )} 396 to={urls.website.blog.initialVerificationAnnouncement}> 397 <Trans context="english-only-resource">Learn more.</Trans> 398 </InlineLinkText> 399 </Trans> 400 </Admonition> 401 )} 402 403 <View> 404 <TextField.LabelText> 405 <Trans>Description</Trans> 406 </TextField.LabelText> 407 <TextField.Root isInvalid={descriptionTooLong}> 408 <Dialog.Input 409 defaultValue={description} 410 onChangeText={setDescription} 411 multiline 412 label={_(msg`Description`)} 413 placeholder={_(msg`Tell us a bit about yourself`)} 414 testID="editProfileDescriptionInput" 415 /> 416 </TextField.Root> 417 {descriptionTooLong && ( 418 <Text 419 style={[ 420 a.text_sm, 421 a.mt_xs, 422 a.font_semi_bold, 423 {color: t.palette.negative_400}, 424 ]}> 425 <Plural 426 value={MAX_DESCRIPTION} 427 other="Description is too long. The maximum number of characters is #." 428 /> 429 </Text> 430 )} 431 </View> 432 433 <View> 434 <TextField.LabelText> 435 <Trans>Pronouns</Trans> 436 </TextField.LabelText> 437 <TextField.Root isInvalid={pronounsTooLong}> 438 <Dialog.Input 439 defaultValue={pronouns} 440 onChangeText={setPronouns} 441 label={_(msg`Pronouns`)} 442 placeholder={_(msg`e.g. she/her`)} 443 testID="editProfilePronounsInput" 444 /> 445 </TextField.Root> 446 {pronounsTooLong && ( 447 <Text 448 style={[ 449 a.text_sm, 450 a.mt_xs, 451 a.font_bold, 452 {color: t.palette.negative_400}, 453 ]}> 454 <Plural 455 value={PRONOUNS_MAX_GRAPHEMES} 456 other="The maximum number of characters is #." 457 /> 458 </Text> 459 )} 460 </View> 461 462 <View> 463 <TextField.LabelText> 464 <Trans>Website</Trans> 465 </TextField.LabelText> 466 <View style={[a.w_full, a.relative]}> 467 <TextField.Root isInvalid={websiteTooLong || websiteInvalidFormat}> 468 {website && <TextField.Icon icon={Globe} />} 469 <Dialog.Input 470 inputRef={websiteInputRef} 471 defaultValue={website} 472 onChangeText={setWebsite} 473 label={_(msg`EditWebsite`)} 474 placeholder={_(msg`URL`)} 475 testID="editProfileWebsiteInput" 476 autoCapitalize="none" 477 keyboardType="url" 478 style={[ 479 website 480 ? { 481 paddingRight: tokens.space._5xl, 482 } 483 : {}, 484 ]} 485 /> 486 </TextField.Root> 487 488 {website && ( 489 <View 490 style={[ 491 a.absolute, 492 a.z_10, 493 a.my_auto, 494 a.inset_0, 495 a.justify_center, 496 a.pr_sm, 497 {left: 'auto'}, 498 ]}> 499 <Pressable 500 testID="clearWebsiteBtn" 501 onPress={onClearWebsite} 502 accessibilityLabel={_(msg`Clear website`)} 503 accessibilityHint={_(msg`Removes the website URL`)} 504 hitSlop={HITSLOP_10} 505 style={[ 506 a.flex_row, 507 a.align_center, 508 a.justify_center, 509 { 510 width: tokens.space._2xl, 511 height: tokens.space._2xl, 512 }, 513 a.rounded_full, 514 ]}> 515 <CircleX 516 width={tokens.space.lg} 517 style={{color: t.palette.contrast_600}} 518 /> 519 </Pressable> 520 </View> 521 )} 522 </View> 523 {websiteTooLong && ( 524 <Text 525 style={[ 526 a.text_sm, 527 a.mt_xs, 528 a.font_bold, 529 {color: t.palette.negative_400}, 530 ]}> 531 <Plural 532 value={WEBSITE_MAX_GRAPHEMES} 533 other="Website is too long. The maximum number of characters is #." 534 /> 535 </Text> 536 )} 537 {websiteInvalidFormat && ( 538 <Text 539 style={[ 540 a.text_sm, 541 a.mt_xs, 542 a.font_bold, 543 {color: t.palette.negative_400}, 544 ]}> 545 <Trans> 546 Website must be a valid URI (e.g., https://bsky.app) 547 </Trans> 548 </Text> 549 )} 550 </View> 551 </View> 552 </Dialog.ScrollableInner> 553 ) 554}