diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index 6da24da075..dc38453d81 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -263,6 +263,13 @@ export type MetricEvents = { 'post:unbookmark': { logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' } + 'post:view': { + uri: string + authorDid: string + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + feedDescriptor?: string + position?: number + } 'bookmarks:view': {} 'bookmarks:post-clicked': {} 'profile:follow': { diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx index 64a6f0f295..92ef5e8661 100644 --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -1,10 +1,11 @@ -import {useCallback, useMemo, useRef, useState} from 'react' +import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {useWindowDimensions, View} from 'react-native' import Animated, {useAnimatedStyle} from 'react-native-reanimated' import {Trans} from '@lingui/macro' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {logger} from '#/logger' import {useFeedFeedback} from '#/state/feed-feedback' import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' import { @@ -73,6 +74,29 @@ export function PostThread({uri}: {uri: string}) { return {hasParents} }, [thread.data.items]) + // Track post:view event when anchor post is viewed + const seenPostUriRef = useRef(null) + useEffect(() => { + if ( + anchor?.type === 'threadPost' && + anchor.value.post.uri !== seenPostUriRef.current + ) { + const post = anchor.value.post + seenPostUriRef.current = post.uri + + logger.metric( + 'post:view', + { + uri: post.uri, + authorDid: post.author.did, + logContext: 'Post', + feedDescriptor: feedFeedback.feedDescriptor, + }, + {statsig: false}, + ) + } + }, [anchor, feedFeedback.feedDescriptor]) + const {openComposer} = useOpenComposer() const optimisticOnPostReply = useCallback( (payload: OnPostSuccessData) => { diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index f3b3f1061c..4f4e6352ab 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -840,12 +840,67 @@ let PostFeed = ({ const liveNowConfig = useLiveNowConfig() const seenActorWithStatusRef = useRef>(new Set()) + const seenPostUrisRef = useRef>(new Set()) + + // Helper to calculate position in feed (count only root posts, not interstitials or thread replies) + const getPostPosition = useNonReactiveCallback( + (type: FeedRow['type'], key: string) => { + // Calculate position: find the row index in feedItems, then calculate position + const rowIndex = feedItems.findIndex( + row => row.type === 'sliceItem' && row.key === key, + ) + + if (rowIndex >= 0) { + let position = 0 + for (let i = 0; i < rowIndex && i < feedItems.length; i++) { + const row = feedItems[i] + if (row.type === 'sliceItem') { + // Only count root posts (indexInSlice === 0), not thread replies + if (row.indexInSlice === 0) { + position++ + } + } else if (row.type === 'videoGridRow') { + // Count each video in the grid row + position += row.items.length + } + } + return position + } + }, + ) + const onItemSeen = useCallback( (item: FeedRow) => { feedFeedback.onItemSeen(item) - if (item.type === 'sliceItem') { - const actor = item.slice.items[item.indexInSlice].post.author + // Track post:view events + if (item.type === 'sliceItem') { + const slice = item.slice + const indexInSlice = item.indexInSlice + const postItem = slice.items[indexInSlice] + const post = postItem.post + + // Only track the root post of each slice (index 0) to avoid double-counting thread items + if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) { + seenPostUrisRef.current.add(post.uri) + + const position = getPostPosition('sliceItem', item.key) + + logger.metric( + 'post:view', + { + uri: post.uri, + authorDid: post.author.did, + logContext: 'FeedItem', + feedDescriptor: feedFeedback.feedDescriptor || feed, + position, + }, + {statsig: false}, + ) + } + + // Live status tracking (existing code) + const actor = post.author if ( actor.status && validateStatus(actor.did, actor.status, liveNowConfig) && @@ -863,9 +918,33 @@ let PostFeed = ({ ) } } + } else if (item.type === 'videoGridRow') { + // Track each video in the grid row + for (let i = 0; i < item.items.length; i++) { + const postItem = item.items[i] + const post = postItem.post + + if (!seenPostUrisRef.current.has(post.uri)) { + seenPostUrisRef.current.add(post.uri) + + const position = getPostPosition('videoGridRow', item.key) + + logger.metric( + 'post:view', + { + uri: post.uri, + authorDid: post.author.did, + logContext: 'FeedItem', + feedDescriptor: feedFeedback.feedDescriptor || feed, + position, + }, + {statsig: false}, + ) + } + } } }, - [feedFeedback, feed, liveNowConfig], + [feedFeedback, feed, liveNowConfig, getPostPosition], ) return (