Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

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