An ATproto social media client -- with an independent Appview.
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

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