diff options
author | Eric Bailey <git@esb.lol> | 2024-04-12 17:01:32 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-04-12 17:01:32 -0500 |
commit | 1f61109cfa8307cbbceea604b1daec7486dd3393 (patch) | |
tree | dbbad83a4367555e1586f6c2d5b0450612600d44 /src/components/ProfileHoverCard/index.web.tsx | |
parent | f91aa37c6bd900bdc4eec1095c9ecd83da2f13f2 (diff) | |
download | voidsky-1f61109cfa8307cbbceea604b1daec7486dd3393.tar.zst |
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>
Diffstat (limited to 'src/components/ProfileHoverCard/index.web.tsx')
-rw-r--r-- | src/components/ProfileHoverCard/index.web.tsx | 290 |
1 files changed, 290 insertions, 0 deletions
diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx new file mode 100644 index 000000000..cfb8cf2fc --- /dev/null +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -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> + ) +} |