From ef3a79495e395585fa54238678738d5113506169 Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Fri, 5 Dec 2025 08:58:05 -0800 Subject: [PATCH] Hook up suggestedUser:seen client events (#9468) * Hook up suggestedUser:seen client events * Fix crash when clicking "find people to follow" * While we're at it, fix the position of the X button on the "find people to follow" modal * Add suggestedDid and category attributes to suggestedUser client events --------- Co-authored-by: Eric Bailey --- src/components/FeedInterstitials.tsx | 88 ++++++++++++++++++- src/components/ProgressGuide/FollowDialog.tsx | 49 ++++++++++- src/logger/metrics.ts | 14 ++- .../StepSuggestedAccounts/index.tsx | 73 ++++++++++++++- .../Profile/Header/SuggestedFollows.tsx | 1 + src/screens/Search/Explore.tsx | 62 ++++++++----- .../modules/ExploreSuggestedAccounts.tsx | 4 + 7 files changed, 266 insertions(+), 25 deletions(-) diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 987d9242f8..7418c8d766 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useCallback, useEffect, useRef} from 'react' import {ScrollView, View} from 'react-native' import {type AppBskyFeedDefs, AtUri} from '@atproto/api' import {msg, Trans} from '@lingui/macro' @@ -8,6 +8,7 @@ import {useNavigation} from '@react-navigation/native' import {type NavigationProp} from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' +import {type MetricEvents} from '#/logger/metrics' import {isIOS} from '#/platform/detection' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useGetPopularFeedsQuery} from '#/state/queries/feed' @@ -241,12 +242,14 @@ export function ProfileGrid({ profiles, recId, viewContext = 'feed', + isVisible = true, }: { isSuggestionsLoading: boolean profiles: bsky.profile.AnyProfileView[] recId?: number error: Error | null viewContext: 'profile' | 'profileHeader' | 'feed' + isVisible?: boolean }) { const t = useTheme() const {_} = useLingui() @@ -261,6 +264,84 @@ export function ProfileGrid({ const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6 const minLength = gtMobile ? 3 : 4 + // Track seen profiles + const seenProfilesRef = useRef>(new Set()) + const containerRef = useRef(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, + suggestedDid: profile.did, + category: null, + }, + {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 ? Array(maxLength) .fill(0) @@ -292,6 +373,8 @@ export function ProfileGrid({ : 'InterstitialProfile', recId, position: index, + suggestedDid: profile.did, + category: null, }) }} style={[ @@ -352,6 +435,8 @@ export function ProfileGrid({ location: 'Card', recId, position: index, + suggestedDid: profile.did, + category: null, }) }} /> @@ -368,6 +453,7 @@ export function ProfileGrid({ return ( >(new Set()) + const itemsRef = useRef(items) + itemsRef.current = items + const selectedInterestRef = useRef(selectedInterest) + selectedInterestRef.current = selectedInterest + + const onViewableItemsChanged = useRef( + ({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 = itemsRef.current.findIndex( + i => i.type === 'profile' && i.profile.did === item.profile.did, + ) + logger.metric( + 'suggestedUser:seen', + { + logContext: 'ProgressGuide', + recId: undefined, + position: position !== -1 ? position : 0, + suggestedDid: item.profile.did, + category: selectedInterestRef.current, + }, + {statsig: true}, + ) + } + } + } + }, + ).current + const viewabilityConfig = useRef({ + itemVisiblePercentThreshold: 50, + }).current + const onSelectTab = useCallback( (interest: string) => { setSelectedInterest(interest) @@ -290,6 +333,8 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { scrollIndicatorInsets={{top: headerHeight}} initialNumToRender={8} maxToRenderPerBatch={8} + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={viewabilityConfig} /> ) } @@ -400,7 +445,7 @@ function HeaderTop({guide}: {guide?: Follow10ProgressGuide}) { style={[ a.absolute, a.z_20, - web({right: -4}), + web({right: 8}), native({right: 0}), native({height: 32, width: 32, borderRadius: 16}), ]} diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index fb82a7c1c8..e4e1ea20fe 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -334,6 +334,8 @@ export type MetricEvents = { location: 'Card' | 'Profile' recId?: number position: number + suggestedDid: string + category: string | null } 'suggestedUser:press': { logContext: @@ -343,11 +345,21 @@ export type MetricEvents = { | 'Onboarding' recId?: number position: number + suggestedDid: string + category: string | null } 'suggestedUser:seen': { - logContext: 'Explore' | 'InterstitialDiscover' | 'InterstitialProfile' + logContext: + | 'Explore' + | 'InterstitialDiscover' + | 'InterstitialProfile' + | 'Profile' + | 'Onboarding' + | 'ProgressGuide' recId?: number position: number + suggestedDid: string + category: string | null } 'suggestedUser:seeMore': { logContext: diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx index 946efda68f..200ccb71e6 100644 --- a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx +++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx @@ -1,4 +1,11 @@ -import {useContext, useMemo, useState} from 'react' +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import {View} from 'react-native' import {type ModerationOpts} from '@atproto/api' import {msg, Trans} from '@lingui/macro' @@ -123,6 +130,28 @@ export function StepSuggestedAccounts() { const canFollowAll = followableDids.length > 0 && !isFollowingAll + // Track seen profiles - shared ref across all cards + const seenProfilesRef = useRef>(new Set()) + const onProfileSeen = useCallback( + (did: string, position: number) => { + if (!seenProfilesRef.current.has(did)) { + seenProfilesRef.current.add(did) + logger.metric( + 'suggestedUser:seen', + { + logContext: 'Onboarding', + recId: undefined, + position, + suggestedDid: did, + category: selectedInterest, + }, + {statsig: true}, + ) + } + }, + [selectedInterest], + ) + return ( @@ -193,6 +222,8 @@ export function StepSuggestedAccounts() { profile={user} moderationOpts={moderationOpts} position={index} + category={selectedInterest} + onSeen={onProfileSeen} /> ))} @@ -303,14 +334,52 @@ function SuggestedProfileCard({ profile, moderationOpts, position, + category, + onSeen, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts position: number + category: string | null + onSeen: (did: string, position: number) => void }) { const t = useTheme() + const cardRef = useRef(null) + const hasTrackedRef = useRef(false) + + useEffect(() => { + const node = cardRef.current + if (!node || hasTrackedRef.current) return + + if (isWeb && typeof IntersectionObserver !== 'undefined') { + const observer = new IntersectionObserver( + entries => { + if (entries[0]?.isIntersecting && !hasTrackedRef.current) { + hasTrackedRef.current = true + onSeen(profile.did, position) + observer.disconnect() + } + }, + {threshold: 0.5}, + ) + // @ts-ignore - web only + observer.observe(node) + return () => observer.disconnect() + } else { + // Native: use a short delay to account for initial layout + const timeout = setTimeout(() => { + if (!hasTrackedRef.current) { + hasTrackedRef.current = true + onSeen(profile.did, position) + } + }, 500) + return () => clearTimeout(timeout) + } + }, [onSeen, profile.did, position]) + return ( ) diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx index e512454fce..b3e26dcfd3 100644 --- a/src/screens/Search/Explore.tsx +++ b/src/screens/Search/Explore.tsx @@ -1030,26 +1030,48 @@ export function Explore({ // track headers and report module viewability const alreadyReportedRef = useRef>(new Map()) - const onItemSeen = useCallback((item: ExploreScreenItems) => { - let module: MetricEvents['explore:module:seen']['module'] - if (item.type === 'trendingTopics' || item.type === 'trendingVideos') { - module = item.type - } else if (item.type === 'profile') { - module = 'suggestedAccounts' - } else if (item.type === 'feed') { - module = 'suggestedFeeds' - } else if (item.type === 'starterPack') { - module = 'suggestedStarterPacks' - } else if (item.type === 'preview:sliceItem') { - module = `feed:feedgen|${item.feed.uri}` - } else { - return - } - if (!alreadyReportedRef.current.has(module)) { - alreadyReportedRef.current.set(module, module) - logger.metric('explore:module:seen', {module}, {statsig: false}) - } - }, []) + const seenProfilesRef = useRef>(new Set()) + const onItemSeen = useCallback( + (item: ExploreScreenItems) => { + let module: MetricEvents['explore:module:seen']['module'] + if (item.type === 'trendingTopics' || item.type === 'trendingVideos') { + module = item.type + } else if (item.type === 'profile') { + 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 + suggestedDid: item.profile.did, + category: null, + }, + {statsig: true}, + ) + } + } else if (item.type === 'feed') { + module = 'suggestedFeeds' + } else if (item.type === 'starterPack') { + module = 'suggestedStarterPacks' + } else if (item.type === 'preview:sliceItem') { + module = `feed:feedgen|${item.feed.uri}` + } else { + return + } + if (!alreadyReportedRef.current.has(module)) { + alreadyReportedRef.current.set(module, module) + logger.metric('explore:module:seen', {module}, {statsig: false}) + } + }, + [suggestedFollowsModule], + ) return (