import React from 'react' import {View} from 'react-native' import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {pluralize} from '#/lib/strings/helpers' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile' import {useSession} from '#/state/session' import {useProfileShadow} from 'state/cache/profile-shadow' import {formatCount} from '#/view/com/util/numeric/format' import {UserAvatar} from '#/view/com/util/UserAvatar' import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useFollowMethods} from '#/components/hooks/useFollowMethods' import {useRichText} from '#/components/hooks/useRichText' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {InlineLinkText, Link} from '#/components/Link' import {Loader} from '#/components/Loader' import {Portal} from '#/components/Portal' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import {ProfileHoverCardProps} from './types' const floatingMiddlewares = [ offset(4), flip({padding: 16}), shift({padding: 16}), size({ padding: 16, apply({availableWidth, availableHeight, elements}) { Object.assign(elements.floating.style, { maxWidth: `${availableWidth}px`, maxHeight: `${availableHeight}px`, }) }, }), ] const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0 export function ProfileHoverCard(props: ProfileHoverCardProps) { return isTouchDevice ? props.children : } type State = | { stage: 'hidden' | 'might-hide' | 'hiding' effect?: () => () => any } | { stage: 'might-show' | 'showing' effect?: () => () => any reason: 'hovered-target' | 'hovered-card' } type Action = | 'pressed' | 'scrolled-while-showing' | 'hovered-target' | 'unhovered-target' | 'hovered-card' | 'unhovered-card' | 'hovered-long-enough' | 'unhovered-long-enough' | 'finished-animating-hide' const SHOW_DELAY = 500 const SHOW_DURATION = 300 const HIDE_DELAY = 150 const HIDE_DURATION = 200 export function ProfileHoverCardInner(props: ProfileHoverCardProps) { const {refs, floatingStyles} = useFloating({ middleware: floatingMiddlewares, }) const [currentState, dispatch] = React.useReducer( // Tip: console.log(state, action) when debugging. (state: State, action: Action): State => { // Pressing within a card should always hide it. // No matter which stage we're in. if (action === 'pressed') { return hidden() } // --- Hidden --- // In the beginning, the card is not displayed. function hidden(): State { return {stage: 'hidden'} } if (state.stage === 'hidden') { // The user can kick things off by hovering a target. if (action === 'hovered-target') { return mightShow({ reason: action, }) } } // --- Might Show --- // The card is not visible yet but we're considering showing it. function mightShow({ waitMs = SHOW_DELAY, reason, }: { waitMs?: number reason: 'hovered-target' | 'hovered-card' }): State { return { stage: 'might-show', reason, effect() { const id = setTimeout(() => dispatch('hovered-long-enough'), waitMs) return () => { clearTimeout(id) } }, } } if (state.stage === 'might-show') { // We'll make a decision at the end of a grace period timeout. if (action === 'unhovered-target' || action === 'unhovered-card') { return hidden() } if (action === 'hovered-long-enough') { return showing({ reason: state.reason, }) } } // --- Showing --- // The card is beginning to show up and then will remain visible. function showing({ reason, }: { reason: 'hovered-target' | 'hovered-card' }): State { return { stage: 'showing', reason, effect() { function onScroll() { dispatch('scrolled-while-showing') } window.addEventListener('scroll', onScroll) return () => window.removeEventListener('scroll', onScroll) }, } } if (state.stage === 'showing') { // If the user moves the pointer away, we'll begin to consider hiding it. if (action === 'unhovered-target' || action === 'unhovered-card') { return mightHide() } // Scrolling away if the hover is on the target instantly hides without a delay. // If the hover is already on the card, we won't this. if ( state.reason === 'hovered-target' && action === 'scrolled-while-showing' ) { return hiding() } } // --- Might Hide --- // The user has moved hover away from a visible card. function mightHide({waitMs = HIDE_DELAY}: {waitMs?: number} = {}): State { return { stage: 'might-hide', effect() { const id = setTimeout( () => dispatch('unhovered-long-enough'), waitMs, ) return () => clearTimeout(id) }, } } if (state.stage === 'might-hide') { // We'll make a decision based on whether it received hover again in time. if (action === 'hovered-target' || action === 'hovered-card') { return showing({ reason: action, }) } if (action === 'unhovered-long-enough') { return hiding() } } // --- Hiding --- // The user waited enough outside that we're hiding the card. function hiding({ animationDurationMs = HIDE_DURATION, }: { animationDurationMs?: number } = {}): State { return { stage: 'hiding', effect() { const id = setTimeout( () => dispatch('finished-animating-hide'), animationDurationMs, ) return () => clearTimeout(id) }, } } if (state.stage === 'hiding') { // While hiding, we don't want to be interrupted by anything else. // When the animation finishes, we loop back to the initial hidden state. if (action === 'finished-animating-hide') { return hidden() } } return state }, {stage: 'hidden'}, ) React.useEffect(() => { if (currentState.effect) { const effect = currentState.effect return effect() } }, [currentState]) const prefetchProfileQuery = usePrefetchProfileQuery() const prefetchedProfile = React.useRef(false) const prefetchIfNeeded = React.useCallback(async () => { if (!prefetchedProfile.current) { prefetchedProfile.current = true prefetchProfileQuery(props.did) } }, [prefetchProfileQuery, props.did]) const didFireHover = React.useRef(false) const onPointerMoveTarget = React.useCallback(() => { prefetchIfNeeded() // Conceptually we want something like onPointerEnter, // but we want to ignore entering only due to scrolling. // So instead we hover on the first onPointerMove. if (!didFireHover.current) { didFireHover.current = true dispatch('hovered-target') } }, [prefetchIfNeeded]) const onPointerLeaveTarget = React.useCallback(() => { didFireHover.current = false dispatch('unhovered-target') }, []) const onPointerEnterCard = React.useCallback(() => { dispatch('hovered-card') }, []) const onPointerLeaveCard = React.useCallback(() => { dispatch('unhovered-card') }, []) const onPress = React.useCallback(() => { dispatch('pressed') }, []) const isVisible = currentState.stage === 'showing' || currentState.stage === 'might-hide' || currentState.stage === 'hiding' const animationStyle = { animation: currentState.stage === 'hiding' ? `avatarHoverFadeOut ${HIDE_DURATION}ms both` : `avatarHoverFadeIn ${SHOW_DURATION}ms both`, } return (
{props.children} {isVisible && (
)}
) } let Card = ({did, hide}: {did: string; hide: () => void}): React.ReactNode => { const t = useTheme() const profile = useProfileQuery({did}) const moderationOpts = useModerationOpts() const data = profile.data return ( {data && moderationOpts ? ( ) : ( )} ) } Card = React.memo(Card) function Inner({ profile, moderationOpts, hide, }: { profile: AppBskyActorDefs.ProfileViewDetailed moderationOpts: ModerationOpts hide: () => void }) { const t = useTheme() const {_} = useLingui() const {currentAccount} = useSession() const moderation = React.useMemo( () => moderateProfile(profile, moderationOpts), [profile, moderationOpts], ) const [descriptionRT] = useRichText(profile.description ?? '') const profileShadow = useProfileShadow(profile) const {follow, unfollow} = useFollowMethods({ profile: profileShadow, logContext: 'ProfileHoverCard', }) const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy const following = formatCount(profile.followsCount || 0) const followers = formatCount(profile.followersCount || 0) const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') const profileURL = makeProfileLink({ did: profile.did, handle: profile.handle, }) const isMe = React.useMemo( () => currentAccount?.did === profile.did, [currentAccount, profile], ) return ( {!isMe && ( )} {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), moderation.ui('displayName'), )} {!blockHide && ( <> {followers} {pluralizedFollowers} {following} following {profile.description?.trim() && !moderation.ui('profileView').blur ? ( ) : undefined} )} ) }