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

Configure Feed

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

at main 334 lines 9.4 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 feedAgent = useMemo( 79 () => (proxyDid ? agent.withProxy('bsky_fg', proxyDid) : null), 80 [agent, proxyDid], 81 ) 82 const enabled = 83 Boolean(feed) && Boolean(proxyDid) && acceptsInteractions && hasSession 84 85 const queue = useRef<Set<string>>(new Set()) 86 const history = useRef< 87 // Use a WeakSet so that we don't need to clear it. 88 // This assumes that referential identity of slice items maps 1:1 to feed (re)fetches. 89 WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction> 90 >(new WeakSet()) 91 92 const flushEvents = useCallback( 93 (stats: AggregatedStats | null, feedDescriptor: string) => { 94 if (stats === null) { 95 return 96 } 97 98 if (stats.clickthroughCount > 0) { 99 ax.metric('feed:clickthrough', { 100 count: stats.clickthroughCount, 101 feed: feedDescriptor, 102 }) 103 stats.clickthroughCount = 0 104 } 105 106 if (stats.engagedCount > 0) { 107 ax.metric('feed:engaged', { 108 count: stats.engagedCount, 109 feed: feedDescriptor, 110 }) 111 stats.engagedCount = 0 112 } 113 114 if (stats.seenCount > 0) { 115 ax.metric('feed:seen', { 116 count: stats.seenCount, 117 feed: feedDescriptor, 118 }) 119 stats.seenCount = 0 120 } 121 }, 122 [ax], 123 ) 124 125 const aggregatedStats = useRef<AggregatedStats | null>(null) 126 const throttledFlushAggregatedStats = useMemo( 127 () => 128 throttle( 129 () => 130 flushEvents( 131 aggregatedStats.current, 132 feed?.feedDescriptor ?? 'unknown', 133 ), 134 45e3, 135 { 136 leading: true, // The outer call is already throttled somewhat. 137 trailing: true, 138 }, 139 ), 140 [feed?.feedDescriptor, flushEvents], 141 ) 142 143 const sendToFeedNoDelay = useCallback(() => { 144 const interactions = Array.from(queue.current).map(toInteraction) 145 queue.current.clear() 146 147 const interactionsToSend = interactions.filter( 148 interaction => 149 interaction.event && 150 isInteractionAllowed(enabled, feed, interaction.event), 151 ) 152 153 if (interactionsToSend.length === 0) { 154 return 155 } 156 157 // Send to the feed 158 feedAgent?.app.bsky.feed 159 .sendInteractions( 160 {interactions: interactionsToSend, feed: feed?.uri}, 161 { 162 encoding: 'application/json', 163 }, 164 ) 165 .catch(() => {}) // ignore upstream errors 166 167 if (aggregatedStats.current === null) { 168 aggregatedStats.current = createAggregatedStats() 169 } 170 sendOrAggregateInteractionsForStats( 171 aggregatedStats.current, 172 interactionsToSend, 173 ) 174 throttledFlushAggregatedStats() 175 logger.debug('flushed') 176 }, [feedAgent, throttledFlushAggregatedStats, enabled, feed]) 177 178 const sendToFeed = useMemo( 179 () => 180 throttle(sendToFeedNoDelay, 10e3, { 181 leading: false, 182 trailing: true, 183 }), 184 [sendToFeedNoDelay], 185 ) 186 187 useEffect(() => { 188 if (!enabled) { 189 return 190 } 191 const sub = AppState.addEventListener('change', (state: AppStateStatus) => { 192 if (state === 'background') { 193 sendToFeed.flush() 194 } 195 }) 196 return () => sub.remove() 197 }, [enabled, sendToFeed]) 198 199 const onItemSeen = useCallback( 200 (feedItem: any) => { 201 if (!enabled) { 202 return 203 } 204 const items = getItemsForFeedback(feedItem) 205 for (const {item: postItem, feedContext, reqId} of items) { 206 if (!history.current.has(postItem)) { 207 history.current.add(postItem) 208 queue.current.add( 209 toString({ 210 item: postItem.uri, 211 event: 'app.bsky.feed.defs#interactionSeen', 212 feedContext, 213 reqId, 214 }), 215 ) 216 sendToFeed() 217 } 218 } 219 }, 220 [enabled, sendToFeed], 221 ) 222 223 const sendInteraction = useCallback( 224 (interaction: AppBskyFeedDefs.Interaction) => { 225 if (!enabled) { 226 return 227 } 228 logger.debug('sendInteraction', { 229 ...interaction, 230 }) 231 if (!history.current.has(interaction)) { 232 history.current.add(interaction) 233 queue.current.add(toString(interaction)) 234 sendToFeed() 235 } 236 }, 237 [enabled, sendToFeed], 238 ) 239 240 return useMemo(() => { 241 return { 242 enabled, 243 // pass this method to the <List> onItemSeen 244 onItemSeen, 245 // call on various events 246 // queues the event to be sent with the throttled sendToFeed call 247 sendInteraction, 248 feedDescriptor: feed?.feedDescriptor, 249 feedSourceInfo: typeof feed === 'object' ? feed : undefined, 250 } 251 }, [enabled, onItemSeen, sendInteraction, feed]) 252} 253 254export const FeedFeedbackProvider = stateContext.Provider 255 256export function useFeedFeedbackContext() { 257 return useContext(stateContext) 258} 259 260// TODO 261// We will introduce a permissions framework for 3p feeds to 262// take advantage of the feed feedback API. Until that's in 263// place, we're hardcoding it to the discover feed. 264// -prf 265export function isDiscoverFeed(feed?: FeedDescriptor) { 266 return !!feed && FEEDBACK_FEEDS.includes(feed) 267} 268 269function isInteractionAllowed( 270 enabled: boolean, 271 feed: FeedSourceFeedInfo | undefined, 272 interaction: AppBskyFeedDefs.Interaction['event'], 273) { 274 if (!enabled || !feed) { 275 return false 276 } 277 const isDiscover = isDiscoverFeed(feed.feedDescriptor) 278 return isDiscover ? true : THIRD_PARTY_ALLOWED_INTERACTIONS.has(interaction) 279} 280 281function toString(interaction: AppBskyFeedDefs.Interaction): string { 282 return `${interaction.item}|${interaction.event}|${ 283 interaction.feedContext || '' 284 }|${interaction.reqId || ''}` 285} 286 287function toInteraction(str: string): AppBskyFeedDefs.Interaction { 288 const [item, event, feedContext, reqId] = str.split('|') 289 return {item, event, feedContext, reqId} 290} 291 292type AggregatedStats = { 293 clickthroughCount: number 294 engagedCount: number 295 seenCount: number 296} 297 298function createAggregatedStats(): AggregatedStats { 299 return { 300 clickthroughCount: 0, 301 engagedCount: 0, 302 seenCount: 0, 303 } 304} 305 306function sendOrAggregateInteractionsForStats( 307 stats: AggregatedStats, 308 interactions: AppBskyFeedDefs.Interaction[], 309) { 310 for (let interaction of interactions) { 311 switch (interaction.event) { 312 // The events are aggregated and sent later in batches. 313 case 'app.bsky.feed.defs#clickthroughAuthor': 314 case 'app.bsky.feed.defs#clickthroughEmbed': 315 case 'app.bsky.feed.defs#clickthroughItem': 316 case 'app.bsky.feed.defs#clickthroughReposter': { 317 stats.clickthroughCount++ 318 break 319 } 320 case 'app.bsky.feed.defs#interactionLike': 321 case 'app.bsky.feed.defs#interactionQuote': 322 case 'app.bsky.feed.defs#interactionReply': 323 case 'app.bsky.feed.defs#interactionRepost': 324 case 'app.bsky.feed.defs#interactionShare': { 325 stats.engagedCount++ 326 break 327 } 328 case 'app.bsky.feed.defs#interactionSeen': { 329 stats.seenCount++ 330 break 331 } 332 } 333 } 334}