this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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