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>
This commit is contained in:
Alex Benzer
2025-11-17 05:04:11 -08:00
committed by GitHub
parent 2f678a3ccd
commit 084f60c88f
6 changed files with 125 additions and 3 deletions
+18
View File
@@ -48,11 +48,15 @@ export function Default({
moderationOpts, moderationOpts,
logContext = 'ProfileCard', logContext = 'ProfileCard',
testID, testID,
position,
contextProfileDid,
}: { }: {
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
logContext?: 'ProfileCard' | 'StarterPackProfilesList' logContext?: 'ProfileCard' | 'StarterPackProfilesList'
testID?: string testID?: string
position?: number
contextProfileDid?: string
}) { }) {
return ( return (
<Link testID={testID} profile={profile}> <Link testID={testID} profile={profile}>
@@ -60,6 +64,8 @@ export function Default({
profile={profile} profile={profile}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
logContext={logContext} logContext={logContext}
position={position}
contextProfileDid={contextProfileDid}
/> />
</Link> </Link>
) )
@@ -69,10 +75,14 @@ export function Card({
profile, profile,
moderationOpts, moderationOpts,
logContext = 'ProfileCard', logContext = 'ProfileCard',
position,
contextProfileDid,
}: { }: {
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
logContext?: 'ProfileCard' | 'StarterPackProfilesList' logContext?: 'ProfileCard' | 'StarterPackProfilesList'
position?: number
contextProfileDid?: string
}) { }) {
return ( return (
<Outer> <Outer>
@@ -83,6 +93,8 @@ export function Card({
profile={profile} profile={profile}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
logContext={logContext} logContext={logContext}
position={position}
contextProfileDid={contextProfileDid}
/> />
</Header> </Header>
@@ -437,6 +449,8 @@ export type FollowButtonProps = {
colorInverted?: boolean colorInverted?: boolean
onFollow?: () => void onFollow?: () => void
withIcon?: boolean withIcon?: boolean
position?: number
contextProfileDid?: string
} & Partial<ButtonProps> } & Partial<ButtonProps>
export function FollowButton(props: FollowButtonProps) { export function FollowButton(props: FollowButtonProps) {
@@ -453,6 +467,8 @@ export function FollowButtonInner({
onFollow, onFollow,
colorInverted, colorInverted,
withIcon = true, withIcon = true,
position,
contextProfileDid,
...rest ...rest
}: FollowButtonProps) { }: FollowButtonProps) {
const {_} = useLingui() const {_} = useLingui()
@@ -461,6 +477,8 @@ export function FollowButtonInner({
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile, profile,
logContext, logContext,
position,
contextProfileDid,
) )
const isRound = Boolean(rest.shape && rest.shape === 'round') const isRound = Boolean(rest.shape && rest.shape === 'round')
+8
View File
@@ -256,9 +256,12 @@ export type MetricEvents = {
'bookmarks:view': {} 'bookmarks:view': {}
'bookmarks:post-clicked': {} 'bookmarks:post-clicked': {}
'profile:follow': { 'profile:follow': {
contextProfileDid?: string
didBecomeMutual: boolean | undefined didBecomeMutual: boolean | undefined
followeeClout: number | undefined followeeClout: number | undefined
followeeDid: string
followerClout: number | undefined followerClout: number | undefined
position?: number
logContext: logContext:
| 'RecommendedFollowsItem' | 'RecommendedFollowsItem'
| 'PostThreadItem' | 'PostThreadItem'
@@ -276,6 +279,11 @@ export type MetricEvents = {
| 'ExploreSuggestedAccounts' | 'ExploreSuggestedAccounts'
| 'OnboardingSuggestedAccounts' | 'OnboardingSuggestedAccounts'
} }
'profileCard:seen': {
contextProfileDid?: string
profileDid: string
position?: number
}
'suggestedUser:follow': { 'suggestedUser:follow': {
logContext: logContext:
| 'Explore' | 'Explore'
+13 -1
View File
@@ -242,12 +242,19 @@ export function useProfileFollowMutationQueue(
profile: Shadow<bsky.profile.AnyProfileView>, profile: Shadow<bsky.profile.AnyProfileView>,
logContext: LogEvents['profile:follow']['logContext'] & logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:follow']['logContext'], LogEvents['profile:follow']['logContext'],
position?: number,
contextProfileDid?: string,
) { ) {
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const did = profile.did const did = profile.did
const initialFollowingUri = profile.viewer?.following const initialFollowingUri = profile.viewer?.following
const followMutation = useProfileFollowMutation(logContext, profile) const followMutation = useProfileFollowMutation(
logContext,
profile,
position,
contextProfileDid,
)
const unfollowMutation = useProfileUnfollowMutation(logContext) const unfollowMutation = useProfileUnfollowMutation(logContext)
const queueToggle = useToggleMutationQueue({ const queueToggle = useToggleMutationQueue({
@@ -314,6 +321,8 @@ export function useProfileFollowMutationQueue(
function useProfileFollowMutation( function useProfileFollowMutation(
logContext: LogEvents['profile:follow']['logContext'], logContext: LogEvents['profile:follow']['logContext'],
profile: Shadow<bsky.profile.AnyProfileView>, profile: Shadow<bsky.profile.AnyProfileView>,
position?: number,
contextProfileDid?: string,
) { ) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
@@ -336,7 +345,10 @@ function useProfileFollowMutation(
'followersCount' in profile 'followersCount' in profile
? toClout(profile.followersCount) ? toClout(profile.followersCount)
: undefined, : undefined,
followeeDid: did,
followerClout: toClout(ownProfile?.followersCount), followerClout: toClout(ownProfile?.followersCount),
position,
contextProfileDid,
}) })
return await agent.follow(did) return await agent.follow(did)
}, },
+6
View File
@@ -9,10 +9,14 @@ export function ProfileCardWithFollowBtn({
profile, profile,
noBorder, noBorder,
logContext = 'ProfileCard', logContext = 'ProfileCard',
position,
contextProfileDid,
}: { }: {
profile: AppBskyActorDefs.ProfileView profile: AppBskyActorDefs.ProfileView
noBorder?: boolean noBorder?: boolean
logContext?: 'ProfileCard' | 'StarterPackProfilesList' logContext?: 'ProfileCard' | 'StarterPackProfilesList'
position?: number
contextProfileDid?: string
}) { }) {
const t = useTheme() const t = useTheme()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
@@ -30,6 +34,8 @@ export function ProfileCardWithFollowBtn({
profile={profile} profile={profile}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
logContext={logContext} logContext={logContext}
position={position}
contextProfileDid={contextProfileDid}
/> />
</View> </View>
) )
+40 -1
View File
@@ -16,15 +16,19 @@ import {ProfileCardWithFollowBtn} from './ProfileCard'
function renderItem({ function renderItem({
item, item,
index, index,
contextProfileDid,
}: { }: {
item: ActorDefs.ProfileView item: ActorDefs.ProfileView
index: number index: number
contextProfileDid: string | undefined
}) { }) {
return ( return (
<ProfileCardWithFollowBtn <ProfileCardWithFollowBtn
key={item.did} key={item.did}
profile={item} profile={item}
noBorder={index === 0} noBorder={index === 0}
position={index + 1}
contextProfileDid={contextProfileDid}
/> />
) )
} }
@@ -83,6 +87,40 @@ export function ProfileFollowers({name}: {name: string}) {
} }
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) }, [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<Set<string>>(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) { if (followers.length < 1) {
return ( return (
<ListMaybePlaceholder <ListMaybePlaceholder
@@ -104,12 +142,13 @@ export function ProfileFollowers({name}: {name: string}) {
return ( return (
<List <List
data={followers} data={followers}
renderItem={renderItem} renderItem={renderItemWithContext}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
refreshing={isPTRing} refreshing={isPTRing}
onRefresh={onRefresh} onRefresh={onRefresh}
onEndReached={onEndReached} onEndReached={onEndReached}
onEndReachedThreshold={4} onEndReachedThreshold={4}
onItemSeen={onItemSeen}
ListFooterComponent={ ListFooterComponent={
<ListFooter <ListFooter
isFetchingNextPage={isFetchingNextPage} isFetchingNextPage={isFetchingNextPage}
+40 -1
View File
@@ -16,15 +16,19 @@ import {ProfileCardWithFollowBtn} from './ProfileCard'
function renderItem({ function renderItem({
item, item,
index, index,
contextProfileDid,
}: { }: {
item: ActorDefs.ProfileView item: ActorDefs.ProfileView
index: number index: number
contextProfileDid: string | undefined
}) { }) {
return ( return (
<ProfileCardWithFollowBtn <ProfileCardWithFollowBtn
key={item.did} key={item.did}
profile={item} profile={item}
noBorder={index === 0} noBorder={index === 0}
position={index + 1}
contextProfileDid={contextProfileDid}
/> />
) )
} }
@@ -83,6 +87,40 @@ export function ProfileFollows({name}: {name: string}) {
} }
}, [error, fetchNextPage, hasNextPage, isFetchingNextPage]) }, [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<Set<string>>(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) { if (follows.length < 1) {
return ( return (
<ListMaybePlaceholder <ListMaybePlaceholder
@@ -104,12 +142,13 @@ export function ProfileFollows({name}: {name: string}) {
return ( return (
<List <List
data={follows} data={follows}
renderItem={renderItem} renderItem={renderItemWithContext}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
refreshing={isPTRing} refreshing={isPTRing}
onRefresh={onRefresh} onRefresh={onRefresh}
onEndReached={onEndReached} onEndReached={onEndReached}
onEndReachedThreshold={4} onEndReachedThreshold={4}
onItemSeen={onItemSeen}
ListFooterComponent={ ListFooterComponent={
<ListFooter <ListFooter
isFetchingNextPage={isFetchingNextPage} isFetchingNextPage={isFetchingNextPage}