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

Configure Feed

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

at main 646 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 * as Pills from '#/components/Pills' 44import {ProfileBadges} from '#/components/ProfileBadges' 45import {RichText} from '#/components/RichText' 46import * as Toast from '#/components/Toast' 47import {Text} from '#/components/Typography' 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 testID={`profileCard-${profile.handle}-link`} 150 label={l`View ${ 151 profile.displayName || sanitizeHandle(profile.handle) 152 }鈥檚 profile`} 153 to={profileURL} 154 style={[a.flex_col, style]} 155 {...rest}> 156 {children} 157 </InternalLink> 158 ) 159} 160 161export function Avatar({ 162 profile, 163 moderationOpts, 164 onPress, 165 disabledPreview, 166 liveOverride, 167 size = 40, 168}: { 169 profile: bsky.profile.AnyProfileView 170 moderationOpts: ModerationOpts 171 onPress?: () => void 172 disabledPreview?: boolean 173 liveOverride?: boolean 174 size?: number 175}) { 176 const moderation = moderateProfile(profile, moderationOpts) 177 178 const {isActive: live} = useActorStatus(profile) 179 180 return disabledPreview ? ( 181 <UserAvatar 182 size={size} 183 avatar={profile.avatar} 184 type={profile.associated?.labeler ? 'labeler' : 'user'} 185 moderation={moderation.ui('avatar')} 186 live={liveOverride ?? live} 187 /> 188 ) : ( 189 <PreviewableUserAvatar 190 size={size} 191 profile={profile} 192 moderation={moderation.ui('avatar')} 193 onBeforePress={onPress} 194 live={liveOverride ?? live} 195 /> 196 ) 197} 198 199export function AvatarPlaceholder({size = 40}: {size?: number}) { 200 const t = useTheme() 201 return ( 202 <View 203 style={[ 204 a.rounded_full, 205 t.atoms.bg_contrast_25, 206 { 207 width: size, 208 height: size, 209 }, 210 ]} 211 /> 212 ) 213} 214 215export function NameAndHandle({ 216 profile, 217 moderationOpts, 218 inline = false, 219}: { 220 profile: bsky.profile.AnyProfileView 221 moderationOpts: ModerationOpts 222 inline?: boolean 223}) { 224 if (inline) { 225 return ( 226 <InlineNameAndHandle profile={profile} moderationOpts={moderationOpts} /> 227 ) 228 } else { 229 return ( 230 <View style={[a.flex_1]}> 231 <Name profile={profile} moderationOpts={moderationOpts} /> 232 <Handle profile={profile} /> 233 </View> 234 ) 235 } 236} 237 238function InlineNameAndHandle({ 239 profile, 240 moderationOpts, 241}: { 242 profile: bsky.profile.AnyProfileView 243 moderationOpts: ModerationOpts 244}) { 245 const t = useTheme() 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 <View 266 style={[ 267 a.pl_2xs, 268 a.self_center, 269 {marginTop: platform({default: 0, android: -1})}, 270 ]}> 271 <ProfileBadges profile={profile} size="sm" /> 272 </View> 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 = profile.viewer?.followedBy 538 ? l({ 539 message: 'Mutuals', 540 comment: 'User is following this account, click to unfollow', 541 }) 542 : l({ 543 message: 'Following', 544 comment: 'User is following this account, click to unfollow', 545 }) 546 const followLabel = profile.viewer?.followedBy 547 ? l({ 548 message: 'Follow back', 549 comment: 'User is not following this account, click to follow back', 550 }) 551 : l({ 552 message: 'Follow', 553 comment: 'User is not following this account, click to follow', 554 }) 555 556 if (!profile.viewer) return null 557 if ( 558 profile.viewer.blockedBy || 559 profile.viewer.blocking || 560 profile.viewer.blockingByList 561 ) 562 return null 563 564 return ( 565 <View> 566 {profile.viewer.following ? ( 567 <Button 568 label={unfollowLabel} 569 size="small" 570 variant="solid" 571 color="secondary" 572 {...rest} 573 onPress={(e: GestureResponderEvent) => { 574 void onPressUnfollow(e) 575 }}> 576 {withIcon && ( 577 <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} /> 578 )} 579 {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>} 580 </Button> 581 ) : ( 582 <Button 583 label={followLabel} 584 size="small" 585 variant="solid" 586 color={colorInverted ? 'secondary_inverted' : 'primary'} 587 {...rest} 588 onPress={(e: GestureResponderEvent) => { 589 void onPressFollow(e) 590 }}> 591 {withIcon && ( 592 <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} /> 593 )} 594 {isRound ? null : <ButtonText>{followLabel}</ButtonText>} 595 </Button> 596 )} 597 </View> 598 ) 599} 600 601export function FollowButtonPlaceholder({style}: ViewStyleProp) { 602 const t = useTheme() 603 604 return ( 605 <View 606 style={[ 607 a.rounded_sm, 608 t.atoms.bg_contrast_25, 609 a.w_full, 610 { 611 height: 33, 612 }, 613 style, 614 ]} 615 /> 616 ) 617} 618 619export function Labels({ 620 profile, 621 moderationOpts, 622}: { 623 profile: bsky.profile.AnyProfileView 624 moderationOpts: ModerationOpts 625}) { 626 const moderation = moderateProfile(profile, moderationOpts) 627 const modui = moderation.ui('profileList') 628 const followedBy = profile.viewer?.followedBy 629 const showFollowsYouBadge = useShowFollowsYouBadge() 630 631 if (!(followedBy && showFollowsYouBadge) && !modui.inform && !modui.alert) { 632 return null 633 } 634 635 return ( 636 <Pills.Row style={[a.pt_xs]}> 637 {followedBy && showFollowsYouBadge && <Pills.FollowsYou />} 638 {modui.alerts.map(alert => ( 639 <Pills.Label key={getModerationCauseKey(alert)} cause={alert} /> 640 ))} 641 {modui.informs.map(inform => ( 642 <Pills.Label key={getModerationCauseKey(inform)} cause={inform} /> 643 ))} 644 </Pills.Row> 645 ) 646}