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

Configure Feed

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

at 8c3553cd66ad07ef8c8c4e760b495cf6ce08cc8d 335 lines 9.2 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 ) 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}