Profile card hover preview (#3508)

* feat: initial user card hover

* feat: flesh it out some more

* fix: initialize middlewares once

* chore: remove floating-ui react-native

* chore: clean up

* Update moderation apis, fix lint

* Refactor profile hover card to alf

* Clean up

* Debounce, fix positioning when loading

* Fix going away

* Close on all link presses

* Tweak styles

* Disable on mobile web

* cleanup some of the changes pt. 1

* cleanup some of the changes pt. 2

* cleanup some of the changes pt. 3

* cleanup some of the changes pt. 4

* Re-revert files

* Fix handle presentation

* Don't follow yourself, silly

* Collapsed notifications group

* ProfileCard

* Tree view replies

* Suggested follows

* Fix hover-back-on-card edge case

* Moar

---------

Co-authored-by: Mary <git@mary.my.id>
Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
Eric Bailey 2024-04-12 17:01:32 -05:00 committed by GitHub
parent f91aa37c6b
commit 1f61109cfa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 576 additions and 146 deletions

View file

@ -0,0 +1,5 @@
import {ProfileHoverCardProps} from './types'
export function ProfileHoverCard({children}: ProfileHoverCardProps) {
return children
}

View file

@ -0,0 +1,290 @@
import React from 'react'
import {View} from 'react-native'
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
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 : <ProfileHoverCardInner {...props} />
}
export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
const [hovered, setHovered] = React.useState(false)
const {refs, floatingStyles} = useFloating({
middleware: floatingMiddlewares,
})
const prefetchProfileQuery = usePrefetchProfileQuery()
const prefetchedProfile = React.useRef(false)
const targetHovered = React.useRef(false)
const cardHovered = React.useRef(false)
const targetClicked = React.useRef(false)
const onPointerEnterTarget = React.useCallback(() => {
targetHovered.current = true
if (prefetchedProfile.current) {
// if we're navigating
if (targetClicked.current) return
setHovered(true)
} else {
prefetchProfileQuery(props.did).then(() => {
if (targetHovered.current) {
setHovered(true)
}
prefetchedProfile.current = true
})
}
}, [props.did, prefetchProfileQuery])
const onPointerEnterCard = React.useCallback(() => {
cardHovered.current = true
// if we're navigating
if (targetClicked.current) return
setHovered(true)
}, [])
const onPointerLeaveTarget = React.useCallback(() => {
targetHovered.current = false
setTimeout(() => {
if (cardHovered.current) return
setHovered(false)
}, 100)
}, [])
const onPointerLeaveCard = React.useCallback(() => {
cardHovered.current = false
setTimeout(() => {
if (targetHovered.current) return
setHovered(false)
}, 100)
}, [])
const onClickTarget = React.useCallback(() => {
targetClicked.current = true
setHovered(false)
}, [])
const hide = React.useCallback(() => {
setHovered(false)
}, [])
return (
<div
ref={refs.setReference}
onPointerEnter={onPointerEnterTarget}
onPointerLeave={onPointerLeaveTarget}
onMouseUp={onClickTarget}>
{props.children}
{hovered && (
<Portal>
<Animated.View
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}>
<div
ref={refs.setFloating}
style={floatingStyles}
onPointerEnter={onPointerEnterCard}
onPointerLeave={onPointerLeaveCard}>
<Card did={props.did} hide={hide} />
</div>
</Animated.View>
</Portal>
)}
</div>
)
}
function Card({did, hide}: {did: string; hide: () => void}) {
const t = useTheme()
const profile = useProfileQuery({did})
const moderationOpts = useModerationOpts()
const data = profile.data
return (
<View
style={[
a.p_lg,
a.border,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg,
t.atoms.border_contrast_low,
t.atoms.shadow_lg,
{
width: 300,
},
]}>
{data && moderationOpts ? (
<Inner profile={data} moderationOpts={moderationOpts} hide={hide} />
) : (
<View style={[a.justify_center]}>
<Loader size="xl" />
</View>
)}
</View>
)
}
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 (
<View>
<View style={[a.flex_row, a.justify_between, a.align_start]}>
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
<UserAvatar
size={64}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
/>
</Link>
{!isMe && (
<Button
size="small"
color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
variant="solid"
label={
profileShadow.viewer?.following ? _('Following') : _('Follow')
}
style={[a.rounded_full]}
onPress={profileShadow.viewer?.following ? unfollow : follow}>
<ButtonIcon
position="left"
icon={profileShadow.viewer?.following ? Check : Plus}
/>
<ButtonText>
{profileShadow.viewer?.following ? _('Following') : _('Follow')}
</ButtonText>
</Button>
)}
</View>
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
<View style={[a.pb_sm, a.flex_1]}>
<Text style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold]}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
</Text>
<ProfileHeaderHandle profile={profileShadow} />
</View>
</Link>
{!blockHide && (
<>
<View style={[a.flex_row, a.flex_wrap, a.gap_md, a.pt_xs]}>
<InlineLinkText
to={makeProfileLink(profile, 'followers')}
label={`${followers} ${pluralizedFollowers}`}
style={[t.atoms.text]}
onPress={hide}>
<Trans>
<Text style={[a.text_md, a.font_bold]}>{followers} </Text>
<Text style={[t.atoms.text_contrast_medium]}>
{pluralizedFollowers}
</Text>
</Trans>
</InlineLinkText>
<InlineLinkText
to={makeProfileLink(profile, 'follows')}
label={_(msg`${following} following`)}
style={[t.atoms.text]}
onPress={hide}>
<Trans>
<Text style={[a.text_md, a.font_bold]}>{following} </Text>
<Text style={[t.atoms.text_contrast_medium]}>following</Text>
</Trans>
</InlineLinkText>
</View>
{profile.description?.trim() && !moderation.ui('profileView').blur ? (
<View style={[a.pt_md]}>
<RichText
numberOfLines={8}
value={descriptionRT}
onLinkPress={hide}
/>
</View>
) : undefined}
</>
)}
</View>
)
}

View file

@ -0,0 +1,6 @@
import React from 'react'
export type ProfileHoverCardProps = {
children: React.ReactElement
did: string
}