Bluesky app fork with some witchin' additions 💫

Fire the post:view client event in more places, anywhere a post can be seen (#9467)

* Fire the post:view client event in more places

* Fix bad import

---------

Co-authored-by: Eric Bailey <git@esb.lol>

authored by

Alex Benzer
Eric Bailey
and committed by
GitHub
9a0cb09d db81cefb

+120 -3
+38
src/lib/hooks/usePostViewTracking.ts
··· 1 + import {useCallback, useRef} from 'react' 2 + import {type AppBskyFeedDefs} from '@atproto/api' 3 + 4 + import {logger} from '#/logger' 5 + import {type MetricEvents} from '#/logger/metrics' 6 + 7 + /** 8 + * Hook that returns a callback to track post:view events. 9 + * Handles deduplication so the same post URI is only tracked once per mount. 10 + * 11 + * @param logContext - The context where the post is being viewed 12 + * @returns A callback that accepts a post and logs the view event 13 + */ 14 + export function usePostViewTracking( 15 + logContext: MetricEvents['post:view']['logContext'], 16 + ) { 17 + const seenUrisRef = useRef(new Set<string>()) 18 + 19 + const trackPostView = useCallback( 20 + (post: AppBskyFeedDefs.PostView) => { 21 + if (seenUrisRef.current.has(post.uri)) return 22 + seenUrisRef.current.add(post.uri) 23 + 24 + logger.metric( 25 + 'post:view', 26 + { 27 + uri: post.uri, 28 + authorDid: post.author.did, 29 + logContext, 30 + }, 31 + {statsig: false}, 32 + ) 33 + }, 34 + [logContext], 35 + ) 36 + 37 + return trackPostView 38 + }
+11 -1
src/logger/metrics.ts
··· 271 271 'post:view': { 272 272 uri: string 273 273 authorDid: string 274 - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 274 + logContext: 275 + | 'FeedItem' 276 + | 'PostThreadItem' 277 + | 'Post' 278 + | 'ImmersiveVideo' 279 + | 'SearchResults' 280 + | 'Bookmarks' 281 + | 'Notifications' 282 + | 'Hashtag' 283 + | 'Topic' 284 + | 'PostQuotes' 275 285 feedDescriptor?: string 276 286 position?: number 277 287 }
+7
src/screens/Bookmarks/index.tsx
··· 15 15 16 16 import {useCleanError} from '#/lib/hooks/useCleanError' 17 17 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 18 + import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' 18 19 import { 19 20 type CommonNavigatorParams, 20 21 type NativeStackScreenProps, ··· 94 95 const initialNumToRender = useInitialNumToRender() 95 96 const cleanError = useCleanError() 96 97 const [isPTRing, setIsPTRing] = useState(false) 98 + const trackPostView = usePostViewTracking('Bookmarks') 97 99 const { 98 100 data, 99 101 isLoading, ··· 176 178 onRefresh={onRefresh} 177 179 onEndReached={onEndReached} 178 180 onEndReachedThreshold={4} 181 + onItemSeen={item => { 182 + if (item.type === 'bookmark') { 183 + trackPostView(item.bookmark.item) 184 + } 185 + }} 179 186 ListFooterComponent={ 180 187 <ListFooter 181 188 isFetchingNextPage={isFetchingNextPage}
+3
src/screens/Hashtag.tsx
··· 8 8 9 9 import {HITSLOP_10} from '#/lib/constants' 10 10 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 11 + import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' 11 12 import {type CommonNavigatorParams} from '#/lib/routes/types' 12 13 import {shareUrl} from '#/lib/sharing' 13 14 import {cleanError} from '#/lib/strings/errors' ··· 169 170 const [isPTR, setIsPTR] = React.useState(false) 170 171 const t = useTheme() 171 172 const {hasSession} = useSession() 173 + const trackPostView = usePostViewTracking('Hashtag') 172 174 173 175 const queryParam = React.useMemo(() => { 174 176 if (!author) return fullTag ··· 264 266 onRefresh={onRefresh} 265 267 onEndReached={onEndReached} 266 268 onEndReachedThreshold={4} 269 + onItemSeen={trackPostView} 267 270 // @ts-ignore web only -prf 268 271 desktopFixedHeight 269 272 ListFooterComponent={
+10
src/screens/PostThread/index.tsx
··· 5 5 6 6 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 7 7 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 8 + import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' 8 9 import {logger} from '#/logger' 9 10 import {useFeedFeedback} from '#/state/feed-feedback' 10 11 import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' ··· 96 97 ) 97 98 } 98 99 }, [anchor, feedFeedback.feedDescriptor]) 100 + 101 + // Track post:view events for parent posts and replies (non-anchor posts) 102 + const trackThreadItemView = usePostViewTracking('PostThreadItem') 99 103 100 104 const {openComposer} = useOpenComposer() 101 105 const optimisticOnPostReply = useCallback( ··· 557 561 onEndReached={onEndReached} 558 562 onEndReachedThreshold={4} 559 563 onStartReachedThreshold={1} 564 + onItemSeen={item => { 565 + // Track post:view for parent posts and replies (non-anchor posts) 566 + if (item.type === 'threadPost' && item.depth !== 0) { 567 + trackThreadItemView(item.value.post) 568 + } 569 + }} 560 570 /** 561 571 * NATIVE ONLY 562 572 * {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition}
+7
src/screens/Search/SearchResults.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 7 7 import {urls} from '#/lib/constants' 8 + import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' 8 9 import {cleanError} from '#/lib/strings/errors' 9 10 import {augmentSearchQuery} from '#/lib/strings/helpers' 10 11 import {useActorSearch} from '#/state/queries/actor-search' ··· 215 216 const {_} = useLingui() 216 217 const {currentAccount, hasSession} = useSession() 217 218 const [isPTR, setIsPTR] = useState(false) 219 + const trackPostView = usePostViewTracking('SearchResults') 218 220 219 221 const augmentedQuery = useMemo(() => { 220 222 return augmentSearchQuery(query || '', {did: currentAccount?.did}) ··· 339 341 refreshing={isPTR} 340 342 onRefresh={onPullToRefresh} 341 343 onEndReached={onEndReached} 344 + onItemSeen={item => { 345 + if (item.type === 'post') { 346 + trackPostView(item.post) 347 + } 348 + }} 342 349 desktopFixedHeight 343 350 ListFooterComponent={ 344 351 <ListFooter
+3
src/screens/Topic.tsx
··· 8 8 9 9 import {HITSLOP_10} from '#/lib/constants' 10 10 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 11 + import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' 11 12 import {type CommonNavigatorParams} from '#/lib/routes/types' 12 13 import {shareUrl} from '#/lib/sharing' 13 14 import {cleanError} from '#/lib/strings/errors' ··· 135 136 const {_} = useLingui() 136 137 const initialNumToRender = useInitialNumToRender() 137 138 const [isPTR, setIsPTR] = React.useState(false) 139 + const trackPostView = usePostViewTracking('Topic') 138 140 139 141 const { 140 142 data, ··· 186 188 onRefresh={onRefresh} 187 189 onEndReached={onEndReached} 188 190 onEndReachedThreshold={4} 191 + onItemSeen={trackPostView} 189 192 // @ts-ignore web only -prf 190 193 desktopFixedHeight 191 194 ListFooterComponent={
+26 -2
src/screens/VideoFeed/index.tsx
··· 492 492 }): React.ReactNode => { 493 493 const postShadow = usePostShadow(post) 494 494 const {width, height} = useSafeAreaFrame() 495 - const {sendInteraction} = useFeedFeedbackContext() 495 + const {sendInteraction, feedDescriptor} = useFeedFeedbackContext() 496 + const hasTrackedView = useRef(false) 496 497 497 498 useEffect(() => { 498 499 if (active) { ··· 502 503 feedContext, 503 504 reqId, 504 505 }) 506 + 507 + // Track post:view event 508 + if (!hasTrackedView.current) { 509 + hasTrackedView.current = true 510 + logger.metric( 511 + 'post:view', 512 + { 513 + uri: post.uri, 514 + authorDid: post.author.did, 515 + logContext: 'ImmersiveVideo', 516 + feedDescriptor, 517 + }, 518 + {statsig: false}, 519 + ) 520 + } 505 521 } 506 - }, [active, post.uri, feedContext, reqId, sendInteraction]) 522 + }, [ 523 + active, 524 + post.uri, 525 + post.author.did, 526 + feedContext, 527 + reqId, 528 + sendInteraction, 529 + feedDescriptor, 530 + ]) 507 531 508 532 // TODO: high-performance android phones should also 509 533 // be capable of rendering 3 video players, but currently
+12
src/view/com/notifications/NotificationFeed.tsx
··· 9 9 import {useLingui} from '@lingui/react' 10 10 11 11 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 12 + import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' 12 13 import {cleanError} from '#/lib/strings/errors' 13 14 import {s} from '#/lib/styles' 14 15 import {logger} from '#/logger' ··· 47 48 const [isPTRing, setIsPTRing] = React.useState(false) 48 49 const {_} = useLingui() 49 50 const moderationOpts = useModerationOpts() 51 + const trackPostView = usePostViewTracking('Notifications') 50 52 const { 51 53 data, 52 54 isFetching, ··· 181 183 onEndReached={onEndReached} 182 184 onEndReachedThreshold={2} 183 185 onScrolledDownChange={onScrolledDownChange} 186 + onItemSeen={item => { 187 + if ( 188 + (item.type === 'reply' || 189 + item.type === 'mention' || 190 + item.type === 'quote') && 191 + item.subject 192 + ) { 193 + trackPostView(item.subject) 194 + } 195 + }} 184 196 contentContainerStyle={s.contentContainer} 185 197 desktopFixedHeight 186 198 initialNumToRender={initialNumToRender}
+3
src/view/com/post-thread/PostQuotes.tsx
··· 9 9 import {useLingui} from '@lingui/react' 10 10 11 11 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 12 + import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' 12 13 import {cleanError} from '#/lib/strings/errors' 13 14 import {logger} from '#/logger' 14 15 import {useModerationOpts} from '#/state/preferences/moderation-opts' ··· 44 45 const {_} = useLingui() 45 46 const initialNumToRender = useInitialNumToRender() 46 47 const [isPTRing, setIsPTRing] = useState(false) 48 + const trackPostView = usePostViewTracking('PostQuotes') 47 49 48 50 const { 49 51 data: resolvedUri, ··· 123 125 onRefresh={onRefresh} 124 126 onEndReached={onEndReached} 125 127 onEndReachedThreshold={4} 128 + onItemSeen={item => trackPostView(item.post)} 126 129 ListFooterComponent={ 127 130 <ListFooter 128 131 isFetchingNextPage={isFetchingNextPage}