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>
This commit is contained in:
		
							parent
							
								
									4360087ced
								
							
						
					
					
						commit
						2d0eefebc3
					
				
					 4 changed files with 64 additions and 75 deletions
				
			
		| 
						 | 
				
			
			@ -12,6 +12,7 @@ import {Link, LinkProps} from '#/components/Link'
 | 
			
		|||
import {Text} from '#/components/Typography'
 | 
			
		||||
 | 
			
		||||
const AVI_SIZE = 30
 | 
			
		||||
const AVI_SIZE_SMALL = 20
 | 
			
		||||
const AVI_BORDER = 1
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
| 
						 | 
				
			
			@ -30,10 +31,12 @@ export function KnownFollowers({
 | 
			
		|||
  profile,
 | 
			
		||||
  moderationOpts,
 | 
			
		||||
  onLinkPress,
 | 
			
		||||
  minimal,
 | 
			
		||||
}: {
 | 
			
		||||
  profile: AppBskyActorDefs.ProfileViewDetailed
 | 
			
		||||
  moderationOpts: ModerationOpts
 | 
			
		||||
  onLinkPress?: LinkProps['onPress']
 | 
			
		||||
  minimal?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
 | 
			
		||||
    new Map(),
 | 
			
		||||
| 
						 | 
				
			
			@ -59,6 +62,7 @@ export function KnownFollowers({
 | 
			
		|||
        cachedKnownFollowers={cachedKnownFollowers}
 | 
			
		||||
        moderationOpts={moderationOpts}
 | 
			
		||||
        onLinkPress={onLinkPress}
 | 
			
		||||
        minimal={minimal}
 | 
			
		||||
      />
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -71,11 +75,13 @@ function KnownFollowersInner({
 | 
			
		|||
  moderationOpts,
 | 
			
		||||
  cachedKnownFollowers,
 | 
			
		||||
  onLinkPress,
 | 
			
		||||
  minimal,
 | 
			
		||||
}: {
 | 
			
		||||
  profile: AppBskyActorDefs.ProfileViewDetailed
 | 
			
		||||
  moderationOpts: ModerationOpts
 | 
			
		||||
  cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
 | 
			
		||||
  onLinkPress?: LinkProps['onPress']
 | 
			
		||||
  minimal?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
| 
						 | 
				
			
			@ -110,6 +116,8 @@ function KnownFollowersInner({
 | 
			
		|||
   */
 | 
			
		||||
  if (slice.length === 0) return null
 | 
			
		||||
 | 
			
		||||
  const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Link
 | 
			
		||||
      label={_(
 | 
			
		||||
| 
						 | 
				
			
			@ -120,7 +128,7 @@ function KnownFollowersInner({
 | 
			
		|||
      style={[
 | 
			
		||||
        a.flex_1,
 | 
			
		||||
        a.flex_row,
 | 
			
		||||
        a.gap_md,
 | 
			
		||||
        minimal ? a.gap_sm : a.gap_md,
 | 
			
		||||
        a.align_center,
 | 
			
		||||
        {marginLeft: -AVI_BORDER},
 | 
			
		||||
      ]}>
 | 
			
		||||
| 
						 | 
				
			
			@ -129,8 +137,8 @@ function KnownFollowersInner({
 | 
			
		|||
          <View
 | 
			
		||||
            style={[
 | 
			
		||||
              {
 | 
			
		||||
                height: AVI_SIZE,
 | 
			
		||||
                width: AVI_SIZE + (slice.length - 1) * a.gap_md.gap,
 | 
			
		||||
                height: SIZE,
 | 
			
		||||
                width: SIZE + (slice.length - 1) * a.gap_md.gap,
 | 
			
		||||
              },
 | 
			
		||||
              pressed && {
 | 
			
		||||
                opacity: 0.5,
 | 
			
		||||
| 
						 | 
				
			
			@ -145,14 +153,14 @@ function KnownFollowersInner({
 | 
			
		|||
                  {
 | 
			
		||||
                    borderWidth: AVI_BORDER,
 | 
			
		||||
                    borderColor: t.atoms.bg.backgroundColor,
 | 
			
		||||
                    width: AVI_SIZE + AVI_BORDER * 2,
 | 
			
		||||
                    height: AVI_SIZE + AVI_BORDER * 2,
 | 
			
		||||
                    width: SIZE + AVI_BORDER * 2,
 | 
			
		||||
                    height: SIZE + AVI_BORDER * 2,
 | 
			
		||||
                    left: i * a.gap_md.gap,
 | 
			
		||||
                    zIndex: AVI_BORDER - i,
 | 
			
		||||
                  },
 | 
			
		||||
                ]}>
 | 
			
		||||
                <UserAvatar
 | 
			
		||||
                  size={AVI_SIZE}
 | 
			
		||||
                  size={SIZE}
 | 
			
		||||
                  avatar={prof.avatar}
 | 
			
		||||
                  moderation={moderation.ui('avatar')}
 | 
			
		||||
                />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
export type Gate =
 | 
			
		||||
  // Keep this alphabetic please.
 | 
			
		||||
  | 'debug_show_feedcontext'
 | 
			
		||||
  | 'explore_page_profile_card_social_proof'
 | 
			
		||||
  | 'native_pwi_disabled'
 | 
			
		||||
  | 'new_user_guided_tour'
 | 
			
		||||
  | 'new_user_progress_guide'
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,6 @@ import {
 | 
			
		|||
  moderateProfile,
 | 
			
		||||
  ModerationDecision,
 | 
			
		||||
} from '@atproto/api'
 | 
			
		||||
import {Trans} from '@lingui/macro'
 | 
			
		||||
import {useQueryClient} from '@tanstack/react-query'
 | 
			
		||||
 | 
			
		||||
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 {s} from 'lib/styles'
 | 
			
		||||
import {precacheProfile} from 'state/queries/profile'
 | 
			
		||||
import {atoms as a} from '#/alf'
 | 
			
		||||
import {
 | 
			
		||||
  KnownFollowers,
 | 
			
		||||
  shouldShowKnownFollowers,
 | 
			
		||||
} from '#/components/KnownFollowers'
 | 
			
		||||
import {Link} from '../util/Link'
 | 
			
		||||
import {Text} from '../util/text/Text'
 | 
			
		||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
 | 
			
		||||
import {FollowButton} from './FollowButton'
 | 
			
		||||
import hairlineWidth = StyleSheet.hairlineWidth
 | 
			
		||||
import {atoms as a} from '#/alf'
 | 
			
		||||
import * as Pills from '#/components/Pills'
 | 
			
		||||
 | 
			
		||||
export function ProfileCard({
 | 
			
		||||
| 
						 | 
				
			
			@ -33,22 +36,22 @@ export function ProfileCard({
 | 
			
		|||
  noModFilter,
 | 
			
		||||
  noBg,
 | 
			
		||||
  noBorder,
 | 
			
		||||
  followers,
 | 
			
		||||
  renderButton,
 | 
			
		||||
  onPress,
 | 
			
		||||
  style,
 | 
			
		||||
  showKnownFollowers,
 | 
			
		||||
}: {
 | 
			
		||||
  testID?: string
 | 
			
		||||
  profile: AppBskyActorDefs.ProfileViewBasic
 | 
			
		||||
  noModFilter?: boolean
 | 
			
		||||
  noBg?: boolean
 | 
			
		||||
  noBorder?: boolean
 | 
			
		||||
  followers?: AppBskyActorDefs.ProfileView[] | undefined
 | 
			
		||||
  renderButton?: (
 | 
			
		||||
    profile: Shadow<AppBskyActorDefs.ProfileViewBasic>,
 | 
			
		||||
  ) => React.ReactNode
 | 
			
		||||
  onPress?: () => void
 | 
			
		||||
  style?: StyleProp<ViewStyle>
 | 
			
		||||
  showKnownFollowers?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  const queryClient = useQueryClient()
 | 
			
		||||
  const pal = usePalette('default')
 | 
			
		||||
| 
						 | 
				
			
			@ -70,6 +73,11 @@ export function ProfileCard({
 | 
			
		|||
    return null
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const knownFollowersVisible =
 | 
			
		||||
    showKnownFollowers &&
 | 
			
		||||
    shouldShowKnownFollowers(profile.viewer?.knownFollowers) &&
 | 
			
		||||
    moderationOpts
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Link
 | 
			
		||||
      testID={testID}
 | 
			
		||||
| 
						 | 
				
			
			@ -118,14 +126,30 @@ export function ProfileCard({
 | 
			
		|||
          <View style={styles.layoutButton}>{renderButton(profile)}</View>
 | 
			
		||||
        ) : undefined}
 | 
			
		||||
      </View>
 | 
			
		||||
      {profile.description ? (
 | 
			
		||||
      {profile.description || knownFollowersVisible ? (
 | 
			
		||||
        <View style={styles.details}>
 | 
			
		||||
          {profile.description ? (
 | 
			
		||||
            <Text style={pal.text} numberOfLines={4}>
 | 
			
		||||
              {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>
 | 
			
		||||
      ) : null}
 | 
			
		||||
      <FollowersList followers={followers} />
 | 
			
		||||
    </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({
 | 
			
		||||
  profile,
 | 
			
		||||
  noBg,
 | 
			
		||||
  noBorder,
 | 
			
		||||
  followers,
 | 
			
		||||
  onPress,
 | 
			
		||||
  logContext = 'ProfileCard',
 | 
			
		||||
  showKnownFollowers,
 | 
			
		||||
}: {
 | 
			
		||||
  profile: AppBskyActorDefs.ProfileViewBasic
 | 
			
		||||
  profile: AppBskyActorDefs.ProfileView
 | 
			
		||||
  noBg?: boolean
 | 
			
		||||
  noBorder?: boolean
 | 
			
		||||
  followers?: AppBskyActorDefs.ProfileView[] | undefined
 | 
			
		||||
  onPress?: () => void
 | 
			
		||||
  logContext?: 'ProfileCard' | 'StarterPackProfilesList'
 | 
			
		||||
  showKnownFollowers?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  const {currentAccount} = useSession()
 | 
			
		||||
  const isMe = profile.did === currentAccount?.did
 | 
			
		||||
| 
						 | 
				
			
			@ -231,7 +202,6 @@ export function ProfileCardWithFollowBtn({
 | 
			
		|||
      profile={profile}
 | 
			
		||||
      noBg={noBg}
 | 
			
		||||
      noBorder={noBorder}
 | 
			
		||||
      followers={followers}
 | 
			
		||||
      renderButton={
 | 
			
		||||
        isMe
 | 
			
		||||
          ? undefined
 | 
			
		||||
| 
						 | 
				
			
			@ -240,6 +210,7 @@ export function ProfileCardWithFollowBtn({
 | 
			
		|||
            )
 | 
			
		||||
      }
 | 
			
		||||
      onPress={onPress}
 | 
			
		||||
      showKnownFollowers={!isMe && showKnownFollowers}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,6 +10,7 @@ import {
 | 
			
		|||
import {msg, Trans} from '@lingui/macro'
 | 
			
		||||
import {useLingui} from '@lingui/react'
 | 
			
		||||
 | 
			
		||||
import {useGate} from '#/lib/statsig/statsig'
 | 
			
		||||
import {logger} from '#/logger'
 | 
			
		||||
import {isWeb} from '#/platform/detection'
 | 
			
		||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
 | 
			
		||||
| 
						 | 
				
			
			@ -241,7 +242,7 @@ type ExploreScreenItems =
 | 
			
		|||
  | {
 | 
			
		||||
      type: 'profile'
 | 
			
		||||
      key: string
 | 
			
		||||
      profile: AppBskyActorDefs.ProfileViewBasic
 | 
			
		||||
      profile: AppBskyActorDefs.ProfileView
 | 
			
		||||
    }
 | 
			
		||||
  | {
 | 
			
		||||
      type: 'feed'
 | 
			
		||||
| 
						 | 
				
			
			@ -291,6 +292,7 @@ export function Explore() {
 | 
			
		|||
    error: feedsError,
 | 
			
		||||
    fetchNextPage: fetchNextFeedsPage,
 | 
			
		||||
  } = useGetPopularFeedsQuery({limit: 10})
 | 
			
		||||
  const gate = useGate()
 | 
			
		||||
 | 
			
		||||
  const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
 | 
			
		||||
  const onLoadMoreProfiles = React.useCallback(async () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -492,7 +494,14 @@ export function Explore() {
 | 
			
		|||
        case 'profile': {
 | 
			
		||||
          return (
 | 
			
		||||
            <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>
 | 
			
		||||
          )
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			@ -555,7 +564,7 @@ export function Explore() {
 | 
			
		|||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    [t, moderationOpts],
 | 
			
		||||
    [t, moderationOpts, gate],
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue