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 | |
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>
-rw-r--r-- | package.json | 2 | ||||
-rw-r--r-- | src/components/ProfileHoverCard/index.tsx | 5 | ||||
-rw-r--r-- | src/components/ProfileHoverCard/index.web.tsx | 290 | ||||
-rw-r--r-- | src/components/ProfileHoverCard/types.ts | 6 | ||||
-rw-r--r-- | src/components/RichText.tsx | 10 | ||||
-rw-r--r-- | src/components/hooks/useFollowMethods.ts | 60 | ||||
-rw-r--r-- | src/components/hooks/useRichText.ts | 33 | ||||
-rw-r--r-- | src/lib/statsig/events.ts | 2 | ||||
-rw-r--r-- | src/screens/Profile/Header/Handle.tsx | 7 | ||||
-rw-r--r-- | src/state/queries/profile.ts | 4 | ||||
-rw-r--r-- | src/view/com/notifications/FeedItem.tsx | 101 | ||||
-rw-r--r-- | src/view/com/profile/ProfileCard.tsx | 37 | ||||
-rw-r--r-- | src/view/com/profile/ProfileHeaderSuggestedFollows.tsx | 32 | ||||
-rw-r--r-- | src/view/com/util/PostMeta.tsx | 23 | ||||
-rw-r--r-- | src/view/com/util/UserAvatar.tsx | 42 | ||||
-rw-r--r-- | src/view/com/util/UserPreviewLink.tsx | 31 | ||||
-rw-r--r-- | yarn.lock | 27 |
17 files changed, 571 insertions, 141 deletions
diff --git a/package.json b/package.json index 85db718dc..8b99b70cc 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,8 @@ "@emoji-mart/react": "^1.1.1", "@expo/html-elements": "^0.4.2", "@expo/webpack-config": "^19.0.0", + "@floating-ui/dom": "^1.6.3", + "@floating-ui/react-dom": "^2.0.8", "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", diff --git a/src/components/ProfileHoverCard/index.tsx b/src/components/ProfileHoverCard/index.tsx new file mode 100644 index 000000000..980336ee4 --- /dev/null +++ b/src/components/ProfileHoverCard/index.tsx @@ -0,0 +1,5 @@ +import {ProfileHoverCardProps} from './types' + +export function ProfileHoverCard({children}: ProfileHoverCardProps) { + return children +} 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> + ) +} diff --git a/src/components/ProfileHoverCard/types.ts b/src/components/ProfileHoverCard/types.ts new file mode 100644 index 000000000..4e70df5f0 --- /dev/null +++ b/src/components/ProfileHoverCard/types.ts @@ -0,0 +1,6 @@ +import React from 'react' + +export type ProfileHoverCardProps = { + children: React.ReactElement + did: string +} diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 5cfa0b24f..17f36c141 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -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>, ) diff --git a/src/components/hooks/useFollowMethods.ts b/src/components/hooks/useFollowMethods.ts new file mode 100644 index 000000000..1e91a1f38 --- /dev/null +++ b/src/components/hooks/useFollowMethods.ts @@ -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, + } +} diff --git a/src/components/hooks/useRichText.ts b/src/components/hooks/useRichText.ts new file mode 100644 index 000000000..e363ae5a9 --- /dev/null +++ b/src/components/hooks/useRichText.ts @@ -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] +} diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 3d650b8b7..1231c5de5 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -99,6 +99,7 @@ export type LogEvents = { | 'ProfileHeader' | 'ProfileHeaderSuggestedFollows' | 'ProfileMenu' + | 'ProfileHoverCard' } 'profile:unfollow': { logContext: @@ -108,5 +109,6 @@ export type LogEvents = { | 'ProfileHeader' | 'ProfileHeaderSuggestedFollows' | 'ProfileMenu' + | 'ProfileHoverCard' } } diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx index fd1cbe533..9ab24fbbe 100644 --- a/src/screens/Profile/Header/Handle.tsx +++ b/src/screens/Profile/Header/Handle.tsx @@ -1,10 +1,10 @@ import React from 'react' import {View} from 'react-native' import {AppBskyActorDefs} from '@atproto/api' -import {isInvalidHandle} from 'lib/strings/handles' -import {Shadow} from '#/state/cache/types' import {Trans} from '@lingui/macro' +import {Shadow} from '#/state/cache/types' +import {isInvalidHandle} from 'lib/strings/handles' import {atoms as a, useTheme, web} from '#/alf' import {Text} from '#/components/Typography' @@ -26,6 +26,7 @@ export function ProfileHeaderHandle({ </View> ) : undefined} <Text + numberOfLines={1} style={[ invalidHandle ? [ @@ -36,7 +37,7 @@ export function ProfileHeaderHandle({ a.rounded_xs, {borderColor: t.palette.contrast_200}, ] - : [a.text_md, t.atoms.text_contrast_medium], + : [a.text_md, a.leading_tight, t.atoms.text_contrast_medium], web({wordBreak: 'break-all'}), ]}> {invalidHandle ? <Trans>ā Invalid Handle</Trans> : `@${profile.handle}`} diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index a962fecff..7842d53d4 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -90,8 +90,8 @@ export function useProfilesQuery({handles}: {handles: string[]}) { export function usePrefetchProfileQuery() { const queryClient = useQueryClient() const prefetchProfileQuery = useCallback( - (did: string) => { - queryClient.prefetchQuery({ + async (did: string) => { + await queryClient.prefetchQuery({ queryKey: RQKEY(did), queryFn: async () => { const res = await getAgent().getProfile({actor: did || ''}) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 78b1677c3..e1dae6659 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -1,20 +1,20 @@ -import React, {memo, useMemo, useState, useEffect} from 'react' +import React, {memo, useEffect, useMemo, useState} from 'react' import { Animated, - TouchableOpacity, Pressable, StyleSheet, + TouchableOpacity, View, } from 'react-native' import { + AppBskyActorDefs, AppBskyEmbedImages, + AppBskyEmbedRecordWithMedia, AppBskyFeedDefs, AppBskyFeedPost, - ModerationOpts, - ModerationDecision, moderateProfile, - AppBskyEmbedRecordWithMedia, - AppBskyActorDefs, + ModerationDecision, + ModerationOpts, } from '@atproto/api' import {AtUri} from '@atproto/api' import { @@ -22,28 +22,30 @@ import { FontAwesomeIconStyle, Props, } from '@fortawesome/react-native-fontawesome' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + import {FeedNotification} from '#/state/queries/notifications/feed' -import {s, colors} from 'lib/styles' -import {niceDate} from 'lib/strings/time' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {usePalette} from 'lib/hooks/usePalette' +import {HeartIconSolid} from 'lib/icons' +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 {HeartIconSolid} from 'lib/icons' -import {Text} from '../util/text/Text' -import {UserAvatar, PreviewableUserAvatar} from '../util/UserAvatar' -import {UserPreviewLink} from '../util/UserPreviewLink' -import {ImageHorzList} from '../util/images/ImageHorzList' +import {niceDate} from 'lib/strings/time' +import {colors, s} from 'lib/styles' +import {isWeb} from 'platform/detection' +import {Link as NewLink} from '#/components/Link' +import {ProfileHoverCard} from '#/components/ProfileHoverCard' +import {FeedSourceCard} from '../feeds/FeedSourceCard' import {Post} from '../post/Post' +import {ImageHorzList} from '../util/images/ImageHorzList' import {Link, TextLink} from '../util/Link' -import {usePalette} from 'lib/hooks/usePalette' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {formatCount} from '../util/numeric/format' -import {makeProfileLink} from 'lib/routes/links' +import {Text} from '../util/text/Text' import {TimeElapsed} from '../util/TimeElapsed' -import {isWeb} from 'platform/detection' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {FeedSourceCard} from '../feeds/FeedSourceCard' +import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar' const MAX_AUTHORS = 5 @@ -356,8 +358,10 @@ function CondensedAuthorsList({ <View style={styles.avis}> {authors.slice(0, MAX_AUTHORS).map(author => ( <View key={author.href} style={s.mr5}> - <UserAvatar + <PreviewableUserAvatar size={35} + did={author.did} + handle={author.handle} avatar={author.avatar} moderation={author.moderation.ui('avatar')} type={author.associated?.labeler ? 'labeler' : 'user'} @@ -386,6 +390,7 @@ function ExpandedAuthorsList({ visible: boolean authors: Author[] }) { + const {_} = useLingui() const pal = usePalette('default') const heightInterp = useAnimatedValue(visible ? 1 : 0) const targetHeight = @@ -409,33 +414,39 @@ function ExpandedAuthorsList({ visible ? s.mb10 : undefined, ]}> {authors.map(author => ( - <UserPreviewLink + <NewLink key={author.did} - did={author.did} - handle={author.handle} - style={styles.expandedAuthor}> - <View style={styles.expandedAuthorAvi}> - <UserAvatar - size={35} - avatar={author.avatar} - moderation={author.moderation.ui('avatar')} - type={author.associated?.labeler ? 'labeler' : 'user'} - /> - </View> - <View style={s.flex1}> - <Text - type="lg-bold" - numberOfLines={1} - style={pal.text} - lineHeight={1.2}> - {sanitizeDisplayName(author.displayName || author.handle)} - - <Text style={[pal.textLight]} lineHeight={1.2}> - {sanitizeHandle(author.handle)} + label={_(msg`See profile`)} + to={makeProfileLink({ + did: author.did, + handle: author.handle, + })}> + <View style={styles.expandedAuthor}> + <View style={styles.expandedAuthorAvi}> + <ProfileHoverCard did={author.did}> + <UserAvatar + size={35} + avatar={author.avatar} + moderation={author.moderation.ui('avatar')} + type={author.associated?.labeler ? 'labeler' : 'user'} + /> + </ProfileHoverCard> + </View> + <View style={s.flex1}> + <Text + type="lg-bold" + numberOfLines={1} + style={pal.text} + lineHeight={1.2}> + {sanitizeDisplayName(author.displayName || author.handle)} + + <Text style={[pal.textLight]} lineHeight={1.2}> + {sanitizeHandle(author.handle)} + </Text> </Text> - </Text> + </View> </View> - </UserPreviewLink> + </NewLink> ))} </Animated.View> ) diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 235139fff..e6df5f6d0 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -6,22 +6,23 @@ import { ModerationCause, ModerationDecision, } from '@atproto/api' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' -import {UserAvatar} from '../util/UserAvatar' -import {s} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {FollowButton} from './FollowButton' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' -import {makeProfileLink} from 'lib/routes/links' -import {getModerationCauseKey, isJustAMute} from 'lib/moderation' +import {Trans} from '@lingui/macro' + +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' +import {useProfileShadow} from '#/state/cache/profile-shadow' import {Shadow} from '#/state/cache/types' import {useModerationOpts} from '#/state/queries/preferences' -import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession} from '#/state/session' -import {Trans} from '@lingui/macro' -import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' +import {usePalette} from 'lib/hooks/usePalette' +import {getModerationCauseKey, isJustAMute} from 'lib/moderation' +import {makeProfileLink} from 'lib/routes/links' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {s} from 'lib/styles' +import {Link} from '../util/Link' +import {Text} from '../util/text/Text' +import {PreviewableUserAvatar} from '../util/UserAvatar' +import {FollowButton} from './FollowButton' export function ProfileCard({ testID, @@ -76,8 +77,10 @@ export function ProfileCard({ anchorNoUnderline> <View style={styles.layout}> <View style={styles.layoutAvi}> - <UserAvatar + <PreviewableUserAvatar size={40} + did={profile.did} + handle={profile.handle} avatar={profile.avatar} moderation={moderation.ui('avatar')} type={isLabeler ? 'labeler' : 'user'} @@ -221,9 +224,11 @@ function FollowersList({ {followersWithMods.slice(0, 3).map(({f, mod}) => ( <View key={f.did} style={styles.followedByAviContainer}> <View style={[styles.followedByAvi, pal.view]}> - <UserAvatar - avatar={f.avatar} + <PreviewableUserAvatar size={32} + did={f.did} + handle={f.handle} + avatar={f.avatar} moderation={mod.ui('avatar')} type={f.associated?.labeler ? 'labeler' : 'user'} /> diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx index 3602cdb9a..cf35885cd 100644 --- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx +++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx @@ -1,28 +1,28 @@ import React from 'react' -import {View, StyleSheet, Pressable, ScrollView} from 'react-native' +import {Pressable, ScrollView, StyleSheet, View} from 'react-native' import {AppBskyActorDefs, moderateProfile} from '@atproto/api' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' -import * as Toast from '../util/Toast' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useModerationOpts} from '#/state/queries/preferences' +import {useProfileFollowMutationQueue} from '#/state/queries/profile' +import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' +import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' -import {Text} from 'view/com/util/text/Text' -import {UserAvatar} from 'view/com/util/UserAvatar' -import {Button} from 'view/com/util/forms/Button' +import {makeProfileLink} from 'lib/routes/links' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' -import {makeProfileLink} from 'lib/routes/links' -import {Link} from 'view/com/util/Link' -import {useAnalytics} from 'lib/analytics/analytics' import {isWeb} from 'platform/detection' -import {useModerationOpts} from '#/state/queries/preferences' -import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' -import {useProfileShadow} from '#/state/cache/profile-shadow' -import {useProfileFollowMutationQueue} from '#/state/queries/profile' -import {useLingui} from '@lingui/react' -import {Trans, msg} from '@lingui/macro' +import {Button} from 'view/com/util/forms/Button' +import {Link} from 'view/com/util/Link' +import {Text} from 'view/com/util/text/Text' +import {PreviewableUserAvatar} from 'view/com/util/UserAvatar' +import * as Toast from '../util/Toast' const OUTER_PADDING = 10 const INNER_PADDING = 14 @@ -218,8 +218,10 @@ function SuggestedFollow({ backgroundColor: pal.view.backgroundColor, }, ]}> - <UserAvatar + <PreviewableUserAvatar size={60} + did={profile.did} + handle={profile.handle} avatar={profile.avatar} moderation={moderation.ui('avatar')} /> diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index 529fc54e0..b37c69448 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -1,18 +1,19 @@ import React, {memo} from 'react' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' -import {Text} from './text/Text' -import {TextLinkOnWebOnly} from './Link' -import {niceDate} from 'lib/strings/time' +import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api' + +import {usePrefetchProfileQuery} from '#/state/queries/profile' import {usePalette} from 'lib/hooks/usePalette' -import {TypographyVariant} from 'lib/ThemeContext' -import {UserAvatar} from './UserAvatar' +import {makeProfileLink} from 'lib/routes/links' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' +import {niceDate} from 'lib/strings/time' +import {TypographyVariant} from 'lib/ThemeContext' import {isAndroid, isWeb} from 'platform/detection' +import {TextLinkOnWebOnly} from './Link' +import {Text} from './text/Text' import {TimeElapsed} from './TimeElapsed' -import {makeProfileLink} from 'lib/routes/links' -import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api' -import {usePrefetchProfileQuery} from '#/state/queries/profile' +import {PreviewableUserAvatar} from './UserAvatar' interface PostMetaOpts { author: AppBskyActorDefs.ProfileViewBasic @@ -38,9 +39,11 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { <View style={[styles.container, opts.style]}> {opts.showAvatar && ( <View style={styles.avatar}> - <UserAvatar - avatar={opts.author.avatar} + <PreviewableUserAvatar size={opts.avatarSize || 16} + did={opts.author.did} + handle={opts.author.handle} + avatar={opts.author.avatar} moderation={opts.avatarModeration} type={opts.author.associated?.labeler ? 'labeler' : 'user'} /> diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 4beedbd5b..89aa56b73 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -1,30 +1,32 @@ import React, {memo, useMemo} from 'react' import {Image, StyleSheet, TouchableOpacity, View} from 'react-native' -import Svg, {Circle, Rect, Path} from 'react-native-svg' import {Image as RNImage} from 'react-native-image-crop-picker' -import {useLingui} from '@lingui/react' -import {msg, Trans} from '@lingui/macro' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import Svg, {Circle, Path, Rect} from 'react-native-svg' import {ModerationUI} from '@atproto/api' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' -import {HighPriorityImage} from 'view/com/util/images/Image' -import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' +import {usePalette} from 'lib/hooks/usePalette' import { - usePhotoLibraryPermission, useCameraPermission, + usePhotoLibraryPermission, } from 'lib/hooks/usePermissions' +import {makeProfileLink} from 'lib/routes/links' import {colors} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {isWeb, isAndroid, isNative} from 'platform/detection' -import {UserPreviewLink} from './UserPreviewLink' -import * as Menu from '#/components/Menu' +import {isAndroid, isNative, isWeb} from 'platform/detection' +import {HighPriorityImage} from 'view/com/util/images/Image' +import {tokens, useTheme} from '#/alf' import { - Camera_Stroke2_Corner0_Rounded as Camera, Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, + Camera_Stroke2_Corner0_Rounded as Camera, } from '#/components/icons/Camera' import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import {useTheme, tokens} from '#/alf' +import {Link} from '#/components/Link' +import * as Menu from '#/components/Menu' +import {ProfileHoverCard} from '#/components/ProfileHoverCard' +import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler' @@ -372,10 +374,18 @@ export {EditableUserAvatar} let PreviewableUserAvatar = ( props: PreviewableUserAvatarProps, ): React.ReactNode => { + const {_} = useLingui() return ( - <UserPreviewLink did={props.did} handle={props.handle}> - <UserAvatar {...props} /> - </UserPreviewLink> + <ProfileHoverCard did={props.did}> + <Link + label={_(msg`See profile`)} + to={makeProfileLink({ + did: props.did, + handle: props.handle, + })}> + <UserAvatar {...props} /> + </Link> + </ProfileHoverCard> ) } PreviewableUserAvatar = memo(PreviewableUserAvatar) diff --git a/src/view/com/util/UserPreviewLink.tsx b/src/view/com/util/UserPreviewLink.tsx deleted file mode 100644 index a2c46afc0..000000000 --- a/src/view/com/util/UserPreviewLink.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react' -import {StyleProp, ViewStyle} from 'react-native' -import {Link} from './Link' -import {isWeb} from 'platform/detection' -import {makeProfileLink} from 'lib/routes/links' -import {usePrefetchProfileQuery} from '#/state/queries/profile' - -interface UserPreviewLinkProps { - did: string - handle: string - style?: StyleProp<ViewStyle> -} -export function UserPreviewLink( - props: React.PropsWithChildren<UserPreviewLinkProps>, -) { - const prefetchProfileQuery = usePrefetchProfileQuery() - return ( - <Link - onPointerEnter={() => { - if (isWeb) { - prefetchProfileQuery(props.did) - } - }} - href={makeProfileLink(props)} - title={props.handle} - asAnchor - style={props.style}> - {props.children} - </Link> - ) -} diff --git a/yarn.lock b/yarn.lock index 1a61c8b03..39bfc6a20 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3511,6 +3511,13 @@ resolved "https://registry.yarnpkg.com/@flatten-js/interval-tree/-/interval-tree-1.1.2.tgz#fcc891da48bc230392884be01c26fe8c625702e8" integrity sha512-OwLoV9E/XM6b7bes2rSFnGNjyRy7vcoIHFTnmBR2WAaZTf0Fe4EX4GdA65vU1KgFAasti7iRSg2dZfYd1Zt00Q== +"@floating-ui/core@^1.0.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1" + integrity sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g== + dependencies: + "@floating-ui/utils" "^0.2.1" + "@floating-ui/core@^1.4.1": version "1.4.1" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.4.1.tgz#0d633f4b76052668afb932492ac452f7ebe97f17" @@ -3526,6 +3533,14 @@ "@floating-ui/core" "^1.4.1" "@floating-ui/utils" "^0.1.1" +"@floating-ui/dom@^1.6.1", "@floating-ui/dom@^1.6.3": + version "1.6.3" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.3.tgz#954e46c1dd3ad48e49db9ada7218b0985cee75ef" + integrity sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw== + dependencies: + "@floating-ui/core" "^1.0.0" + "@floating-ui/utils" "^0.2.0" + "@floating-ui/react-dom@^2.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.1.tgz#7972a4fc488a8c746cded3cfe603b6057c308a91" @@ -3533,11 +3548,23 @@ dependencies: "@floating-ui/dom" "^1.3.0" +"@floating-ui/react-dom@^2.0.8": + version "2.0.8" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.8.tgz#afc24f9756d1b433e1fe0d047c24bd4d9cefaa5d" + integrity sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw== + dependencies: + "@floating-ui/dom" "^1.6.1" + "@floating-ui/utils@^0.1.1": version "0.1.1" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.1.tgz#1a5b1959a528e374e8037c4396c3e825d6cf4a83" integrity sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw== +"@floating-ui/utils@^0.2.0", "@floating-ui/utils@^0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" + integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== + "@fortawesome/fontawesome-common-types@6.4.2": version "6.4.2" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.2.tgz#1766039cad33f8ad87f9467b98e0d18fbc8f01c5" |