An ATproto social media client -- with an independent Appview.
1import {
2 createContext,
3 useCallback,
4 useContext,
5 useEffect,
6 useMemo,
7 useRef,
8} from 'react'
9import {AppState, type AppStateStatus} from 'react-native'
10import {type AppBskyFeedDefs} from '@atproto/api'
11import throttle from 'lodash.throttle'
12
13import {PROD_FEEDS, STAGING_FEEDS} from '#/lib/constants'
14import {isNetworkError} from '#/lib/hooks/useCleanError'
15import {Logger} from '#/logger'
16import {
17 type FeedSourceFeedInfo,
18 type FeedSourceInfo,
19 isFeedSourceFeedInfo,
20} from '#/state/queries/feed'
21import {
22 type FeedDescriptor,
23 type FeedPostSliceItem,
24} from '#/state/queries/post-feed'
25import {getItemsForFeedback} from '#/view/com/posts/PostFeed'
26import {useAgent} from './session'
27
28export const FEEDBACK_FEEDS = [...PROD_FEEDS, ...STAGING_FEEDS]
29
30export const THIRD_PARTY_ALLOWED_INTERACTIONS = new Set<
31 AppBskyFeedDefs.Interaction['event']
32>([
33 // These are explicit actions and are therefore fine to send.
34 'app.bsky.feed.defs#requestLess',
35 'app.bsky.feed.defs#requestMore',
36 // These can be inferred from the firehose and are therefore fine to send.
37 'app.bsky.feed.defs#interactionLike',
38 'app.bsky.feed.defs#interactionQuote',
39 'app.bsky.feed.defs#interactionReply',
40 'app.bsky.feed.defs#interactionRepost',
41 // This can be inferred from pagination requests for everything except the very last page
42 // so it is fine to send. It is crucial for third party algorithmic feeds to receive these.
43 'app.bsky.feed.defs#interactionSeen',
44])
45
46const logger = Logger.create(Logger.Context.FeedFeedback)
47
48export type StateContext = {
49 enabled: boolean
50 onItemSeen: (item: any) => void
51 sendInteraction: (interaction: AppBskyFeedDefs.Interaction) => void
52 feedDescriptor: FeedDescriptor | undefined
53 feedSourceInfo: FeedSourceInfo | undefined
54}
55
56const stateContext = createContext<StateContext>({
57 enabled: false,
58 onItemSeen: (_item: any) => {},
59 sendInteraction: (_interaction: AppBskyFeedDefs.Interaction) => {},
60 feedDescriptor: undefined,
61 feedSourceInfo: undefined,
62})
63stateContext.displayName = 'FeedFeedbackContext'
64
65export function useFeedFeedback(
66 feedSourceInfo: FeedSourceInfo | undefined,
67 hasSession: boolean,
68) {
69 const agent = useAgent()
70
71 const feed =
72 !!feedSourceInfo && isFeedSourceFeedInfo(feedSourceInfo)
73 ? feedSourceInfo
74 : undefined
75
76 const isDiscover = isDiscoverFeed(feed?.feedDescriptor)
77 const acceptsInteractions = Boolean(isDiscover || feed?.acceptsInteractions)
78 const proxyDid = feed?.view?.did
79 const enabled =
80 Boolean(feed) && Boolean(proxyDid) && acceptsInteractions && hasSession
81
82 const queue = useRef<Set<string>>(new Set())
83 const history = useRef<
84 // Use a WeakSet so that we don't need to clear it.
85 // This assumes that referential identity of slice items maps 1:1 to feed (re)fetches.
86 WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction>
87 >(new WeakSet())
88
89 const aggregatedStats = useRef<AggregatedStats | null>(null)
90 const throttledFlushAggregatedStats = useMemo(
91 () =>
92 throttle(
93 () =>
94 flushToStatsig(
95 aggregatedStats.current,
96 feed?.feedDescriptor ?? 'unknown',
97 ),
98 45e3,
99 {
100 leading: true, // The outer call is already throttled somewhat.
101 trailing: true,
102 },
103 ),
104 [feed?.feedDescriptor],
105 )
106
107 const sendToFeedNoDelay = useCallback(() => {
108 const interactions = Array.from(queue.current).map(toInteraction)
109 queue.current.clear()
110
111 const interactionsToSend = interactions.filter(
112 interaction =>
113 interaction.event &&
114 isInteractionAllowed(enabled, feed, interaction.event),
115 )
116
117 if (interactionsToSend.length === 0) {
118 return
119 }
120
121 // Send to the feed
122 agent.app.bsky.feed
123 .sendInteractions(
124 {interactions: interactionsToSend},
125 {
126 encoding: 'application/json',
127 headers: {
128 'atproto-proxy': `${proxyDid}#bsky_fg`,
129 },
130 },
131 )
132 .catch((e: any) => {
133 if (!isNetworkError(e)) {
134 logger.warn('Failed to send feed interactions', {error: e})
135 }
136 })
137
138 // Send to Statsig
139 if (aggregatedStats.current === null) {
140 aggregatedStats.current = createAggregatedStats()
141 }
142 sendOrAggregateInteractionsForStats(
143 aggregatedStats.current,
144 interactionsToSend,
145 feed?.feedDescriptor ?? 'unknown',
146 )
147 throttledFlushAggregatedStats()
148 logger.debug('flushed')
149 }, [agent, throttledFlushAggregatedStats, proxyDid, enabled, feed])
150
151 const sendToFeed = useMemo(
152 () =>
153 throttle(sendToFeedNoDelay, 10e3, {
154 leading: false,
155 trailing: true,
156 }),
157 [sendToFeedNoDelay],
158 )
159
160 useEffect(() => {
161 if (!enabled) {
162 return
163 }
164 const sub = AppState.addEventListener('change', (state: AppStateStatus) => {
165 if (state === 'background') {
166 sendToFeed.flush()
167 }
168 })
169 return () => sub.remove()
170 }, [enabled, sendToFeed])
171
172 const onItemSeen = useCallback(
173 (feedItem: any) => {
174 if (!enabled) {
175 return
176 }
177 const items = getItemsForFeedback(feedItem)
178 for (const {item: postItem, feedContext, reqId} of items) {
179 if (!history.current.has(postItem)) {
180 history.current.add(postItem)
181 queue.current.add(
182 toString({
183 item: postItem.uri,
184 event: 'app.bsky.feed.defs#interactionSeen',
185 feedContext,
186 reqId,
187 }),
188 )
189 sendToFeed()
190 }
191 }
192 },
193 [enabled, sendToFeed],
194 )
195
196 const sendInteraction = useCallback(
197 (interaction: AppBskyFeedDefs.Interaction) => {
198 if (!enabled) {
199 return
200 }
201 logger.debug('sendInteraction', {
202 ...interaction,
203 })
204 if (!history.current.has(interaction)) {
205 history.current.add(interaction)
206 queue.current.add(toString(interaction))
207 sendToFeed()
208 }
209 },
210 [enabled, sendToFeed],
211 )
212
213 return useMemo(() => {
214 return {
215 enabled,
216 // pass this method to the <List> onItemSeen
217 onItemSeen,
218 // call on various events
219 // queues the event to be sent with the throttled sendToFeed call
220 sendInteraction,
221 feedDescriptor: feed?.feedDescriptor,
222 feedSourceInfo: typeof feed === 'object' ? feed : undefined,
223 }
224 }, [enabled, onItemSeen, sendInteraction, feed])
225}
226
227export const FeedFeedbackProvider = stateContext.Provider
228
229export function useFeedFeedbackContext() {
230 return useContext(stateContext)
231}
232
233// TODO
234// We will introduce a permissions framework for 3p feeds to
235// take advantage of the feed feedback API. Until that's in
236// place, we're hardcoding it to the discover feed.
237// -prf
238export function isDiscoverFeed(feed?: FeedDescriptor) {
239 return !!feed && FEEDBACK_FEEDS.includes(feed)
240}
241
242function isInteractionAllowed(
243 enabled: boolean,
244 feed: FeedSourceFeedInfo | undefined,
245 interaction: AppBskyFeedDefs.Interaction['event'],
246) {
247 if (!enabled || !feed) {
248 return false
249 }
250 const isDiscover = isDiscoverFeed(feed.feedDescriptor)
251 return isDiscover ? true : THIRD_PARTY_ALLOWED_INTERACTIONS.has(interaction)
252}
253
254function toString(interaction: AppBskyFeedDefs.Interaction): string {
255 return `${interaction.item}|${interaction.event}|${
256 interaction.feedContext || ''
257 }|${interaction.reqId || ''}`
258}
259
260function toInteraction(str: string): AppBskyFeedDefs.Interaction {
261 const [item, event, feedContext, reqId] = str.split('|')
262 return {item, event, feedContext, reqId}
263}
264
265type AggregatedStats = {
266 clickthroughCount: number
267 engagedCount: number
268 seenCount: number
269}
270
271function createAggregatedStats(): AggregatedStats {
272 return {
273 clickthroughCount: 0,
274 engagedCount: 0,
275 seenCount: 0,
276 }
277}
278
279function sendOrAggregateInteractionsForStats(
280 stats: AggregatedStats,
281 interactions: AppBskyFeedDefs.Interaction[],
282 feed: string,
283) {
284 for (let interaction of interactions) {
285 switch (interaction.event) {
286 // Pressing "Show more" / "Show less" is relatively uncommon so we won't aggregate them.
287 // This lets us send the feed context together with them.
288 case 'app.bsky.feed.defs#requestLess': {
289 logger.metric('feed:showLess', {
290 feed,
291 feedContext: interaction.feedContext ?? '',
292 })
293 break
294 }
295 case 'app.bsky.feed.defs#requestMore': {
296 logger.metric('feed:showMore', {
297 feed,
298 feedContext: interaction.feedContext ?? '',
299 })
300 break
301 }
302
303 // The rest of the events are aggregated and sent later in batches.
304 case 'app.bsky.feed.defs#clickthroughAuthor':
305 case 'app.bsky.feed.defs#clickthroughEmbed':
306 case 'app.bsky.feed.defs#clickthroughItem':
307 case 'app.bsky.feed.defs#clickthroughReposter': {
308 stats.clickthroughCount++
309 break
310 }
311 case 'app.bsky.feed.defs#interactionLike':
312 case 'app.bsky.feed.defs#interactionQuote':
313 case 'app.bsky.feed.defs#interactionReply':
314 case 'app.bsky.feed.defs#interactionRepost':
315 case 'app.bsky.feed.defs#interactionShare': {
316 stats.engagedCount++
317 break
318 }
319 case 'app.bsky.feed.defs#interactionSeen': {
320 stats.seenCount++
321 break
322 }
323 }
324 }
325}
326
327function flushToStatsig(stats: AggregatedStats | null, feedDescriptor: string) {
328 if (stats === null) {
329 return
330 }
331
332 if (stats.clickthroughCount > 0) {
333 logger.metric('feed:clickthrough', {
334 count: stats.clickthroughCount,
335 feed: feedDescriptor,
336 })
337 stats.clickthroughCount = 0
338 }
339
340 if (stats.engagedCount > 0) {
341 logger.metric('feed:engaged', {
342 count: stats.engagedCount,
343 feed: feedDescriptor,
344 })
345 stats.engagedCount = 0
346 }
347
348 if (stats.seenCount > 0) {
349 logger.metric('feed:seen', {
350 count: stats.seenCount,
351 feed: feedDescriptor,
352 })
353 stats.seenCount = 0
354 }
355}