From 28bd708c6bf4901626cb379d6558c5bdde480f92 Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Mon, 8 Jun 2026 10:43:36 -0400 Subject: [PATCH] Refactor photo embed analytics to post:photoEmbed:* namespace Replaces the legacy post:gallery:* events, which only instrumented the carousel layout (and so were biased by the PostGalleryEmbedEnable rollout) with a coherent, layout-agnostic family that fires identically across the single image, 2-4 image grid, and carousel renderers. The new events all carry uri, authorDid, and an optional feedDescriptor, and a layout discriminator ('single' | 'grid' | 'carousel') so opens and impressions are directly comparable across layouts. - post:photoEmbed:impression: fires once per mount of a post photo embed - post:photoEmbed:open: fires from the shared onPress in ImageEmbed, so all three layouts emit it without per-layout duplication - post:photoEmbed:carouselSwipe: in-feed carousel swipe (debounced) - post:photoEmbed:lightboxSwipe: fires from the lightbox pager regardless of which layout opened it The dead post:gallery:impression definition is removed, and post:gallery:openLightbox / post:gallery:swipe are hard-removed with no dual-emit period. Quoted-post photos are attributed to the quoted post (quote.uri / quote.author.did), not the surrounding post. --- src/analytics/metrics/types.ts | 33 ++++++++++--- src/components/Lightbox/pager/ImagePager.tsx | 20 +++++++- src/components/Lightbox/state.tsx | 10 ++++ src/components/Post/Embed/ImageEmbed.tsx | 46 ++++++++++++++++++- src/components/Post/Embed/index.tsx | 4 ++ src/components/Post/Embed/types.ts | 6 +++ src/components/images/Gallery/index.tsx | 20 +++++--- .../components/ThreadItemAnchor.tsx | 2 + .../PostThread/components/ThreadItemPost.tsx | 2 + .../components/ThreadItemTreePost.tsx | 2 + src/view/com/post/Post.tsx | 2 + src/view/com/posts/PostFeedItem.tsx | 6 +++ 12 files changed, 136 insertions(+), 17 deletions(-) diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 90dbc4f983..d4b87eda2e 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 + uri: string + authorDid: string + feedDescriptor?: string + } + 'post:photoEmbed:open': { + layout: 'single' | 'grid' | 'carousel' + fromImage: number + totalImages: number + uri: string + authorDid: string + feedDescriptor?: string + } + 'post:photoEmbed:carouselSwipe': { fromImage: number toImage: number totalImages: number + uri: string + authorDid: string + feedDescriptor?: string } - 'post:gallery:openLightbox': { + 'post:photoEmbed:lightboxSwipe': { + layout: 'single' | 'grid' | 'carousel' fromImage: number + toImage: number totalImages: number - } - 'post:gallery:impression': { - totalImages: number - postUri: string + uri: string + authorDid: string + feedDescriptor?: string } } diff --git a/src/components/Lightbox/pager/ImagePager.tsx b/src/components/Lightbox/pager/ImagePager.tsx index 7b8815f276..1e3ef670d0 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, + uri: metricsContext.uri, + authorDid: metricsContext.authorDid, + 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..355e9e77df 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' + uri: string + authorDid: 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..3648604219 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -1,4 +1,4 @@ -import {useRef} from 'react' +import {useEffect, useRef} from 'react' import {InteractionManager, View} from 'react-native' import {type AnimatedRef} from 'react-native-reanimated' import {Image} from 'expo-image' @@ -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,35 @@ 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.uri && rest.authorDid + ? { + uri: rest.uri, + authorDid: rest.authorDid, + feedDescriptor: rest.feedDescriptor, + } + : undefined + const metricsContext: LightboxMetricsContext | undefined = postContext + ? {layout, ...postContext} + : undefined + + // Impression: one per mount of a post photo embed. Covers all three layouts + // identically so the opens/impressions CTR is unbiased by layout. + useEffect(() => { + if (images.length > 0 && postContext) { + ax.metric('post:photoEmbed:impression', { + layout, + totalImages: images.length, + ...postContext, + }) + } + // Fire once per mount; intentionally not reactive to post context changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + // 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 +89,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 +107,7 @@ export function ImageEmbed({ type: 'image', })), index, + metricsContext, }) } const onPressIn = (_: number) => { @@ -132,6 +173,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..4f863749a7 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -345,6 +345,10 @@ 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. + uri={quote.uri} + authorDid={quote.author.did} /> )} diff --git a/src/components/Post/Embed/types.ts b/src/components/Post/Embed/types.ts index 6c023a14ef..74d7ba4a9b 100644 --- a/src/components/Post/Embed/types.ts +++ b/src/components/Post/Embed/types.ts @@ -15,6 +15,12 @@ export type CommonProps = { viewContext?: PostEmbedViewContext isWithinQuote?: boolean allowNestedQuotes?: boolean + // Post context for analytics on photo embed events (post:photoEmbed:*). + // When the embed has no owning post (e.g. previews), leave these undefined + // and no events will be emitted. + uri?: string + authorDid?: string + feedDescriptor?: string } export type EmbedProps = CommonProps & { diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx index 0ca46c0b67..027d2b19d2 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?: { + uri: string + authorDid: 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, + uri: metricsPostContext.uri, + authorDid: metricsPostContext.authorDid, + 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..69d78c8149 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} + uri={post.uri} + authorDid={post.author.did} /> )} diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx index 841c2af745..1ae44bfe8b 100644 --- a/src/screens/PostThread/components/ThreadItemPost.tsx +++ b/src/screens/PostThread/components/ThreadItemPost.tsx @@ -349,6 +349,8 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({ embed={post.embed} moderation={moderation} viewContext={PostEmbedViewContext.Feed} + uri={post.uri} + authorDid={post.author.did} /> )} diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx index 6ee116d166..6fbe6bed3d 100644 --- a/src/screens/PostThread/components/ThreadItemTreePost.tsx +++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx @@ -371,6 +371,8 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({ embed={post.embed} moderation={moderation} viewContext={PostEmbedViewContext.Feed} + uri={post.uri} + authorDid={post.author.did} /> )} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 62e7cb2aae..8686db10ee 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -255,6 +255,8 @@ function PostInner({ embed={post.embed} moderation={moderation} viewContext={PostEmbedViewContext.Feed} + uri={post.uri} + authorDid={post.author.did} /> ) : null} diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index 748d14faca..97531d6312 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,9 @@ let PostContent = ({ moderation={moderation} onOpen={onOpenEmbed} viewContext={PostEmbedViewContext.Feed} + uri={post.uri} + authorDid={post.author.did} + feedDescriptor={feedDescriptor} /> ) : null}