From 084f60c88f5341b657e09a0b9723dc32304be567 Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Mon, 17 Nov 2025 05:04:11 -0800 Subject: [PATCH] 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 --- src/components/ProfileCard.tsx | 18 ++++++++++ src/logger/metrics.ts | 8 +++++ src/state/queries/profile.ts | 14 +++++++- src/view/com/profile/ProfileCard.tsx | 6 ++++ src/view/com/profile/ProfileFollowers.tsx | 41 ++++++++++++++++++++++- src/view/com/profile/ProfileFollows.tsx | 41 ++++++++++++++++++++++- 6 files changed, 125 insertions(+), 3 deletions(-) diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 41626ff399..55bef226b3 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -48,11 +48,15 @@ export function Default({ moderationOpts, logContext = 'ProfileCard', testID, + position, + contextProfileDid, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts logContext?: 'ProfileCard' | 'StarterPackProfilesList' testID?: string + position?: number + contextProfileDid?: string }) { return ( @@ -60,6 +64,8 @@ export function Default({ profile={profile} moderationOpts={moderationOpts} logContext={logContext} + position={position} + contextProfileDid={contextProfileDid} /> ) @@ -69,10 +75,14 @@ export function Card({ profile, moderationOpts, logContext = 'ProfileCard', + position, + contextProfileDid, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts logContext?: 'ProfileCard' | 'StarterPackProfilesList' + position?: number + contextProfileDid?: string }) { return ( @@ -83,6 +93,8 @@ export function Card({ profile={profile} moderationOpts={moderationOpts} logContext={logContext} + position={position} + contextProfileDid={contextProfileDid} /> @@ -437,6 +449,8 @@ export type FollowButtonProps = { colorInverted?: boolean onFollow?: () => void withIcon?: boolean + position?: number + contextProfileDid?: string } & Partial export function FollowButton(props: FollowButtonProps) { @@ -453,6 +467,8 @@ export function FollowButtonInner({ onFollow, colorInverted, withIcon = true, + position, + contextProfileDid, ...rest }: FollowButtonProps) { const {_} = useLingui() @@ -461,6 +477,8 @@ export function FollowButtonInner({ const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( profile, logContext, + position, + contextProfileDid, ) const isRound = Boolean(rest.shape && rest.shape === 'round') diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index c7bac2fecd..37b8e21f74 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -256,9 +256,12 @@ export type MetricEvents = { 'bookmarks:view': {} 'bookmarks:post-clicked': {} 'profile:follow': { + contextProfileDid?: string didBecomeMutual: boolean | undefined followeeClout: number | undefined + followeeDid: string followerClout: number | undefined + position?: number logContext: | 'RecommendedFollowsItem' | 'PostThreadItem' @@ -276,6 +279,11 @@ export type MetricEvents = { | 'ExploreSuggestedAccounts' | 'OnboardingSuggestedAccounts' } + 'profileCard:seen': { + contextProfileDid?: string + profileDid: string + position?: number + } 'suggestedUser:follow': { logContext: | 'Explore' diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index eb65fef7c2..9d30288d40 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -242,12 +242,19 @@ export function useProfileFollowMutationQueue( profile: Shadow, logContext: LogEvents['profile:follow']['logContext'] & LogEvents['profile:follow']['logContext'], + position?: number, + contextProfileDid?: string, ) { const agent = useAgent() const queryClient = useQueryClient() const did = profile.did const initialFollowingUri = profile.viewer?.following - const followMutation = useProfileFollowMutation(logContext, profile) + const followMutation = useProfileFollowMutation( + logContext, + profile, + position, + contextProfileDid, + ) const unfollowMutation = useProfileUnfollowMutation(logContext) const queueToggle = useToggleMutationQueue({ @@ -314,6 +321,8 @@ export function useProfileFollowMutationQueue( function useProfileFollowMutation( logContext: LogEvents['profile:follow']['logContext'], profile: Shadow, + position?: number, + contextProfileDid?: string, ) { const {currentAccount} = useSession() const agent = useAgent() @@ -336,7 +345,10 @@ function useProfileFollowMutation( 'followersCount' in profile ? toClout(profile.followersCount) : undefined, + followeeDid: did, followerClout: toClout(ownProfile?.followersCount), + position, + contextProfileDid, }) return await agent.follow(did) }, diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index cee9507030..f200a62cb6 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -9,10 +9,14 @@ export function ProfileCardWithFollowBtn({ profile, noBorder, logContext = 'ProfileCard', + position, + contextProfileDid, }: { profile: AppBskyActorDefs.ProfileView noBorder?: boolean logContext?: 'ProfileCard' | 'StarterPackProfilesList' + position?: number + contextProfileDid?: string }) { const t = useTheme() const moderationOpts = useModerationOpts() @@ -30,6 +34,8 @@ export function ProfileCardWithFollowBtn({ profile={profile} moderationOpts={moderationOpts} logContext={logContext} + position={position} + contextProfileDid={contextProfileDid} /> ) diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index dfb63909e4..b5838f0c19 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -16,15 +16,19 @@ import {ProfileCardWithFollowBtn} from './ProfileCard' function renderItem({ item, index, + contextProfileDid, }: { item: ActorDefs.ProfileView index: number + contextProfileDid: string | undefined }) { return ( ) } @@ -83,6 +87,40 @@ export function ProfileFollowers({name}: {name: string}) { } }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) + const renderItemWithContext = React.useCallback( + ({item, index}: {item: ActorDefs.ProfileView; index: number}) => + renderItem({item, index, contextProfileDid: resolvedDid}), + [resolvedDid], + ) + + // track seen items + const seenItemsRef = React.useRef>(new Set()) + React.useEffect(() => { + seenItemsRef.current.clear() + }, [resolvedDid]) + const onItemSeen = React.useCallback( + (item: ActorDefs.ProfileView) => { + if (seenItemsRef.current.has(item.did)) { + return + } + seenItemsRef.current.add(item.did) + const position = followers.findIndex(p => p.did === item.did) + 1 + if (position === 0) { + return + } + logger.metric( + 'profileCard:seen', + { + profileDid: item.did, + position, + ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), + }, + {statsig: false}, + ) + }, + [followers, resolvedDid], + ) + if (followers.length < 1) { return ( ) } @@ -83,6 +87,40 @@ export function ProfileFollows({name}: {name: string}) { } }, [error, fetchNextPage, hasNextPage, isFetchingNextPage]) + const renderItemWithContext = React.useCallback( + ({item, index}: {item: ActorDefs.ProfileView; index: number}) => + renderItem({item, index, contextProfileDid: resolvedDid}), + [resolvedDid], + ) + + // track seen items + const seenItemsRef = React.useRef>(new Set()) + React.useEffect(() => { + seenItemsRef.current.clear() + }, [resolvedDid]) + const onItemSeen = React.useCallback( + (item: ActorDefs.ProfileView) => { + if (seenItemsRef.current.has(item.did)) { + return + } + seenItemsRef.current.add(item.did) + const position = follows.findIndex(p => p.did === item.did) + 1 + if (position === 0) { + return + } + logger.metric( + 'profileCard:seen', + { + profileDid: item.did, + position, + ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), + }, + {statsig: false}, + ) + }, + [follows, resolvedDid], + ) + if (follows.length < 1) { return (