diff --git a/src/lib/hooks/usePostViewTracking.ts b/src/lib/hooks/usePostViewTracking.ts new file mode 100644 index 0000000000..b9f4d0eb02 --- /dev/null +++ b/src/lib/hooks/usePostViewTracking.ts @@ -0,0 +1,38 @@ +import {useCallback, useRef} from 'react' +import {type AppBskyFeedDefs} from '@atproto/api' + +import {logger} from '#/logger' +import {type MetricEvents} from '#/logger/metrics' + +/** + * Hook that returns a callback to track post:view events. + * Handles deduplication so the same post URI is only tracked once per mount. + * + * @param logContext - The context where the post is being viewed + * @returns A callback that accepts a post and logs the view event + */ +export function usePostViewTracking( + logContext: MetricEvents['post:view']['logContext'], +) { + const seenUrisRef = useRef(new Set()) + + const trackPostView = useCallback( + (post: AppBskyFeedDefs.PostView) => { + if (seenUrisRef.current.has(post.uri)) return + seenUrisRef.current.add(post.uri) + + logger.metric( + 'post:view', + { + uri: post.uri, + authorDid: post.author.did, + logContext, + }, + {statsig: false}, + ) + }, + [logContext], + ) + + return trackPostView +} diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index e4e1ea20fe..8aef2a8256 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -271,7 +271,17 @@ export type MetricEvents = { 'post:view': { uri: string authorDid: string - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + logContext: + | 'FeedItem' + | 'PostThreadItem' + | 'Post' + | 'ImmersiveVideo' + | 'SearchResults' + | 'Bookmarks' + | 'Notifications' + | 'Hashtag' + | 'Topic' + | 'PostQuotes' feedDescriptor?: string position?: number } diff --git a/src/screens/Bookmarks/index.tsx b/src/screens/Bookmarks/index.tsx index a98756a392..5faa60d927 100644 --- a/src/screens/Bookmarks/index.tsx +++ b/src/screens/Bookmarks/index.tsx @@ -15,6 +15,7 @@ import { import {useCleanError} from '#/lib/hooks/useCleanError' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' import { type CommonNavigatorParams, type NativeStackScreenProps, @@ -94,6 +95,7 @@ function BookmarksInner() { const initialNumToRender = useInitialNumToRender() const cleanError = useCleanError() const [isPTRing, setIsPTRing] = useState(false) + const trackPostView = usePostViewTracking('Bookmarks') const { data, isLoading, @@ -176,6 +178,11 @@ function BookmarksInner() { onRefresh={onRefresh} onEndReached={onEndReached} onEndReachedThreshold={4} + onItemSeen={item => { + if (item.type === 'bookmark') { + trackPostView(item.bookmark.item) + } + }} ListFooterComponent={ { if (!author) return fullTag @@ -264,6 +266,7 @@ function HashtagScreenTab({ onRefresh={onRefresh} onEndReached={onEndReached} onEndReachedThreshold={4} + onItemSeen={trackPostView} // @ts-ignore web only -prf desktopFixedHeight ListFooterComponent={ diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx index 92ef5e8661..7e130f71f6 100644 --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -5,6 +5,7 @@ import {Trans} from '@lingui/macro' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' import {logger} from '#/logger' import {useFeedFeedback} from '#/state/feed-feedback' import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' @@ -97,6 +98,9 @@ export function PostThread({uri}: {uri: string}) { } }, [anchor, feedFeedback.feedDescriptor]) + // Track post:view events for parent posts and replies (non-anchor posts) + const trackThreadItemView = usePostViewTracking('PostThreadItem') + const {openComposer} = useOpenComposer() const optimisticOnPostReply = useCallback( (payload: OnPostSuccessData) => { @@ -557,6 +561,12 @@ export function PostThread({uri}: {uri: string}) { onEndReached={onEndReached} onEndReachedThreshold={4} onStartReachedThreshold={1} + onItemSeen={item => { + // Track post:view for parent posts and replies (non-anchor posts) + if (item.type === 'threadPost' && item.depth !== 0) { + trackThreadItemView(item.value.post) + } + }} /** * NATIVE ONLY * {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition} diff --git a/src/screens/Search/SearchResults.tsx b/src/screens/Search/SearchResults.tsx index 63decdad47..6d4dd64784 100644 --- a/src/screens/Search/SearchResults.tsx +++ b/src/screens/Search/SearchResults.tsx @@ -5,6 +5,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {urls} from '#/lib/constants' +import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' import {cleanError} from '#/lib/strings/errors' import {augmentSearchQuery} from '#/lib/strings/helpers' import {useActorSearch} from '#/state/queries/actor-search' @@ -215,6 +216,7 @@ let SearchScreenPostResults = ({ const {_} = useLingui() const {currentAccount, hasSession} = useSession() const [isPTR, setIsPTR] = useState(false) + const trackPostView = usePostViewTracking('SearchResults') const augmentedQuery = useMemo(() => { return augmentSearchQuery(query || '', {did: currentAccount?.did}) @@ -339,6 +341,11 @@ let SearchScreenPostResults = ({ refreshing={isPTR} onRefresh={onPullToRefresh} onEndReached={onEndReached} + onItemSeen={item => { + if (item.type === 'post') { + trackPostView(item.post) + } + }} desktopFixedHeight ListFooterComponent={ { const postShadow = usePostShadow(post) const {width, height} = useSafeAreaFrame() - const {sendInteraction} = useFeedFeedbackContext() + const {sendInteraction, feedDescriptor} = useFeedFeedbackContext() + const hasTrackedView = useRef(false) useEffect(() => { if (active) { @@ -502,8 +503,31 @@ let VideoItem = ({ feedContext, reqId, }) + + // Track post:view event + if (!hasTrackedView.current) { + hasTrackedView.current = true + logger.metric( + 'post:view', + { + uri: post.uri, + authorDid: post.author.did, + logContext: 'ImmersiveVideo', + feedDescriptor, + }, + {statsig: false}, + ) + } } - }, [active, post.uri, feedContext, reqId, sendInteraction]) + }, [ + active, + post.uri, + post.author.did, + feedContext, + reqId, + sendInteraction, + feedDescriptor, + ]) // TODO: high-performance android phones should also // be capable of rendering 3 video players, but currently diff --git a/src/view/com/notifications/NotificationFeed.tsx b/src/view/com/notifications/NotificationFeed.tsx index 513c0f3724..04c470e1c7 100644 --- a/src/view/com/notifications/NotificationFeed.tsx +++ b/src/view/com/notifications/NotificationFeed.tsx @@ -9,6 +9,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' import {cleanError} from '#/lib/strings/errors' import {s} from '#/lib/styles' import {logger} from '#/logger' @@ -47,6 +48,7 @@ export function NotificationFeed({ const [isPTRing, setIsPTRing] = React.useState(false) const {_} = useLingui() const moderationOpts = useModerationOpts() + const trackPostView = usePostViewTracking('Notifications') const { data, isFetching, @@ -181,6 +183,16 @@ export function NotificationFeed({ onEndReached={onEndReached} onEndReachedThreshold={2} onScrolledDownChange={onScrolledDownChange} + onItemSeen={item => { + if ( + (item.type === 'reply' || + item.type === 'mention' || + item.type === 'quote') && + item.subject + ) { + trackPostView(item.subject) + } + }} contentContainerStyle={s.contentContainer} desktopFixedHeight initialNumToRender={initialNumToRender} diff --git a/src/view/com/post-thread/PostQuotes.tsx b/src/view/com/post-thread/PostQuotes.tsx index 8f178968d9..1f48f50d37 100644 --- a/src/view/com/post-thread/PostQuotes.tsx +++ b/src/view/com/post-thread/PostQuotes.tsx @@ -9,6 +9,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' import {useModerationOpts} from '#/state/preferences/moderation-opts' @@ -44,6 +45,7 @@ export function PostQuotes({uri}: {uri: string}) { const {_} = useLingui() const initialNumToRender = useInitialNumToRender() const [isPTRing, setIsPTRing] = useState(false) + const trackPostView = usePostViewTracking('PostQuotes') const { data: resolvedUri, @@ -123,6 +125,7 @@ export function PostQuotes({uri}: {uri: string}) { onRefresh={onRefresh} onEndReached={onEndReached} onEndReachedThreshold={4} + onItemSeen={item => trackPostView(item.post)} ListFooterComponent={