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

Configure Feed

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

at 20bf2cd11723d81fe75cc74dc13abf085113dd4d 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 {forceLTR} from '#/lib/strings/bidi' 18import {NON_BREAKING_SPACE} from '#/lib/strings/constants' 19import {sanitizeDisplayName} from '#/lib/strings/display-names' 20import {sanitizeHandle} from '#/lib/strings/handles' 21import {useProfileShadow} from '#/state/cache/profile-shadow' 22import {useProfileFollowMutationQueue} from '#/state/queries/profile' 23import {useSession} from '#/state/session' 24import * as Toast from '#/view/com/util/Toast' 25import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' 26import { 27 atoms as a, 28 platform, 29 type TextStyleProp, 30 useTheme, 31 type ViewStyleProp, 32} from '#/alf' 33import { 34 Button, 35 ButtonIcon, 36 type ButtonProps, 37 ButtonText, 38} from '#/components/Button' 39import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 40import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 41import {Link as InternalLink, type LinkProps} from '#/components/Link' 42import * as Pills from '#/components/Pills' 43import {RichText} from '#/components/RichText' 44import {Text} from '#/components/Typography' 45import {useSimpleVerificationState} from '#/components/verification' 46import {VerificationCheck} from '#/components/verification/VerificationCheck' 47import {type Metrics} from '#/analytics' 48import {useActorStatus} from '#/features/liveNow' 49import type * as bsky from '#/types/bsky' 50 51export function Default({ 52 profile, 53 moderationOpts, 54 logContext = 'ProfileCard', 55 testID, 56 position, 57 contextProfileDid, 58 onPress, 59}: { 60 profile: bsky.profile.AnyProfileView 61 moderationOpts: ModerationOpts 62 logContext?: 'ProfileCard' | 'StarterPackProfilesList' 63 testID?: string 64 position?: number 65 contextProfileDid?: string 66 onPress?: (e: GestureResponderEvent) => void 67}) { 68 return ( 69 <Link testID={testID} profile={profile} onPress={onPress}> 70 <Card 71 profile={profile} 72 moderationOpts={moderationOpts} 73 logContext={logContext} 74 position={position} 75 contextProfileDid={contextProfileDid} 76 /> 77 </Link> 78 ) 79} 80 81export function Card({ 82 profile, 83 moderationOpts, 84 logContext = 'ProfileCard', 85 position, 86 contextProfileDid, 87}: { 88 profile: bsky.profile.AnyProfileView 89 moderationOpts: ModerationOpts 90 logContext?: 'ProfileCard' | 'StarterPackProfilesList' 91 position?: number 92 contextProfileDid?: string 93}) { 94 return ( 95 <Outer> 96 <Header> 97 <Avatar profile={profile} moderationOpts={moderationOpts} /> 98 <NameAndHandle profile={profile} moderationOpts={moderationOpts} /> 99 <FollowButton 100 profile={profile} 101 moderationOpts={moderationOpts} 102 logContext={logContext} 103 position={position} 104 contextProfileDid={contextProfileDid} 105 /> 106 </Header> 107 108 <Labels profile={profile} moderationOpts={moderationOpts} /> 109 110 <Description profile={profile} /> 111 </Outer> 112 ) 113} 114 115export function Outer({ 116 children, 117}: { 118 children: React.ReactNode | React.ReactNode[] 119}) { 120 return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View> 121} 122 123export function Header({ 124 children, 125}: { 126 children: React.ReactNode | React.ReactNode[] 127}) { 128 return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View> 129} 130 131export function Link({ 132 profile, 133 children, 134 style, 135 ...rest 136}: { 137 profile: bsky.profile.AnyProfileView 138} & Omit<LinkProps, 'to' | 'label'>) { 139 const {t: l} = useLingui() 140 141 return ( 142 <InternalLink 143 label={l`View ${ 144 profile.displayName || sanitizeHandle(profile.handle) 145 }鈥檚 profile`} 146 to={{ 147 screen: 'Profile', 148 params: {name: profile.did}, 149 }} 150 style={[a.flex_col, style]} 151 {...rest}> 152 {children} 153 </InternalLink> 154 ) 155} 156 157export function Avatar({ 158 profile, 159 moderationOpts, 160 onPress, 161 disabledPreview, 162 liveOverride, 163 size = 40, 164}: { 165 profile: bsky.profile.AnyProfileView 166 moderationOpts: ModerationOpts 167 onPress?: () => void 168 disabledPreview?: boolean 169 liveOverride?: boolean 170 size?: number 171}) { 172 const moderation = moderateProfile(profile, moderationOpts) 173 174 const {isActive: live} = useActorStatus(profile) 175 176 return disabledPreview ? ( 177 <UserAvatar 178 size={size} 179 avatar={profile.avatar} 180 type={profile.associated?.labeler ? 'labeler' : 'user'} 181 moderation={moderation.ui('avatar')} 182 live={liveOverride ?? live} 183 /> 184 ) : ( 185 <PreviewableUserAvatar 186 size={size} 187 profile={profile} 188 moderation={moderation.ui('avatar')} 189 onBeforePress={onPress} 190 live={liveOverride ?? live} 191 /> 192 ) 193} 194 195export function AvatarPlaceholder({size = 40}: {size?: number}) { 196 const t = useTheme() 197 return ( 198 <View 199 style={[ 200 a.rounded_full, 201 t.atoms.bg_contrast_25, 202 { 203 width: size, 204 height: size, 205 }, 206 ]} 207 /> 208 ) 209} 210 211export function NameAndHandle({ 212 profile, 213 moderationOpts, 214 inline = false, 215}: { 216 profile: bsky.profile.AnyProfileView 217 moderationOpts: ModerationOpts 218 inline?: boolean 219}) { 220 if (inline) { 221 return ( 222 <InlineNameAndHandle profile={profile} moderationOpts={moderationOpts} /> 223 ) 224 } else { 225 return ( 226 <View style={[a.flex_1]}> 227 <Name profile={profile} moderationOpts={moderationOpts} /> 228 <Handle profile={profile} /> 229 </View> 230 ) 231 } 232} 233 234function InlineNameAndHandle({ 235 profile, 236 moderationOpts, 237}: { 238 profile: bsky.profile.AnyProfileView 239 moderationOpts: ModerationOpts 240}) { 241 const t = useTheme() 242 const verification = useSimpleVerificationState({profile}) 243 const moderation = moderateProfile(profile, moderationOpts) 244 const name = sanitizeDisplayName( 245 profile.displayName || sanitizeHandle(profile.handle), 246 moderation.ui('displayName'), 247 ) 248 const handle = sanitizeHandle(profile.handle, '@') 249 return ( 250 <View style={[a.flex_row, a.align_end, a.flex_shrink]}> 251 <Text 252 emoji 253 style={[ 254 a.font_semi_bold, 255 a.leading_tight, 256 a.flex_shrink_0, 257 {maxWidth: '70%'}, 258 ]} 259 numberOfLines={1}> 260 {forceLTR(name)} 261 </Text> 262 {verification.showBadge && ( 263 <View 264 style={[ 265 a.pl_2xs, 266 a.self_center, 267 {marginTop: platform({default: 0, android: -1})}, 268 ]}> 269 <VerificationCheck 270 width={platform({android: 13, default: 12})} 271 verifier={verification.role === 'verifier'} 272 /> 273 </View> 274 )} 275 <Text 276 emoji 277 style={[ 278 a.leading_tight, 279 t.atoms.text_contrast_medium, 280 {flexShrink: 10}, 281 ]} 282 numberOfLines={1}> 283 {NON_BREAKING_SPACE + handle} 284 </Text> 285 </View> 286 ) 287} 288 289export function Name({ 290 profile, 291 moderationOpts, 292 style, 293 textStyle, 294}: { 295 profile: bsky.profile.AnyProfileView 296 moderationOpts: ModerationOpts 297 style?: StyleProp<ViewStyle> 298 textStyle?: StyleProp<TextStyle> 299}) { 300 const moderation = moderateProfile(profile, moderationOpts) 301 const name = sanitizeDisplayName( 302 profile.displayName || sanitizeHandle(profile.handle), 303 moderation.ui('displayName'), 304 ) 305 const verification = useSimpleVerificationState({profile}) 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 {verification.showBadge && ( 322 <View style={[a.pl_xs]}> 323 <VerificationCheck 324 width={14} 325 verifier={verification.role === 'verifier'} 326 /> 327 </View> 328 )} 329 </View> 330 ) 331} 332 333export function Handle({ 334 profile, 335 textStyle, 336}: { 337 profile: bsky.profile.AnyProfileView 338 textStyle?: StyleProp<TextStyle> 339}) { 340 const t = useTheme() 341 const handle = sanitizeHandle(profile.handle, '@') 342 343 return ( 344 <Text 345 emoji 346 style={[a.leading_snug, t.atoms.text_contrast_medium, textStyle]} 347 numberOfLines={1}> 348 {handle} 349 </Text> 350 ) 351} 352 353export function NameAndHandlePlaceholder() { 354 const t = useTheme() 355 356 return ( 357 <View style={[a.flex_1, a.gap_xs]}> 358 <View 359 style={[ 360 a.rounded_xs, 361 t.atoms.bg_contrast_25, 362 { 363 width: '60%', 364 height: 14, 365 }, 366 ]} 367 /> 368 369 <View 370 style={[ 371 a.rounded_xs, 372 t.atoms.bg_contrast_25, 373 { 374 width: '40%', 375 height: 10, 376 }, 377 ]} 378 /> 379 </View> 380 ) 381} 382 383export function NamePlaceholder({style}: ViewStyleProp) { 384 const t = useTheme() 385 386 return ( 387 <View 388 style={[ 389 a.rounded_xs, 390 t.atoms.bg_contrast_25, 391 { 392 width: '60%', 393 height: 14, 394 }, 395 style, 396 ]} 397 /> 398 ) 399} 400 401export function Description({ 402 profile: profileUnshadowed, 403 numberOfLines = 3, 404 style, 405}: { 406 profile: bsky.profile.AnyProfileView 407 numberOfLines?: number 408} & TextStyleProp) { 409 const profile = useProfileShadow(profileUnshadowed) 410 const rt = useMemo(() => { 411 if (!('description' in profile)) return 412 const rt = new RichTextApi({text: profile.description || ''}) 413 rt.detectFacetsWithoutResolution() 414 return rt 415 }, [profile]) 416 if (!rt) return null 417 if ( 418 profile.viewer && 419 (profile.viewer.blockedBy || 420 profile.viewer.blocking || 421 profile.viewer.blockingByList) 422 ) 423 return null 424 return ( 425 <View style={[a.pt_xs]}> 426 <RichText 427 value={rt} 428 style={style} 429 numberOfLines={numberOfLines} 430 disableLinks 431 /> 432 </View> 433 ) 434} 435 436export function DescriptionPlaceholder({ 437 numberOfLines = 3, 438}: { 439 numberOfLines?: number 440}) { 441 const t = useTheme() 442 return ( 443 <View style={[a.pt_2xs, {gap: 6}]}> 444 {Array(numberOfLines) 445 .fill(0) 446 .map((_, i) => ( 447 <View 448 key={i} 449 style={[ 450 a.rounded_xs, 451 a.w_full, 452 t.atoms.bg_contrast_25, 453 {height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'}, 454 ]} 455 /> 456 ))} 457 </View> 458 ) 459} 460 461export type FollowButtonProps = { 462 profile: bsky.profile.AnyProfileView 463 moderationOpts: ModerationOpts 464 logContext: Metrics['profile:follow']['logContext'] & 465 Metrics['profile:unfollow']['logContext'] 466 colorInverted?: boolean 467 onFollow?: () => void 468 withIcon?: boolean 469 position?: number 470 contextProfileDid?: string 471} & Partial<ButtonProps> 472 473export function FollowButton(props: FollowButtonProps) { 474 const {currentAccount, hasSession} = useSession() 475 const isMe = props.profile.did === currentAccount?.did 476 return hasSession && !isMe ? <FollowButtonInner {...props} /> : null 477} 478 479export function FollowButtonInner({ 480 profile: profileUnshadowed, 481 moderationOpts, 482 logContext, 483 onPress: onPressProp, 484 onFollow, 485 colorInverted, 486 withIcon = true, 487 position, 488 contextProfileDid, 489 ...rest 490}: FollowButtonProps) { 491 const {t: l} = useLingui() 492 const profile = useProfileShadow(profileUnshadowed) 493 const moderation = moderateProfile(profile, moderationOpts) 494 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 495 profile, 496 logContext, 497 position, 498 contextProfileDid, 499 ) 500 const isRound = Boolean(rest.shape && rest.shape === 'round') 501 502 const onPressFollow = async (e: GestureResponderEvent) => { 503 e.preventDefault() 504 e.stopPropagation() 505 try { 506 await queueFollow() 507 Toast.show( 508 l`Following ${sanitizeDisplayName( 509 profile.displayName || profile.handle, 510 moderation.ui('displayName'), 511 )}`, 512 ) 513 onPressProp?.(e) 514 onFollow?.() 515 } catch (e) { 516 const err = e as Error 517 if (err?.name !== 'AbortError') { 518 Toast.show(l`An issue occurred, please try again.`, 'xmark') 519 } 520 } 521 } 522 523 const onPressUnfollow = async (e: GestureResponderEvent) => { 524 e.preventDefault() 525 e.stopPropagation() 526 try { 527 await queueUnfollow() 528 Toast.show( 529 l`No longer following ${sanitizeDisplayName( 530 profile.displayName || profile.handle, 531 moderation.ui('displayName'), 532 )}`, 533 ) 534 onPressProp?.(e) 535 } catch (e) { 536 const err = e as Error 537 if (err?.name !== 'AbortError') { 538 Toast.show(l`An issue occurred, please try again.`, 'xmark') 539 } 540 } 541 } 542 543 const unfollowLabel = 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 631 if (!followedBy && !modui.inform && !modui.alert) { 632 return null 633 } 634 635 return ( 636 <Pills.Row style={[a.pt_xs]}> 637 {followedBy && <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}