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