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

Configure Feed

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

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