Hook up suggestedUser:seen client events

This commit is contained in:
Alex Benzer
2025-11-30 15:22:55 -08:00
parent 1eacf427b5
commit de1368559c
6 changed files with 195 additions and 24 deletions
+81 -1
View File
@@ -1,4 +1,4 @@
import React from 'react' import React, {useCallback, useEffect, useRef} from 'react'
import {ScrollView, View} from 'react-native' import {ScrollView, View} from 'react-native'
import {type AppBskyFeedDefs, AtUri} from '@atproto/api' import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
@@ -8,6 +8,7 @@ import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
import {isIOS} from '#/platform/detection' import {isIOS} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {useGetPopularFeedsQuery} from '#/state/queries/feed'
@@ -239,12 +240,14 @@ export function ProfileGrid({
profiles, profiles,
recId, recId,
viewContext = 'feed', viewContext = 'feed',
isVisible = true,
}: { }: {
isSuggestionsLoading: boolean isSuggestionsLoading: boolean
profiles: bsky.profile.AnyProfileView[] profiles: bsky.profile.AnyProfileView[]
recId?: number recId?: number
error: Error | null error: Error | null
viewContext: 'profile' | 'profileHeader' | 'feed' viewContext: 'profile' | 'profileHeader' | 'feed'
isVisible?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@@ -258,6 +261,82 @@ export function ProfileGrid({
const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6 const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
const minLength = gtMobile ? 3 : 4 const minLength = gtMobile ? 3 : 4
// Track seen profiles
const seenProfilesRef = useRef<Set<string>>(new Set())
const containerRef = useRef<View>(null)
const hasTrackedRef = useRef(false)
const logContext: MetricEvents['suggestedUser:seen']['logContext'] =
isFeedContext
? 'InterstitialDiscover'
: isProfileHeaderContext
? 'Profile'
: 'InterstitialProfile'
// Callback to fire seen events
const fireSeen = useCallback(() => {
if (isLoading || error || !profiles.length) return
if (hasTrackedRef.current) return
hasTrackedRef.current = true
const profilesToShow = profiles.slice(0, maxLength)
profilesToShow.forEach((profile, index) => {
if (!seenProfilesRef.current.has(profile.did)) {
seenProfilesRef.current.add(profile.did)
logger.metric(
'suggestedUser:seen',
{
logContext,
recId,
position: index,
},
{statsig: true},
)
}
})
}, [isLoading, error, profiles, maxLength, logContext, recId])
// For profile header, fire when isVisible becomes true
useEffect(() => {
if (isProfileHeaderContext) {
if (!isVisible) {
hasTrackedRef.current = false
return
}
fireSeen()
}
}, [isVisible, isProfileHeaderContext, fireSeen])
// For feed interstitials, use IntersectionObserver to detect actual visibility
useEffect(() => {
if (isProfileHeaderContext) return // handled above
if (isLoading || error || !profiles.length) return
const node = containerRef.current
if (!node) return
// Use IntersectionObserver on web to detect when actually visible
if (typeof IntersectionObserver !== 'undefined') {
const observer = new IntersectionObserver(
entries => {
if (entries[0]?.isIntersecting) {
fireSeen()
observer.disconnect()
}
},
{threshold: 0.5},
)
// @ts-ignore - web only
observer.observe(node)
return () => observer.disconnect()
} else {
// On native, delay slightly to account for layout shifts during hydration
const timeout = setTimeout(() => {
fireSeen()
}, 500)
return () => clearTimeout(timeout)
}
}, [isProfileHeaderContext, isLoading, error, profiles.length, fireSeen])
const content = isLoading const content = isLoading
? Array(maxLength) ? Array(maxLength)
.fill(0) .fill(0)
@@ -365,6 +444,7 @@ export function ProfileGrid({
return ( return (
<View <View
ref={containerRef}
style={[ style={[
!isProfileHeaderContext && a.border_t, !isProfileHeaderContext && a.border_t,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
+44 -1
View File
@@ -1,11 +1,18 @@
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {TextInput, useWindowDimensions, View} from 'react-native' import {
TextInput,
useWindowDimensions,
View,
type ViewabilityConfig,
type ViewToken,
} from 'react-native'
import {type ModerationOpts} from '@atproto/api' import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorSearch} from '#/state/queries/actor-search' import {useActorSearch} from '#/state/queries/actor-search'
@@ -226,6 +233,40 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
[moderationOpts], [moderationOpts],
) )
// Track seen profiles
const seenProfilesRef = useRef<Set<string>>(new Set())
const onViewableItemsChanged = useCallback(
({viewableItems}: {viewableItems: ViewToken[]}) => {
for (const viewableItem of viewableItems) {
const item = viewableItem.item as Item
if (item.type === 'profile') {
if (!seenProfilesRef.current.has(item.profile.did)) {
seenProfilesRef.current.add(item.profile.did)
const position = items.findIndex(
i => i.type === 'profile' && i.profile.did === item.profile.did,
)
logger.metric(
'suggestedUser:seen',
{
logContext: 'ProgressGuide',
recId: undefined,
position: position !== -1 ? position : 0,
},
{statsig: true},
)
}
}
}
},
[items],
)
const viewabilityConfig: ViewabilityConfig = useMemo(
() => ({
itemVisiblePercentThreshold: 50,
}),
[],
)
const onSelectTab = useCallback( const onSelectTab = useCallback(
(interest: string) => { (interest: string) => {
setSelectedInterest(interest) setSelectedInterest(interest)
@@ -273,6 +314,8 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
scrollIndicatorInsets={{top: headerHeight}} scrollIndicatorInsets={{top: headerHeight}}
initialNumToRender={8} initialNumToRender={8}
maxToRenderPerBatch={8} maxToRenderPerBatch={8}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
/> />
) )
} }
+7 -1
View File
@@ -322,7 +322,13 @@ export type MetricEvents = {
position: number position: number
} }
'suggestedUser:seen': { 'suggestedUser:seen': {
logContext: 'Explore' | 'InterstitialDiscover' | 'InterstitialProfile' logContext:
| 'Explore'
| 'InterstitialDiscover'
| 'InterstitialProfile'
| 'Profile'
| 'Onboarding'
| 'ProgressGuide'
recId?: number recId?: number
position: number position: number
} }
@@ -1,4 +1,4 @@
import {useContext, useMemo, useState} from 'react' import {useContext, useEffect, useMemo, useRef, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {type ModerationOpts} from '@atproto/api' import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
@@ -123,6 +123,27 @@ export function StepSuggestedAccounts() {
const canFollowAll = followableDids.length > 0 && !isFollowingAll const canFollowAll = followableDids.length > 0 && !isFollowingAll
// Track seen profiles
const seenProfilesRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (isLoading || !moderationOpts || !suggestedUsers?.actors.length) return
suggestedUsers.actors.forEach((profile, index) => {
if (!seenProfilesRef.current.has(profile.did)) {
seenProfilesRef.current.add(profile.did)
logger.metric(
'suggestedUser:seen',
{
logContext: 'Onboarding',
recId: undefined,
position: index,
},
{statsig: true},
)
}
})
}, [isLoading, moderationOpts, suggestedUsers])
return ( return (
<View style={[a.align_start]} testID="onboardingInterests"> <View style={[a.align_start]} testID="onboardingInterests">
<Text style={[a.font_bold, a.text_3xl]}> <Text style={[a.font_bold, a.text_3xl]}>
@@ -47,6 +47,7 @@ export function AnimatedProfileHeaderSuggestedFollows({
recId={data.recId} recId={data.recId}
error={error} error={error}
viewContext="profileHeader" viewContext="profileHeader"
isVisible={isExpanded}
/> />
</AccordionAnimation> </AccordionAnimation>
) )
+22 -2
View File
@@ -1030,12 +1030,30 @@ export function Explore({
// track headers and report module viewability // track headers and report module viewability
const alreadyReportedRef = useRef<Map<string, string>>(new Map()) const alreadyReportedRef = useRef<Map<string, string>>(new Map())
const onItemSeen = useCallback((item: ExploreScreenItems) => { const seenProfilesRef = useRef<Set<string>>(new Set())
const onItemSeen = useCallback(
(item: ExploreScreenItems) => {
let module: MetricEvents['explore:module:seen']['module'] let module: MetricEvents['explore:module:seen']['module']
if (item.type === 'trendingTopics' || item.type === 'trendingVideos') { if (item.type === 'trendingTopics' || item.type === 'trendingVideos') {
module = item.type module = item.type
} else if (item.type === 'profile') { } else if (item.type === 'profile') {
module = 'suggestedAccounts' module = 'suggestedAccounts'
// Track individual profile seen events
if (!seenProfilesRef.current.has(item.profile.did)) {
seenProfilesRef.current.add(item.profile.did)
const position = suggestedFollowsModule.findIndex(
i => i.type === 'profile' && i.profile.did === item.profile.did,
)
logger.metric(
'suggestedUser:seen',
{
logContext: 'Explore',
recId: item.recId,
position: position !== -1 ? position - 1 : 0, // -1 to account for header
},
{statsig: true},
)
}
} else if (item.type === 'feed') { } else if (item.type === 'feed') {
module = 'suggestedFeeds' module = 'suggestedFeeds'
} else if (item.type === 'starterPack') { } else if (item.type === 'starterPack') {
@@ -1049,7 +1067,9 @@ export function Explore({
alreadyReportedRef.current.set(module, module) alreadyReportedRef.current.set(module, module)
logger.metric('explore:module:seen', {module}, {statsig: false}) logger.metric('explore:module:seen', {module}, {statsig: false})
} }
}, []) },
[suggestedFollowsModule],
)
return ( return (
<List <List