Implement FeedFeedback API (#3498)

* Implement onViewableItemsChanged on List.web.tsx

* Introduce onItemSeen to List API

* Add FeedFeedback tracker

* Add clickthrough interaction tracking

* Add engagement interaction tracking

* Reduce duplicate sends, introduce a flushAndReset to be triggered on refreshes, and modify the api design a bit

* Wire up SDK types and feedContext

* Avoid needless function allocations

* Fix schema usage

* Add show more / show less buttons

* Fix minor rendering issue on mobile menu

* Wire up sendInteractions()

* Fix logic error

* Fix: it's item not uri

* Update 'seen' to mean 3 seconds on-screen with some significant portion visible

* Fix non-reactive debounce

* Move methods out

* Use a WeakSet for deduping

* Reset timeout

* 3 -> 2 seconds

* Oopsie

* Throttle instead

* Fix divider

* Remove explicit flush calls

* Rm unused

---------

Co-authored-by: dan <dan.abramov@gmail.com>
This commit is contained in:
Paul Frazee 2024-05-06 19:08:33 -07:00 committed by GitHub
parent e264dfbb87
commit 4fad18b2fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 516 additions and 64 deletions

151
src/state/feed-feedback.tsx Normal file
View file

@ -0,0 +1,151 @@
import React from 'react'
import {AppState, AppStateStatus} from 'react-native'
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import throttle from 'lodash.throttle'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {logger} from '#/logger'
import {
FeedDescriptor,
FeedPostSliceItem,
isFeedPostSlice,
} from '#/state/queries/post-feed'
import {useAgent} from './session'
type StateContext = {
enabled: boolean
onItemSeen: (item: any) => void
sendInteraction: (interaction: AppBskyFeedDefs.Interaction) => void
}
const stateContext = React.createContext<StateContext>({
enabled: false,
onItemSeen: (_item: any) => {},
sendInteraction: (_interaction: AppBskyFeedDefs.Interaction) => {},
})
export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
const {getAgent} = useAgent()
const enabled = isDiscoverFeed(feed) && hasSession
const queue = React.useRef<Set<string>>(new Set())
const history = React.useRef<
// Use a WeakSet so that we don't need to clear it.
// This assumes that referential identity of slice items maps 1:1 to feed (re)fetches.
WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction>
>(new WeakSet())
const sendToFeedNoDelay = React.useCallback(() => {
const proxyAgent = getAgent().withProxy(
// @ts-ignore TODO need to update withProxy() to support this key -prf
'bsky_fg',
// TODO when we start sending to other feeds, we need to grab their DID -prf
'did:web:discover.bsky.app',
) as BskyAgent
const interactions = Array.from(queue.current).map(toInteraction)
queue.current.clear()
proxyAgent.app.bsky.feed
.sendInteractions({interactions})
.catch((e: any) => {
logger.warn('Failed to send feed interactions', {error: e})
})
}, [getAgent])
const sendToFeed = React.useMemo(
() =>
throttle(sendToFeedNoDelay, 15e3, {
leading: false,
trailing: true,
}),
[sendToFeedNoDelay],
)
React.useEffect(() => {
if (!enabled) {
return
}
const sub = AppState.addEventListener('change', (state: AppStateStatus) => {
if (state === 'background') {
sendToFeed.flush()
}
})
return () => sub.remove()
}, [enabled, sendToFeed])
const onItemSeen = React.useCallback(
(slice: any) => {
if (!enabled) {
return
}
if (!isFeedPostSlice(slice)) {
return
}
for (const postItem of slice.items) {
if (!history.current.has(postItem)) {
history.current.add(postItem)
queue.current.add(
toString({
item: postItem.uri,
event: 'app.bsky.feed.defs#interactionSeen',
feedContext: postItem.feedContext,
}),
)
sendToFeed()
}
}
},
[enabled, sendToFeed],
)
const sendInteraction = React.useCallback(
(interaction: AppBskyFeedDefs.Interaction) => {
if (!enabled) {
return
}
if (!history.current.has(interaction)) {
history.current.add(interaction)
queue.current.add(toString(interaction))
sendToFeed()
}
},
[enabled, sendToFeed],
)
return React.useMemo(() => {
return {
enabled,
// pass this method to the <List> onItemSeen
onItemSeen,
// call on various events
// queues the event to be sent with the throttled sendToFeed call
sendInteraction,
}
}, [enabled, onItemSeen, sendInteraction])
}
export const FeedFeedbackProvider = stateContext.Provider
export function useFeedFeedbackContext() {
return React.useContext(stateContext)
}
// TODO
// We will introduce a permissions framework for 3p feeds to
// take advantage of the feed feedback API. Until that's in
// place, we're hardcoding it to the discover feed.
// -prf
function isDiscoverFeed(feed: FeedDescriptor) {
return feed === `feedgen|${PROD_DEFAULT_FEED('whats-hot')}`
}
function toString(interaction: AppBskyFeedDefs.Interaction): string {
return `${interaction.item}|${interaction.event}|${
interaction.feedContext || ''
}`
}
function toInteraction(str: string): AppBskyFeedDefs.Interaction {
const [item, event, feedContext] = str.split('|')
return {item, event, feedContext}
}

View file

@ -70,10 +70,12 @@ export interface FeedPostSliceItem {
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
feedContext: string | undefined
moderation: ModerationDecision
}
export interface FeedPostSlice {
_isFeedPostSlice: boolean
_reactKey: string
rootUri: string
isThread: boolean
@ -276,6 +278,7 @@ export function usePostFeedQuery(
return {
_reactKey: slice._reactKey,
_isFeedPostSlice: true,
rootUri: slice.rootItem.post.uri,
isThread:
slice.items.length > 1 &&
@ -300,6 +303,7 @@ export function usePostFeedQuery(
i === 0 && slice.source
? slice.source
: item.reason,
feedContext: item.feedContext,
moderation: moderations[i],
}
}
@ -507,3 +511,9 @@ export function resetProfilePostsQueries(
})
}, timeout)
}
export function isFeedPostSlice(v: any): v is FeedPostSlice {
return (
v && typeof v === 'object' && '_isFeedPostSlice' in v && v._isFeedPostSlice
)
}