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
}

View file

@ -7,7 +7,7 @@ import {toShortUrl} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {atoms as a, flatten, native, TextStyleProp, useTheme, web} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {InlineLinkText} from '#/components/Link'
import {InlineLinkText, LinkProps} from '#/components/Link'
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
import {Text, TextProps} from '#/components/Typography'
@ -22,6 +22,7 @@ export function RichText({
selectable,
enableTags = false,
authorHandle,
onLinkPress,
}: TextStyleProp &
Pick<TextProps, 'selectable'> & {
value: RichTextAPI | string
@ -30,6 +31,7 @@ export function RichText({
disableLinks?: boolean
enableTags?: boolean
authorHandle?: string
onLinkPress?: LinkProps['onPress']
}) {
const richText = React.useMemo(
() =>
@ -90,7 +92,8 @@ export function RichText({
to={`/profile/${mention.did}`}
style={[...styles, {pointerEvents: 'auto'}]}
// @ts-ignore TODO
dataSet={WORD_WRAP}>
dataSet={WORD_WRAP}
onPress={onLinkPress}>
{segment.text}
</InlineLinkText>,
)
@ -106,7 +109,8 @@ export function RichText({
style={[...styles, {pointerEvents: 'auto'}]}
// @ts-ignore TODO
dataSet={WORD_WRAP}
shareOnLongPress>
shareOnLongPress
onPress={onLinkPress}>
{toShortUrl(segment.text)}
</InlineLinkText>,
)

View file

@ -0,0 +1,60 @@
import React from 'react'
import {AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {LogEvents} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {Shadow} from '#/state/cache/types'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useRequireAuth} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
export function useFollowMethods({
profile,
logContext,
}: {
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
}) {
const {_} = useLingui()
const requireAuth = useRequireAuth()
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
logContext,
)
const follow = React.useCallback(() => {
requireAuth(async () => {
try {
await queueFollow()
} catch (e: any) {
logger.error(`useFollowMethods: failed to follow`, {message: String(e)})
if (e?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`))
}
}
})
}, [_, queueFollow, requireAuth])
const unfollow = React.useCallback(() => {
requireAuth(async () => {
try {
await queueUnfollow()
} catch (e: any) {
logger.error(`useFollowMethods: failed to unfollow`, {
message: String(e),
})
if (e?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`))
}
}
})
}, [_, queueUnfollow, requireAuth])
return {
follow,
unfollow,
}
}

View file

@ -0,0 +1,33 @@
import React from 'react'
import {RichText as RichTextAPI} from '@atproto/api'
import {getAgent} from '#/state/session'
export function useRichText(text: string): [RichTextAPI, boolean] {
const [prevText, setPrevText] = React.useState(text)
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))
const [resolvedRT, setResolvedRT] = React.useState<RichTextAPI | null>(null)
if (text !== prevText) {
setPrevText(text)
setRawRT(new RichTextAPI({text}))
setResolvedRT(null)
// This will queue an immediate re-render
}
React.useEffect(() => {
let ignore = false
async function resolveRTFacets() {
// new each time
const resolvedRT = new RichTextAPI({text})
await resolvedRT.detectFacets(getAgent())
if (!ignore) {
setResolvedRT(resolvedRT)
}
}
resolveRTFacets()
return () => {
ignore = true
}
}, [text])
const isResolving = resolvedRT === null
return [resolvedRT ?? rawRT, isResolving]
}