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