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