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