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",
|
"@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",
|
||||||
|
|
|
@ -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 {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>,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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'
|
| '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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}`}
|
||||||
|
|
|
@ -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 || ''})
|
||||||
|
|
|
@ -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
|
||||||
|
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)}
|
||||||
|
|
||||||
|
<Text style={[pal.textLight]} lineHeight={1.2}>
|
||||||
|
{sanitizeHandle(author.handle)}
|
||||||
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</UserPreviewLink>
|
</NewLink>
|
||||||
))}
|
))}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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'}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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')}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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'}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"
|
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"
|
||||||
|
|
Loading…
Reference in New Issue