Refactor photo embed analytics to post:photoEmbed:* namespace (#10784)

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Spence Pope
2026-06-08 12:44:59 -04:00
committed by GitHub
parent c4f3a2cb89
commit 0090285fc0
13 changed files with 164 additions and 26 deletions
+25 -6
View File
@@ -1175,18 +1175,37 @@ export type Events = {
'profile:associated:germ:self-disconnect': {} 'profile:associated:germ:self-disconnect': {}
'profile:associated:germ:self-reconnect': {} 'profile:associated:germ:self-reconnect': {}
// Gallery carousel events // Post photo embed events
'post:gallery:swipe': { '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 fromImage: number
toImage: number toImage: number
totalImages: number totalImages: number
postUri: string
postAuthorDid: string
feedDescriptor?: string
} }
'post:gallery:openLightbox': { 'post:photoEmbed:lightboxSwipe': {
layout: 'single' | 'grid' | 'carousel'
fromImage: number fromImage: number
totalImages: number toImage: number
}
'post:gallery:impression': {
totalImages: number totalImages: number
postUri: string postUri: string
postAuthorDid: string
feedDescriptor?: string
} }
} }
+18 -2
View File
@@ -39,6 +39,7 @@ import {type Dimensions} from '#/lib/media/types'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
import {setSystemUITheme} from '#/alf/util/systemUI' import {setSystemUITheme} from '#/alf/util/systemUI'
import {type Lightbox} from '#/components/Lightbox/state' import {type Lightbox} from '#/components/Lightbox/state'
import {useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env' import {IS_IOS} from '#/env'
import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army' import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
import {Footer} from '../chrome/Footer' import {Footer} from '../chrome/Footer'
@@ -228,7 +229,8 @@ function ImageView({
openProgress: SharedValue<number> openProgress: SharedValue<number>
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>> thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
}) { }) {
const {images, index: initialImageIndex} = lightbox const {images, index: initialImageIndex, metricsContext} = lightbox
const ax = useAnalytics()
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox]) const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
const [isScaled, setIsScaled] = useState(false) const [isScaled, setIsScaled] = useState(false)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
@@ -377,7 +379,21 @@ function ImageView({
scrollEnabled={!isScaled} scrollEnabled={!isScaled}
initialPage={initialImageIndex} initialPage={initialImageIndex}
onPageSelected={e => { 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) setIsScaled(false)
}} }}
onPageScrollStateChanged={e => { onPageScrollStateChanged={e => {
+10
View File
@@ -11,10 +11,20 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useHotkeysContext} from '#/lib/hotkeys' import {useHotkeysContext} from '#/lib/hotkeys'
import {type ImageSource} from '#/components/Lightbox/types' import {type ImageSource} from '#/components/Lightbox/types'
export type LightboxMetricsContext = {
layout: 'single' | 'grid' | 'carousel'
postUri: string
postAuthorDid: string
feedDescriptor?: string
}
export type Lightbox = { export type Lightbox = {
id: string id: string
images: ImageSource[] images: ImageSource[]
index: number 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<{ const LightboxContext = createContext<{
+28 -1
View File
@@ -8,7 +8,10 @@ import {atoms as a, tokens} from '#/alf'
import {AutoSizedImage} from '#/components/images/AutoSizedImage' import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {Gallery} from '#/components/images/Gallery' import {Gallery} from '#/components/images/Gallery'
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid' 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 {type Dimensions} from '#/components/Lightbox/types'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu' import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {PostEmbedViewContext} from '#/components/Post/Embed/types'
@@ -40,6 +43,20 @@ export function ImageEmbed({
? images.length > MAX_GRID_IMAGES ? images.length > MAX_GRID_IMAGES
: ax.features.enabled(ax.features.PostGalleryEmbedEnable) : 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 // 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. // ref + dims that a tap would — keeps the lightbox's return animation intact.
const singleContainerRef = useRef<AnimatedRef<any> | null>(null) const singleContainerRef = useRef<AnimatedRef<any> | null>(null)
@@ -57,6 +74,14 @@ export function ImageEmbed({
refs: AnimatedRef<any>[], refs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[], fetchedDims: (Dimensions | null)[],
) => { ) => {
if (postContext) {
ax.metric('post:photoEmbed:open', {
layout,
fromImage: index + 1,
totalImages: images.length,
...postContext,
})
}
openLightbox({ openLightbox({
images: items.map((item, i) => ({ images: items.map((item, i) => ({
...item, ...item,
@@ -67,6 +92,7 @@ export function ImageEmbed({
type: 'image', type: 'image',
})), })),
index, index,
metricsContext,
}) })
} }
const onPressIn = (_: number) => { const onPressIn = (_: number) => {
@@ -132,6 +158,7 @@ export function ImageEmbed({
onPressIn={onPressIn} onPressIn={onPressIn}
viewContext={rest.viewContext} viewContext={rest.viewContext}
isWithinQuote={rest.isWithinQuote} isWithinQuote={rest.isWithinQuote}
metricsPostContext={postContext}
/> />
</View> </View>
) )
+3
View File
@@ -345,6 +345,9 @@ export function QuoteEmbed({
allowNestedQuotes={ allowNestedQuotes={
parentIsWithinQuote ? false : parentAllowNestedQuotes 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}
/> />
)} )}
</> </>
+7
View File
@@ -15,6 +15,13 @@ export type CommonProps = {
viewContext?: PostEmbedViewContext viewContext?: PostEmbedViewContext
isWithinQuote?: boolean isWithinQuote?: boolean
allowNestedQuotes?: 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 & { export type EmbedProps = CommonProps & {
+14 -6
View File
@@ -55,6 +55,13 @@ interface GalleryProps {
onPressIn?: (index: number) => void onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext viewContext?: PostEmbedViewContext
isWithinQuote?: boolean 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<{ const Context = createContext<{
@@ -99,6 +106,7 @@ export function Gallery({
onPressIn, onPressIn,
viewContext, viewContext,
isWithinQuote, isWithinQuote,
metricsPostContext,
}: GalleryProps) { }: GalleryProps) {
const {t: l} = useLingui() const {t: l} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
@@ -169,13 +177,17 @@ export function Gallery({
const emitSwipeMetric = useMemo( const emitSwipeMetric = useMemo(
() => () =>
debounce((fromIndex: number, toIndex: number) => { 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 fromImage: fromIndex + 1, // convert to 1-based index for easier analysis
toImage: toIndex + 1, // convert to 1-based index for easier analysis toImage: toIndex + 1, // convert to 1-based index for easier analysis
totalImages: images.length, totalImages: images.length,
postUri: metricsPostContext.postUri,
postAuthorDid: metricsPostContext.postAuthorDid,
feedDescriptor: metricsPostContext.feedDescriptor,
}) })
}, 200), }, 200),
[ax, images.length], [ax, images.length, metricsPostContext],
) )
const setCurrentIndex = (index: number) => { const setCurrentIndex = (index: number) => {
@@ -277,10 +289,6 @@ export function Gallery({
renderItem={({item, index}) => { renderItem={({item, index}) => {
const openLightboxAtIndex = onPress 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<any>[] = [] const refs: AnimatedRef<any>[] = []
const dims: (Dimensions | null)[] = [] const dims: (Dimensions | null)[] = []
for (let i = 0; i < images.length; i++) { for (let i = 0; i < images.length; i++) {
@@ -414,6 +414,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
moderation={moderation} moderation={moderation}
viewContext={PostEmbedViewContext.ThreadHighlighted} viewContext={PostEmbedViewContext.ThreadHighlighted}
onOpen={onOpenEmbed} onOpen={onOpenEmbed}
post={post}
feedDescriptor={feedFeedback.feedDescriptor}
/> />
</View> </View>
)} )}
@@ -349,6 +349,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
embed={post.embed} embed={post.embed}
moderation={moderation} moderation={moderation}
viewContext={PostEmbedViewContext.Feed} viewContext={PostEmbedViewContext.Feed}
post={post}
/> />
</View> </View>
)} )}
@@ -371,6 +371,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
embed={post.embed} embed={post.embed}
moderation={moderation} moderation={moderation}
viewContext={PostEmbedViewContext.Feed} viewContext={PostEmbedViewContext.Feed}
post={post}
/> />
</View> </View>
)} )}
+1
View File
@@ -255,6 +255,7 @@ function PostInner({
embed={post.embed} embed={post.embed}
moderation={moderation} moderation={moderation}
viewContext={PostEmbedViewContext.Feed} viewContext={PostEmbedViewContext.Feed}
post={post}
/> />
</View> </View>
) : null} ) : null}
+49 -11
View File
@@ -13,6 +13,8 @@ import {
import { import {
type AppBskyActorDefs, type AppBskyActorDefs,
AppBskyEmbedExternal, AppBskyEmbedExternal,
AppBskyEmbedGallery,
AppBskyEmbedImages,
AppBskyEmbedVideo, AppBskyEmbedVideo,
type AppBskyFeedDefs, type AppBskyFeedDefs,
} from '@atproto/api' } from '@atproto/api'
@@ -907,7 +909,9 @@ let PostFeed = ({
const seenActorWithStatusRef = useRef<Set<string>>(new Set()) const seenActorWithStatusRef = useRef<Set<string>>(new Set())
const seenPostUrisRef = useRef<Set<string>>(new Set()) const seenPostUrisRef = useRef<Set<string>>(new Set())
const seenStandardSiteUrisRef = useRef<Set<string>>(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<Set<string>>(new Set())
// Helper to calculate position in feed (count only root posts, not interstitials or thread replies) // Helper to calculate position in feed (count only root posts, not interstitials or thread replies)
const getPostPosition = useNonReactiveCallback( const getPostPosition = useNonReactiveCallback(
@@ -940,6 +944,48 @@ let PostFeed = ({
(item: FeedRow) => { (item: FeedRow) => {
feedFeedback.onItemSeen(item) 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 // Track post:view events
if (item.type === 'sliceItem') { if (item.type === 'sliceItem') {
const slice = item.slice const slice = item.slice
@@ -947,6 +993,8 @@ let PostFeed = ({
const postItem = slice.items[indexInSlice] const postItem = slice.items[indexInSlice]
const post = postItem.post const post = postItem.post
onPostSeen(post)
// Only track the root post of each slice (index 0) to avoid double-counting thread items // Only track the root post of each slice (index 0) to avoid double-counting thread items
if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) { if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) {
seenPostUrisRef.current.add(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') { } else if (item.type === 'videoGridRow') {
// Track each video in the grid row // Track each video in the grid row
for (let i = 0; i < item.items.length; i++) { for (let i = 0; i < item.items.length; i++) {
+5
View File
@@ -429,6 +429,7 @@ let FeedItemInner = ({
onOpenEmbed={onOpenEmbed} onOpenEmbed={onOpenEmbed}
post={post} post={post}
additionalPostAlerts={additionalPostAlerts} additionalPostAlerts={additionalPostAlerts}
feedDescriptor={feedDescriptor}
/> />
<PostControls <PostControls
post={post} post={post}
@@ -460,6 +461,7 @@ let PostContent = ({
postAuthor, postAuthor,
onOpenEmbed, onOpenEmbed,
additionalPostAlerts, additionalPostAlerts,
feedDescriptor,
}: { }: {
moderation: ModerationDecision moderation: ModerationDecision
richText: RichTextAPI richText: RichTextAPI
@@ -468,6 +470,7 @@ let PostContent = ({
onOpenEmbed: () => void onOpenEmbed: () => void
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
additionalPostAlerts?: AppModerationCause[] additionalPostAlerts?: AppModerationCause[]
feedDescriptor?: string
}): React.ReactNode => { }): React.ReactNode => {
const [limitLines, setLimitLines] = useState( const [limitLines, setLimitLines] = useState(
() => countLines(richText.text) >= MAX_POST_LINES, () => countLines(richText.text) >= MAX_POST_LINES,
@@ -528,6 +531,8 @@ let PostContent = ({
moderation={moderation} moderation={moderation}
onOpen={onOpenEmbed} onOpen={onOpenEmbed}
viewContext={PostEmbedViewContext.Feed} viewContext={PostEmbedViewContext.Feed}
post={post}
feedDescriptor={feedDescriptor}
/> />
</View> </View>
) : null} ) : null}