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 641 lines 20 kB view raw
1import {memo, useEffect, useMemo, useReducer, useRef} from 'react' 2import {View} from 'react-native' 3import { 4 type AppBskyActorDefs, 5 moderateProfile, 6 type ModerationOpts, 7} from '@atproto/api' 8import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom' 9import {msg, plural} from '@lingui/core/macro' 10import {useLingui} from '@lingui/react' 11import {useNavigation} from '@react-navigation/native' 12 13import {getModerationCauseKey} from '#/lib/moderation' 14import {makeProfileLink} from '#/lib/routes/links' 15import {type NavigationProp} from '#/lib/routes/types' 16import {sanitizeDisplayName} from '#/lib/strings/display-names' 17import {sanitizeHandle} from '#/lib/strings/handles' 18import {useProfileShadow} from '#/state/cache/profile-shadow' 19import {useDisableFollowedByMetrics} from '#/state/preferences/disable-followed-by-metrics' 20import {useDisableFollowersMetrics} from '#/state/preferences/disable-followers-metrics' 21import {useDisableFollowingMetrics} from '#/state/preferences/disable-following-metrics' 22import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 23import {useModerationOpts} from '#/state/preferences/moderation-opts' 24import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile' 25import {useSession} from '#/state/session' 26import {formatCount} from '#/view/com/util/numeric/format' 27import {UserAvatar} from '#/view/com/util/UserAvatar' 28import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle' 29import {atoms as a, useTheme} from '#/alf' 30import {Button, ButtonIcon, ButtonText} from '#/components/Button' 31import {useFollowMethods} from '#/components/hooks/useFollowMethods' 32import {useRichText} from '#/components/hooks/useRichText' 33import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 34import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 35import { 36 KnownFollowers, 37 shouldShowKnownFollowers, 38} from '#/components/KnownFollowers' 39import {InlineLinkText, Link} from '#/components/Link' 40import {Loader} from '#/components/Loader' 41import * as Pills from '#/components/Pills' 42import {Portal} from '#/components/Portal' 43import {ProfileBadges} from '#/components/ProfileBadges' 44import {RichText} from '#/components/RichText' 45import {Text} from '#/components/Typography' 46import {IS_WEB_TOUCH_DEVICE} from '#/env' 47import {useActorStatus} from '#/features/liveNow' 48import {LiveStatus} from '#/features/liveNow/components/LiveStatusDialog' 49import {type ProfileHoverCardProps} from './types' 50 51const floatingMiddlewares = [ 52 offset(4), 53 flip({padding: 16}), 54 shift({padding: 16}), 55 size({ 56 padding: 16, 57 apply({availableWidth, availableHeight, elements}) { 58 Object.assign(elements.floating.style, { 59 maxWidth: `${availableWidth}px`, 60 maxHeight: `${availableHeight}px`, 61 }) 62 }, 63 }), 64] 65 66export function ProfileHoverCard(props: ProfileHoverCardProps) { 67 const prefetchProfileQuery = usePrefetchProfileQuery() 68 const prefetchedProfile = useRef(false) 69 const onPointerMove = () => { 70 if (!prefetchedProfile.current) { 71 prefetchedProfile.current = true 72 prefetchProfileQuery(props.did) 73 } 74 } 75 76 if (props.disable || IS_WEB_TOUCH_DEVICE) { 77 return props.children 78 } else { 79 return ( 80 <View 81 onPointerMove={onPointerMove} 82 style={[a.flex_shrink, props.inline && a.inline, props.style]}> 83 <ProfileHoverCardInner {...props} /> 84 </View> 85 ) 86 } 87} 88 89type State = 90 | { 91 stage: 'hidden' | 'might-hide' | 'hiding' 92 effect?: () => () => any 93 } 94 | { 95 stage: 'might-show' | 'showing' 96 effect?: () => () => any 97 reason: 'hovered-target' | 'hovered-card' 98 } 99 100type Action = 101 | 'pressed' 102 | 'scrolled-while-showing' 103 | 'hovered-target' 104 | 'unhovered-target' 105 | 'hovered-card' 106 | 'unhovered-card' 107 | 'hovered-long-enough' 108 | 'unhovered-long-enough' 109 | 'finished-animating-hide' 110 111const SHOW_DELAY = 500 112const SHOW_DURATION = 300 113const HIDE_DELAY = 150 114const HIDE_DURATION = 200 115 116export function ProfileHoverCardInner(props: ProfileHoverCardProps) { 117 const navigation = useNavigation<NavigationProp>() 118 119 const {refs, floatingStyles} = useFloating({ 120 middleware: floatingMiddlewares, 121 }) 122 123 const [currentState, dispatch] = useReducer( 124 // Tip: console.log(state, action) when debugging. 125 (state: State, action: Action): State => { 126 // Pressing within a card should always hide it. 127 // No matter which stage we're in. 128 if (action === 'pressed') { 129 return hidden() 130 } 131 132 // --- Hidden --- 133 // In the beginning, the card is not displayed. 134 function hidden(): State { 135 return {stage: 'hidden'} 136 } 137 if (state.stage === 'hidden') { 138 // The user can kick things off by hovering a target. 139 if (action === 'hovered-target') { 140 return mightShow({ 141 reason: action, 142 }) 143 } 144 } 145 146 // --- Might Show --- 147 // The card is not visible yet but we're considering showing it. 148 function mightShow({ 149 waitMs = SHOW_DELAY, 150 reason, 151 }: { 152 waitMs?: number 153 reason: 'hovered-target' | 'hovered-card' 154 }): State { 155 return { 156 stage: 'might-show', 157 reason, 158 effect() { 159 const id = setTimeout(() => dispatch('hovered-long-enough'), waitMs) 160 return () => { 161 clearTimeout(id) 162 } 163 }, 164 } 165 } 166 if (state.stage === 'might-show') { 167 // We'll make a decision at the end of a grace period timeout. 168 if (action === 'unhovered-target' || action === 'unhovered-card') { 169 return hidden() 170 } 171 if (action === 'hovered-long-enough') { 172 return showing({ 173 reason: state.reason, 174 }) 175 } 176 } 177 178 // --- Showing --- 179 // The card is beginning to show up and then will remain visible. 180 function showing({ 181 reason, 182 }: { 183 reason: 'hovered-target' | 'hovered-card' 184 }): State { 185 return { 186 stage: 'showing', 187 reason, 188 effect() { 189 function onScroll() { 190 dispatch('scrolled-while-showing') 191 } 192 window.addEventListener('scroll', onScroll) 193 return () => window.removeEventListener('scroll', onScroll) 194 }, 195 } 196 } 197 if (state.stage === 'showing') { 198 // If the user moves the pointer away, we'll begin to consider hiding it. 199 if (action === 'unhovered-target' || action === 'unhovered-card') { 200 return mightHide() 201 } 202 // Scrolling away if the hover is on the target instantly hides without a delay. 203 // If the hover is already on the card, we won't this. 204 if ( 205 state.reason === 'hovered-target' && 206 action === 'scrolled-while-showing' 207 ) { 208 return hiding() 209 } 210 } 211 212 // --- Might Hide --- 213 // The user has moved hover away from a visible card. 214 function mightHide({waitMs = HIDE_DELAY}: {waitMs?: number} = {}): State { 215 return { 216 stage: 'might-hide', 217 effect() { 218 const id = setTimeout( 219 () => dispatch('unhovered-long-enough'), 220 waitMs, 221 ) 222 return () => clearTimeout(id) 223 }, 224 } 225 } 226 if (state.stage === 'might-hide') { 227 // We'll make a decision based on whether it received hover again in time. 228 if (action === 'hovered-target' || action === 'hovered-card') { 229 return showing({ 230 reason: action, 231 }) 232 } 233 if (action === 'unhovered-long-enough') { 234 return hiding() 235 } 236 } 237 238 // --- Hiding --- 239 // The user waited enough outside that we're hiding the card. 240 function hiding({ 241 animationDurationMs = HIDE_DURATION, 242 }: { 243 animationDurationMs?: number 244 } = {}): State { 245 return { 246 stage: 'hiding', 247 effect() { 248 const id = setTimeout( 249 () => dispatch('finished-animating-hide'), 250 animationDurationMs, 251 ) 252 return () => clearTimeout(id) 253 }, 254 } 255 } 256 if (state.stage === 'hiding') { 257 // While hiding, we don't want to be interrupted by anything else. 258 // When the animation finishes, we loop back to the initial hidden state. 259 if (action === 'finished-animating-hide') { 260 return hidden() 261 } 262 } 263 264 return state 265 }, 266 {stage: 'hidden'}, 267 ) 268 269 useEffect(() => { 270 if (currentState.effect) { 271 const effect = currentState.effect 272 return effect() 273 } 274 }, [currentState]) 275 276 const prefetchProfileQuery = usePrefetchProfileQuery() 277 const prefetchedProfile = useRef(false) 278 const prefetchIfNeeded = async () => { 279 if (!prefetchedProfile.current) { 280 prefetchedProfile.current = true 281 prefetchProfileQuery(props.did) 282 } 283 } 284 285 const didFireHover = useRef(false) 286 const onPointerMoveTarget = () => { 287 prefetchIfNeeded() 288 // Conceptually we want something like onPointerEnter, 289 // but we want to ignore entering only due to scrolling. 290 // So instead we hover on the first onPointerMove. 291 if (!didFireHover.current) { 292 didFireHover.current = true 293 dispatch('hovered-target') 294 } 295 } 296 297 const onPointerLeaveTarget = () => { 298 didFireHover.current = false 299 dispatch('unhovered-target') 300 } 301 302 const onPointerEnterCard = () => { 303 dispatch('hovered-card') 304 } 305 306 const onPointerLeaveCard = () => { 307 dispatch('unhovered-card') 308 } 309 310 const onPress = () => { 311 dispatch('pressed') 312 } 313 314 const isVisible = 315 currentState.stage === 'showing' || 316 currentState.stage === 'might-hide' || 317 currentState.stage === 'hiding' 318 319 const animationStyle = { 320 animation: 321 currentState.stage === 'hiding' 322 ? `fadeOut ${HIDE_DURATION}ms both` 323 : `fadeIn ${SHOW_DURATION}ms both`, 324 } 325 326 return ( 327 <View 328 // @ts-ignore View is being used as div 329 ref={refs.setReference} 330 onPointerMove={onPointerMoveTarget} 331 onPointerLeave={onPointerLeaveTarget} 332 // @ts-ignore web only prop 333 onMouseUp={onPress} 334 style={[a.flex_shrink, props.inline && a.inline]}> 335 {props.children} 336 {isVisible && ( 337 <Portal> 338 <div 339 ref={refs.setFloating} 340 style={floatingStyles} 341 onPointerEnter={onPointerEnterCard} 342 onPointerLeave={onPointerLeaveCard}> 343 <div style={{willChange: 'transform', ...animationStyle}}> 344 <Card did={props.did} hide={onPress} navigation={navigation} /> 345 </div> 346 </div> 347 </Portal> 348 )} 349 </View> 350 ) 351} 352 353let Card = ({ 354 did, 355 hide, 356 navigation, 357}: { 358 did: string 359 hide: () => void 360 navigation: NavigationProp 361}): React.ReactNode => { 362 const t = useTheme() 363 364 const profile = useProfileQuery({did}) 365 const moderationOpts = useModerationOpts() 366 367 const data = profile.data 368 369 const status = useActorStatus(data) 370 371 const onPressOpenProfile = () => { 372 if (!status.isActive || !data) return 373 hide() 374 navigation.push('Profile', { 375 name: data.handle, 376 }) 377 } 378 379 return ( 380 <View 381 style={[ 382 !status.isActive && a.p_lg, 383 a.border, 384 a.rounded_md, 385 a.overflow_hidden, 386 t.atoms.bg, 387 t.atoms.border_contrast_low, 388 t.atoms.shadow_lg, 389 {width: status.isActive ? 350 : 300}, 390 a.max_w_full, 391 ]}> 392 {data && moderationOpts ? ( 393 status.isActive ? ( 394 <LiveStatus 395 status={status} 396 profile={data} 397 embed={status.embed} 398 padding="lg" 399 onPressOpenProfile={onPressOpenProfile} 400 /> 401 ) : ( 402 <Inner profile={data} moderationOpts={moderationOpts} hide={hide} /> 403 ) 404 ) : ( 405 <View 406 style={[ 407 a.justify_center, 408 a.align_center, 409 {minHeight: 200}, 410 a.w_full, 411 ]}> 412 <Loader size="xl" /> 413 </View> 414 )} 415 </View> 416 ) 417} 418Card = memo(Card) 419 420function Inner({ 421 profile, 422 moderationOpts, 423 hide, 424}: { 425 profile: AppBskyActorDefs.ProfileViewDetailed 426 moderationOpts: ModerationOpts 427 hide: () => void 428}) { 429 const t = useTheme() 430 const {_, i18n} = useLingui() 431 const {currentAccount} = useSession() 432 const moderation = useMemo( 433 () => moderateProfile(profile, moderationOpts), 434 [profile, moderationOpts], 435 ) 436 const [descriptionRT] = useRichText(profile.description ?? '') 437 const profileShadow = useProfileShadow(profile) 438 const {follow, unfollow} = useFollowMethods({ 439 profile: profileShadow, 440 logContext: 'ProfileHoverCard', 441 }) 442 const isBlockedUser = 443 profile.viewer?.blocking || 444 profile.viewer?.blockedBy || 445 profile.viewer?.blockingByList 446 const following = formatCount(i18n, profile.followsCount || 0) 447 const followers = formatCount(i18n, profile.followersCount || 0) 448 const pluralizedFollowers = plural(profile.followersCount || 0, { 449 one: 'follower', 450 other: 'followers', 451 }) 452 const pluralizedFollowings = plural(profile.followsCount || 0, { 453 one: 'following', 454 other: 'following', 455 }) 456 const profileURL = makeProfileLink({ 457 did: profile.did, 458 handle: profile.handle, 459 }) 460 const isMe = useMemo( 461 () => currentAccount?.did === profile.did, 462 [currentAccount, profile], 463 ) 464 const isLabeler = profile.associated?.labeler 465 466 const enableSquareButtons = useEnableSquareButtons() 467 468 // disable metrics 469 const disableFollowersMetrics = useDisableFollowersMetrics() 470 const disableFollowingMetrics = useDisableFollowingMetrics() 471 const disableFollowedByMetrics = useDisableFollowedByMetrics() 472 473 return ( 474 <View> 475 <View style={[a.flex_row, a.justify_between, a.align_start]}> 476 <Link to={profileURL} label={_(msg`View profile`)} onPress={hide}> 477 <UserAvatar 478 size={64} 479 avatar={profile.avatar} 480 type={isLabeler ? 'labeler' : 'user'} 481 moderation={moderation.ui('avatar')} 482 /> 483 </Link> 484 485 {!isMe && 486 !isLabeler && 487 (isBlockedUser ? ( 488 <Link 489 to={profileURL} 490 label={_(msg`View blocked user's profile`)} 491 onPress={hide} 492 size="small" 493 color="secondary" 494 variant="solid" 495 style={enableSquareButtons ? [a.rounded_sm] : [a.rounded_full]}> 496 <ButtonText>{_(msg`View profile`)}</ButtonText> 497 </Link> 498 ) : ( 499 <Button 500 size="small" 501 color={profileShadow.viewer?.following ? 'secondary' : 'primary'} 502 variant="solid" 503 label={ 504 profileShadow.viewer?.following 505 ? profileShadow.viewer?.followedBy 506 ? _(msg`Mutuals`) 507 : _(msg`Following`) 508 : profileShadow.viewer?.followedBy 509 ? _(msg`Follow back`) 510 : _(msg`Follow`) 511 } 512 style={enableSquareButtons ? [a.rounded_sm] : [a.rounded_full]} 513 onPress={profileShadow.viewer?.following ? unfollow : follow}> 514 <ButtonIcon 515 position="left" 516 icon={profileShadow.viewer?.following ? Check : Plus} 517 /> 518 <ButtonText> 519 {profileShadow.viewer?.following 520 ? profileShadow.viewer?.followedBy 521 ? _(msg`Mutuals`) 522 : _(msg`Following`) 523 : profileShadow.viewer?.followedBy 524 ? _(msg`Follow back`) 525 : _(msg`Follow`)} 526 </ButtonText> 527 </Button> 528 ))} 529 </View> 530 531 <View style={[a.pb_sm, a.flex_1]}> 532 <Link to={profileURL} label={_(msg`View profile`)} onPress={hide}> 533 <View style={[a.flex_row, a.align_center, a.pt_md, a.pb_xs]}> 534 <Text 535 numberOfLines={1} 536 style={[ 537 a.text_lg, 538 a.leading_snug, 539 a.font_semi_bold, 540 a.self_start, 541 ]}> 542 {sanitizeDisplayName( 543 profile.displayName || sanitizeHandle(profile.handle), 544 moderation.ui('displayName'), 545 )} 546 </Text> 547 <ProfileBadges 548 profile={profile} 549 size="md" 550 pdsInteractive={false} 551 style={[ 552 a.pl_xs, 553 { 554 marginTop: -1, 555 }, 556 ]} 557 /> 558 </View> 559 </Link> 560 561 <ProfileHeaderHandle 562 profile={profileShadow} 563 disableAuxiliaryTaps 564 onLinkPress={hide} 565 /> 566 </View> 567 568 {isBlockedUser && ( 569 <View style={[a.flex_row, a.flex_wrap, a.gap_xs]}> 570 {moderation.ui('profileView').alerts.map(cause => ( 571 <Pills.Label 572 key={getModerationCauseKey(cause)} 573 size="lg" 574 cause={cause} 575 disableDetailsDialog 576 /> 577 ))} 578 </View> 579 )} 580 581 {!isBlockedUser && ( 582 <> 583 {disableFollowersMetrics && disableFollowingMetrics ? null : ( 584 <View style={[a.flex_row, a.flex_wrap, a.gap_md, a.pt_xs]}> 585 {!disableFollowersMetrics ? ( 586 <InlineLinkText 587 to={makeProfileLink(profile, 'followers')} 588 label={`${followers} ${pluralizedFollowers}`} 589 style={[t.atoms.text]} 590 onPress={hide}> 591 <Text style={[a.text_md, a.font_semi_bold]}> 592 {followers}{' '} 593 </Text> 594 <Text style={[t.atoms.text_contrast_medium]}> 595 {pluralizedFollowers} 596 </Text> 597 </InlineLinkText> 598 ) : null} 599 {!disableFollowingMetrics ? ( 600 <InlineLinkText 601 to={makeProfileLink(profile, 'follows')} 602 label={_(msg`${following} following`)} 603 style={[t.atoms.text]} 604 onPress={hide}> 605 <Text style={[a.text_md, a.font_semi_bold]}> 606 {following}{' '} 607 </Text> 608 <Text style={[t.atoms.text_contrast_medium]}> 609 {pluralizedFollowings} 610 </Text> 611 </InlineLinkText> 612 ) : null} 613 </View> 614 )} 615 616 {profile.description?.trim() && !moderation.ui('profileView').blur ? ( 617 <View style={[a.pt_md]}> 618 <RichText 619 numberOfLines={8} 620 value={descriptionRT} 621 onLinkPress={hide} 622 /> 623 </View> 624 ) : undefined} 625 626 {!isMe && 627 !disableFollowedByMetrics && 628 shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( 629 <View style={[a.flex_row, a.align_center, a.gap_sm, a.pt_md]}> 630 <KnownFollowers 631 profile={profile} 632 moderationOpts={moderationOpts} 633 onLinkPress={hide} 634 /> 635 </View> 636 )} 637 </> 638 )} 639 </View> 640 ) 641}