diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 90dbc4f983..5157c66e30 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -1175,18 +1175,37 @@ export type Events = { 'profile:associated:germ:self-disconnect': {} 'profile:associated:germ:self-reconnect': {} - // Gallery carousel events - 'post:gallery:swipe': { + // Post photo embed events + 'post:photoEmbed:impression': { + layout: 'single' | 'grid' | 'carousel' + totalImages: number + postUri: string + postAuthorDid: string + feedDescriptor?: string + } + 'post:photoEmbed:open': { + layout: 'single' | 'grid' | 'carousel' + fromImage: number + totalImages: number + postUri: string + postAuthorDid: string + feedDescriptor?: string + } + 'post:photoEmbed:carouselSwipe': { fromImage: number toImage: number totalImages: number + postUri: string + postAuthorDid: string + feedDescriptor?: string } - 'post:gallery:openLightbox': { + 'post:photoEmbed:lightboxSwipe': { + layout: 'single' | 'grid' | 'carousel' fromImage: number - totalImages: number - } - 'post:gallery:impression': { + toImage: number totalImages: number postUri: string + postAuthorDid: string + feedDescriptor?: string } } diff --git a/src/components/Lightbox/pager/ImagePager.tsx b/src/components/Lightbox/pager/ImagePager.tsx index 7b8815f276..43942b0f37 100644 --- a/src/components/Lightbox/pager/ImagePager.tsx +++ b/src/components/Lightbox/pager/ImagePager.tsx @@ -39,6 +39,7 @@ import {type Dimensions} from '#/lib/media/types' import {useTheme} from '#/alf' import {setSystemUITheme} from '#/alf/util/systemUI' import {type Lightbox} from '#/components/Lightbox/state' +import {useAnalytics} from '#/analytics' import {IS_IOS} from '#/env' import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army' import {Footer} from '../chrome/Footer' @@ -228,7 +229,8 @@ function ImageView({ openProgress: SharedValue thumbRects: SharedValue> }) { - const {images, index: initialImageIndex} = lightbox + const {images, index: initialImageIndex, metricsContext} = lightbox + const ax = useAnalytics() const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox]) const [isScaled, setIsScaled] = useState(false) const [isDragging, setIsDragging] = useState(false) @@ -377,7 +379,21 @@ function ImageView({ scrollEnabled={!isScaled} initialPage={initialImageIndex} onPageSelected={e => { - setImageIndex(e.nativeEvent.position) + const next = e.nativeEvent.position + setImageIndex(prev => { + if (metricsContext && prev !== next) { + ax.metric('post:photoEmbed:lightboxSwipe', { + layout: metricsContext.layout, + fromImage: prev + 1, + toImage: next + 1, + totalImages: images.length, + postUri: metricsContext.postUri, + postAuthorDid: metricsContext.postAuthorDid, + feedDescriptor: metricsContext.feedDescriptor, + }) + } + return next + }) setIsScaled(false) }} onPageScrollStateChanged={e => { diff --git a/src/components/Lightbox/state.tsx b/src/components/Lightbox/state.tsx index 23af527734..6a8fdbe0b7 100644 --- a/src/components/Lightbox/state.tsx +++ b/src/components/Lightbox/state.tsx @@ -11,10 +11,20 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useHotkeysContext} from '#/lib/hotkeys' import {type ImageSource} from '#/components/Lightbox/types' +export type LightboxMetricsContext = { + layout: 'single' | 'grid' | 'carousel' + postUri: string + postAuthorDid: string + feedDescriptor?: string +} + export type Lightbox = { id: string images: ImageSource[] index: number + // Set for post photo embeds so the lightbox can emit post:photoEmbed:lightboxSwipe. + // Left unset for non-post contexts (e.g. profile avatar/banner lightbox). + metricsContext?: LightboxMetricsContext } const LightboxContext = createContext<{ diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index 0a620efde4..9d47302063 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -8,7 +8,10 @@ import {atoms as a, tokens} from '#/alf' import {AutoSizedImage} from '#/components/images/AutoSizedImage' import {Gallery} from '#/components/images/Gallery' import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid' -import {useLightboxControls} from '#/components/Lightbox/state' +import { + type LightboxMetricsContext, + useLightboxControls, +} from '#/components/Lightbox/state' import {type Dimensions} from '#/components/Lightbox/types' import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu' import {PostEmbedViewContext} from '#/components/Post/Embed/types' @@ -40,6 +43,20 @@ export function ImageEmbed({ ? images.length > MAX_GRID_IMAGES : ax.features.enabled(ax.features.PostGalleryEmbedEnable) + const layout: 'single' | 'grid' | 'carousel' = + images.length === 1 ? 'single' : useExpandedLayout ? 'carousel' : 'grid' + + const postContext = rest.post + ? { + postUri: rest.post.uri, + postAuthorDid: rest.post.author.did, + feedDescriptor: rest.feedDescriptor, + } + : undefined + const metricsContext: LightboxMetricsContext | undefined = postContext + ? {layout, ...postContext} + : undefined + // Captured from AutoSizedImage so the peek-commit handler can reuse the same // ref + dims that a tap would — keeps the lightbox's return animation intact. const singleContainerRef = useRef | null>(null) @@ -57,6 +74,14 @@ export function ImageEmbed({ refs: AnimatedRef[], fetchedDims: (Dimensions | null)[], ) => { + if (postContext) { + ax.metric('post:photoEmbed:open', { + layout, + fromImage: index + 1, + totalImages: images.length, + ...postContext, + }) + } openLightbox({ images: items.map((item, i) => ({ ...item, @@ -67,6 +92,7 @@ export function ImageEmbed({ type: 'image', })), index, + metricsContext, }) } const onPressIn = (_: number) => { @@ -132,6 +158,7 @@ export function ImageEmbed({ onPressIn={onPressIn} viewContext={rest.viewContext} isWithinQuote={rest.isWithinQuote} + metricsPostContext={postContext} /> ) diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index 28eed2aaa9..69d92983ed 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -345,6 +345,9 @@ export function QuoteEmbed({ allowNestedQuotes={ parentIsWithinQuote ? false : parentAllowNestedQuotes } + // The photo embed belongs to the quoted post, so attribute its + // analytics to the quoted post rather than the parent. + post={quote} /> )} diff --git a/src/components/Post/Embed/types.ts b/src/components/Post/Embed/types.ts index 6c023a14ef..77319b0e86 100644 --- a/src/components/Post/Embed/types.ts +++ b/src/components/Post/Embed/types.ts @@ -15,6 +15,13 @@ export type CommonProps = { viewContext?: PostEmbedViewContext isWithinQuote?: boolean allowNestedQuotes?: boolean + /** + * The post that contains this embed. Used for analytics on photo embed + * events (post:photoEmbed:*). When the embed has no owning post (e.g. + * composer previews), leave this undefined and no events will be emitted. + */ + post?: AppBskyFeedDefs.PostView + feedDescriptor?: string } export type EmbedProps = CommonProps & { diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx index 0ca46c0b67..1b4e1d6239 100644 --- a/src/components/images/Gallery/index.tsx +++ b/src/components/images/Gallery/index.tsx @@ -55,6 +55,13 @@ interface GalleryProps { onPressIn?: (index: number) => void viewContext?: PostEmbedViewContext isWithinQuote?: boolean + // Post context for the in-feed carousel swipe metric. Omit for non-post + // contexts (no event will be emitted). + metricsPostContext?: { + postUri: string + postAuthorDid: string + feedDescriptor?: string + } } const Context = createContext<{ @@ -99,6 +106,7 @@ export function Gallery({ onPressIn, viewContext, isWithinQuote, + metricsPostContext, }: GalleryProps) { const {t: l} = useLingui() const ax = useAnalytics() @@ -169,13 +177,17 @@ export function Gallery({ const emitSwipeMetric = useMemo( () => debounce((fromIndex: number, toIndex: number) => { - ax.metric('post:gallery:swipe', { + if (!metricsPostContext) return + ax.metric('post:photoEmbed:carouselSwipe', { fromImage: fromIndex + 1, // convert to 1-based index for easier analysis toImage: toIndex + 1, // convert to 1-based index for easier analysis totalImages: images.length, + postUri: metricsPostContext.postUri, + postAuthorDid: metricsPostContext.postAuthorDid, + feedDescriptor: metricsPostContext.feedDescriptor, }) }, 200), - [ax, images.length], + [ax, images.length, metricsPostContext], ) const setCurrentIndex = (index: number) => { @@ -277,10 +289,6 @@ export function Gallery({ renderItem={({item, index}) => { const openLightboxAtIndex = onPress ? () => { - ax.metric('post:gallery:openLightbox', { - fromImage: index + 1, // convert to 1-based index for easier analysis - totalImages: images.length, - }) const refs: AnimatedRef[] = [] const dims: (Dimensions | null)[] = [] for (let i = 0; i < images.length; i++) { diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index 17c8e54be8..dc3c55f125 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -414,6 +414,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ moderation={moderation} viewContext={PostEmbedViewContext.ThreadHighlighted} onOpen={onOpenEmbed} + post={post} + feedDescriptor={feedFeedback.feedDescriptor} /> )} diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx index 841c2af745..c1e27222ff 100644 --- a/src/screens/PostThread/components/ThreadItemPost.tsx +++ b/src/screens/PostThread/components/ThreadItemPost.tsx @@ -349,6 +349,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({ embed={post.embed} moderation={moderation} viewContext={PostEmbedViewContext.Feed} + post={post} /> )} diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx index 6ee116d166..9868786648 100644 --- a/src/screens/PostThread/components/ThreadItemTreePost.tsx +++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx @@ -371,6 +371,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({ embed={post.embed} moderation={moderation} viewContext={PostEmbedViewContext.Feed} + post={post} /> )} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 62e7cb2aae..c952224553 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -255,6 +255,7 @@ function PostInner({ embed={post.embed} moderation={moderation} viewContext={PostEmbedViewContext.Feed} + post={post} /> ) : null} diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 48754d2753..20c6a31a3a 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -13,6 +13,8 @@ import { import { type AppBskyActorDefs, AppBskyEmbedExternal, + AppBskyEmbedGallery, + AppBskyEmbedImages, AppBskyEmbedVideo, type AppBskyFeedDefs, } from '@atproto/api' @@ -907,7 +909,9 @@ let PostFeed = ({ const seenActorWithStatusRef = useRef>(new Set()) const seenPostUrisRef = useRef>(new Set()) - const seenStandardSiteUrisRef = useRef>(new Set()) + // Tracks every post we've seen so we can fire per-post events exactly once, + // regardless of the post's position within its slice. + const seenPerPostUrisRef = useRef>(new Set()) // Helper to calculate position in feed (count only root posts, not interstitials or thread replies) const getPostPosition = useNonReactiveCallback( @@ -940,6 +944,48 @@ let PostFeed = ({ (item: FeedRow) => { feedFeedback.onItemSeen(item) + // Events that should fire exactly once for every new post, regardless of + // its position within a slice or video grid row. + const onPostSeen = (post: AppBskyFeedDefs.PostView) => { + if (seenPerPostUrisRef.current.has(post.uri)) return + seenPerPostUrisRef.current.add(post.uri) + + // Standard site embed view tracking + if ( + AppBskyEmbedExternal.isView(post.embed) && + isStandardSiteEmbed(post.embed.external) + ) { + ax.metric('embed:standardSite:view', {url: post.embed.external.uri}) + } + + // Photo embed impression tracking + if ( + AppBskyEmbedImages.isView(post.embed) || + AppBskyEmbedGallery.isView(post.embed) + ) { + const totalImages = AppBskyEmbedGallery.isView(post.embed) + ? post.embed.items.filter(AppBskyEmbedGallery.isViewImage).length + : post.embed.images.length + const useExpandedLayout = AppBskyEmbedGallery.isView(post.embed) + ? totalImages > 4 + : ax.features.enabled(ax.features.PostGalleryEmbedEnable) + const layout = + totalImages === 1 + ? 'single' + : useExpandedLayout + ? 'carousel' + : 'grid' + + ax.metric('post:photoEmbed:impression', { + layout, + totalImages, + postUri: post.uri, + postAuthorDid: post.author.did, + feedDescriptor: feedFeedback.feedDescriptor || feed, + }) + } + } + // Track post:view events if (item.type === 'sliceItem') { const slice = item.slice @@ -947,6 +993,8 @@ let PostFeed = ({ const postItem = slice.items[indexInSlice] const post = postItem.post + onPostSeen(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) @@ -977,16 +1025,6 @@ let PostFeed = ({ }) } } - - // Standard site embed view tracking - if ( - AppBskyEmbedExternal.isView(post.embed) && - isStandardSiteEmbed(post.embed.external) && - !seenStandardSiteUrisRef.current.has(post.embed.external.uri) - ) { - seenStandardSiteUrisRef.current.add(post.embed.external.uri) - ax.metric('embed:standardSite:view', {url: post.embed.external.uri}) - } } else if (item.type === 'videoGridRow') { // Track each video in the grid row for (let i = 0; i < item.items.length; i++) { diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index 748d14faca..c0ff26206d 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -429,6 +429,7 @@ let FeedItemInner = ({ onOpenEmbed={onOpenEmbed} post={post} additionalPostAlerts={additionalPostAlerts} + feedDescriptor={feedDescriptor} /> void post: AppBskyFeedDefs.PostView additionalPostAlerts?: AppModerationCause[] + feedDescriptor?: string }): React.ReactNode => { const [limitLines, setLimitLines] = useState( () => countLines(richText.text) >= MAX_POST_LINES, @@ -528,6 +531,8 @@ let PostContent = ({ moderation={moderation} onOpen={onOpenEmbed} viewContext={PostEmbedViewContext.Feed} + post={post} + feedDescriptor={feedDescriptor} /> ) : null}