this repo has no description
0
fork

Configure Feed

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

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