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