Bluesky app fork with some witchin' additions 馃挮
at main 668 lines 16 kB view raw
1import {useMemo} from 'react' 2import { 3 type GestureResponderEvent, 4 type StyleProp, 5 type TextStyle, 6 View, 7 type ViewStyle, 8} from 'react-native' 9import { 10 moderateProfile, 11 type ModerationOpts, 12 RichText as RichTextApi, 13} from '@atproto/api' 14import {msg} from '@lingui/core/macro' 15import {useLingui} from '@lingui/react' 16 17import {getModerationCauseKey} from '#/lib/moderation' 18import {forceLTR} from '#/lib/strings/bidi' 19import {NON_BREAKING_SPACE} from '#/lib/strings/constants' 20import {sanitizeDisplayName} from '#/lib/strings/display-names' 21import {sanitizeHandle} from '#/lib/strings/handles' 22import {useProfileShadow} from '#/state/cache/profile-shadow' 23import {useProfileFollowMutationQueue} from '#/state/queries/profile' 24import {useSession} from '#/state/session' 25import * as Toast from '#/view/com/util/Toast' 26import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' 27import { 28 atoms as a, 29 platform, 30 type TextStyleProp, 31 useTheme, 32 type ViewStyleProp, 33} from '#/alf' 34import { 35 Button, 36 ButtonIcon, 37 type ButtonProps, 38 ButtonText, 39} from '#/components/Button' 40import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 41import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 42import {Link as InternalLink, type LinkProps} from '#/components/Link' 43import {PdsBadge} from '#/components/PdsBadge' 44import * as Pills from '#/components/Pills' 45import {RichText} from '#/components/RichText' 46import {Text} from '#/components/Typography' 47import {useSimpleVerificationState} from '#/components/verification' 48import {VerificationCheck} from '#/components/verification/VerificationCheck' 49import {type Metrics} from '#/analytics' 50import {useActorStatus} from '#/features/liveNow' 51import type * as bsky from '#/types/bsky' 52 53export function Default({ 54 profile, 55 moderationOpts, 56 logContext = 'ProfileCard', 57 testID, 58 position, 59 contextProfileDid, 60}: { 61 profile: bsky.profile.AnyProfileView 62 moderationOpts: ModerationOpts 63 logContext?: 'ProfileCard' | 'StarterPackProfilesList' 64 testID?: string 65 position?: number 66 contextProfileDid?: string 67}) { 68 return ( 69 <Link testID={testID} profile={profile}> 70 <Card 71 profile={profile} 72 moderationOpts={moderationOpts} 73 logContext={logContext} 74 position={position} 75 contextProfileDid={contextProfileDid} 76 /> 77 </Link> 78 ) 79} 80 81export function Card({ 82 profile, 83 moderationOpts, 84 logContext = 'ProfileCard', 85 position, 86 contextProfileDid, 87}: { 88 profile: bsky.profile.AnyProfileView 89 moderationOpts: ModerationOpts 90 logContext?: 'ProfileCard' | 'StarterPackProfilesList' 91 position?: number 92 contextProfileDid?: string 93}) { 94 return ( 95 <Outer> 96 <Header> 97 <Avatar profile={profile} moderationOpts={moderationOpts} /> 98 <NameAndHandle profile={profile} moderationOpts={moderationOpts} /> 99 <FollowButton 100 profile={profile} 101 moderationOpts={moderationOpts} 102 logContext={logContext} 103 position={position} 104 contextProfileDid={contextProfileDid} 105 /> 106 </Header> 107 108 <Labels profile={profile} moderationOpts={moderationOpts} /> 109 110 <Description profile={profile} /> 111 </Outer> 112 ) 113} 114 115export function Outer({ 116 children, 117}: { 118 children: React.ReactNode | React.ReactNode[] 119}) { 120 return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View> 121} 122 123export function Header({ 124 children, 125}: { 126 children: React.ReactNode | React.ReactNode[] 127}) { 128 return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View> 129} 130 131export function Link({ 132 profile, 133 children, 134 style, 135 ...rest 136}: { 137 profile: bsky.profile.AnyProfileView 138} & Omit<LinkProps, 'to' | 'label'>) { 139 const {_} = useLingui() 140 return ( 141 <InternalLink 142 label={_( 143 msg`View ${ 144 profile.displayName || sanitizeHandle(profile.handle) 145 }'s profile`, 146 )} 147 to={{ 148 screen: 'Profile', 149 params: {name: profile.did}, 150 }} 151 style={[a.flex_col, style]} 152 {...rest}> 153 {children} 154 </InternalLink> 155 ) 156} 157 158export function Avatar({ 159 profile, 160 moderationOpts, 161 onPress, 162 disabledPreview, 163 liveOverride, 164 size = 40, 165}: { 166 profile: bsky.profile.AnyProfileView 167 moderationOpts: ModerationOpts 168 onPress?: () => void 169 disabledPreview?: boolean 170 liveOverride?: boolean 171 size?: number 172}) { 173 const moderation = moderateProfile(profile, moderationOpts) 174 175 const {isActive: live} = useActorStatus(profile) 176 177 return disabledPreview ? ( 178 <UserAvatar 179 size={size} 180 avatar={profile.avatar} 181 type={profile.associated?.labeler ? 'labeler' : 'user'} 182 moderation={moderation.ui('avatar')} 183 live={liveOverride ?? live} 184 /> 185 ) : ( 186 <PreviewableUserAvatar 187 size={size} 188 profile={profile} 189 moderation={moderation.ui('avatar')} 190 onBeforePress={onPress} 191 live={liveOverride ?? live} 192 /> 193 ) 194} 195 196export function AvatarPlaceholder({size = 40}: {size?: number}) { 197 const t = useTheme() 198 return ( 199 <View 200 style={[ 201 a.rounded_full, 202 t.atoms.bg_contrast_25, 203 { 204 width: size, 205 height: size, 206 }, 207 ]} 208 /> 209 ) 210} 211 212export function NameAndHandle({ 213 profile, 214 moderationOpts, 215 inline = false, 216}: { 217 profile: bsky.profile.AnyProfileView 218 moderationOpts: ModerationOpts 219 inline?: boolean 220}) { 221 if (inline) { 222 return ( 223 <InlineNameAndHandle profile={profile} moderationOpts={moderationOpts} /> 224 ) 225 } else { 226 return ( 227 <View style={[a.flex_1]}> 228 <Name profile={profile} moderationOpts={moderationOpts} /> 229 <Handle profile={profile} /> 230 </View> 231 ) 232 } 233} 234 235function InlineNameAndHandle({ 236 profile, 237 moderationOpts, 238}: { 239 profile: bsky.profile.AnyProfileView 240 moderationOpts: ModerationOpts 241}) { 242 const t = useTheme() 243 const verification = useSimpleVerificationState({profile}) 244 const moderation = moderateProfile(profile, moderationOpts) 245 const name = sanitizeDisplayName( 246 profile.displayName || sanitizeHandle(profile.handle), 247 moderation.ui('displayName'), 248 ) 249 const handle = sanitizeHandle(profile.handle, '@') 250 return ( 251 <View style={[a.flex_row, a.align_end, a.flex_shrink]}> 252 <Text 253 emoji 254 style={[ 255 a.font_semi_bold, 256 a.leading_tight, 257 a.flex_shrink_0, 258 {maxWidth: '70%'}, 259 ]} 260 numberOfLines={1}> 261 {forceLTR(name)} 262 </Text> 263 <View 264 style={[ 265 a.pl_2xs, 266 a.self_center, 267 {marginTop: platform({default: 0, android: -1})}, 268 ]}> 269 <PdsBadge did={profile.did} size="sm" /> 270 </View> 271 {verification.showBadge && ( 272 <View 273 style={[ 274 a.pl_2xs, 275 a.self_center, 276 {marginTop: platform({default: 0, android: -1})}, 277 ]}> 278 <VerificationCheck 279 width={platform({android: 13, default: 12})} 280 verifier={verification.role === 'verifier'} 281 /> 282 </View> 283 )} 284 <Text 285 emoji 286 style={[ 287 a.leading_tight, 288 t.atoms.text_contrast_medium, 289 {flexShrink: 10}, 290 ]} 291 numberOfLines={1}> 292 {NON_BREAKING_SPACE + handle} 293 </Text> 294 </View> 295 ) 296} 297 298export function Name({ 299 profile, 300 moderationOpts, 301 style, 302 textStyle, 303}: { 304 profile: bsky.profile.AnyProfileView 305 moderationOpts: ModerationOpts 306 style?: StyleProp<ViewStyle> 307 textStyle?: StyleProp<TextStyle> 308}) { 309 const moderation = moderateProfile(profile, moderationOpts) 310 const name = sanitizeDisplayName( 311 profile.displayName || sanitizeHandle(profile.handle), 312 moderation.ui('displayName'), 313 ) 314 const verification = useSimpleVerificationState({profile}) 315 return ( 316 <View style={[a.flex_row, a.align_center, a.max_w_full, style]}> 317 <Text 318 emoji 319 style={[ 320 a.text_md, 321 a.font_semi_bold, 322 a.leading_snug, 323 a.self_start, 324 a.flex_shrink, 325 textStyle, 326 ]} 327 numberOfLines={1}> 328 {name} 329 </Text> 330 <View style={[a.pl_xs]}> 331 <PdsBadge did={profile.did} size="sm" /> 332 </View> 333 {verification.showBadge && ( 334 <View style={[a.pl_xs]}> 335 <VerificationCheck 336 width={14} 337 verifier={verification.role === 'verifier'} 338 /> 339 </View> 340 )} 341 </View> 342 ) 343} 344 345export function Handle({ 346 profile, 347 textStyle, 348}: { 349 profile: bsky.profile.AnyProfileView 350 textStyle?: StyleProp<TextStyle> 351}) { 352 const t = useTheme() 353 const handle = sanitizeHandle(profile.handle, '@') 354 355 return ( 356 <Text 357 emoji 358 style={[a.leading_snug, t.atoms.text_contrast_medium, textStyle]} 359 numberOfLines={1}> 360 {handle} 361 </Text> 362 ) 363} 364 365export function NameAndHandlePlaceholder() { 366 const t = useTheme() 367 368 return ( 369 <View style={[a.flex_1, a.gap_xs]}> 370 <View 371 style={[ 372 a.rounded_xs, 373 t.atoms.bg_contrast_25, 374 { 375 width: '60%', 376 height: 14, 377 }, 378 ]} 379 /> 380 381 <View 382 style={[ 383 a.rounded_xs, 384 t.atoms.bg_contrast_25, 385 { 386 width: '40%', 387 height: 10, 388 }, 389 ]} 390 /> 391 </View> 392 ) 393} 394 395export function NamePlaceholder({style}: ViewStyleProp) { 396 const t = useTheme() 397 398 return ( 399 <View 400 style={[ 401 a.rounded_xs, 402 t.atoms.bg_contrast_25, 403 { 404 width: '60%', 405 height: 14, 406 }, 407 style, 408 ]} 409 /> 410 ) 411} 412 413export function Description({ 414 profile: profileUnshadowed, 415 numberOfLines = 3, 416 style, 417}: { 418 profile: bsky.profile.AnyProfileView 419 numberOfLines?: number 420} & TextStyleProp) { 421 const profile = useProfileShadow(profileUnshadowed) 422 const rt = useMemo(() => { 423 if (!('description' in profile)) return 424 const rt = new RichTextApi({text: profile.description || ''}) 425 rt.detectFacetsWithoutResolution() 426 return rt 427 }, [profile]) 428 if (!rt) return null 429 if ( 430 profile.viewer && 431 (profile.viewer.blockedBy || 432 profile.viewer.blocking || 433 profile.viewer.blockingByList) 434 ) 435 return null 436 return ( 437 <View style={[a.pt_xs]}> 438 <RichText 439 value={rt} 440 style={style} 441 numberOfLines={numberOfLines} 442 disableLinks 443 /> 444 </View> 445 ) 446} 447 448export function DescriptionPlaceholder({ 449 numberOfLines = 3, 450}: { 451 numberOfLines?: number 452}) { 453 const t = useTheme() 454 return ( 455 <View style={[a.pt_2xs, {gap: 6}]}> 456 {Array(numberOfLines) 457 .fill(0) 458 .map((_, i) => ( 459 <View 460 key={i} 461 style={[ 462 a.rounded_xs, 463 a.w_full, 464 t.atoms.bg_contrast_25, 465 {height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'}, 466 ]} 467 /> 468 ))} 469 </View> 470 ) 471} 472 473export type FollowButtonProps = { 474 profile: bsky.profile.AnyProfileView 475 moderationOpts: ModerationOpts 476 logContext: Metrics['profile:follow']['logContext'] & 477 Metrics['profile:unfollow']['logContext'] 478 colorInverted?: boolean 479 onFollow?: () => void 480 withIcon?: boolean 481 position?: number 482 contextProfileDid?: string 483} & Partial<ButtonProps> 484 485export function FollowButton(props: FollowButtonProps) { 486 const {currentAccount, hasSession} = useSession() 487 const isMe = props.profile.did === currentAccount?.did 488 return hasSession && !isMe ? <FollowButtonInner {...props} /> : null 489} 490 491export function FollowButtonInner({ 492 profile: profileUnshadowed, 493 moderationOpts, 494 logContext, 495 onPress: onPressProp, 496 onFollow, 497 colorInverted, 498 withIcon = true, 499 position, 500 contextProfileDid, 501 ...rest 502}: FollowButtonProps) { 503 const {_} = useLingui() 504 const profile = useProfileShadow(profileUnshadowed) 505 const moderation = moderateProfile(profile, moderationOpts) 506 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 507 profile, 508 logContext, 509 position, 510 contextProfileDid, 511 ) 512 const isRound = Boolean(rest.shape && rest.shape === 'round') 513 514 const onPressFollow = async (e: GestureResponderEvent) => { 515 e.preventDefault() 516 e.stopPropagation() 517 try { 518 await queueFollow() 519 Toast.show( 520 _( 521 msg`Following ${sanitizeDisplayName( 522 profile.displayName || profile.handle, 523 moderation.ui('displayName'), 524 )}`, 525 ), 526 ) 527 onPressProp?.(e) 528 onFollow?.() 529 } catch (err: any) { 530 if (err?.name !== 'AbortError') { 531 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') 532 } 533 } 534 } 535 536 const onPressUnfollow = async (e: GestureResponderEvent) => { 537 e.preventDefault() 538 e.stopPropagation() 539 try { 540 await queueUnfollow() 541 Toast.show( 542 _( 543 msg`No longer following ${sanitizeDisplayName( 544 profile.displayName || profile.handle, 545 moderation.ui('displayName'), 546 )}`, 547 ), 548 ) 549 onPressProp?.(e) 550 } catch (err: any) { 551 if (err?.name !== 'AbortError') { 552 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') 553 } 554 } 555 } 556 557 const unfollowLabel = profile.viewer?.followedBy 558 ? _( 559 msg({ 560 message: 'Mutuals', 561 comment: 'User is following this account, click to unfollow', 562 }), 563 ) 564 : _( 565 msg({ 566 message: 'Following', 567 comment: 'User is following this account, click to unfollow', 568 }), 569 ) 570 const followLabel = profile.viewer?.followedBy 571 ? _( 572 msg({ 573 message: 'Follow back', 574 comment: 'User is not following this account, click to follow back', 575 }), 576 ) 577 : _( 578 msg({ 579 message: 'Follow', 580 comment: 'User is not following this account, click to follow', 581 }), 582 ) 583 584 if (!profile.viewer) return null 585 if ( 586 profile.viewer.blockedBy || 587 profile.viewer.blocking || 588 profile.viewer.blockingByList 589 ) 590 return null 591 592 return ( 593 <View> 594 {profile.viewer.following ? ( 595 <Button 596 label={unfollowLabel} 597 size="small" 598 variant="solid" 599 color="secondary" 600 {...rest} 601 onPress={onPressUnfollow}> 602 {withIcon && ( 603 <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} /> 604 )} 605 {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>} 606 </Button> 607 ) : ( 608 <Button 609 label={followLabel} 610 size="small" 611 variant="solid" 612 color={colorInverted ? 'secondary_inverted' : 'primary'} 613 {...rest} 614 onPress={onPressFollow}> 615 {withIcon && ( 616 <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} /> 617 )} 618 {isRound ? null : <ButtonText>{followLabel}</ButtonText>} 619 </Button> 620 )} 621 </View> 622 ) 623} 624 625export function FollowButtonPlaceholder({style}: ViewStyleProp) { 626 const t = useTheme() 627 628 return ( 629 <View 630 style={[ 631 a.rounded_sm, 632 t.atoms.bg_contrast_25, 633 a.w_full, 634 { 635 height: 33, 636 }, 637 style, 638 ]} 639 /> 640 ) 641} 642 643export function Labels({ 644 profile, 645 moderationOpts, 646}: { 647 profile: bsky.profile.AnyProfileView 648 moderationOpts: ModerationOpts 649}) { 650 const moderation = moderateProfile(profile, moderationOpts) 651 const modui = moderation.ui('profileList') 652 const followedBy = profile.viewer?.followedBy 653 654 if (!followedBy && !modui.inform && !modui.alert) { 655 return null 656 } 657 658 return ( 659 <Pills.Row style={[a.pt_xs]}> 660 {modui.alerts.map(alert => ( 661 <Pills.Label key={getModerationCauseKey(alert)} cause={alert} /> 662 ))} 663 {modui.informs.map(inform => ( 664 <Pills.Label key={getModerationCauseKey(inform)} cause={inform} /> 665 ))} 666 </Pills.Row> 667 ) 668}