From 1f61109cfa8307cbbceea604b1daec7486dd3393 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 12 Apr 2024 17:01:32 -0500 Subject: 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 Co-authored-by: Hailey --- src/components/ProfileHoverCard/index.web.tsx | 290 ++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 src/components/ProfileHoverCard/index.web.tsx (limited to 'src/components/ProfileHoverCard/index.web.tsx') 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 : +} + +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 ( +
+ {props.children} + + {hovered && ( + + +
+ +
+
+
+ )} +
+ ) +} + +function Card({did, hide}: {did: string; hide: () => void}) { + const t = useTheme() + + const profile = useProfileQuery({did}) + const moderationOpts = useModerationOpts() + + const data = profile.data + + return ( + + {data && moderationOpts ? ( + + ) : ( + + + + )} + + ) +} + +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} + + )} + + ) +} -- cgit 1.4.1