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>
This commit is contained in:
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

@ -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)}
&nbsp;
<Text style={[pal.textLight]} lineHeight={1.2}>
{sanitizeHandle(author.handle)}
label={_(msg`See profile`)}
to={makeProfileLink({
did: author.did,
handle: author.handle,
})}>
<View style={styles.expandedAuthor}>
<View style={styles.expandedAuthorAvi}>
<ProfileHoverCard did={author.did}>
<UserAvatar
size={35}
avatar={author.avatar}
moderation={author.moderation.ui('avatar')}
type={author.associated?.labeler ? 'labeler' : 'user'}
/>
</ProfileHoverCard>
</View>
<View style={s.flex1}>
<Text
type="lg-bold"
numberOfLines={1}
style={pal.text}
lineHeight={1.2}>
{sanitizeDisplayName(author.displayName || author.handle)}
&nbsp;
<Text style={[pal.textLight]} lineHeight={1.2}>
{sanitizeHandle(author.handle)}
</Text>
</Text>
</Text>
</View>
</View>
</UserPreviewLink>
</NewLink>
))}
</Animated.View>
)

View file

@ -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'}
/>

View file

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

View file

@ -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'}
/>

View file

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

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