Bluesky app fork with some witchin' additions 💫

Post view client event (#9408)

* Adds post:view client event tracking in feeds

* Add post:view event on the post page itself

* Don't send post:view to statsig for now

* convert to non reactive callback to reduce rerenders

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by

Alex Benzer
Samuel Newman
and committed by
GitHub
002a63b1 2faf62c7

+113 -3
+7
src/logger/metrics.ts
··· 263 263 'post:unbookmark': { 264 264 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 265 265 } 266 + 'post:view': { 267 + uri: string 268 + authorDid: string 269 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 270 + feedDescriptor?: string 271 + position?: number 272 + } 266 273 'bookmarks:view': {} 267 274 'bookmarks:post-clicked': {} 268 275 'profile:follow': {
+25 -1
src/screens/PostThread/index.tsx
··· 1 - import {useCallback, useMemo, useRef, useState} from 'react' 1 + import {useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 2 import {useWindowDimensions, View} from 'react-native' 3 3 import Animated, {useAnimatedStyle} from 'react-native-reanimated' 4 4 import {Trans} from '@lingui/macro' 5 5 6 6 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 7 7 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 8 + import {logger} from '#/logger' 8 9 import {useFeedFeedback} from '#/state/feed-feedback' 9 10 import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' 10 11 import { ··· 72 73 } 73 74 return {hasParents} 74 75 }, [thread.data.items]) 76 + 77 + // Track post:view event when anchor post is viewed 78 + const seenPostUriRef = useRef<string | null>(null) 79 + useEffect(() => { 80 + if ( 81 + anchor?.type === 'threadPost' && 82 + anchor.value.post.uri !== seenPostUriRef.current 83 + ) { 84 + const post = anchor.value.post 85 + seenPostUriRef.current = post.uri 86 + 87 + logger.metric( 88 + 'post:view', 89 + { 90 + uri: post.uri, 91 + authorDid: post.author.did, 92 + logContext: 'Post', 93 + feedDescriptor: feedFeedback.feedDescriptor, 94 + }, 95 + {statsig: false}, 96 + ) 97 + } 98 + }, [anchor, feedFeedback.feedDescriptor]) 75 99 76 100 const {openComposer} = useOpenComposer() 77 101 const optimisticOnPostReply = useCallback(
+81 -2
src/view/com/posts/PostFeed.tsx
··· 840 840 const liveNowConfig = useLiveNowConfig() 841 841 842 842 const seenActorWithStatusRef = useRef<Set<string>>(new Set()) 843 + const seenPostUrisRef = useRef<Set<string>>(new Set()) 844 + 845 + // Helper to calculate position in feed (count only root posts, not interstitials or thread replies) 846 + const getPostPosition = useNonReactiveCallback( 847 + (type: FeedRow['type'], key: string) => { 848 + // Calculate position: find the row index in feedItems, then calculate position 849 + const rowIndex = feedItems.findIndex( 850 + row => row.type === 'sliceItem' && row.key === key, 851 + ) 852 + 853 + if (rowIndex >= 0) { 854 + let position = 0 855 + for (let i = 0; i < rowIndex && i < feedItems.length; i++) { 856 + const row = feedItems[i] 857 + if (row.type === 'sliceItem') { 858 + // Only count root posts (indexInSlice === 0), not thread replies 859 + if (row.indexInSlice === 0) { 860 + position++ 861 + } 862 + } else if (row.type === 'videoGridRow') { 863 + // Count each video in the grid row 864 + position += row.items.length 865 + } 866 + } 867 + return position 868 + } 869 + }, 870 + ) 871 + 843 872 const onItemSeen = useCallback( 844 873 (item: FeedRow) => { 845 874 feedFeedback.onItemSeen(item) 875 + 876 + // Track post:view events 846 877 if (item.type === 'sliceItem') { 847 - const actor = item.slice.items[item.indexInSlice].post.author 878 + const slice = item.slice 879 + const indexInSlice = item.indexInSlice 880 + const postItem = slice.items[indexInSlice] 881 + const post = postItem.post 882 + 883 + // Only track the root post of each slice (index 0) to avoid double-counting thread items 884 + if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) { 885 + seenPostUrisRef.current.add(post.uri) 886 + 887 + const position = getPostPosition('sliceItem', item.key) 888 + 889 + logger.metric( 890 + 'post:view', 891 + { 892 + uri: post.uri, 893 + authorDid: post.author.did, 894 + logContext: 'FeedItem', 895 + feedDescriptor: feedFeedback.feedDescriptor || feed, 896 + position, 897 + }, 898 + {statsig: false}, 899 + ) 900 + } 848 901 902 + // Live status tracking (existing code) 903 + const actor = post.author 849 904 if ( 850 905 actor.status && 851 906 validateStatus(actor.did, actor.status, liveNowConfig) && ··· 863 918 ) 864 919 } 865 920 } 921 + } else if (item.type === 'videoGridRow') { 922 + // Track each video in the grid row 923 + for (let i = 0; i < item.items.length; i++) { 924 + const postItem = item.items[i] 925 + const post = postItem.post 926 + 927 + if (!seenPostUrisRef.current.has(post.uri)) { 928 + seenPostUrisRef.current.add(post.uri) 929 + 930 + const position = getPostPosition('videoGridRow', item.key) 931 + 932 + logger.metric( 933 + 'post:view', 934 + { 935 + uri: post.uri, 936 + authorDid: post.author.did, 937 + logContext: 'FeedItem', 938 + feedDescriptor: feedFeedback.feedDescriptor || feed, 939 + position, 940 + }, 941 + {statsig: false}, 942 + ) 943 + } 944 + } 866 945 } 867 946 }, 868 - [feedFeedback, feed, liveNowConfig], 947 + [feedFeedback, feed, liveNowConfig, getPostPosition], 869 948 ) 870 949 871 950 return (