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
Samuel Newman 2024-07-09 17:10:50 +01:00 committed by GitHub
parent 4360087ced
commit 2d0eefebc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 64 additions and 75 deletions

View File

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

View File

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

View File

@ -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}>
<Text style={pal.text} numberOfLines={4}> {profile.description ? (
{profile.description as string} <Text style={pal.text} numberOfLines={4}>
</Text> {profile.description as string}
</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}
/> />
) )
} }

View File

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