about summary refs log tree commit diff
path: root/src/state/feed-feedback.tsx
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2024-05-06 19:08:33 -0700
committerGitHub <noreply@github.com>2024-05-07 03:08:33 +0100
commit4fad18b2fa3c12ffdf1d49afac5228f7df658bc2 (patch)
tree8fa8df48dcf544c288bc0618127fcead58014962 /src/state/feed-feedback.tsx
parente264dfbb875118036d5b155f46f2b0b71261e1ff (diff)
downloadvoidsky-4fad18b2fa3c12ffdf1d49afac5228f7df658bc2.tar.zst
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>
Diffstat (limited to 'src/state/feed-feedback.tsx')
-rw-r--r--src/state/feed-feedback.tsx151
1 files changed, 151 insertions, 0 deletions
diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx
new file mode 100644
index 000000000..5bfc77d0a
--- /dev/null
+++ b/src/state/feed-feedback.tsx
@@ -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}
+}