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/queries/preferences' 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-show' | 'showing' | 'might-hide' | 'hiding' effect?: () => () => any } type Action = | 'pressed' | 'hovered' | 'unhovered' | 'show-timer-elapsed' | 'hide-timer-elapsed' | 'hide-animation-completed' const SHOW_DELAY = 350 const SHOW_DURATION = 300 const HIDE_DELAY = 200 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 => { // Regardless of which stage we're in, pressing always hides the card. if (action === 'pressed') { return {stage: 'hidden'} } if (state.stage === 'hidden') { // Our story starts when the card is hidden. // If the user hovers, we kick off a grace period before showing the card. if (action === 'hovered') { return { stage: 'might-show', effect() { const id = setTimeout( () => dispatch('show-timer-elapsed'), SHOW_DELAY, ) return () => { clearTimeout(id) } }, } } } if (state.stage === 'might-show') { // We're in the grace period when we decide whether to show the card. // At this point, two things can happen. Either the user unhovers, and // we go back to hidden--or they linger enough that we'll show the card. if (action === 'unhovered') { return {stage: 'hidden'} } if (action === 'show-timer-elapsed') { return {stage: 'showing'} } } if (state.stage === 'showing') { // We're showing the card now. // If the user unhovers, we'll start a grace period before hiding the card. if (action === 'unhovered') { return { stage: 'might-hide', effect() { const id = setTimeout( () => dispatch('hide-timer-elapsed'), HIDE_DELAY, ) return () => clearTimeout(id) }, } } } if (state.stage === 'might-hide') { // We're in the grace period when we decide whether to hide the card. // At this point, two things can happen. Either the user hovers, and // we go back to showing it--or they linger enough that we'll start hiding the card. if (action === 'hovered') { return {stage: 'showing'} } if (action === 'hide-timer-elapsed') { return { stage: 'hiding', effect() { const id = setTimeout( () => dispatch('hide-animation-completed'), HIDE_DURATION, ) return () => clearTimeout(id) }, } } } if (state.stage === 'hiding') { // We're currently playing the hiding animation. // We'll ignore all inputs now and wait for the animation to finish. // At that point, we'll hide the entire thing, going back to square one. if (action === 'hide-animation-completed') { return {stage: 'hidden'} } } // Something else happened. Keep calm and carry on. return state }, {stage: 'hidden'}, ) React.useEffect(() => { if (currentState.effect) { const effect = currentState.effect delete currentState.effect // Mark as completed 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 onPointerEnterTarget = React.useCallback(() => { prefetchIfNeeded() dispatch('hovered') }, [prefetchIfNeeded]) const onPointerLeaveTarget = React.useCallback(() => { dispatch('unhovered') }, []) const onPointerEnterCard = React.useCallback(() => { dispatch('hovered') }, []) const onPointerLeaveCard = React.useCallback(() => { dispatch('unhovered') }, []) 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} )} ) }