Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 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}