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