Bluesky app fork with some witchin' additions 馃挮
at main 333 lines 9.3 kB view raw
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}