Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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 <git@esb.lol>

authored by

Alex Benzer
Eric Bailey
and committed by
GitHub
ef3a7949 6686ebc2

+266 -25
+87 -1
src/components/FeedInterstitials.tsx
··· 1 - import React from 'react' 1 + import React, {useCallback, useEffect, useRef} from 'react' 2 2 import {ScrollView, View} from 'react-native' 3 3 import {type AppBskyFeedDefs, AtUri} from '@atproto/api' 4 4 import {msg, Trans} from '@lingui/macro' ··· 8 8 import {type NavigationProp} from '#/lib/routes/types' 9 9 import {logEvent} from '#/lib/statsig/statsig' 10 10 import {logger} from '#/logger' 11 + import {type MetricEvents} from '#/logger/metrics' 11 12 import {isIOS} from '#/platform/detection' 12 13 import {useModerationOpts} from '#/state/preferences/moderation-opts' 13 14 import {useGetPopularFeedsQuery} from '#/state/queries/feed' ··· 241 242 profiles, 242 243 recId, 243 244 viewContext = 'feed', 245 + isVisible = true, 244 246 }: { 245 247 isSuggestionsLoading: boolean 246 248 profiles: bsky.profile.AnyProfileView[] 247 249 recId?: number 248 250 error: Error | null 249 251 viewContext: 'profile' | 'profileHeader' | 'feed' 252 + isVisible?: boolean 250 253 }) { 251 254 const t = useTheme() 252 255 const {_} = useLingui() ··· 261 264 const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6 262 265 const minLength = gtMobile ? 3 : 4 263 266 267 + // Track seen profiles 268 + const seenProfilesRef = useRef<Set<string>>(new Set()) 269 + const containerRef = useRef<View>(null) 270 + const hasTrackedRef = useRef(false) 271 + const logContext: MetricEvents['suggestedUser:seen']['logContext'] = 272 + isFeedContext 273 + ? 'InterstitialDiscover' 274 + : isProfileHeaderContext 275 + ? 'Profile' 276 + : 'InterstitialProfile' 277 + 278 + // Callback to fire seen events 279 + const fireSeen = useCallback(() => { 280 + if (isLoading || error || !profiles.length) return 281 + if (hasTrackedRef.current) return 282 + hasTrackedRef.current = true 283 + 284 + const profilesToShow = profiles.slice(0, maxLength) 285 + profilesToShow.forEach((profile, index) => { 286 + if (!seenProfilesRef.current.has(profile.did)) { 287 + seenProfilesRef.current.add(profile.did) 288 + logger.metric( 289 + 'suggestedUser:seen', 290 + { 291 + logContext, 292 + recId, 293 + position: index, 294 + suggestedDid: profile.did, 295 + category: null, 296 + }, 297 + {statsig: true}, 298 + ) 299 + } 300 + }) 301 + }, [isLoading, error, profiles, maxLength, logContext, recId]) 302 + 303 + // For profile header, fire when isVisible becomes true 304 + useEffect(() => { 305 + if (isProfileHeaderContext) { 306 + if (!isVisible) { 307 + hasTrackedRef.current = false 308 + return 309 + } 310 + fireSeen() 311 + } 312 + }, [isVisible, isProfileHeaderContext, fireSeen]) 313 + 314 + // For feed interstitials, use IntersectionObserver to detect actual visibility 315 + useEffect(() => { 316 + if (isProfileHeaderContext) return // handled above 317 + if (isLoading || error || !profiles.length) return 318 + 319 + const node = containerRef.current 320 + if (!node) return 321 + 322 + // Use IntersectionObserver on web to detect when actually visible 323 + if (typeof IntersectionObserver !== 'undefined') { 324 + const observer = new IntersectionObserver( 325 + entries => { 326 + if (entries[0]?.isIntersecting) { 327 + fireSeen() 328 + observer.disconnect() 329 + } 330 + }, 331 + {threshold: 0.5}, 332 + ) 333 + // @ts-ignore - web only 334 + observer.observe(node) 335 + return () => observer.disconnect() 336 + } else { 337 + // On native, delay slightly to account for layout shifts during hydration 338 + const timeout = setTimeout(() => { 339 + fireSeen() 340 + }, 500) 341 + return () => clearTimeout(timeout) 342 + } 343 + }, [isProfileHeaderContext, isLoading, error, profiles.length, fireSeen]) 344 + 264 345 const content = isLoading 265 346 ? Array(maxLength) 266 347 .fill(0) ··· 292 373 : 'InterstitialProfile', 293 374 recId, 294 375 position: index, 376 + suggestedDid: profile.did, 377 + category: null, 295 378 }) 296 379 }} 297 380 style={[ ··· 352 435 location: 'Card', 353 436 recId, 354 437 position: index, 438 + suggestedDid: profile.did, 439 + category: null, 355 440 }) 356 441 }} 357 442 /> ··· 368 453 369 454 return ( 370 455 <View 456 + ref={containerRef} 371 457 style={[ 372 458 !isProfileHeaderContext && a.border_t, 373 459 t.atoms.border_contrast_low,
+47 -2
src/components/ProgressGuide/FollowDialog.tsx
··· 1 1 import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 - import {TextInput, useWindowDimensions, View} from 'react-native' 2 + import { 3 + TextInput, 4 + useWindowDimensions, 5 + View, 6 + type ViewToken, 7 + } from 'react-native' 3 8 import {type ModerationOpts} from '@atproto/api' 4 9 import {msg, Trans} from '@lingui/macro' 5 10 import {useLingui} from '@lingui/react' 6 11 7 12 import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' 8 13 import {logEvent} from '#/lib/statsig/statsig' 14 + import {logger} from '#/logger' 9 15 import {isWeb} from '#/platform/detection' 10 16 import {useModerationOpts} from '#/state/preferences/moderation-opts' 11 17 import {useActorSearch} from '#/state/queries/actor-search' ··· 243 249 [moderationOpts], 244 250 ) 245 251 252 + // Track seen profiles 253 + const seenProfilesRef = useRef<Set<string>>(new Set()) 254 + const itemsRef = useRef(items) 255 + itemsRef.current = items 256 + const selectedInterestRef = useRef(selectedInterest) 257 + selectedInterestRef.current = selectedInterest 258 + 259 + const onViewableItemsChanged = useRef( 260 + ({viewableItems}: {viewableItems: ViewToken[]}) => { 261 + for (const viewableItem of viewableItems) { 262 + const item = viewableItem.item as Item 263 + if (item.type === 'profile') { 264 + if (!seenProfilesRef.current.has(item.profile.did)) { 265 + seenProfilesRef.current.add(item.profile.did) 266 + const position = itemsRef.current.findIndex( 267 + i => i.type === 'profile' && i.profile.did === item.profile.did, 268 + ) 269 + logger.metric( 270 + 'suggestedUser:seen', 271 + { 272 + logContext: 'ProgressGuide', 273 + recId: undefined, 274 + position: position !== -1 ? position : 0, 275 + suggestedDid: item.profile.did, 276 + category: selectedInterestRef.current, 277 + }, 278 + {statsig: true}, 279 + ) 280 + } 281 + } 282 + } 283 + }, 284 + ).current 285 + const viewabilityConfig = useRef({ 286 + itemVisiblePercentThreshold: 50, 287 + }).current 288 + 246 289 const onSelectTab = useCallback( 247 290 (interest: string) => { 248 291 setSelectedInterest(interest) ··· 290 333 scrollIndicatorInsets={{top: headerHeight}} 291 334 initialNumToRender={8} 292 335 maxToRenderPerBatch={8} 336 + onViewableItemsChanged={onViewableItemsChanged} 337 + viewabilityConfig={viewabilityConfig} 293 338 /> 294 339 ) 295 340 } ··· 400 445 style={[ 401 446 a.absolute, 402 447 a.z_20, 403 - web({right: -4}), 448 + web({right: 8}), 404 449 native({right: 0}), 405 450 native({height: 32, width: 32, borderRadius: 16}), 406 451 ]}
+13 -1
src/logger/metrics.ts
··· 334 334 location: 'Card' | 'Profile' 335 335 recId?: number 336 336 position: number 337 + suggestedDid: string 338 + category: string | null 337 339 } 338 340 'suggestedUser:press': { 339 341 logContext: ··· 343 345 | 'Onboarding' 344 346 recId?: number 345 347 position: number 348 + suggestedDid: string 349 + category: string | null 346 350 } 347 351 'suggestedUser:seen': { 348 - logContext: 'Explore' | 'InterstitialDiscover' | 'InterstitialProfile' 352 + logContext: 353 + | 'Explore' 354 + | 'InterstitialDiscover' 355 + | 'InterstitialProfile' 356 + | 'Profile' 357 + | 'Onboarding' 358 + | 'ProgressGuide' 349 359 recId?: number 350 360 position: number 361 + suggestedDid: string 362 + category: string | null 351 363 } 352 364 'suggestedUser:seeMore': { 353 365 logContext:
+72 -1
src/screens/Onboarding/StepSuggestedAccounts/index.tsx
··· 1 - import {useContext, useMemo, useState} from 'react' 1 + import { 2 + useCallback, 3 + useContext, 4 + useEffect, 5 + useMemo, 6 + useRef, 7 + useState, 8 + } from 'react' 2 9 import {View} from 'react-native' 3 10 import {type ModerationOpts} from '@atproto/api' 4 11 import {msg, Trans} from '@lingui/macro' ··· 123 130 124 131 const canFollowAll = followableDids.length > 0 && !isFollowingAll 125 132 133 + // Track seen profiles - shared ref across all cards 134 + const seenProfilesRef = useRef<Set<string>>(new Set()) 135 + const onProfileSeen = useCallback( 136 + (did: string, position: number) => { 137 + if (!seenProfilesRef.current.has(did)) { 138 + seenProfilesRef.current.add(did) 139 + logger.metric( 140 + 'suggestedUser:seen', 141 + { 142 + logContext: 'Onboarding', 143 + recId: undefined, 144 + position, 145 + suggestedDid: did, 146 + category: selectedInterest, 147 + }, 148 + {statsig: true}, 149 + ) 150 + } 151 + }, 152 + [selectedInterest], 153 + ) 154 + 126 155 return ( 127 156 <View style={[a.align_start]} testID="onboardingInterests"> 128 157 <Text style={[a.font_bold, a.text_3xl]}> ··· 193 222 profile={user} 194 223 moderationOpts={moderationOpts} 195 224 position={index} 225 + category={selectedInterest} 226 + onSeen={onProfileSeen} 196 227 /> 197 228 ))} 198 229 </View> ··· 303 334 profile, 304 335 moderationOpts, 305 336 position, 337 + category, 338 + onSeen, 306 339 }: { 307 340 profile: bsky.profile.AnyProfileView 308 341 moderationOpts: ModerationOpts 309 342 position: number 343 + category: string | null 344 + onSeen: (did: string, position: number) => void 310 345 }) { 311 346 const t = useTheme() 347 + const cardRef = useRef<View>(null) 348 + const hasTrackedRef = useRef(false) 349 + 350 + useEffect(() => { 351 + const node = cardRef.current 352 + if (!node || hasTrackedRef.current) return 353 + 354 + if (isWeb && typeof IntersectionObserver !== 'undefined') { 355 + const observer = new IntersectionObserver( 356 + entries => { 357 + if (entries[0]?.isIntersecting && !hasTrackedRef.current) { 358 + hasTrackedRef.current = true 359 + onSeen(profile.did, position) 360 + observer.disconnect() 361 + } 362 + }, 363 + {threshold: 0.5}, 364 + ) 365 + // @ts-ignore - web only 366 + observer.observe(node) 367 + return () => observer.disconnect() 368 + } else { 369 + // Native: use a short delay to account for initial layout 370 + const timeout = setTimeout(() => { 371 + if (!hasTrackedRef.current) { 372 + hasTrackedRef.current = true 373 + onSeen(profile.did, position) 374 + } 375 + }, 500) 376 + return () => clearTimeout(timeout) 377 + } 378 + }, [onSeen, profile.did, position]) 379 + 312 380 return ( 313 381 <View 382 + ref={cardRef} 314 383 style={[ 315 384 a.w_full, 316 385 a.py_lg, ··· 342 411 location: 'Card', 343 412 recId: undefined, 344 413 position, 414 + suggestedDid: profile.did, 415 + category, 345 416 }, 346 417 {statsig: true}, 347 418 )
+1
src/screens/Profile/Header/SuggestedFollows.tsx
··· 47 47 recId={data.recId} 48 48 error={error} 49 49 viewContext="profileHeader" 50 + isVisible={isExpanded} 50 51 /> 51 52 </AccordionAnimation> 52 53 )
+42 -20
src/screens/Search/Explore.tsx
··· 1030 1030 1031 1031 // track headers and report module viewability 1032 1032 const alreadyReportedRef = useRef<Map<string, string>>(new Map()) 1033 - const onItemSeen = useCallback((item: ExploreScreenItems) => { 1034 - let module: MetricEvents['explore:module:seen']['module'] 1035 - if (item.type === 'trendingTopics' || item.type === 'trendingVideos') { 1036 - module = item.type 1037 - } else if (item.type === 'profile') { 1038 - module = 'suggestedAccounts' 1039 - } else if (item.type === 'feed') { 1040 - module = 'suggestedFeeds' 1041 - } else if (item.type === 'starterPack') { 1042 - module = 'suggestedStarterPacks' 1043 - } else if (item.type === 'preview:sliceItem') { 1044 - module = `feed:feedgen|${item.feed.uri}` 1045 - } else { 1046 - return 1047 - } 1048 - if (!alreadyReportedRef.current.has(module)) { 1049 - alreadyReportedRef.current.set(module, module) 1050 - logger.metric('explore:module:seen', {module}, {statsig: false}) 1051 - } 1052 - }, []) 1033 + const seenProfilesRef = useRef<Set<string>>(new Set()) 1034 + const onItemSeen = useCallback( 1035 + (item: ExploreScreenItems) => { 1036 + let module: MetricEvents['explore:module:seen']['module'] 1037 + if (item.type === 'trendingTopics' || item.type === 'trendingVideos') { 1038 + module = item.type 1039 + } else if (item.type === 'profile') { 1040 + module = 'suggestedAccounts' 1041 + // Track individual profile seen events 1042 + if (!seenProfilesRef.current.has(item.profile.did)) { 1043 + seenProfilesRef.current.add(item.profile.did) 1044 + const position = suggestedFollowsModule.findIndex( 1045 + i => i.type === 'profile' && i.profile.did === item.profile.did, 1046 + ) 1047 + logger.metric( 1048 + 'suggestedUser:seen', 1049 + { 1050 + logContext: 'Explore', 1051 + recId: item.recId, 1052 + position: position !== -1 ? position - 1 : 0, // -1 to account for header 1053 + suggestedDid: item.profile.did, 1054 + category: null, 1055 + }, 1056 + {statsig: true}, 1057 + ) 1058 + } 1059 + } else if (item.type === 'feed') { 1060 + module = 'suggestedFeeds' 1061 + } else if (item.type === 'starterPack') { 1062 + module = 'suggestedStarterPacks' 1063 + } else if (item.type === 'preview:sliceItem') { 1064 + module = `feed:feedgen|${item.feed.uri}` 1065 + } else { 1066 + return 1067 + } 1068 + if (!alreadyReportedRef.current.has(module)) { 1069 + alreadyReportedRef.current.set(module, module) 1070 + logger.metric('explore:module:seen', {module}, {statsig: false}) 1071 + } 1072 + }, 1073 + [suggestedFollowsModule], 1074 + ) 1053 1075 1054 1076 return ( 1055 1077 <List
+4
src/screens/Search/modules/ExploreSuggestedAccounts.tsx
··· 123 123 logContext: 'Explore', 124 124 recId, 125 125 position, 126 + suggestedDid: profile.did, 127 + category: null, 126 128 }, 127 129 {statsig: true}, 128 130 ) ··· 162 164 location: 'Card', 163 165 recId, 164 166 position, 167 + suggestedDid: profile.did, 168 + category: null, 165 169 }, 166 170 {statsig: true}, 167 171 )