Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at ece6dc251cdb7eaf260819a4005b3a3e3e74ac8b 649 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 {useProfileFollowMutationQueue} from '#/state/queries/profile' 24import {useSession} from '#/state/session' 25import * as Toast from '#/view/com/util/Toast' 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 * as Pills from '#/components/Pills' 44import {RichText} from '#/components/RichText' 45import {Text} from '#/components/Typography' 46import {useSimpleVerificationState} from '#/components/verification' 47import {VerificationCheck} from '#/components/verification/VerificationCheck' 48import {type Metrics} from '#/analytics' 49import {useActorStatus} from '#/features/liveNow' 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 onPress, 60}: { 61 profile: bsky.profile.AnyProfileView 62 moderationOpts: ModerationOpts 63 logContext?: 'ProfileCard' | 'StarterPackProfilesList' 64 testID?: string 65 position?: number 66 contextProfileDid?: string 67 onPress?: (e: GestureResponderEvent) => void 68}) { 69 return ( 70 <Link testID={testID} profile={profile} onPress={onPress}> 71 <Card 72 profile={profile} 73 moderationOpts={moderationOpts} 74 logContext={logContext} 75 position={position} 76 contextProfileDid={contextProfileDid} 77 /> 78 </Link> 79 ) 80} 81 82export function Card({ 83 profile, 84 moderationOpts, 85 logContext = 'ProfileCard', 86 position, 87 contextProfileDid, 88}: { 89 profile: bsky.profile.AnyProfileView 90 moderationOpts: ModerationOpts 91 logContext?: 'ProfileCard' | 'StarterPackProfilesList' 92 position?: number 93 contextProfileDid?: string 94}) { 95 return ( 96 <Outer> 97 <Header> 98 <Avatar profile={profile} moderationOpts={moderationOpts} /> 99 <NameAndHandle profile={profile} moderationOpts={moderationOpts} /> 100 <FollowButton 101 profile={profile} 102 moderationOpts={moderationOpts} 103 logContext={logContext} 104 position={position} 105 contextProfileDid={contextProfileDid} 106 /> 107 </Header> 108 109 <Labels profile={profile} moderationOpts={moderationOpts} /> 110 111 <Description profile={profile} /> 112 </Outer> 113 ) 114} 115 116export function Outer({ 117 children, 118}: { 119 children: React.ReactNode | React.ReactNode[] 120}) { 121 return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View> 122} 123 124export function Header({ 125 children, 126}: { 127 children: React.ReactNode | React.ReactNode[] 128}) { 129 return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View> 130} 131 132export function Link({ 133 profile, 134 children, 135 style, 136 ...rest 137}: { 138 profile: bsky.profile.AnyProfileView 139} & Omit<LinkProps, 'to' | 'label'>) { 140 const {t: l} = useLingui() 141 142 const profileURL = makeProfileLink({ 143 did: profile.did, 144 handle: profile.handle, 145 }) 146 147 return ( 148 <InternalLink 149 label={l`View ${ 150 profile.displayName || sanitizeHandle(profile.handle) 151 }鈥檚 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 verification = useSimpleVerificationState({profile}) 246 const moderation = moderateProfile(profile, moderationOpts) 247 const name = sanitizeDisplayName( 248 profile.displayName || sanitizeHandle(profile.handle), 249 moderation.ui('displayName'), 250 ) 251 const handle = sanitizeHandle(profile.handle, '@') 252 return ( 253 <View style={[a.flex_row, a.align_end, a.flex_shrink]}> 254 <Text 255 emoji 256 style={[ 257 a.font_semi_bold, 258 a.leading_tight, 259 a.flex_shrink_0, 260 {maxWidth: '70%'}, 261 ]} 262 numberOfLines={1}> 263 {forceLTR(name)} 264 </Text> 265 {verification.showBadge && ( 266 <View 267 style={[ 268 a.pl_2xs, 269 a.self_center, 270 {marginTop: platform({default: 0, android: -1})}, 271 ]}> 272 <VerificationCheck 273 width={platform({android: 13, default: 12})} 274 verifier={verification.role === 'verifier'} 275 /> 276 </View> 277 )} 278 <Text 279 emoji 280 style={[ 281 a.leading_tight, 282 t.atoms.text_contrast_medium, 283 {flexShrink: 10}, 284 ]} 285 numberOfLines={1}> 286 {NON_BREAKING_SPACE + handle} 287 </Text> 288 </View> 289 ) 290} 291 292export function Name({ 293 profile, 294 moderationOpts, 295 style, 296 textStyle, 297}: { 298 profile: bsky.profile.AnyProfileView 299 moderationOpts: ModerationOpts 300 style?: StyleProp<ViewStyle> 301 textStyle?: StyleProp<TextStyle> 302}) { 303 const moderation = moderateProfile(profile, moderationOpts) 304 const name = sanitizeDisplayName( 305 profile.displayName || sanitizeHandle(profile.handle), 306 moderation.ui('displayName'), 307 ) 308 const verification = useSimpleVerificationState({profile}) 309 return ( 310 <View style={[a.flex_row, a.align_center, a.max_w_full, style]}> 311 <Text 312 emoji 313 style={[ 314 a.text_md, 315 a.font_semi_bold, 316 a.leading_snug, 317 a.self_start, 318 a.flex_shrink, 319 textStyle, 320 ]} 321 numberOfLines={1}> 322 {name} 323 </Text> 324 {verification.showBadge && ( 325 <View style={[a.pl_xs]}> 326 <VerificationCheck 327 width={14} 328 verifier={verification.role === 'verifier'} 329 /> 330 </View> 331 )} 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.`, 'xmark') 522 } 523 } 524 } 525 526 const onPressUnfollow = async (e: GestureResponderEvent) => { 527 e.preventDefault() 528 e.stopPropagation() 529 try { 530 await queueUnfollow() 531 Toast.show( 532 l`No longer following ${sanitizeDisplayName( 533 profile.displayName || profile.handle, 534 moderation.ui('displayName'), 535 )}`, 536 ) 537 onPressProp?.(e) 538 } catch (e) { 539 const err = e as Error 540 if (err?.name !== 'AbortError') { 541 Toast.show(l`An issue occurred, please try again.`, 'xmark') 542 } 543 } 544 } 545 546 const unfollowLabel = l({ 547 message: 'Following', 548 comment: 'User is following this account, click to unfollow', 549 }) 550 const followLabel = profile.viewer?.followedBy 551 ? l({ 552 message: 'Follow back', 553 comment: 'User is not following this account, click to follow back', 554 }) 555 : l({ 556 message: 'Follow', 557 comment: 'User is not following this account, click to follow', 558 }) 559 560 if (!profile.viewer) return null 561 if ( 562 profile.viewer.blockedBy || 563 profile.viewer.blocking || 564 profile.viewer.blockingByList 565 ) 566 return null 567 568 return ( 569 <View> 570 {profile.viewer.following ? ( 571 <Button 572 label={unfollowLabel} 573 size="small" 574 variant="solid" 575 color="secondary" 576 {...rest} 577 onPress={(e: GestureResponderEvent) => { 578 void onPressUnfollow(e) 579 }}> 580 {withIcon && ( 581 <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} /> 582 )} 583 {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>} 584 </Button> 585 ) : ( 586 <Button 587 label={followLabel} 588 size="small" 589 variant="solid" 590 color={colorInverted ? 'secondary_inverted' : 'primary'} 591 {...rest} 592 onPress={(e: GestureResponderEvent) => { 593 void onPressFollow(e) 594 }}> 595 {withIcon && ( 596 <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} /> 597 )} 598 {isRound ? null : <ButtonText>{followLabel}</ButtonText>} 599 </Button> 600 )} 601 </View> 602 ) 603} 604 605export function FollowButtonPlaceholder({style}: ViewStyleProp) { 606 const t = useTheme() 607 608 return ( 609 <View 610 style={[ 611 a.rounded_sm, 612 t.atoms.bg_contrast_25, 613 a.w_full, 614 { 615 height: 33, 616 }, 617 style, 618 ]} 619 /> 620 ) 621} 622 623export function Labels({ 624 profile, 625 moderationOpts, 626}: { 627 profile: bsky.profile.AnyProfileView 628 moderationOpts: ModerationOpts 629}) { 630 const moderation = moderateProfile(profile, moderationOpts) 631 const modui = moderation.ui('profileList') 632 const followedBy = profile.viewer?.followedBy 633 634 if (!followedBy && !modui.inform && !modui.alert) { 635 return null 636 } 637 638 return ( 639 <Pills.Row style={[a.pt_xs]}> 640 {followedBy && <Pills.FollowsYou />} 641 {modui.alerts.map(alert => ( 642 <Pills.Label key={getModerationCauseKey(alert)} cause={alert} /> 643 ))} 644 {modui.informs.map(inform => ( 645 <Pills.Label key={getModerationCauseKey(inform)} cause={inform} /> 646 ))} 647 </Pills.Row> 648 ) 649}