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({profile}: {profile: bsky.profile.AnyProfileView}) { 334 const t = useTheme() 335 const handle = sanitizeHandle(profile.handle, '@') 336 337 return ( 338 <Text 339 emoji 340 style={[a.leading_snug, t.atoms.text_contrast_medium]} 341 numberOfLines={1}> 342 {handle} 343 </Text> 344 ) 345} 346 347export function NameAndHandlePlaceholder() { 348 const t = useTheme() 349 350 return ( 351 <View style={[a.flex_1, a.gap_xs]}> 352 <View 353 style={[ 354 a.rounded_xs, 355 t.atoms.bg_contrast_25, 356 { 357 width: '60%', 358 height: 14, 359 }, 360 ]} 361 /> 362 363 <View 364 style={[ 365 a.rounded_xs, 366 t.atoms.bg_contrast_25, 367 { 368 width: '40%', 369 height: 10, 370 }, 371 ]} 372 /> 373 </View> 374 ) 375} 376 377export function NamePlaceholder({style}: ViewStyleProp) { 378 const t = useTheme() 379 380 return ( 381 <View 382 style={[ 383 a.rounded_xs, 384 t.atoms.bg_contrast_25, 385 { 386 width: '60%', 387 height: 14, 388 }, 389 style, 390 ]} 391 /> 392 ) 393} 394 395export function Description({ 396 profile: profileUnshadowed, 397 numberOfLines = 3, 398 style, 399}: { 400 profile: bsky.profile.AnyProfileView 401 numberOfLines?: number 402} & TextStyleProp) { 403 const profile = useProfileShadow(profileUnshadowed) 404 const rt = useMemo(() => { 405 if (!('description' in profile)) return 406 const rt = new RichTextApi({text: profile.description || ''}) 407 rt.detectFacetsWithoutResolution() 408 return rt 409 }, [profile]) 410 if (!rt) return null 411 if ( 412 profile.viewer && 413 (profile.viewer.blockedBy || 414 profile.viewer.blocking || 415 profile.viewer.blockingByList) 416 ) 417 return null 418 return ( 419 <View style={[a.pt_xs]}> 420 <RichText 421 value={rt} 422 style={style} 423 numberOfLines={numberOfLines} 424 disableLinks 425 /> 426 </View> 427 ) 428} 429 430export function DescriptionPlaceholder({ 431 numberOfLines = 3, 432}: { 433 numberOfLines?: number 434}) { 435 const t = useTheme() 436 return ( 437 <View style={[a.pt_2xs, {gap: 6}]}> 438 {Array(numberOfLines) 439 .fill(0) 440 .map((_, i) => ( 441 <View 442 key={i} 443 style={[ 444 a.rounded_xs, 445 a.w_full, 446 t.atoms.bg_contrast_25, 447 {height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'}, 448 ]} 449 /> 450 ))} 451 </View> 452 ) 453} 454 455export type FollowButtonProps = { 456 profile: bsky.profile.AnyProfileView 457 moderationOpts: ModerationOpts 458 logContext: LogEvents['profile:follow']['logContext'] & 459 LogEvents['profile:unfollow']['logContext'] 460 colorInverted?: boolean 461 onFollow?: () => void 462 withIcon?: boolean 463 position?: number 464 contextProfileDid?: string 465} & Partial<ButtonProps> 466 467export function FollowButton(props: FollowButtonProps) { 468 const {currentAccount, hasSession} = useSession() 469 const isMe = props.profile.did === currentAccount?.did 470 return hasSession && !isMe ? <FollowButtonInner {...props} /> : null 471} 472 473export function FollowButtonInner({ 474 profile: profileUnshadowed, 475 moderationOpts, 476 logContext, 477 onPress: onPressProp, 478 onFollow, 479 colorInverted, 480 withIcon = true, 481 position, 482 contextProfileDid, 483 ...rest 484}: FollowButtonProps) { 485 const {_} = useLingui() 486 const profile = useProfileShadow(profileUnshadowed) 487 const moderation = moderateProfile(profile, moderationOpts) 488 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 489 profile, 490 logContext, 491 position, 492 contextProfileDid, 493 ) 494 const isRound = Boolean(rest.shape && rest.shape === 'round') 495 496 const onPressFollow = async (e: GestureResponderEvent) => { 497 e.preventDefault() 498 e.stopPropagation() 499 try { 500 await queueFollow() 501 Toast.show( 502 _( 503 msg`Following ${sanitizeDisplayName( 504 profile.displayName || profile.handle, 505 moderation.ui('displayName'), 506 )}`, 507 ), 508 ) 509 onPressProp?.(e) 510 onFollow?.() 511 } catch (err: any) { 512 if (err?.name !== 'AbortError') { 513 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') 514 } 515 } 516 } 517 518 const onPressUnfollow = async (e: GestureResponderEvent) => { 519 e.preventDefault() 520 e.stopPropagation() 521 try { 522 await queueUnfollow() 523 Toast.show( 524 _( 525 msg`No longer following ${sanitizeDisplayName( 526 profile.displayName || profile.handle, 527 moderation.ui('displayName'), 528 )}`, 529 ), 530 ) 531 onPressProp?.(e) 532 } catch (err: any) { 533 if (err?.name !== 'AbortError') { 534 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') 535 } 536 } 537 } 538 539 const unfollowLabel = profile.viewer?.followedBy 540 ? _( 541 msg({ 542 message: 'Mutuals', 543 comment: 'User is following this account, click to unfollow', 544 }), 545 ) 546 : _( 547 msg({ 548 message: 'Following', 549 comment: 'User is following this account, click to unfollow', 550 }), 551 ) 552 const followLabel = profile.viewer?.followedBy 553 ? _( 554 msg({ 555 message: 'Follow back', 556 comment: 'User is not following this account, click to follow back', 557 }), 558 ) 559 : _( 560 msg({ 561 message: 'Follow', 562 comment: 'User is not following this account, click to follow', 563 }), 564 ) 565 566 if (!profile.viewer) return null 567 if ( 568 profile.viewer.blockedBy || 569 profile.viewer.blocking || 570 profile.viewer.blockingByList 571 ) 572 return null 573 574 return ( 575 <View> 576 {profile.viewer.following ? ( 577 <Button 578 label={unfollowLabel} 579 size="small" 580 variant="solid" 581 color="secondary" 582 {...rest} 583 onPress={onPressUnfollow}> 584 {withIcon && ( 585 <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} /> 586 )} 587 {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>} 588 </Button> 589 ) : ( 590 <Button 591 label={followLabel} 592 size="small" 593 variant="solid" 594 color={colorInverted ? 'secondary_inverted' : 'primary'} 595 {...rest} 596 onPress={onPressFollow}> 597 {withIcon && ( 598 <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} /> 599 )} 600 {isRound ? null : <ButtonText>{followLabel}</ButtonText>} 601 </Button> 602 )} 603 </View> 604 ) 605} 606 607export function FollowButtonPlaceholder({style}: ViewStyleProp) { 608 const t = useTheme() 609 610 return ( 611 <View 612 style={[ 613 a.rounded_sm, 614 t.atoms.bg_contrast_25, 615 a.w_full, 616 { 617 height: 33, 618 }, 619 style, 620 ]} 621 /> 622 ) 623} 624 625export function Labels({ 626 profile, 627 moderationOpts, 628}: { 629 profile: bsky.profile.AnyProfileView 630 moderationOpts: ModerationOpts 631}) { 632 const moderation = moderateProfile(profile, moderationOpts) 633 const modui = moderation.ui('profileList') 634 const followedBy = profile.viewer?.followedBy 635 636 if (!followedBy && !modui.inform && !modui.alert) { 637 return null 638 } 639 640 return ( 641 <Pills.Row style={[a.pt_xs]}> 642 {modui.alerts.map(alert => ( 643 <Pills.Label key={getModerationCauseKey(alert)} cause={alert} /> 644 ))} 645 {modui.informs.map(inform => ( 646 <Pills.Label key={getModerationCauseKey(inform)} cause={inform} /> 647 ))} 648 </Pills.Row> 649 ) 650}