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