An ATproto social media client -- with an independent Appview.
7
fork

Configure Feed

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

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