about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--package.json2
-rw-r--r--src/components/ProfileHoverCard/index.tsx5
-rw-r--r--src/components/ProfileHoverCard/index.web.tsx290
-rw-r--r--src/components/ProfileHoverCard/types.ts6
-rw-r--r--src/components/RichText.tsx10
-rw-r--r--src/components/hooks/useFollowMethods.ts60
-rw-r--r--src/components/hooks/useRichText.ts33
-rw-r--r--src/lib/statsig/events.ts2
-rw-r--r--src/screens/Profile/Header/Handle.tsx7
-rw-r--r--src/state/queries/profile.ts4
-rw-r--r--src/view/com/notifications/FeedItem.tsx101
-rw-r--r--src/view/com/profile/ProfileCard.tsx37
-rw-r--r--src/view/com/profile/ProfileHeaderSuggestedFollows.tsx32
-rw-r--r--src/view/com/util/PostMeta.tsx23
-rw-r--r--src/view/com/util/UserAvatar.tsx42
-rw-r--r--src/view/com/util/UserPreviewLink.tsx31
-rw-r--r--yarn.lock27
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)}
-              &nbsp;
-              <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)}
+                &nbsp;
+                <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"