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>
zio/stable
Eric Bailey 2024-04-12 17:01:32 -05:00 committed by GitHub
parent f91aa37c6b
commit 1f61109cfa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 576 additions and 146 deletions

View File

@ -57,6 +57,8 @@
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@expo/html-elements": "^0.4.2", "@expo/html-elements": "^0.4.2",
"@expo/webpack-config": "^19.0.0", "@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/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1",

View File

@ -0,0 +1,5 @@
import {ProfileHoverCardProps} from './types'
export function ProfileHoverCard({children}: ProfileHoverCardProps) {
return children
}

View File

@ -0,0 +1,290 @@
import React from 'react'
import {View} from 'react-native'
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {pluralize} from '#/lib/strings/helpers'
import {useModerationOpts} from '#/state/queries/preferences'
import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {formatCount} from '#/view/com/util/numeric/format'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useFollowMethods} from '#/components/hooks/useFollowMethods'
import {useRichText} from '#/components/hooks/useRichText'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Portal} from '#/components/Portal'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {ProfileHoverCardProps} from './types'
const floatingMiddlewares = [
offset(4),
flip({padding: 16}),
shift({padding: 16}),
size({
padding: 16,
apply({availableWidth, availableHeight, elements}) {
Object.assign(elements.floating.style, {
maxWidth: `${availableWidth}px`,
maxHeight: `${availableHeight}px`,
})
},
}),
]
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0
export function ProfileHoverCard(props: ProfileHoverCardProps) {
return isTouchDevice ? props.children : <ProfileHoverCardInner {...props} />
}
export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
const [hovered, setHovered] = React.useState(false)
const {refs, floatingStyles} = useFloating({
middleware: floatingMiddlewares,
})
const prefetchProfileQuery = usePrefetchProfileQuery()
const prefetchedProfile = React.useRef(false)
const targetHovered = React.useRef(false)
const cardHovered = React.useRef(false)
const targetClicked = React.useRef(false)
const onPointerEnterTarget = React.useCallback(() => {
targetHovered.current = true
if (prefetchedProfile.current) {
// if we're navigating
if (targetClicked.current) return
setHovered(true)
} else {
prefetchProfileQuery(props.did).then(() => {
if (targetHovered.current) {
setHovered(true)
}
prefetchedProfile.current = true
})
}
}, [props.did, prefetchProfileQuery])
const onPointerEnterCard = React.useCallback(() => {
cardHovered.current = true
// if we're navigating
if (targetClicked.current) return
setHovered(true)
}, [])
const onPointerLeaveTarget = React.useCallback(() => {
targetHovered.current = false
setTimeout(() => {
if (cardHovered.current) return
setHovered(false)
}, 100)
}, [])
const onPointerLeaveCard = React.useCallback(() => {
cardHovered.current = false
setTimeout(() => {
if (targetHovered.current) return
setHovered(false)
}, 100)
}, [])
const onClickTarget = React.useCallback(() => {
targetClicked.current = true
setHovered(false)
}, [])
const hide = React.useCallback(() => {
setHovered(false)
}, [])
return (
<div
ref={refs.setReference}
onPointerEnter={onPointerEnterTarget}
onPointerLeave={onPointerLeaveTarget}
onMouseUp={onClickTarget}>
{props.children}
{hovered && (
<Portal>
<Animated.View
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}>
<div
ref={refs.setFloating}
style={floatingStyles}
onPointerEnter={onPointerEnterCard}
onPointerLeave={onPointerLeaveCard}>
<Card did={props.did} hide={hide} />
</div>
</Animated.View>
</Portal>
)}
</div>
)
}
function Card({did, hide}: {did: string; hide: () => void}) {
const t = useTheme()
const profile = useProfileQuery({did})
const moderationOpts = useModerationOpts()
const data = profile.data
return (
<View
style={[
a.p_lg,
a.border,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg,
t.atoms.border_contrast_low,
t.atoms.shadow_lg,
{
width: 300,
},
]}>
{data && moderationOpts ? (
<Inner profile={data} moderationOpts={moderationOpts} hide={hide} />
) : (
<View style={[a.justify_center]}>
<Loader size="xl" />
</View>
)}
</View>
)
}
function Inner({
profile,
moderationOpts,
hide,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
hide: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const moderation = React.useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
const [descriptionRT] = useRichText(profile.description ?? '')
const profileShadow = useProfileShadow(profile)
const {follow, unfollow} = useFollowMethods({
profile: profileShadow,
logContext: 'ProfileHoverCard',
})
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
const following = formatCount(profile.followsCount || 0)
const followers = formatCount(profile.followersCount || 0)
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
const profileURL = makeProfileLink({
did: profile.did,
handle: profile.handle,
})
const isMe = React.useMemo(
() => currentAccount?.did === profile.did,
[currentAccount, profile],
)
return (
<View>
<View style={[a.flex_row, a.justify_between, a.align_start]}>
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
<UserAvatar
size={64}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
/>
</Link>
{!isMe && (
<Button
size="small"
color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
variant="solid"
label={
profileShadow.viewer?.following ? _('Following') : _('Follow')
}
style={[a.rounded_full]}
onPress={profileShadow.viewer?.following ? unfollow : follow}>
<ButtonIcon
position="left"
icon={profileShadow.viewer?.following ? Check : Plus}
/>
<ButtonText>
{profileShadow.viewer?.following ? _('Following') : _('Follow')}
</ButtonText>
</Button>
)}
</View>
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
<View style={[a.pb_sm, a.flex_1]}>
<Text style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold]}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
</Text>
<ProfileHeaderHandle profile={profileShadow} />
</View>
</Link>
{!blockHide && (
<>
<View style={[a.flex_row, a.flex_wrap, a.gap_md, a.pt_xs]}>
<InlineLinkText
to={makeProfileLink(profile, 'followers')}
label={`${followers} ${pluralizedFollowers}`}
style={[t.atoms.text]}
onPress={hide}>
<Trans>
<Text style={[a.text_md, a.font_bold]}>{followers} </Text>
<Text style={[t.atoms.text_contrast_medium]}>
{pluralizedFollowers}
</Text>
</Trans>
</InlineLinkText>
<InlineLinkText
to={makeProfileLink(profile, 'follows')}
label={_(msg`${following} following`)}
style={[t.atoms.text]}
onPress={hide}>
<Trans>
<Text style={[a.text_md, a.font_bold]}>{following} </Text>
<Text style={[t.atoms.text_contrast_medium]}>following</Text>
</Trans>
</InlineLinkText>
</View>
{profile.description?.trim() && !moderation.ui('profileView').blur ? (
<View style={[a.pt_md]}>
<RichText
numberOfLines={8}
value={descriptionRT}
onLinkPress={hide}
/>
</View>
) : undefined}
</>
)}
</View>
)
}

View File

@ -0,0 +1,6 @@
import React from 'react'
export type ProfileHoverCardProps = {
children: React.ReactElement
did: string
}

View File

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

View File

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

View File

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

View File

@ -99,6 +99,7 @@ export type LogEvents = {
| 'ProfileHeader' | 'ProfileHeader'
| 'ProfileHeaderSuggestedFollows' | 'ProfileHeaderSuggestedFollows'
| 'ProfileMenu' | 'ProfileMenu'
| 'ProfileHoverCard'
} }
'profile:unfollow': { 'profile:unfollow': {
logContext: logContext:
@ -108,5 +109,6 @@ export type LogEvents = {
| 'ProfileHeader' | 'ProfileHeader'
| 'ProfileHeaderSuggestedFollows' | 'ProfileHeaderSuggestedFollows'
| 'ProfileMenu' | 'ProfileMenu'
| 'ProfileHoverCard'
} }
} }

View File

@ -1,10 +1,10 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api' import {AppBskyActorDefs} from '@atproto/api'
import {isInvalidHandle} from 'lib/strings/handles'
import {Shadow} from '#/state/cache/types'
import {Trans} from '@lingui/macro' 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 {atoms as a, useTheme, web} from '#/alf'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@ -26,6 +26,7 @@ export function ProfileHeaderHandle({
</View> </View>
) : undefined} ) : undefined}
<Text <Text
numberOfLines={1}
style={[ style={[
invalidHandle invalidHandle
? [ ? [
@ -36,7 +37,7 @@ export function ProfileHeaderHandle({
a.rounded_xs, a.rounded_xs,
{borderColor: t.palette.contrast_200}, {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'}), web({wordBreak: 'break-all'}),
]}> ]}>
{invalidHandle ? <Trans>Invalid Handle</Trans> : `@${profile.handle}`} {invalidHandle ? <Trans>Invalid Handle</Trans> : `@${profile.handle}`}

View File

@ -90,8 +90,8 @@ export function useProfilesQuery({handles}: {handles: string[]}) {
export function usePrefetchProfileQuery() { export function usePrefetchProfileQuery() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const prefetchProfileQuery = useCallback( const prefetchProfileQuery = useCallback(
(did: string) => { async (did: string) => {
queryClient.prefetchQuery({ await queryClient.prefetchQuery({
queryKey: RQKEY(did), queryKey: RQKEY(did),
queryFn: async () => { queryFn: async () => {
const res = await getAgent().getProfile({actor: did || ''}) const res = await getAgent().getProfile({actor: did || ''})

View File

@ -1,20 +1,20 @@
import React, {memo, useMemo, useState, useEffect} from 'react' import React, {memo, useEffect, useMemo, useState} from 'react'
import { import {
Animated, Animated,
TouchableOpacity,
Pressable, Pressable,
StyleSheet, StyleSheet,
TouchableOpacity,
View, View,
} from 'react-native' } from 'react-native'
import { import {
AppBskyActorDefs,
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
ModerationOpts,
ModerationDecision,
moderateProfile, moderateProfile,
AppBskyEmbedRecordWithMedia, ModerationDecision,
AppBskyActorDefs, ModerationOpts,
} from '@atproto/api' } from '@atproto/api'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import { import {
@ -22,28 +22,30 @@ import {
FontAwesomeIconStyle, FontAwesomeIconStyle,
Props, Props,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FeedNotification} from '#/state/queries/notifications/feed' import {FeedNotification} from '#/state/queries/notifications/feed'
import {s, colors} from 'lib/styles' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {niceDate} from 'lib/strings/time' 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 {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {pluralize} from 'lib/strings/helpers' import {pluralize} from 'lib/strings/helpers'
import {HeartIconSolid} from 'lib/icons' import {niceDate} from 'lib/strings/time'
import {Text} from '../util/text/Text' import {colors, s} from 'lib/styles'
import {UserAvatar, PreviewableUserAvatar} from '../util/UserAvatar'
import {UserPreviewLink} from '../util/UserPreviewLink'
import {ImageHorzList} from '../util/images/ImageHorzList'
import {Post} from '../post/Post'
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 {TimeElapsed} from '../util/TimeElapsed'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {Trans, msg} from '@lingui/macro' import {Link as NewLink} from '#/components/Link'
import {useLingui} from '@lingui/react' import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {FeedSourceCard} from '../feeds/FeedSourceCard' import {FeedSourceCard} from '../feeds/FeedSourceCard'
import {Post} from '../post/Post'
import {ImageHorzList} from '../util/images/ImageHorzList'
import {Link, TextLink} from '../util/Link'
import {formatCount} from '../util/numeric/format'
import {Text} from '../util/text/Text'
import {TimeElapsed} from '../util/TimeElapsed'
import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar'
const MAX_AUTHORS = 5 const MAX_AUTHORS = 5
@ -356,8 +358,10 @@ function CondensedAuthorsList({
<View style={styles.avis}> <View style={styles.avis}>
{authors.slice(0, MAX_AUTHORS).map(author => ( {authors.slice(0, MAX_AUTHORS).map(author => (
<View key={author.href} style={s.mr5}> <View key={author.href} style={s.mr5}>
<UserAvatar <PreviewableUserAvatar
size={35} size={35}
did={author.did}
handle={author.handle}
avatar={author.avatar} avatar={author.avatar}
moderation={author.moderation.ui('avatar')} moderation={author.moderation.ui('avatar')}
type={author.associated?.labeler ? 'labeler' : 'user'} type={author.associated?.labeler ? 'labeler' : 'user'}
@ -386,6 +390,7 @@ function ExpandedAuthorsList({
visible: boolean visible: boolean
authors: Author[] authors: Author[]
}) { }) {
const {_} = useLingui()
const pal = usePalette('default') const pal = usePalette('default')
const heightInterp = useAnimatedValue(visible ? 1 : 0) const heightInterp = useAnimatedValue(visible ? 1 : 0)
const targetHeight = const targetHeight =
@ -409,33 +414,39 @@ function ExpandedAuthorsList({
visible ? s.mb10 : undefined, visible ? s.mb10 : undefined,
]}> ]}>
{authors.map(author => ( {authors.map(author => (
<UserPreviewLink <NewLink
key={author.did} key={author.did}
did={author.did} label={_(msg`See profile`)}
handle={author.handle} to={makeProfileLink({
style={styles.expandedAuthor}> did: author.did,
<View style={styles.expandedAuthorAvi}> handle: author.handle,
<UserAvatar })}>
size={35} <View style={styles.expandedAuthor}>
avatar={author.avatar} <View style={styles.expandedAuthorAvi}>
moderation={author.moderation.ui('avatar')} <ProfileHoverCard did={author.did}>
type={author.associated?.labeler ? 'labeler' : 'user'} <UserAvatar
/> size={35}
</View> avatar={author.avatar}
<View style={s.flex1}> moderation={author.moderation.ui('avatar')}
<Text type={author.associated?.labeler ? 'labeler' : 'user'}
type="lg-bold" />
numberOfLines={1} </ProfileHoverCard>
style={pal.text} </View>
lineHeight={1.2}> <View style={s.flex1}>
{sanitizeDisplayName(author.displayName || author.handle)} <Text
&nbsp; type="lg-bold"
<Text style={[pal.textLight]} lineHeight={1.2}> numberOfLines={1}
{sanitizeHandle(author.handle)} 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>
</Text> </View>
</View> </View>
</UserPreviewLink> </NewLink>
))} ))}
</Animated.View> </Animated.View>
) )

View File

@ -6,22 +6,23 @@ import {
ModerationCause, ModerationCause,
ModerationDecision, ModerationDecision,
} from '@atproto/api' } from '@atproto/api'
import {Link} from '../util/Link' import {Trans} from '@lingui/macro'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar' import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {s} from 'lib/styles' import {useProfileShadow} from '#/state/cache/profile-shadow'
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 {Shadow} from '#/state/cache/types' import {Shadow} from '#/state/cache/types'
import {useModerationOpts} from '#/state/queries/preferences' import {useModerationOpts} from '#/state/queries/preferences'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {Trans} from '@lingui/macro' import {usePalette} from 'lib/hooks/usePalette'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' 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({ export function ProfileCard({
testID, testID,
@ -76,8 +77,10 @@ export function ProfileCard({
anchorNoUnderline> anchorNoUnderline>
<View style={styles.layout}> <View style={styles.layout}>
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
<UserAvatar <PreviewableUserAvatar
size={40} size={40}
did={profile.did}
handle={profile.handle}
avatar={profile.avatar} avatar={profile.avatar}
moderation={moderation.ui('avatar')} moderation={moderation.ui('avatar')}
type={isLabeler ? 'labeler' : 'user'} type={isLabeler ? 'labeler' : 'user'}
@ -221,9 +224,11 @@ function FollowersList({
{followersWithMods.slice(0, 3).map(({f, mod}) => ( {followersWithMods.slice(0, 3).map(({f, mod}) => (
<View key={f.did} style={styles.followedByAviContainer}> <View key={f.did} style={styles.followedByAviContainer}>
<View style={[styles.followedByAvi, pal.view]}> <View style={[styles.followedByAvi, pal.view]}>
<UserAvatar <PreviewableUserAvatar
avatar={f.avatar}
size={32} size={32}
did={f.did}
handle={f.handle}
avatar={f.avatar}
moderation={mod.ui('avatar')} moderation={mod.ui('avatar')}
type={f.associated?.labeler ? 'labeler' : 'user'} type={f.associated?.labeler ? 'labeler' : 'user'}
/> />

View File

@ -1,28 +1,28 @@
import React from 'react' 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 {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } 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 {usePalette} from 'lib/hooks/usePalette'
import {Text} from 'view/com/util/text/Text' import {makeProfileLink} from 'lib/routes/links'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {Button} from 'view/com/util/forms/Button'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles' 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 {isWeb} from 'platform/detection'
import {useModerationOpts} from '#/state/queries/preferences' import {Button} from 'view/com/util/forms/Button'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' import {Link} from 'view/com/util/Link'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {Text} from 'view/com/util/text/Text'
import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
import {useLingui} from '@lingui/react' import * as Toast from '../util/Toast'
import {Trans, msg} from '@lingui/macro'
const OUTER_PADDING = 10 const OUTER_PADDING = 10
const INNER_PADDING = 14 const INNER_PADDING = 14
@ -218,8 +218,10 @@ function SuggestedFollow({
backgroundColor: pal.view.backgroundColor, backgroundColor: pal.view.backgroundColor,
}, },
]}> ]}>
<UserAvatar <PreviewableUserAvatar
size={60} size={60}
did={profile.did}
handle={profile.handle}
avatar={profile.avatar} avatar={profile.avatar}
moderation={moderation.ui('avatar')} moderation={moderation.ui('avatar')}
/> />

View File

@ -1,18 +1,19 @@
import React, {memo} from 'react' import React, {memo} from 'react'
import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
import {Text} from './text/Text' import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api'
import {TextLinkOnWebOnly} from './Link'
import {niceDate} from 'lib/strings/time' import {usePrefetchProfileQuery} from '#/state/queries/profile'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {TypographyVariant} from 'lib/ThemeContext' import {makeProfileLink} from 'lib/routes/links'
import {UserAvatar} from './UserAvatar'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {niceDate} from 'lib/strings/time'
import {TypographyVariant} from 'lib/ThemeContext'
import {isAndroid, isWeb} from 'platform/detection' import {isAndroid, isWeb} from 'platform/detection'
import {TextLinkOnWebOnly} from './Link'
import {Text} from './text/Text'
import {TimeElapsed} from './TimeElapsed' import {TimeElapsed} from './TimeElapsed'
import {makeProfileLink} from 'lib/routes/links' import {PreviewableUserAvatar} from './UserAvatar'
import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api'
import {usePrefetchProfileQuery} from '#/state/queries/profile'
interface PostMetaOpts { interface PostMetaOpts {
author: AppBskyActorDefs.ProfileViewBasic author: AppBskyActorDefs.ProfileViewBasic
@ -38,9 +39,11 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
<View style={[styles.container, opts.style]}> <View style={[styles.container, opts.style]}>
{opts.showAvatar && ( {opts.showAvatar && (
<View style={styles.avatar}> <View style={styles.avatar}>
<UserAvatar <PreviewableUserAvatar
avatar={opts.author.avatar}
size={opts.avatarSize || 16} size={opts.avatarSize || 16}
did={opts.author.did}
handle={opts.author.handle}
avatar={opts.author.avatar}
moderation={opts.avatarModeration} moderation={opts.avatarModeration}
type={opts.author.associated?.labeler ? 'labeler' : 'user'} type={opts.author.associated?.labeler ? 'labeler' : 'user'}
/> />

View File

@ -1,30 +1,32 @@
import React, {memo, useMemo} from 'react' import React, {memo, useMemo} from 'react'
import {Image, StyleSheet, TouchableOpacity, View} from 'react-native' 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 {Image as RNImage} from 'react-native-image-crop-picker'
import {useLingui} from '@lingui/react' import Svg, {Circle, Path, Rect} from 'react-native-svg'
import {msg, Trans} from '@lingui/macro'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {ModerationUI} from '@atproto/api' 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 {
usePhotoLibraryPermission,
useCameraPermission,
} from 'lib/hooks/usePermissions'
import {colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {isWeb, isAndroid, isNative} from 'platform/detection'
import {UserPreviewLink} from './UserPreviewLink'
import * as Menu from '#/components/Menu'
import { import {
Camera_Stroke2_Corner0_Rounded as Camera, useCameraPermission,
usePhotoLibraryPermission,
} from 'lib/hooks/usePermissions'
import {makeProfileLink} from 'lib/routes/links'
import {colors} from 'lib/styles'
import {isAndroid, isNative, isWeb} from 'platform/detection'
import {HighPriorityImage} from 'view/com/util/images/Image'
import {tokens, useTheme} from '#/alf'
import {
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
Camera_Stroke2_Corner0_Rounded as Camera,
} from '#/components/icons/Camera' } from '#/components/icons/Camera'
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive' import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 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' export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
@ -372,10 +374,18 @@ export {EditableUserAvatar}
let PreviewableUserAvatar = ( let PreviewableUserAvatar = (
props: PreviewableUserAvatarProps, props: PreviewableUserAvatarProps,
): React.ReactNode => { ): React.ReactNode => {
const {_} = useLingui()
return ( return (
<UserPreviewLink did={props.did} handle={props.handle}> <ProfileHoverCard did={props.did}>
<UserAvatar {...props} /> <Link
</UserPreviewLink> label={_(msg`See profile`)}
to={makeProfileLink({
did: props.did,
handle: props.handle,
})}>
<UserAvatar {...props} />
</Link>
</ProfileHoverCard>
) )
} }
PreviewableUserAvatar = memo(PreviewableUserAvatar) PreviewableUserAvatar = memo(PreviewableUserAvatar)

View File

@ -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>
)
}

View File

@ -3511,6 +3511,13 @@
resolved "https://registry.yarnpkg.com/@flatten-js/interval-tree/-/interval-tree-1.1.2.tgz#fcc891da48bc230392884be01c26fe8c625702e8" resolved "https://registry.yarnpkg.com/@flatten-js/interval-tree/-/interval-tree-1.1.2.tgz#fcc891da48bc230392884be01c26fe8c625702e8"
integrity sha512-OwLoV9E/XM6b7bes2rSFnGNjyRy7vcoIHFTnmBR2WAaZTf0Fe4EX4GdA65vU1KgFAasti7iRSg2dZfYd1Zt00Q== 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": "@floating-ui/core@^1.4.1":
version "1.4.1" version "1.4.1"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.4.1.tgz#0d633f4b76052668afb932492ac452f7ebe97f17" 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/core" "^1.4.1"
"@floating-ui/utils" "^0.1.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": "@floating-ui/react-dom@^2.0.0":
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.1.tgz#7972a4fc488a8c746cded3cfe603b6057c308a91" resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.1.tgz#7972a4fc488a8c746cded3cfe603b6057c308a91"
@ -3533,11 +3548,23 @@
dependencies: dependencies:
"@floating-ui/dom" "^1.3.0" "@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": "@floating-ui/utils@^0.1.1":
version "0.1.1" version "0.1.1"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.1.tgz#1a5b1959a528e374e8037c4396c3e825d6cf4a83" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.1.tgz#1a5b1959a528e374e8037c4396c3e825d6cf4a83"
integrity sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw== 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": "@fortawesome/fontawesome-common-types@6.4.2":
version "6.4.2" version "6.4.2"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.2.tgz#1766039cad33f8ad87f9467b98e0d18fbc8f01c5" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.2.tgz#1766039cad33f8ad87f9467b98e0d18fbc8f01c5"