Skip to content

Commit c222eb3

Browse files
mozziusclaude
andcommitted
add following pill impression, press and hide metrics
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 237f761 commit c222eb3

6 files changed

Lines changed: 605 additions & 11 deletions

File tree

‎src/analytics/metrics/types.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import {
1212
type FollowingV2CheckOutcome,
1313
type FollowingV2Surface,
1414
} from '#/features/followingV2/home/metrics'
15+
import {
16+
type PrependPillHideReason,
17+
type PrependPillPresentation,
18+
type PrependPillSource,
19+
} from '#/features/followingV2/home/pillMetrics'
1520
import {type FollowingSnapshotRejection} from '#/features/followingV2/home/queries/followingSnapshot'
1621
import {
1722
type GapFillOutcome,
@@ -433,6 +438,38 @@ export type Events = {
433438
/** How many pages below the gap it dropped. */
434439
droppedPageCount: number
435440
}
441+
/**
442+
* The new posts pill showed an offer for the first time. One of each pill
443+
* event per offer: from the pill going up, as a restore or a significant
444+
* return puts it up, to it coming down. Only while Home Following is on
445+
* screen, so an offer that comes down unseen reports nothing.
446+
*/
447+
'followingV2:pill:impression': {
448+
surface: FollowingV2Surface
449+
source: PrependPillSource
450+
/** How many posts it offered as it showed. */
451+
count: number
452+
presentation: PrependPillPresentation
453+
}
454+
/** The reader pressed the pill, which scrolls up to its posts. */
455+
'followingV2:pill:press': {
456+
surface: FollowingV2Surface
457+
source: PrependPillSource
458+
count: number
459+
presentation: PrependPillPresentation
460+
/** How long since it first showed. */
461+
shownMs: number
462+
}
463+
/** The pill came down, after it had shown. */
464+
'followingV2:pill:hide': {
465+
surface: FollowingV2Surface
466+
source: PrependPillSource
467+
reason: PrependPillHideReason
468+
/** How many posts it offered as it last showed. */
469+
count: number
470+
/** How long since it first showed. */
471+
shownMs: number
472+
}
436473

437474
'composer:gif:open': {}
438475
'composer:gif:select': {}

‎src/features/followingV2/home/PostFeed.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import * as bsky from '#/types/bsky'
7979
import {GapRow} from './GapRow'
8080
import {isStagedFull, useFollowingV2Metrics} from './metrics'
8181
import {mixSamples} from './mixSamples'
82+
import {usePrependPillMetrics} from './pillMetrics'
8283
import {PrependPill} from './PrependPill'
8384
import {
8485
type AuthorFilter,
@@ -975,7 +976,7 @@ let PostFeed = ({
975976
trendingIndices,
976977
])
977978

978-
const pill = usePrependPill({
979+
const prependPill = usePrependPill({
979980
enabled: isAnchored,
980981
isActive,
981982
staged: prepend.staged,
@@ -991,6 +992,13 @@ let PostFeed = ({
991992
},
992993
onRead: prepend.markRead,
993994
})
995+
const pill = usePrependPillMetrics({
996+
enabled: isAnchored,
997+
metric: ax.metric,
998+
isActive,
999+
top: feedData?.pages[0],
1000+
pill: prependPill,
1001+
})
9941002
/** Tells the prepend the reader saw a row, if it's from the top page. */
9951003
const onTopRowSeen = useNonReactiveCallback((row: FeedRow) => {
9961004
const top = data?.pages[0]

‎src/features/followingV2/home/metrics.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ import {type NewPostsCheckTrigger} from './useNewPostsCheck'
2727
*/
2828

2929
/**
30-
* Where a check happens. Only Home's Following feed checks this way so far;
31-
* other surfaces join as they gain checks.
30+
* Where a check, or a new posts pill, happens. Only Home's Following feed has
31+
* them so far; other surfaces join as they gain them.
3232
*/
3333
export type FollowingV2Surface = 'homeFollowing'
3434

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import {act, renderHook} from '@testing-library/react-native'
2+
3+
import {
4+
prependPillGoneReason,
5+
prependPillPresentation,
6+
usePrependPillMetrics,
7+
} from './pillMetrics'
8+
9+
/**
10+
* What the hook subscribes to significant returns with, for tests to report
11+
* one.
12+
*/
13+
const mockReturnListeners = new Set<() => void>()
14+
jest.mock('#/lib/appState', () => {
15+
const {useEffect} = jest.requireActual<{
16+
useEffect: (effect: () => () => void, deps: unknown[]) => void
17+
}>('react')
18+
const {useNonReactiveCallback} = jest.requireActual<{
19+
useNonReactiveCallback: <F extends () => void>(fn: F) => F
20+
}>('#/lib/hooks/useNonReactiveCallback')
21+
return {
22+
useOnAppReturnedFromBackground(cb: () => void) {
23+
const onReturn = useNonReactiveCallback(cb)
24+
useEffect(() => {
25+
const listener = () => onReturn()
26+
mockReturnListeners.add(listener)
27+
return () => {
28+
mockReturnListeners.delete(listener)
29+
}
30+
}, [onReturn])
31+
},
32+
}
33+
})
34+
35+
function significantReturn() {
36+
act(() => {
37+
for (const listener of [...mockReturnListeners]) listener()
38+
})
39+
}
40+
41+
describe('prependPillPresentation', () => {
42+
it('is a facepile from four posts with a face, as the pill draws it', () => {
43+
expect(prependPillPresentation(4, 1)).toBe('facepile')
44+
expect(prependPillPresentation(3, 3)).toBe('generic')
45+
expect(prependPillPresentation(10, 0)).toBe('generic')
46+
})
47+
})
48+
49+
describe('prependPillGoneReason', () => {
50+
it('is replaced once a page fetched from the top since it went up is on top', () => {
51+
expect(prependPillGoneReason({fetchedAt: 2}, 1)).toBe('replaced')
52+
expect(prependPillGoneReason({fetchedAt: 2, since: 's'}, 1)).toBe('gone')
53+
expect(prependPillGoneReason({fetchedAt: 0}, 1)).toBe('gone')
54+
expect(prependPillGoneReason({}, 1)).toBe('gone')
55+
})
56+
})
57+
58+
describe('usePrependPillMetrics', () => {
59+
type Props = Parameters<typeof usePrependPillMetrics>[0]
60+
const T0 = new Date('2026-10-11T12:00:00.000Z').getTime()
61+
const DOWN = {isUp: false, visible: false, count: 0, authors: []}
62+
const UP = {isUp: true, visible: true, count: 2, authors: []}
63+
64+
function setup(initial: Partial<Props> = {}) {
65+
const metric = jest.fn()
66+
const dismiss = jest.fn()
67+
const onPress = jest.fn()
68+
let props: Props = {
69+
enabled: true,
70+
metric,
71+
isActive: true,
72+
top: {fetchedAt: T0, since: 'since'},
73+
pill: {...DOWN, dismiss, onPress},
74+
...initial,
75+
}
76+
const hook = renderHook((p: Props) => usePrependPillMetrics(p), {
77+
initialProps: props,
78+
})
79+
const update = (
80+
next: Omit<Partial<Props>, 'pill'> & {pill?: Partial<Props['pill']>},
81+
) => {
82+
props = {...props, ...next, pill: {...props.pill, ...next.pill}}
83+
hook.rerender(props)
84+
}
85+
const reported = () =>
86+
metric.mock.calls.map(([event, payload]) => [event, payload])
87+
return {hook, metric, dismiss, onPress, update, reported}
88+
}
89+
90+
beforeEach(() => {
91+
jest.useFakeTimers()
92+
jest.setSystemTime(T0)
93+
})
94+
afterEach(() => {
95+
jest.useRealTimers()
96+
})
97+
98+
it('reports one impression per offer, from a restore, and why it came down', () => {
99+
const {hook, dismiss, update, reported} = setup()
100+
101+
update({pill: UP})
102+
update({pill: {count: 3}})
103+
jest.advanceTimersByTime(5000)
104+
act(() => hook.result.current.dismiss())
105+
update({pill: DOWN})
106+
107+
expect(dismiss).toHaveBeenCalled()
108+
expect(reported()).toEqual([
109+
[
110+
'followingV2:pill:impression',
111+
{
112+
surface: 'homeFollowing',
113+
source: 'restore',
114+
count: 2,
115+
presentation: 'generic',
116+
},
117+
],
118+
[
119+
'followingV2:pill:hide',
120+
{
121+
surface: 'homeFollowing',
122+
source: 'restore',
123+
reason: 'reachedTop',
124+
count: 3,
125+
shownMs: 5000,
126+
},
127+
],
128+
])
129+
})
130+
131+
it('reports a press, and comes down as pressed', () => {
132+
const {hook, onPress, update, reported} = setup()
133+
update({pill: UP})
134+
jest.advanceTimersByTime(1000)
135+
136+
act(() => hook.result.current.onPress())
137+
act(() => hook.result.current.dismiss())
138+
update({pill: DOWN})
139+
140+
expect(onPress).toHaveBeenCalled()
141+
expect(reported().slice(1)).toEqual([
142+
[
143+
'followingV2:pill:press',
144+
{
145+
surface: 'homeFollowing',
146+
source: 'restore',
147+
count: 2,
148+
presentation: 'generic',
149+
shownMs: 1000,
150+
},
151+
],
152+
[
153+
'followingV2:pill:hide',
154+
expect.objectContaining({reason: 'pressed', shownMs: 1000}),
155+
],
156+
])
157+
})
158+
159+
it('tells a refresh that replaced the feed from posts that went', () => {
160+
const {update, reported} = setup()
161+
update({pill: UP})
162+
jest.advanceTimersByTime(1000)
163+
update({pill: DOWN, top: {fetchedAt: Date.now()}})
164+
165+
// Posts staged above the refreshed feed, then muted away.
166+
jest.advanceTimersByTime(1000)
167+
update({pill: UP, top: {fetchedAt: Date.now(), since: 'refreshed'}})
168+
update({pill: DOWN})
169+
170+
expect(
171+
reported()
172+
.filter(([event]) => event === 'followingV2:pill:hide')
173+
.map(([, payload]) => (payload as {reason: string}).reason),
174+
).toEqual(['replaced', 'gone'])
175+
})
176+
177+
it('reports an offer only once it shows, as the view becomes active', () => {
178+
const {update, reported} = setup({isActive: false})
179+
update({pill: {...UP, visible: false}})
180+
expect(reported()).toEqual([])
181+
182+
update({isActive: true, pill: {visible: true}})
183+
184+
expect(reported()).toEqual([
185+
['followingV2:pill:impression', expect.objectContaining({count: 2})],
186+
])
187+
})
188+
189+
it('reports nothing for an offer that comes down unseen', () => {
190+
const {update, reported} = setup({isActive: false})
191+
update({pill: {...UP, visible: false}})
192+
update({pill: DOWN})
193+
194+
expect(reported()).toEqual([])
195+
})
196+
197+
it('reports offers after a significant return while active as from the return', () => {
198+
const {update, reported} = setup({isActive: false})
199+
// Away from Home, a return doesn't offer anything there.
200+
significantReturn()
201+
update({isActive: true, pill: UP})
202+
update({pill: DOWN})
203+
significantReturn()
204+
update({pill: UP})
205+
206+
expect(
207+
reported()
208+
.filter(([event]) => event === 'followingV2:pill:impression')
209+
.map(([, payload]) => (payload as {source: string}).source),
210+
).toEqual(['restore', 'return'])
211+
})
212+
213+
it('reports nothing while disabled, and passes presses on', () => {
214+
const {hook, onPress, update, reported} = setup({enabled: false})
215+
update({pill: UP})
216+
act(() => hook.result.current.onPress())
217+
update({pill: DOWN})
218+
219+
expect(onPress).toHaveBeenCalled()
220+
expect(reported()).toEqual([])
221+
})
222+
})

0 commit comments

Comments
 (0)