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:
parent
e264dfbb87
commit
4fad18b2fa
22 changed files with 516 additions and 64 deletions
151
src/state/feed-feedback.tsx
Normal file
151
src/state/feed-feedback.tsx
Normal 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}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue