Bluesky app fork with some witchin' additions 💫

Profile follow client events (#9385)

* Add parameters to profile:follow

Track who was followed, whose profile generated the follow, and the position of the person who was followed in the list

* Add profileCard:seen event

* Don't send "profileCard:seen" event to Statsig

* Clean up

* prevent overzealous clearing

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by

Alex Benzer
Samuel Newman
and committed by
GitHub
084f60c8 2f678a3c

+125 -3
+18
src/components/ProfileCard.tsx
··· 48 48 moderationOpts, 49 49 logContext = 'ProfileCard', 50 50 testID, 51 + position, 52 + contextProfileDid, 51 53 }: { 52 54 profile: bsky.profile.AnyProfileView 53 55 moderationOpts: ModerationOpts 54 56 logContext?: 'ProfileCard' | 'StarterPackProfilesList' 55 57 testID?: string 58 + position?: number 59 + contextProfileDid?: string 56 60 }) { 57 61 return ( 58 62 <Link testID={testID} profile={profile}> ··· 60 64 profile={profile} 61 65 moderationOpts={moderationOpts} 62 66 logContext={logContext} 67 + position={position} 68 + contextProfileDid={contextProfileDid} 63 69 /> 64 70 </Link> 65 71 ) ··· 69 75 profile, 70 76 moderationOpts, 71 77 logContext = 'ProfileCard', 78 + position, 79 + contextProfileDid, 72 80 }: { 73 81 profile: bsky.profile.AnyProfileView 74 82 moderationOpts: ModerationOpts 75 83 logContext?: 'ProfileCard' | 'StarterPackProfilesList' 84 + position?: number 85 + contextProfileDid?: string 76 86 }) { 77 87 return ( 78 88 <Outer> ··· 83 93 profile={profile} 84 94 moderationOpts={moderationOpts} 85 95 logContext={logContext} 96 + position={position} 97 + contextProfileDid={contextProfileDid} 86 98 /> 87 99 </Header> 88 100 ··· 437 449 colorInverted?: boolean 438 450 onFollow?: () => void 439 451 withIcon?: boolean 452 + position?: number 453 + contextProfileDid?: string 440 454 } & Partial<ButtonProps> 441 455 442 456 export function FollowButton(props: FollowButtonProps) { ··· 453 467 onFollow, 454 468 colorInverted, 455 469 withIcon = true, 470 + position, 471 + contextProfileDid, 456 472 ...rest 457 473 }: FollowButtonProps) { 458 474 const {_} = useLingui() ··· 461 477 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 462 478 profile, 463 479 logContext, 480 + position, 481 + contextProfileDid, 464 482 ) 465 483 const isRound = Boolean(rest.shape && rest.shape === 'round') 466 484
+8
src/logger/metrics.ts
··· 256 256 'bookmarks:view': {} 257 257 'bookmarks:post-clicked': {} 258 258 'profile:follow': { 259 + contextProfileDid?: string 259 260 didBecomeMutual: boolean | undefined 260 261 followeeClout: number | undefined 262 + followeeDid: string 261 263 followerClout: number | undefined 264 + position?: number 262 265 logContext: 263 266 | 'RecommendedFollowsItem' 264 267 | 'PostThreadItem' ··· 275 278 | 'ImmersiveVideo' 276 279 | 'ExploreSuggestedAccounts' 277 280 | 'OnboardingSuggestedAccounts' 281 + } 282 + 'profileCard:seen': { 283 + contextProfileDid?: string 284 + profileDid: string 285 + position?: number 278 286 } 279 287 'suggestedUser:follow': { 280 288 logContext:
+13 -1
src/state/queries/profile.ts
··· 242 242 profile: Shadow<bsky.profile.AnyProfileView>, 243 243 logContext: LogEvents['profile:follow']['logContext'] & 244 244 LogEvents['profile:follow']['logContext'], 245 + position?: number, 246 + contextProfileDid?: string, 245 247 ) { 246 248 const agent = useAgent() 247 249 const queryClient = useQueryClient() 248 250 const did = profile.did 249 251 const initialFollowingUri = profile.viewer?.following 250 - const followMutation = useProfileFollowMutation(logContext, profile) 252 + const followMutation = useProfileFollowMutation( 253 + logContext, 254 + profile, 255 + position, 256 + contextProfileDid, 257 + ) 251 258 const unfollowMutation = useProfileUnfollowMutation(logContext) 252 259 253 260 const queueToggle = useToggleMutationQueue({ ··· 314 321 function useProfileFollowMutation( 315 322 logContext: LogEvents['profile:follow']['logContext'], 316 323 profile: Shadow<bsky.profile.AnyProfileView>, 324 + position?: number, 325 + contextProfileDid?: string, 317 326 ) { 318 327 const {currentAccount} = useSession() 319 328 const agent = useAgent() ··· 336 345 'followersCount' in profile 337 346 ? toClout(profile.followersCount) 338 347 : undefined, 348 + followeeDid: did, 339 349 followerClout: toClout(ownProfile?.followersCount), 350 + position, 351 + contextProfileDid, 340 352 }) 341 353 return await agent.follow(did) 342 354 },
+6
src/view/com/profile/ProfileCard.tsx
··· 9 9 profile, 10 10 noBorder, 11 11 logContext = 'ProfileCard', 12 + position, 13 + contextProfileDid, 12 14 }: { 13 15 profile: AppBskyActorDefs.ProfileView 14 16 noBorder?: boolean 15 17 logContext?: 'ProfileCard' | 'StarterPackProfilesList' 18 + position?: number 19 + contextProfileDid?: string 16 20 }) { 17 21 const t = useTheme() 18 22 const moderationOpts = useModerationOpts() ··· 30 34 profile={profile} 31 35 moderationOpts={moderationOpts} 32 36 logContext={logContext} 37 + position={position} 38 + contextProfileDid={contextProfileDid} 33 39 /> 34 40 </View> 35 41 )
+40 -1
src/view/com/profile/ProfileFollowers.tsx
··· 16 16 function renderItem({ 17 17 item, 18 18 index, 19 + contextProfileDid, 19 20 }: { 20 21 item: ActorDefs.ProfileView 21 22 index: number 23 + contextProfileDid: string | undefined 22 24 }) { 23 25 return ( 24 26 <ProfileCardWithFollowBtn 25 27 key={item.did} 26 28 profile={item} 27 29 noBorder={index === 0} 30 + position={index + 1} 31 + contextProfileDid={contextProfileDid} 28 32 /> 29 33 ) 30 34 } ··· 83 87 } 84 88 }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) 85 89 90 + const renderItemWithContext = React.useCallback( 91 + ({item, index}: {item: ActorDefs.ProfileView; index: number}) => 92 + renderItem({item, index, contextProfileDid: resolvedDid}), 93 + [resolvedDid], 94 + ) 95 + 96 + // track seen items 97 + const seenItemsRef = React.useRef<Set<string>>(new Set()) 98 + React.useEffect(() => { 99 + seenItemsRef.current.clear() 100 + }, [resolvedDid]) 101 + const onItemSeen = React.useCallback( 102 + (item: ActorDefs.ProfileView) => { 103 + if (seenItemsRef.current.has(item.did)) { 104 + return 105 + } 106 + seenItemsRef.current.add(item.did) 107 + const position = followers.findIndex(p => p.did === item.did) + 1 108 + if (position === 0) { 109 + return 110 + } 111 + logger.metric( 112 + 'profileCard:seen', 113 + { 114 + profileDid: item.did, 115 + position, 116 + ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), 117 + }, 118 + {statsig: false}, 119 + ) 120 + }, 121 + [followers, resolvedDid], 122 + ) 123 + 86 124 if (followers.length < 1) { 87 125 return ( 88 126 <ListMaybePlaceholder ··· 104 142 return ( 105 143 <List 106 144 data={followers} 107 - renderItem={renderItem} 145 + renderItem={renderItemWithContext} 108 146 keyExtractor={keyExtractor} 109 147 refreshing={isPTRing} 110 148 onRefresh={onRefresh} 111 149 onEndReached={onEndReached} 112 150 onEndReachedThreshold={4} 151 + onItemSeen={onItemSeen} 113 152 ListFooterComponent={ 114 153 <ListFooter 115 154 isFetchingNextPage={isFetchingNextPage}
+40 -1
src/view/com/profile/ProfileFollows.tsx
··· 16 16 function renderItem({ 17 17 item, 18 18 index, 19 + contextProfileDid, 19 20 }: { 20 21 item: ActorDefs.ProfileView 21 22 index: number 23 + contextProfileDid: string | undefined 22 24 }) { 23 25 return ( 24 26 <ProfileCardWithFollowBtn 25 27 key={item.did} 26 28 profile={item} 27 29 noBorder={index === 0} 30 + position={index + 1} 31 + contextProfileDid={contextProfileDid} 28 32 /> 29 33 ) 30 34 } ··· 83 87 } 84 88 }, [error, fetchNextPage, hasNextPage, isFetchingNextPage]) 85 89 90 + const renderItemWithContext = React.useCallback( 91 + ({item, index}: {item: ActorDefs.ProfileView; index: number}) => 92 + renderItem({item, index, contextProfileDid: resolvedDid}), 93 + [resolvedDid], 94 + ) 95 + 96 + // track seen items 97 + const seenItemsRef = React.useRef<Set<string>>(new Set()) 98 + React.useEffect(() => { 99 + seenItemsRef.current.clear() 100 + }, [resolvedDid]) 101 + const onItemSeen = React.useCallback( 102 + (item: ActorDefs.ProfileView) => { 103 + if (seenItemsRef.current.has(item.did)) { 104 + return 105 + } 106 + seenItemsRef.current.add(item.did) 107 + const position = follows.findIndex(p => p.did === item.did) + 1 108 + if (position === 0) { 109 + return 110 + } 111 + logger.metric( 112 + 'profileCard:seen', 113 + { 114 + profileDid: item.did, 115 + position, 116 + ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), 117 + }, 118 + {statsig: false}, 119 + ) 120 + }, 121 + [follows, resolvedDid], 122 + ) 123 + 86 124 if (follows.length < 1) { 87 125 return ( 88 126 <ListMaybePlaceholder ··· 104 142 return ( 105 143 <List 106 144 data={follows} 107 - renderItem={renderItem} 145 + renderItem={renderItemWithContext} 108 146 keyExtractor={keyExtractor} 109 147 refreshing={isPTRing} 110 148 onRefresh={onRefresh} 111 149 onEndReached={onEndReached} 112 150 onEndReachedThreshold={4} 151 + onItemSeen={onItemSeen} 113 152 ListFooterComponent={ 114 153 <ListFooter 115 154 isFetchingNextPage={isFetchingNextPage}