Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

Samuel Newman
Eric Bailey
and committed by
GitHub
2d0eefeb 4360087c

+64 -75
+14 -6
src/components/KnownFollowers.tsx
··· 12 12 import {Text} from '#/components/Typography' 13 13 14 14 const AVI_SIZE = 30 15 + const AVI_SIZE_SMALL = 20 15 16 const AVI_BORDER = 1 16 17 17 18 /** ··· 30 31 profile, 31 32 moderationOpts, 32 33 onLinkPress, 34 + minimal, 33 35 }: { 34 36 profile: AppBskyActorDefs.ProfileViewDetailed 35 37 moderationOpts: ModerationOpts 36 38 onLinkPress?: LinkProps['onPress'] 39 + minimal?: boolean 37 40 }) { 38 41 const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>( 39 42 new Map(), ··· 59 62 cachedKnownFollowers={cachedKnownFollowers} 60 63 moderationOpts={moderationOpts} 61 64 onLinkPress={onLinkPress} 65 + minimal={minimal} 62 66 /> 63 67 ) 64 68 } ··· 71 75 moderationOpts, 72 76 cachedKnownFollowers, 73 77 onLinkPress, 78 + minimal, 74 79 }: { 75 80 profile: AppBskyActorDefs.ProfileViewDetailed 76 81 moderationOpts: ModerationOpts 77 82 cachedKnownFollowers: AppBskyActorDefs.KnownFollowers 78 83 onLinkPress?: LinkProps['onPress'] 84 + minimal?: boolean 79 85 }) { 80 86 const t = useTheme() 81 87 const {_} = useLingui() ··· 110 116 */ 111 117 if (slice.length === 0) return null 112 118 119 + const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE 120 + 113 121 return ( 114 122 <Link 115 123 label={_( ··· 120 128 style={[ 121 129 a.flex_1, 122 130 a.flex_row, 123 - a.gap_md, 131 + minimal ? a.gap_sm : a.gap_md, 124 132 a.align_center, 125 133 {marginLeft: -AVI_BORDER}, 126 134 ]}> ··· 129 137 <View 130 138 style={[ 131 139 { 132 - height: AVI_SIZE, 133 - width: AVI_SIZE + (slice.length - 1) * a.gap_md.gap, 140 + height: SIZE, 141 + width: SIZE + (slice.length - 1) * a.gap_md.gap, 134 142 }, 135 143 pressed && { 136 144 opacity: 0.5, ··· 145 153 { 146 154 borderWidth: AVI_BORDER, 147 155 borderColor: t.atoms.bg.backgroundColor, 148 - width: AVI_SIZE + AVI_BORDER * 2, 149 - height: AVI_SIZE + AVI_BORDER * 2, 156 + width: SIZE + AVI_BORDER * 2, 157 + height: SIZE + AVI_BORDER * 2, 150 158 left: i * a.gap_md.gap, 151 159 zIndex: AVI_BORDER - i, 152 160 }, 153 161 ]}> 154 162 <UserAvatar 155 - size={AVI_SIZE} 163 + size={SIZE} 156 164 avatar={prof.avatar} 157 165 moderation={moderation.ui('avatar')} 158 166 />
+1
src/lib/statsig/gates.ts
··· 1 1 export type Gate = 2 2 // Keep this alphabetic please. 3 3 | 'debug_show_feedcontext' 4 + | 'explore_page_profile_card_social_proof' 4 5 | 'native_pwi_disabled' 5 6 | 'new_user_guided_tour' 6 7 | 'new_user_progress_guide'
+37 -66
src/view/com/profile/ProfileCard.tsx
··· 5 5 moderateProfile, 6 6 ModerationDecision, 7 7 } from '@atproto/api' 8 - import {Trans} from '@lingui/macro' 9 8 import {useQueryClient} from '@tanstack/react-query' 10 9 11 10 import {useProfileShadow} from '#/state/cache/profile-shadow' ··· 19 18 import {sanitizeHandle} from 'lib/strings/handles' 20 19 import {s} from 'lib/styles' 21 20 import {precacheProfile} from 'state/queries/profile' 21 + import {atoms as a} from '#/alf' 22 + import { 23 + KnownFollowers, 24 + shouldShowKnownFollowers, 25 + } from '#/components/KnownFollowers' 22 26 import {Link} from '../util/Link' 23 27 import {Text} from '../util/text/Text' 24 28 import {PreviewableUserAvatar} from '../util/UserAvatar' 25 29 import {FollowButton} from './FollowButton' 26 30 import hairlineWidth = StyleSheet.hairlineWidth 27 - import {atoms as a} from '#/alf' 28 31 import * as Pills from '#/components/Pills' 29 32 30 33 export function ProfileCard({ ··· 33 36 noModFilter, 34 37 noBg, 35 38 noBorder, 36 - followers, 37 39 renderButton, 38 40 onPress, 39 41 style, 42 + showKnownFollowers, 40 43 }: { 41 44 testID?: string 42 45 profile: AppBskyActorDefs.ProfileViewBasic 43 46 noModFilter?: boolean 44 47 noBg?: boolean 45 48 noBorder?: boolean 46 - followers?: AppBskyActorDefs.ProfileView[] | undefined 47 49 renderButton?: ( 48 50 profile: Shadow<AppBskyActorDefs.ProfileViewBasic>, 49 51 ) => React.ReactNode 50 52 onPress?: () => void 51 53 style?: StyleProp<ViewStyle> 54 + showKnownFollowers?: boolean 52 55 }) { 53 56 const queryClient = useQueryClient() 54 57 const pal = usePalette('default') ··· 70 73 return null 71 74 } 72 75 76 + const knownFollowersVisible = 77 + showKnownFollowers && 78 + shouldShowKnownFollowers(profile.viewer?.knownFollowers) && 79 + moderationOpts 80 + 73 81 return ( 74 82 <Link 75 83 testID={testID} ··· 118 126 <View style={styles.layoutButton}>{renderButton(profile)}</View> 119 127 ) : undefined} 120 128 </View> 121 - {profile.description ? ( 129 + {profile.description || knownFollowersVisible ? ( 122 130 <View style={styles.details}> 123 - <Text style={pal.text} numberOfLines={4}> 124 - {profile.description as string} 125 - </Text> 131 + {profile.description ? ( 132 + <Text style={pal.text} numberOfLines={4}> 133 + {profile.description as string} 134 + </Text> 135 + ) : null} 136 + {knownFollowersVisible ? ( 137 + <View 138 + style={[ 139 + a.flex_row, 140 + a.align_center, 141 + a.gap_sm, 142 + !!profile.description && a.mt_md, 143 + ]}> 144 + <KnownFollowers 145 + minimal 146 + profile={profile} 147 + moderationOpts={moderationOpts} 148 + /> 149 + </View> 150 + ) : null} 126 151 </View> 127 152 ) : null} 128 - <FollowersList followers={followers} /> 129 153 </Link> 130 154 ) 131 155 } ··· 155 179 ) 156 180 } 157 181 158 - function FollowersList({ 159 - followers, 160 - }: { 161 - followers?: AppBskyActorDefs.ProfileView[] | undefined 162 - }) { 163 - const pal = usePalette('default') 164 - const moderationOpts = useModerationOpts() 165 - 166 - const followersWithMods = React.useMemo(() => { 167 - if (!followers || !moderationOpts) { 168 - return [] 169 - } 170 - 171 - return followers 172 - .map(f => ({ 173 - f, 174 - mod: moderateProfile(f, moderationOpts), 175 - })) 176 - .filter(({mod}) => !mod.ui('profileList').filter) 177 - }, [followers, moderationOpts]) 178 - 179 - if (!followersWithMods?.length) { 180 - return null 181 - } 182 - 183 - return ( 184 - <View style={styles.followedBy}> 185 - <Text 186 - type="sm" 187 - style={[styles.followsByDesc, pal.textLight]} 188 - numberOfLines={2} 189 - lineHeight={1.2}> 190 - <Trans> 191 - Followed by{' '} 192 - {followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')} 193 - </Trans> 194 - </Text> 195 - {followersWithMods.slice(0, 3).map(({f, mod}) => ( 196 - <View key={f.did} style={styles.followedByAviContainer}> 197 - <View style={[styles.followedByAvi, pal.view]}> 198 - <PreviewableUserAvatar 199 - size={32} 200 - profile={f} 201 - moderation={mod.ui('avatar')} 202 - type={f.associated?.labeler ? 'labeler' : 'user'} 203 - /> 204 - </View> 205 - </View> 206 - ))} 207 - </View> 208 - ) 209 - } 210 - 211 182 export function ProfileCardWithFollowBtn({ 212 183 profile, 213 184 noBg, 214 185 noBorder, 215 - followers, 216 186 onPress, 217 187 logContext = 'ProfileCard', 188 + showKnownFollowers, 218 189 }: { 219 - profile: AppBskyActorDefs.ProfileViewBasic 190 + profile: AppBskyActorDefs.ProfileView 220 191 noBg?: boolean 221 192 noBorder?: boolean 222 - followers?: AppBskyActorDefs.ProfileView[] | undefined 223 193 onPress?: () => void 224 194 logContext?: 'ProfileCard' | 'StarterPackProfilesList' 195 + showKnownFollowers?: boolean 225 196 }) { 226 197 const {currentAccount} = useSession() 227 198 const isMe = profile.did === currentAccount?.did ··· 231 202 profile={profile} 232 203 noBg={noBg} 233 204 noBorder={noBorder} 234 - followers={followers} 235 205 renderButton={ 236 206 isMe 237 207 ? undefined ··· 240 210 ) 241 211 } 242 212 onPress={onPress} 213 + showKnownFollowers={!isMe && showKnownFollowers} 243 214 /> 244 215 ) 245 216 }
+12 -3
src/view/screens/Search/Explore.tsx
··· 10 10 import {msg, Trans} from '@lingui/macro' 11 11 import {useLingui} from '@lingui/react' 12 12 13 + import {useGate} from '#/lib/statsig/statsig' 13 14 import {logger} from '#/logger' 14 15 import {isWeb} from '#/platform/detection' 15 16 import {useModerationOpts} from '#/state/preferences/moderation-opts' ··· 241 242 | { 242 243 type: 'profile' 243 244 key: string 244 - profile: AppBskyActorDefs.ProfileViewBasic 245 + profile: AppBskyActorDefs.ProfileView 245 246 } 246 247 | { 247 248 type: 'feed' ··· 291 292 error: feedsError, 292 293 fetchNextPage: fetchNextFeedsPage, 293 294 } = useGetPopularFeedsQuery({limit: 10}) 295 + const gate = useGate() 294 296 295 297 const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles 296 298 const onLoadMoreProfiles = React.useCallback(async () => { ··· 492 494 case 'profile': { 493 495 return ( 494 496 <View style={[a.border_b, t.atoms.border_contrast_low]}> 495 - <ProfileCardWithFollowBtn profile={item.profile} noBg noBorder /> 497 + <ProfileCardWithFollowBtn 498 + profile={item.profile} 499 + noBg 500 + noBorder 501 + showKnownFollowers={gate( 502 + 'explore_page_profile_card_social_proof', 503 + )} 504 + /> 496 505 </View> 497 506 ) 498 507 } ··· 555 564 } 556 565 } 557 566 }, 558 - [t, moderationOpts], 567 + [t, moderationOpts, gate], 559 568 ) 560 569 561 570 return (