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