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

Configure Feed

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

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