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