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