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
parent
f91aa37c6b
commit
1f61109cfa
|
@ -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",
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import {ProfileHoverCardProps} from './types'
|
||||
|
||||
export function ProfileHoverCard({children}: ProfileHoverCardProps) {
|
||||
return children
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import React from 'react'
|
||||
|
||||
export type ProfileHoverCardProps = {
|
||||
children: React.ReactElement
|
||||
did: string
|
||||
}
|
|
@ -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>,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}`}
|
||||
|
|
|
@ -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 || ''})
|
||||
|
|
|
@ -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 {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 {niceDate} from 'lib/strings/time'
|
||||
import {colors, s} from 'lib/styles'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
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 {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
|
||||
|
||||
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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'}
|
||||
/>
|
||||
|
|
|
@ -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')}
|
||||
/>
|
||||
|
|
|
@ -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'}
|
||||
/>
|
||||
|
|
|
@ -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 {
|
||||
usePhotoLibraryPermission,
|
||||
useCameraPermission,
|
||||
} from 'lib/hooks/usePermissions'
|
||||
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 {
|
||||
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_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)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
27
yarn.lock
27
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"
|
||||
|
|
Loading…
Reference in New Issue