Add social proof to suggested follows (#4602)
* replace unused `followers` prop with social proof * Introduce 'minimal' version * Gate social proof one explore page, fix space if no desc * Use smaller avis for minimal --------- Co-authored-by: Eric Bailey <git@esb.lol>zio/stable
parent
4360087ced
commit
2d0eefebc3
|
@ -12,6 +12,7 @@ import {Link, LinkProps} from '#/components/Link'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
const AVI_SIZE = 30
|
const AVI_SIZE = 30
|
||||||
|
const AVI_SIZE_SMALL = 20
|
||||||
const AVI_BORDER = 1
|
const AVI_BORDER = 1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -30,10 +31,12 @@ export function KnownFollowers({
|
||||||
profile,
|
profile,
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
onLinkPress,
|
onLinkPress,
|
||||||
|
minimal,
|
||||||
}: {
|
}: {
|
||||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
moderationOpts: ModerationOpts
|
moderationOpts: ModerationOpts
|
||||||
onLinkPress?: LinkProps['onPress']
|
onLinkPress?: LinkProps['onPress']
|
||||||
|
minimal?: boolean
|
||||||
}) {
|
}) {
|
||||||
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
|
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
|
@ -59,6 +62,7 @@ export function KnownFollowers({
|
||||||
cachedKnownFollowers={cachedKnownFollowers}
|
cachedKnownFollowers={cachedKnownFollowers}
|
||||||
moderationOpts={moderationOpts}
|
moderationOpts={moderationOpts}
|
||||||
onLinkPress={onLinkPress}
|
onLinkPress={onLinkPress}
|
||||||
|
minimal={minimal}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -71,11 +75,13 @@ function KnownFollowersInner({
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
cachedKnownFollowers,
|
cachedKnownFollowers,
|
||||||
onLinkPress,
|
onLinkPress,
|
||||||
|
minimal,
|
||||||
}: {
|
}: {
|
||||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
moderationOpts: ModerationOpts
|
moderationOpts: ModerationOpts
|
||||||
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
|
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
|
||||||
onLinkPress?: LinkProps['onPress']
|
onLinkPress?: LinkProps['onPress']
|
||||||
|
minimal?: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
@ -110,6 +116,8 @@ function KnownFollowersInner({
|
||||||
*/
|
*/
|
||||||
if (slice.length === 0) return null
|
if (slice.length === 0) return null
|
||||||
|
|
||||||
|
const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
label={_(
|
label={_(
|
||||||
|
@ -120,7 +128,7 @@ function KnownFollowersInner({
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.gap_md,
|
minimal ? a.gap_sm : a.gap_md,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
{marginLeft: -AVI_BORDER},
|
{marginLeft: -AVI_BORDER},
|
||||||
]}>
|
]}>
|
||||||
|
@ -129,8 +137,8 @@ function KnownFollowersInner({
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
height: AVI_SIZE,
|
height: SIZE,
|
||||||
width: AVI_SIZE + (slice.length - 1) * a.gap_md.gap,
|
width: SIZE + (slice.length - 1) * a.gap_md.gap,
|
||||||
},
|
},
|
||||||
pressed && {
|
pressed && {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
|
@ -145,14 +153,14 @@ function KnownFollowersInner({
|
||||||
{
|
{
|
||||||
borderWidth: AVI_BORDER,
|
borderWidth: AVI_BORDER,
|
||||||
borderColor: t.atoms.bg.backgroundColor,
|
borderColor: t.atoms.bg.backgroundColor,
|
||||||
width: AVI_SIZE + AVI_BORDER * 2,
|
width: SIZE + AVI_BORDER * 2,
|
||||||
height: AVI_SIZE + AVI_BORDER * 2,
|
height: SIZE + AVI_BORDER * 2,
|
||||||
left: i * a.gap_md.gap,
|
left: i * a.gap_md.gap,
|
||||||
zIndex: AVI_BORDER - i,
|
zIndex: AVI_BORDER - i,
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
size={AVI_SIZE}
|
size={SIZE}
|
||||||
avatar={prof.avatar}
|
avatar={prof.avatar}
|
||||||
moderation={moderation.ui('avatar')}
|
moderation={moderation.ui('avatar')}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
export type Gate =
|
export type Gate =
|
||||||
// Keep this alphabetic please.
|
// Keep this alphabetic please.
|
||||||
| 'debug_show_feedcontext'
|
| 'debug_show_feedcontext'
|
||||||
|
| 'explore_page_profile_card_social_proof'
|
||||||
| 'native_pwi_disabled'
|
| 'native_pwi_disabled'
|
||||||
| 'new_user_guided_tour'
|
| 'new_user_guided_tour'
|
||||||
| 'new_user_progress_guide'
|
| 'new_user_progress_guide'
|
||||||
|
|
|
@ -5,7 +5,6 @@ import {
|
||||||
moderateProfile,
|
moderateProfile,
|
||||||
ModerationDecision,
|
ModerationDecision,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {Trans} from '@lingui/macro'
|
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
|
@ -19,12 +18,16 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
import {sanitizeHandle} from 'lib/strings/handles'
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {precacheProfile} from 'state/queries/profile'
|
import {precacheProfile} from 'state/queries/profile'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {
|
||||||
|
KnownFollowers,
|
||||||
|
shouldShowKnownFollowers,
|
||||||
|
} from '#/components/KnownFollowers'
|
||||||
import {Link} from '../util/Link'
|
import {Link} from '../util/Link'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||||
import {FollowButton} from './FollowButton'
|
import {FollowButton} from './FollowButton'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
import hairlineWidth = StyleSheet.hairlineWidth
|
||||||
import {atoms as a} from '#/alf'
|
|
||||||
import * as Pills from '#/components/Pills'
|
import * as Pills from '#/components/Pills'
|
||||||
|
|
||||||
export function ProfileCard({
|
export function ProfileCard({
|
||||||
|
@ -33,22 +36,22 @@ export function ProfileCard({
|
||||||
noModFilter,
|
noModFilter,
|
||||||
noBg,
|
noBg,
|
||||||
noBorder,
|
noBorder,
|
||||||
followers,
|
|
||||||
renderButton,
|
renderButton,
|
||||||
onPress,
|
onPress,
|
||||||
style,
|
style,
|
||||||
|
showKnownFollowers,
|
||||||
}: {
|
}: {
|
||||||
testID?: string
|
testID?: string
|
||||||
profile: AppBskyActorDefs.ProfileViewBasic
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
noModFilter?: boolean
|
noModFilter?: boolean
|
||||||
noBg?: boolean
|
noBg?: boolean
|
||||||
noBorder?: boolean
|
noBorder?: boolean
|
||||||
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
|
||||||
renderButton?: (
|
renderButton?: (
|
||||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>,
|
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>,
|
||||||
) => React.ReactNode
|
) => React.ReactNode
|
||||||
onPress?: () => void
|
onPress?: () => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
|
showKnownFollowers?: boolean
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
@ -70,6 +73,11 @@ export function ProfileCard({
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const knownFollowersVisible =
|
||||||
|
showKnownFollowers &&
|
||||||
|
shouldShowKnownFollowers(profile.viewer?.knownFollowers) &&
|
||||||
|
moderationOpts
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
testID={testID}
|
testID={testID}
|
||||||
|
@ -118,14 +126,30 @@ export function ProfileCard({
|
||||||
<View style={styles.layoutButton}>{renderButton(profile)}</View>
|
<View style={styles.layoutButton}>{renderButton(profile)}</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</View>
|
</View>
|
||||||
{profile.description ? (
|
{profile.description || knownFollowersVisible ? (
|
||||||
<View style={styles.details}>
|
<View style={styles.details}>
|
||||||
|
{profile.description ? (
|
||||||
<Text style={pal.text} numberOfLines={4}>
|
<Text style={pal.text} numberOfLines={4}>
|
||||||
{profile.description as string}
|
{profile.description as string}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{knownFollowersVisible ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
!!profile.description && a.mt_md,
|
||||||
|
]}>
|
||||||
|
<KnownFollowers
|
||||||
|
minimal
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<FollowersList followers={followers} />
|
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -155,73 +179,20 @@ export function ProfileCardPills({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FollowersList({
|
|
||||||
followers,
|
|
||||||
}: {
|
|
||||||
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
|
||||||
}) {
|
|
||||||
const pal = usePalette('default')
|
|
||||||
const moderationOpts = useModerationOpts()
|
|
||||||
|
|
||||||
const followersWithMods = React.useMemo(() => {
|
|
||||||
if (!followers || !moderationOpts) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
return followers
|
|
||||||
.map(f => ({
|
|
||||||
f,
|
|
||||||
mod: moderateProfile(f, moderationOpts),
|
|
||||||
}))
|
|
||||||
.filter(({mod}) => !mod.ui('profileList').filter)
|
|
||||||
}, [followers, moderationOpts])
|
|
||||||
|
|
||||||
if (!followersWithMods?.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.followedBy}>
|
|
||||||
<Text
|
|
||||||
type="sm"
|
|
||||||
style={[styles.followsByDesc, pal.textLight]}
|
|
||||||
numberOfLines={2}
|
|
||||||
lineHeight={1.2}>
|
|
||||||
<Trans>
|
|
||||||
Followed by{' '}
|
|
||||||
{followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')}
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
{followersWithMods.slice(0, 3).map(({f, mod}) => (
|
|
||||||
<View key={f.did} style={styles.followedByAviContainer}>
|
|
||||||
<View style={[styles.followedByAvi, pal.view]}>
|
|
||||||
<PreviewableUserAvatar
|
|
||||||
size={32}
|
|
||||||
profile={f}
|
|
||||||
moderation={mod.ui('avatar')}
|
|
||||||
type={f.associated?.labeler ? 'labeler' : 'user'}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProfileCardWithFollowBtn({
|
export function ProfileCardWithFollowBtn({
|
||||||
profile,
|
profile,
|
||||||
noBg,
|
noBg,
|
||||||
noBorder,
|
noBorder,
|
||||||
followers,
|
|
||||||
onPress,
|
onPress,
|
||||||
logContext = 'ProfileCard',
|
logContext = 'ProfileCard',
|
||||||
|
showKnownFollowers,
|
||||||
}: {
|
}: {
|
||||||
profile: AppBskyActorDefs.ProfileViewBasic
|
profile: AppBskyActorDefs.ProfileView
|
||||||
noBg?: boolean
|
noBg?: boolean
|
||||||
noBorder?: boolean
|
noBorder?: boolean
|
||||||
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
|
||||||
onPress?: () => void
|
onPress?: () => void
|
||||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||||
|
showKnownFollowers?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const isMe = profile.did === currentAccount?.did
|
const isMe = profile.did === currentAccount?.did
|
||||||
|
@ -231,7 +202,6 @@ export function ProfileCardWithFollowBtn({
|
||||||
profile={profile}
|
profile={profile}
|
||||||
noBg={noBg}
|
noBg={noBg}
|
||||||
noBorder={noBorder}
|
noBorder={noBorder}
|
||||||
followers={followers}
|
|
||||||
renderButton={
|
renderButton={
|
||||||
isMe
|
isMe
|
||||||
? undefined
|
? undefined
|
||||||
|
@ -240,6 +210,7 @@ export function ProfileCardWithFollowBtn({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
|
showKnownFollowers={!isMe && showKnownFollowers}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
@ -241,7 +242,7 @@ type ExploreScreenItems =
|
||||||
| {
|
| {
|
||||||
type: 'profile'
|
type: 'profile'
|
||||||
key: string
|
key: string
|
||||||
profile: AppBskyActorDefs.ProfileViewBasic
|
profile: AppBskyActorDefs.ProfileView
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'feed'
|
type: 'feed'
|
||||||
|
@ -291,6 +292,7 @@ export function Explore() {
|
||||||
error: feedsError,
|
error: feedsError,
|
||||||
fetchNextPage: fetchNextFeedsPage,
|
fetchNextPage: fetchNextFeedsPage,
|
||||||
} = useGetPopularFeedsQuery({limit: 10})
|
} = useGetPopularFeedsQuery({limit: 10})
|
||||||
|
const gate = useGate()
|
||||||
|
|
||||||
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
|
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
|
||||||
const onLoadMoreProfiles = React.useCallback(async () => {
|
const onLoadMoreProfiles = React.useCallback(async () => {
|
||||||
|
@ -492,7 +494,14 @@ export function Explore() {
|
||||||
case 'profile': {
|
case 'profile': {
|
||||||
return (
|
return (
|
||||||
<View style={[a.border_b, t.atoms.border_contrast_low]}>
|
<View style={[a.border_b, t.atoms.border_contrast_low]}>
|
||||||
<ProfileCardWithFollowBtn profile={item.profile} noBg noBorder />
|
<ProfileCardWithFollowBtn
|
||||||
|
profile={item.profile}
|
||||||
|
noBg
|
||||||
|
noBorder
|
||||||
|
showKnownFollowers={gate(
|
||||||
|
'explore_page_profile_card_social_proof',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -555,7 +564,7 @@ export function Explore() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[t, moderationOpts],
|
[t, moderationOpts, gate],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
Loading…
Reference in New Issue