mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react'
2import {AppState, AppStateStatus} from 'react-native'
3import {AppBskyFeedDefs} from '@atproto/api'
4import throttle from 'lodash.throttle'
5
6import {PROD_DEFAULT_FEED} from '#/lib/constants'
7import {logEvent} from '#/lib/statsig/statsig'
8import {logger} from '#/logger'
9import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed'
10import {getFeedPostSlice} from '#/view/com/posts/PostFeed'
11import {useAgent} from './session'
12
13type StateContext = {
14 enabled: boolean
15 onItemSeen: (item: any) => void
16 sendInteraction: (interaction: AppBskyFeedDefs.Interaction) => void
17}
18
19const stateContext = React.createContext<StateContext>({
20 enabled: false,
21 onItemSeen: (_item: any) => {},
22 sendInteraction: (_interaction: AppBskyFeedDefs.Interaction) => {},
23})
24
25export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
26 const agent = useAgent()
27 const enabled = isDiscoverFeed(feed) && hasSession
28 const queue = React.useRef<Set<string>>(new Set())
29 const history = React.useRef<
30 // Use a WeakSet so that we don't need to clear it.
31 // This assumes that referential identity of slice items maps 1:1 to feed (re)fetches.
32 WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction>
33 >(new WeakSet())
34
35 const aggregatedStats = React.useRef<AggregatedStats | null>(null)
36 const throttledFlushAggregatedStats = React.useMemo(
37 () =>
38 throttle(() => flushToStatsig(aggregatedStats.current), 45e3, {
39 leading: true, // The outer call is already throttled somewhat.
40 trailing: true,
41 }),
42 [],
43 )
44
45 const sendToFeedNoDelay = React.useCallback(() => {
46 const interactions = Array.from(queue.current).map(toInteraction)
47 queue.current.clear()
48
49 // Send to the feed
50 agent.app.bsky.feed
51 .sendInteractions(
52 {interactions},
53 {
54 encoding: 'application/json',
55 headers: {
56 // TODO when we start sending to other feeds, we need to grab their DID -prf
57 'atproto-proxy': 'did:web:discover.bsky.app#bsky_fg',
58 },
59 },
60 )
61 .catch((e: any) => {
62 logger.warn('Failed to send feed interactions', {error: e})
63 })
64
65 // Send to Statsig
66 if (aggregatedStats.current === null) {
67 aggregatedStats.current = createAggregatedStats()
68 }
69 sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions)
70 throttledFlushAggregatedStats()
71 }, [agent, throttledFlushAggregatedStats])
72
73 const sendToFeed = React.useMemo(
74 () =>
75 throttle(sendToFeedNoDelay, 10e3, {
76 leading: false,
77 trailing: true,
78 }),
79 [sendToFeedNoDelay],
80 )
81
82 React.useEffect(() => {
83 if (!enabled) {
84 return
85 }
86 const sub = AppState.addEventListener('change', (state: AppStateStatus) => {
87 if (state === 'background') {
88 sendToFeed.flush()
89 }
90 })
91 return () => sub.remove()
92 }, [enabled, sendToFeed])
93
94 const onItemSeen = React.useCallback(
95 (feedItem: any) => {
96 if (!enabled) {
97 return
98 }
99 const slice = getFeedPostSlice(feedItem)
100 if (slice === null) {
101 return
102 }
103 for (const postItem of slice.items) {
104 if (!history.current.has(postItem)) {
105 history.current.add(postItem)
106 queue.current.add(
107 toString({
108 item: postItem.uri,
109 event: 'app.bsky.feed.defs#interactionSeen',
110 feedContext: slice.feedContext,
111 }),
112 )
113 sendToFeed()
114 }
115 }
116 },
117 [enabled, sendToFeed],
118 )
119
120 const sendInteraction = React.useCallback(
121 (interaction: AppBskyFeedDefs.Interaction) => {
122 if (!enabled) {
123 return
124 }
125 if (!history.current.has(interaction)) {
126 history.current.add(interaction)
127 queue.current.add(toString(interaction))
128 sendToFeed()
129 }
130 },
131 [enabled, sendToFeed],
132 )
133
134 return React.useMemo(() => {
135 return {
136 enabled,
137 // pass this method to the <List> onItemSeen
138 onItemSeen,
139 // call on various events
140 // queues the event to be sent with the throttled sendToFeed call
141 sendInteraction,
142 }
143 }, [enabled, onItemSeen, sendInteraction])
144}
145
146export const FeedFeedbackProvider = stateContext.Provider
147
148export function useFeedFeedbackContext() {
149 return React.useContext(stateContext)
150}
151
152// TODO
153// We will introduce a permissions framework for 3p feeds to
154// take advantage of the feed feedback API. Until that's in
155// place, we're hardcoding it to the discover feed.
156// -prf
157function isDiscoverFeed(feed: FeedDescriptor) {
158 return feed === `feedgen|${PROD_DEFAULT_FEED('whats-hot')}`
159}
160
161function toString(interaction: AppBskyFeedDefs.Interaction): string {
162 return `${interaction.item}|${interaction.event}|${
163 interaction.feedContext || ''
164 }`
165}
166
167function toInteraction(str: string): AppBskyFeedDefs.Interaction {
168 const [item, event, feedContext] = str.split('|')
169 return {item, event, feedContext}
170}
171
172type AggregatedStats = {
173 clickthroughCount: number
174 engagedCount: number
175 seenCount: number
176}
177
178function createAggregatedStats(): AggregatedStats {
179 return {
180 clickthroughCount: 0,
181 engagedCount: 0,
182 seenCount: 0,
183 }
184}
185
186function sendOrAggregateInteractionsForStats(
187 stats: AggregatedStats,
188 interactions: AppBskyFeedDefs.Interaction[],
189) {
190 for (let interaction of interactions) {
191 switch (interaction.event) {
192 // Pressing "Show more" / "Show less" is relatively uncommon so we won't aggregate them.
193 // This lets us send the feed context together with them.
194 case 'app.bsky.feed.defs#requestLess': {
195 logEvent('discover:showLess', {
196 feedContext: interaction.feedContext ?? '',
197 })
198 break
199 }
200 case 'app.bsky.feed.defs#requestMore': {
201 logEvent('discover:showMore', {
202 feedContext: interaction.feedContext ?? '',
203 })
204 break
205 }
206
207 // The rest of the events are aggregated and sent later in batches.
208 case 'app.bsky.feed.defs#clickthroughAuthor':
209 case 'app.bsky.feed.defs#clickthroughEmbed':
210 case 'app.bsky.feed.defs#clickthroughItem':
211 case 'app.bsky.feed.defs#clickthroughReposter': {
212 stats.clickthroughCount++
213 break
214 }
215 case 'app.bsky.feed.defs#interactionLike':
216 case 'app.bsky.feed.defs#interactionQuote':
217 case 'app.bsky.feed.defs#interactionReply':
218 case 'app.bsky.feed.defs#interactionRepost':
219 case 'app.bsky.feed.defs#interactionShare': {
220 stats.engagedCount++
221 break
222 }
223 case 'app.bsky.feed.defs#interactionSeen': {
224 stats.seenCount++
225 break
226 }
227 }
228 }
229}
230
231function flushToStatsig(stats: AggregatedStats | null) {
232 if (stats === null) {
233 return
234 }
235
236 if (stats.clickthroughCount > 0) {
237 logEvent('discover:clickthrough', {
238 count: stats.clickthroughCount,
239 })
240 stats.clickthroughCount = 0
241 }
242
243 if (stats.engagedCount > 0) {
244 logEvent('discover:engaged', {
245 count: stats.engagedCount,
246 })
247 stats.engagedCount = 0
248 }
249
250 if (stats.seenCount > 0) {
251 logEvent('discover:seen', {
252 count: stats.seenCount,
253 })
254 stats.seenCount = 0
255 }
256}