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

Configure Feed

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

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