about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--assets/icons/emojiSmile_stroke2_corner0_rounded.svg1
-rw-r--r--package.json2
-rw-r--r--src/components/icons/Emoji.tsx4
-rw-r--r--src/state/feed-feedback.tsx151
-rw-r--r--src/state/queries/post-feed.ts10
-rw-r--r--src/view/com/feeds/FeedPage.tsx32
-rw-r--r--src/view/com/posts/Feed.tsx3
-rw-r--r--src/view/com/posts/FeedItem.tsx61
-rw-r--r--src/view/com/posts/FeedSlice.tsx15
-rw-r--r--src/view/com/util/Link.tsx2
-rw-r--r--src/view/com/util/List.tsx25
-rw-r--r--src/view/com/util/List.web.tsx77
-rw-r--r--src/view/com/util/PostMeta.tsx14
-rw-r--r--src/view/com/util/UserAvatar.tsx5
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx51
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx50
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx7
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx19
-rw-r--r--src/view/com/util/post-embeds/index.tsx14
-rw-r--r--src/view/screens/DebugMod.tsx1
-rw-r--r--src/view/screens/ProfileFeed.tsx27
-rw-r--r--yarn.lock7
22 files changed, 515 insertions, 63 deletions
diff --git a/assets/icons/emojiSmile_stroke2_corner0_rounded.svg b/assets/icons/emojiSmile_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..fd329b509
--- /dev/null
+++ b/assets/icons/emojiSmile_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M17.657 6.343A8 8 0 1 0 6.343 17.657 8 8 0 0 0 17.657 6.343ZM4.929 4.93c3.905-3.905 10.237-3.905 14.142 0 3.905 3.905 3.905 10.237 0 14.142-3.905 3.905-10.237 3.905-14.142 0-3.905-3.905-3.905-10.237 0-14.142Zm3.536 9.192a1 1 0 0 1 1.414 0 3 3 0 0 0 4.243 0 1 1 0 0 1 1.414 1.415 5 5 0 0 1-7.071 0 1 1 0 0 1 0-1.415Z M10.5 9.5c0 .828-.56 1.5-1.25 1.5S8 10.328 8 9.5 8.56 8 9.25 8s1.25.672 1.25 1.5ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5Z" clip-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/package.json b/package.json
index e5d88be57..4a6bbf5a4 100644
--- a/package.json
+++ b/package.json
@@ -100,6 +100,7 @@
     "@tiptap/react": "^2.0.0-beta.220",
     "@tiptap/suggestion": "^2.0.0-beta.220",
     "@types/invariant": "^2.2.37",
+    "@types/lodash.throttle": "^4.1.9",
     "@types/node": "^18.16.2",
     "@zxing/text-encoding": "^0.9.0",
     "array.prototype.findlast": "^1.2.3",
@@ -151,6 +152,7 @@
     "lodash.samplesize": "^4.2.0",
     "lodash.set": "^4.3.2",
     "lodash.shuffle": "^4.2.0",
+    "lodash.throttle": "^4.1.1",
     "mobx": "^6.6.1",
     "mobx-react-lite": "^3.4.0",
     "mobx-utils": "^6.0.6",
diff --git a/src/components/icons/Emoji.tsx b/src/components/icons/Emoji.tsx
index ef7ffd435..b7427c84b 100644
--- a/src/components/icons/Emoji.tsx
+++ b/src/components/icons/Emoji.tsx
@@ -4,6 +4,10 @@ export const EmojiSad_Stroke2_Corner0_Rounded = createSinglePathSVG({
   path: 'M6.343 6.343a8 8 0 1 1 11.314 11.314A8 8 0 0 1 6.343 6.343ZM19.071 4.93c-3.905-3.905-10.237-3.905-14.142 0-3.905 3.905-3.905 10.237 0 14.142 3.905 3.905 10.237 3.905 14.142 0 3.905-3.905 3.905-10.237 0-14.142Zm-3.537 9.535a5 5 0 0 0-7.07 0 1 1 0 1 0 1.413 1.415 3 3 0 0 1 4.243 0 1 1 0 0 0 1.414-1.415ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5ZM9.25 11c.69 0 1.25-.672 1.25-1.5S9.94 8 9.25 8 8 8.672 8 9.5 8.56 11 9.25 11Z',
 })
 
+export const EmojiSmile_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M17.657 6.343A8 8 0 1 0 6.343 17.657 8 8 0 0 0 17.657 6.343ZM4.929 4.93c3.905-3.905 10.237-3.905 14.142 0 3.905 3.905 3.905 10.237 0 14.142-3.905 3.905-10.237 3.905-14.142 0-3.905-3.905-3.905-10.237 0-14.142Zm3.536 9.192a1 1 0 0 1 1.414 0 3 3 0 0 0 4.243 0 1 1 0 0 1 1.414 1.415 5 5 0 0 1-7.071 0 1 1 0 0 1 0-1.415ZM10.5 9.5c0 .828-.56 1.5-1.25 1.5S8 10.328 8 9.5 8.56 8 9.25 8s1.25.672 1.25 1.5ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5Z',
+})
+
 export const EmojiArc_Stroke2_Corner0_Rounded = createSinglePathSVG({
   path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-5a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm-5.894 7.803a1 1 0 0 1 1.341-.447c1.719.859 3.387.859 5.106 0a1 1 0 1 1 .894 1.788c-2.281 1.141-4.613 1.141-6.894 0a1 1 0 0 1-.447-1.341Z',
 })
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}
+}
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 827f8a2a8..dc86a9ba0 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -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
+  )
+}
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index 4ebf64da9..bb782809d 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -9,6 +9,7 @@ import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers'
 import {logEvent, useGate} from '#/lib/statsig/statsig'
 import {isNative} from '#/platform/detection'
 import {listenSoftReset} from '#/state/events'
+import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
 import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
 import {truncateAndInvalidate} from '#/state/queries/util'
@@ -51,6 +52,7 @@ export function FeedPage({
   const setMinimalShellMode = useSetMinimalShellMode()
   const {screen, track} = useAnalytics()
   const headerOffset = useHeaderOffset()
+  const feedFeedback = useFeedFeedback(feed, hasSession)
   const scrollElRef = React.useRef<ListMethods>(null)
   const [hasNew, setHasNew] = React.useState(false)
   const gate = useGate()
@@ -113,20 +115,22 @@ export function FeedPage({
   return (
     <View testID={testID} style={s.h100pct}>
       <MainScrollProvider>
-        <Feed
-          testID={testID ? `${testID}-feed` : undefined}
-          enabled={isPageFocused}
-          feed={feed}
-          feedParams={feedParams}
-          pollInterval={POLL_FREQ}
-          disablePoll={hasNew}
-          scrollElRef={scrollElRef}
-          onScrolledDownChange={setIsScrolledDown}
-          onHasNew={setHasNew}
-          renderEmptyState={renderEmptyState}
-          renderEndOfFeed={renderEndOfFeed}
-          headerOffset={headerOffset}
-        />
+        <FeedFeedbackProvider value={feedFeedback}>
+          <Feed
+            testID={testID ? `${testID}-feed` : undefined}
+            enabled={isPageFocused}
+            feed={feed}
+            feedParams={feedParams}
+            pollInterval={POLL_FREQ}
+            disablePoll={hasNew}
+            scrollElRef={scrollElRef}
+            onScrolledDownChange={setIsScrolledDown}
+            onHasNew={setHasNew}
+            renderEmptyState={renderEmptyState}
+            renderEndOfFeed={renderEndOfFeed}
+            headerOffset={headerOffset}
+          />
+        </FeedFeedbackProvider>
       </MainScrollProvider>
       {(isScrolledDown || adjustedHasNew) && (
         <LoadLatestBtn
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index fb67d35c5..8969f7cd2 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -17,6 +17,7 @@ import {logEvent} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
 import {isWeb} from '#/platform/detection'
 import {listenPostCreated} from '#/state/events'
+import {useFeedFeedbackContext} from '#/state/feed-feedback'
 import {STALE} from '#/state/queries'
 import {
   FeedDescriptor,
@@ -88,6 +89,7 @@ let Feed = ({
   const queryClient = useQueryClient()
   const {currentAccount} = useSession()
   const initialNumToRender = useInitialNumToRender()
+  const feedFeedback = useFeedFeedbackContext()
   const [isPTRing, setIsPTRing] = React.useState(false)
   const checkForNewRef = React.useRef<(() => void) | null>(null)
   const lastFetchRef = React.useRef<number>(Date.now())
@@ -353,6 +355,7 @@ let Feed = ({
         }
         initialNumToRender={initialNumToRender}
         windowSize={11}
+        onItemSeen={feedFeedback.onItemSeen}
       />
     </View>
   )
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 605dffde9..5b4efe2af 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -16,6 +16,7 @@ import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
 
 import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
+import {useFeedFeedbackContext} from '#/state/feed-feedback'
 import {useComposerControls} from '#/state/shell/composer'
 import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
 import {MAX_POST_LINES} from 'lib/constants'
@@ -45,6 +46,7 @@ export function FeedItem({
   post,
   record,
   reason,
+  feedContext,
   moderation,
   isThreadChild,
   isThreadLastChild,
@@ -53,6 +55,7 @@ export function FeedItem({
   post: AppBskyFeedDefs.PostView
   record: AppBskyFeedPost.Record
   reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
+  feedContext: string | undefined
   moderation: ModerationDecision
   isThreadChild?: boolean
   isThreadLastChild?: boolean
@@ -78,6 +81,7 @@ export function FeedItem({
         post={postShadowed}
         record={record}
         reason={reason}
+        feedContext={feedContext}
         richText={richText}
         moderation={moderation}
         isThreadChild={isThreadChild}
@@ -93,6 +97,7 @@ let FeedItemInner = ({
   post,
   record,
   reason,
+  feedContext,
   richText,
   moderation,
   isThreadChild,
@@ -102,6 +107,7 @@ let FeedItemInner = ({
   post: Shadow<AppBskyFeedDefs.PostView>
   record: AppBskyFeedPost.Record
   reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
+  feedContext: string | undefined
   richText: RichTextAPI
   moderation: ModerationDecision
   isThreadChild?: boolean
@@ -116,6 +122,7 @@ let FeedItemInner = ({
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey)
   }, [post.uri, post.author])
+  const {sendInteraction} = useFeedFeedbackContext()
 
   const replyAuthorDid = useMemo(() => {
     if (!record?.reply) {
@@ -126,6 +133,11 @@ let FeedItemInner = ({
   }, [record?.reply])
 
   const onPressReply = React.useCallback(() => {
+    sendInteraction({
+      item: post.uri,
+      event: 'app.bsky.feed.defs#interactionReply',
+      feedContext,
+    })
     openComposer({
       replyTo: {
         uri: post.uri,
@@ -136,11 +148,40 @@ let FeedItemInner = ({
         moderation,
       },
     })
-  }, [post, record, openComposer, moderation])
+  }, [post, record, openComposer, moderation, sendInteraction, feedContext])
+
+  const onOpenAuthor = React.useCallback(() => {
+    sendInteraction({
+      item: post.uri,
+      event: 'app.bsky.feed.defs#clickthroughAuthor',
+      feedContext,
+    })
+  }, [sendInteraction, post, feedContext])
+
+  const onOpenReposter = React.useCallback(() => {
+    sendInteraction({
+      item: post.uri,
+      event: 'app.bsky.feed.defs#clickthroughReposter',
+      feedContext,
+    })
+  }, [sendInteraction, post, feedContext])
+
+  const onOpenEmbed = React.useCallback(() => {
+    sendInteraction({
+      item: post.uri,
+      event: 'app.bsky.feed.defs#clickthroughEmbed',
+      feedContext,
+    })
+  }, [sendInteraction, post, feedContext])
 
   const onBeforePress = React.useCallback(() => {
+    sendInteraction({
+      item: post.uri,
+      event: 'app.bsky.feed.defs#clickthroughItem',
+      feedContext,
+    })
     precacheProfile(queryClient, post.author)
-  }, [queryClient, post.author])
+  }, [queryClient, post, sendInteraction, feedContext])
 
   const outerStyles = [
     styles.outer,
@@ -207,7 +248,8 @@ let FeedItemInner = ({
                 msg`Reposted by ${sanitizeDisplayName(
                   reason.by.displayName || reason.by.handle,
                 )}`,
-              )}>
+              )}
+              onBeforePress={onOpenReposter}>
               <FontAwesomeIcon
                 icon="retweet"
                 style={{
@@ -235,6 +277,7 @@ let FeedItemInner = ({
                         moderation.ui('displayName'),
                       )}
                       href={makeProfileLink(reason.by)}
+                      onBeforePress={onOpenReposter}
                     />
                   </ProfileHoverCard>
                 </Trans>
@@ -251,6 +294,7 @@ let FeedItemInner = ({
             profile={post.author}
             moderation={moderation.ui('avatar')}
             type={post.author.associated?.labeler ? 'labeler' : 'user'}
+            onBeforePress={onOpenAuthor}
           />
           {isThreadParent && (
             <View
@@ -272,6 +316,7 @@ let FeedItemInner = ({
             authorHasWarning={!!post.author.labels?.length}
             timestamp={post.indexedAt}
             postHref={href}
+            onOpenAuthor={onOpenAuthor}
           />
           {!isThreadChild && replyAuthorDid !== '' && (
             <View style={[s.flexRow, s.mb2, s.alignCenter]}>
@@ -308,6 +353,7 @@ let FeedItemInner = ({
             richText={richText}
             postEmbed={post.embed}
             postAuthor={post.author}
+            onOpenEmbed={onOpenEmbed}
           />
           <PostCtrls
             post={post}
@@ -315,6 +361,7 @@ let FeedItemInner = ({
             richText={richText}
             onPressReply={onPressReply}
             logContext="FeedItem"
+            feedContext={feedContext}
           />
         </View>
       </View>
@@ -328,11 +375,13 @@ let PostContent = ({
   richText,
   postEmbed,
   postAuthor,
+  onOpenEmbed,
 }: {
   moderation: ModerationDecision
   richText: RichTextAPI
   postEmbed: AppBskyFeedDefs.PostView['embed']
   postAuthor: AppBskyFeedDefs.PostView['author']
+  onOpenEmbed: () => void
 }): React.ReactNode => {
   const pal = usePalette('default')
   const {_} = useLingui()
@@ -373,7 +422,11 @@ let PostContent = ({
       ) : undefined}
       {postEmbed ? (
         <View style={[a.pb_sm]}>
-          <PostEmbeds embed={postEmbed} moderation={moderation} />
+          <PostEmbeds
+            embed={postEmbed}
+            moderation={moderation}
+            onOpen={onOpenEmbed}
+          />
         </View>
       ) : null}
     </ContentHider>
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index 49e48aa20..27a9ff8c0 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -1,14 +1,15 @@
 import React, {memo} from 'react'
 import {StyleSheet, View} from 'react-native'
-import {FeedPostSlice} from '#/state/queries/post-feed'
+import Svg, {Circle, Line} from 'react-native-svg'
 import {AtUri} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+
+import {FeedPostSlice} from '#/state/queries/post-feed'
+import {usePalette} from 'lib/hooks/usePalette'
+import {makeProfileLink} from 'lib/routes/links'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
-import Svg, {Circle, Line} from 'react-native-svg'
 import {FeedItem} from './FeedItem'
-import {usePalette} from 'lib/hooks/usePalette'
-import {makeProfileLink} from 'lib/routes/links'
-import {Trans} from '@lingui/macro'
 
 let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
   if (slice.isThread && slice.items.length > 3) {
@@ -20,6 +21,7 @@ let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
           post={slice.items[0].post}
           record={slice.items[0].record}
           reason={slice.items[0].reason}
+          feedContext={slice.items[0].feedContext}
           moderation={slice.items[0].moderation}
           isThreadParent={isThreadParentAt(slice.items, 0)}
           isThreadChild={isThreadChildAt(slice.items, 0)}
@@ -29,6 +31,7 @@ let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
           post={slice.items[1].post}
           record={slice.items[1].record}
           reason={slice.items[1].reason}
+          feedContext={slice.items[1].feedContext}
           moderation={slice.items[1].moderation}
           isThreadParent={isThreadParentAt(slice.items, 1)}
           isThreadChild={isThreadChildAt(slice.items, 1)}
@@ -39,6 +42,7 @@ let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
           post={slice.items[last].post}
           record={slice.items[last].record}
           reason={slice.items[last].reason}
+          feedContext={slice.items[last].feedContext}
           moderation={slice.items[last].moderation}
           isThreadParent={isThreadParentAt(slice.items, last)}
           isThreadChild={isThreadChildAt(slice.items, last)}
@@ -56,6 +60,7 @@ let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
           post={slice.items[i].post}
           record={slice.items[i].record}
           reason={slice.items[i].reason}
+          feedContext={slice.items[i].feedContext}
           moderation={slice.items[i].moderation}
           isThreadParent={isThreadParentAt(slice.items, i)}
           isThreadChild={isThreadChildAt(slice.items, i)}
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 78d995ee8..df82124f9 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -220,6 +220,7 @@ export const TextLink = memo(function TextLink({
       )
     },
     [
+      onBeforePress,
       onPress,
       closeModal,
       openModal,
@@ -229,7 +230,6 @@ export const TextLink = memo(function TextLink({
       disableMismatchWarning,
       navigationAction,
       openLink,
-      onBeforePress,
     ],
   )
   const hrefAttrs = useMemo(() => {
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index 84b401e63..194f81c5c 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -1,5 +1,5 @@
 import React, {memo} from 'react'
-import {FlatListProps, RefreshControl} from 'react-native'
+import {FlatListProps, RefreshControl, ViewToken} from 'react-native'
 import {runOnJS, useSharedValue} from 'react-native-reanimated'
 
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
@@ -23,6 +23,7 @@ export type ListProps<ItemT> = Omit<
   headerOffset?: number
   refreshing?: boolean
   onRefresh?: () => void
+  onItemSeen?: (item: ItemT) => void
   containWeb?: boolean
 }
 export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
@@ -34,6 +35,7 @@ function ListImpl<ItemT>(
     onScrolledDownChange,
     refreshing,
     onRefresh,
+    onItemSeen,
     headerOffset,
     style,
     ...props
@@ -73,6 +75,25 @@ function ListImpl<ItemT>(
     },
   })
 
+  const [onViewableItemsChanged, viewabilityConfig] = React.useMemo(() => {
+    if (!onItemSeen) {
+      return [undefined, undefined]
+    }
+    return [
+      (info: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => {
+        for (const item of info.changed) {
+          if (item.isViewable) {
+            onItemSeen(item.item)
+          }
+        }
+      },
+      {
+        itemVisiblePercentThreshold: 40,
+        minimumViewTime: 2e3,
+      },
+    ]
+  }, [onItemSeen])
+
   let refreshControl
   if (refreshing !== undefined || onRefresh !== undefined) {
     refreshControl = (
@@ -102,6 +123,8 @@ function ListImpl<ItemT>(
       refreshControl={refreshControl}
       onScroll={scrollHandler}
       scrollEventThrottle={1}
+      onViewableItemsChanged={onViewableItemsChanged}
+      viewabilityConfig={viewabilityConfig}
       style={style}
       ref={ref}
     />
diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx
index 9bea2d795..b6ecf02ec 100644
--- a/src/view/com/util/List.web.tsx
+++ b/src/view/com/util/List.web.tsx
@@ -20,11 +20,17 @@ export type ListProps<ItemT> = Omit<
   headerOffset?: number
   refreshing?: boolean
   onRefresh?: () => void
+  onItemSeen?: (item: ItemT) => void
   desktopFixedHeight: any // TODO: Better types.
   containWeb?: boolean
 }
 export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
 
+const ON_ITEM_SEEN_WAIT_DURATION = 2e3 // post must be "seen" 2 seconds before capturing
+const ON_ITEM_SEEN_INTERSECTION_OPTS = {
+  rootMargin: '-200px 0px -200px 0px',
+} // post must be 200px visible to be "seen"
+
 function ListImpl<ItemT>(
   {
     ListHeaderComponent,
@@ -43,6 +49,7 @@ function ListImpl<ItemT>(
     onRefresh: _unsupportedOnRefresh,
     onScrolledDownChange,
     onContentSizeChange,
+    onItemSeen,
     renderItem,
     extraData,
     style,
@@ -319,15 +326,19 @@ function ListImpl<ItemT>(
           />
         )}
         {header}
-        {(data as Array<ItemT>).map((item, index) => (
-          <Row<ItemT>
-            key={keyExtractor!(item, index)}
-            item={item}
-            index={index}
-            renderItem={renderItem}
-            extraData={extraData}
-          />
-        ))}
+        {(data as Array<ItemT>).map((item, index) => {
+          const key = keyExtractor!(item, index)
+          return (
+            <Row<ItemT>
+              key={key}
+              item={item}
+              index={index}
+              renderItem={renderItem}
+              extraData={extraData}
+              onItemSeen={onItemSeen}
+            />
+          )
+        })}
         {onEndReached && (
           <Visibility
             root={containWeb ? nativeRef : null}
@@ -372,6 +383,7 @@ let Row = function RowImpl<ItemT>({
   index,
   renderItem,
   extraData: _unused,
+  onItemSeen,
 }: {
   item: ItemT
   index: number
@@ -380,12 +392,57 @@ let Row = function RowImpl<ItemT>({
     | undefined
     | ((data: {index: number; item: any; separators: any}) => React.ReactNode)
   extraData: any
+  onItemSeen: ((item: any) => void) | undefined
 }): React.ReactNode {
+  const rowRef = React.useRef(null)
+  const intersectionTimeout = React.useRef<NodeJS.Timer | undefined>(undefined)
+
+  const handleIntersection = useNonReactiveCallback(
+    (entries: IntersectionObserverEntry[]) => {
+      batchedUpdates(() => {
+        if (!onItemSeen) {
+          return
+        }
+        entries.forEach(entry => {
+          if (entry.isIntersecting) {
+            if (!intersectionTimeout.current) {
+              intersectionTimeout.current = setTimeout(() => {
+                intersectionTimeout.current = undefined
+                onItemSeen!(item)
+              }, ON_ITEM_SEEN_WAIT_DURATION)
+            }
+          } else {
+            if (intersectionTimeout.current) {
+              clearTimeout(intersectionTimeout.current)
+              intersectionTimeout.current = undefined
+            }
+          }
+        })
+      })
+    },
+  )
+
+  React.useEffect(() => {
+    if (!onItemSeen) {
+      return
+    }
+    const observer = new IntersectionObserver(
+      handleIntersection,
+      ON_ITEM_SEEN_INTERSECTION_OPTS,
+    )
+    const row: Element | null = rowRef.current!
+    observer.observe(row)
+    return () => {
+      observer.unobserve(row)
+    }
+  }, [handleIntersection, onItemSeen])
+
   if (!renderItem) {
     return null
   }
+
   return (
-    <View style={styles.row}>
+    <View style={styles.row} ref={rowRef}>
       {renderItem({item, index, separators: null as any})}
     </View>
   )
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index e7ce18535..c0e4d8099 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -28,6 +28,7 @@ interface PostMetaOpts {
   avatarSize?: number
   displayNameType?: TypographyVariant
   displayNameStyle?: StyleProp<TextStyle>
+  onOpenAuthor?: () => void
   style?: StyleProp<ViewStyle>
 }
 
@@ -43,7 +44,12 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
     : undefined
 
   const queryClient = useQueryClient()
-  const onBeforePress = useCallback(() => {
+  const onOpenAuthor = opts.onOpenAuthor
+  const onBeforePressAuthor = useCallback(() => {
+    precacheProfile(queryClient, opts.author)
+    onOpenAuthor?.()
+  }, [queryClient, opts.author, onOpenAuthor])
+  const onBeforePressPost = useCallback(() => {
     precacheProfile(queryClient, opts.author)
   }, [queryClient, opts.author])
 
@@ -77,7 +83,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
               </>
             }
             href={profileLink}
-            onBeforePress={onBeforePress}
+            onBeforePress={onBeforePressAuthor}
             onPointerEnter={onPointerEnter}
           />
           <TextLinkOnWebOnly
@@ -86,7 +92,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
             style={[pal.textLight, {flexShrink: 4}]}
             text={'\xa0' + sanitizeHandle(handle, '@')}
             href={profileLink}
-            onBeforePress={onBeforePress}
+            onBeforePress={onBeforePressAuthor}
             onPointerEnter={onPointerEnter}
             anchorNoUnderline
           />
@@ -112,7 +118,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
             title={niceDate(opts.timestamp)}
             accessibilityHint=""
             href={opts.postHref}
-            onBeforePress={onBeforePress}
+            onBeforePress={onBeforePressPost}
           />
         )}
       </TimeElapsed>
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 45327669b..83c61a4f2 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -50,6 +50,7 @@ interface EditableUserAvatarProps extends BaseUserAvatarProps {
 
 interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
   moderation?: ModerationUI
+  onBeforePress?: () => void
   profile: AppBskyActorDefs.ProfileViewBasic
 }
 
@@ -382,14 +383,16 @@ export {EditableUserAvatar}
 let PreviewableUserAvatar = ({
   moderation,
   profile,
+  onBeforePress,
   ...rest
 }: PreviewableUserAvatarProps): React.ReactNode => {
   const {_} = useLingui()
   const queryClient = useQueryClient()
 
   const onPress = React.useCallback(() => {
+    onBeforePress?.()
     precacheProfile(queryClient, profile)
-  }, [profile, queryClient])
+  }, [profile, queryClient, onBeforePress])
 
   return (
     <ProfileHoverCard did={profile.did}>
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index ac97f3da2..7a62ce7cb 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -18,6 +18,7 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers'
 import {getTranslatorLink} from '#/locale/helpers'
 import {logger} from '#/logger'
 import {isWeb} from '#/platform/detection'
+import {useFeedFeedbackContext} from '#/state/feed-feedback'
 import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
 import {useLanguagePrefs} from '#/state/preferences'
 import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
@@ -36,6 +37,10 @@ import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons
 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
 import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
+import {
+  EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
+  EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
+} from '#/components/icons/Emoji'
 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
 import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
@@ -53,6 +58,7 @@ let PostDropdownBtn = ({
   postAuthor,
   postCid,
   postUri,
+  postFeedContext,
   record,
   richText,
   style,
@@ -63,6 +69,7 @@ let PostDropdownBtn = ({
   postAuthor: AppBskyActorDefs.ProfileViewBasic
   postCid: string
   postUri: string
+  postFeedContext: string | undefined
   record: AppBskyFeedPost.Record
   richText: RichTextAPI
   style?: StyleProp<ViewStyle>
@@ -81,6 +88,7 @@ let PostDropdownBtn = ({
   const postDeleteMutation = usePostDeleteMutation()
   const hiddenPosts = useHiddenPosts()
   const {hidePost} = useHiddenPostsApi()
+  const feedFeedback = useFeedFeedbackContext()
   const openLink = useOpenLink()
   const navigation = useNavigation()
   const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
@@ -183,6 +191,24 @@ let PostDropdownBtn = ({
     shareUrl(url)
   }, [href])
 
+  const onPressShowMore = React.useCallback(() => {
+    feedFeedback.sendInteraction({
+      event: 'app.bsky.feed.defs#requestMore',
+      item: postUri,
+      feedContext: postFeedContext,
+    })
+    Toast.show('Feedback sent!')
+  }, [feedFeedback, postUri, postFeedContext])
+
+  const onPressShowLess = React.useCallback(() => {
+    feedFeedback.sendInteraction({
+      event: 'app.bsky.feed.defs#requestLess',
+      item: postUri,
+      feedContext: postFeedContext,
+    })
+    Toast.show('Feedback sent!')
+  }, [feedFeedback, postUri, postFeedContext])
+
   const canEmbed = isWeb && gtMobile && !hideInPWI
 
   return (
@@ -262,10 +288,32 @@ let PostDropdownBtn = ({
             )}
           </Menu.Group>
 
-          {hasSession && (
+          {hasSession && feedFeedback.enabled && (
             <>
               <Menu.Divider />
+              <Menu.Group>
+                <Menu.Item
+                  testID="postDropdownShowMoreBtn"
+                  label={_(msg`Show more like this`)}
+                  onPress={onPressShowMore}>
+                  <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText>
+                  <Menu.ItemIcon icon={EmojiSmile} position="right" />
+                </Menu.Item>
+
+                <Menu.Item
+                  testID="postDropdownShowLessBtn"
+                  label={_(msg`Show less like this`)}
+                  onPress={onPressShowLess}>
+                  <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
+                  <Menu.ItemIcon icon={EmojiSad} position="right" />
+                </Menu.Item>
+              </Menu.Group>
+            </>
+          )}
 
+          {hasSession && (
+            <>
+              <Menu.Divider />
               <Menu.Group>
                 <Menu.Item
                   testID="postDropdownMuteThreadBtn"
@@ -308,7 +356,6 @@ let PostDropdownBtn = ({
           {hasSession && (
             <>
               <Menu.Divider />
-
               <Menu.Group>
                 {!isAuthor && (
                   <Menu.Item
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 7ebcde9a0..b6c07d573 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -23,6 +23,7 @@ import {toShareUrl} from '#/lib/strings/url-helpers'
 import {s} from '#/lib/styles'
 import {useTheme} from '#/lib/ThemeContext'
 import {Shadow} from '#/state/cache/types'
+import {useFeedFeedbackContext} from '#/state/feed-feedback'
 import {useModalControls} from '#/state/modals'
 import {
   usePostLikeMutationQueue,
@@ -43,6 +44,7 @@ let PostCtrls = ({
   post,
   record,
   richText,
+  feedContext,
   style,
   onPressReply,
   logContext,
@@ -51,6 +53,7 @@ let PostCtrls = ({
   post: Shadow<AppBskyFeedDefs.PostView>
   record: AppBskyFeedPost.Record
   richText: RichTextAPI
+  feedContext?: string | undefined
   style?: StyleProp<ViewStyle>
   onPressReply: () => void
   logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
@@ -66,6 +69,7 @@ let PostCtrls = ({
   )
   const requireAuth = useRequireAuth()
   const loggedOutWarningPromptControl = useDialogControl()
+  const {sendInteraction} = useFeedFeedbackContext()
   const playHaptic = useHaptics()
 
   const shouldShowLoggedOutWarning = React.useMemo(() => {
@@ -85,6 +89,11 @@ let PostCtrls = ({
     try {
       if (!post.viewer?.like) {
         playHaptic()
+        sendInteraction({
+          item: post.uri,
+          event: 'app.bsky.feed.defs#interactionLike',
+          feedContext,
+        })
         await queueLike()
       } else {
         await queueUnlike()
@@ -94,13 +103,26 @@ let PostCtrls = ({
         throw e
       }
     }
-  }, [playHaptic, post.viewer?.like, queueLike, queueUnlike])
+  }, [
+    playHaptic,
+    post.uri,
+    post.viewer?.like,
+    queueLike,
+    queueUnlike,
+    sendInteraction,
+    feedContext,
+  ])
 
   const onRepost = useCallback(async () => {
     closeModal()
     try {
       if (!post.viewer?.repost) {
         playHaptic()
+        sendInteraction({
+          item: post.uri,
+          event: 'app.bsky.feed.defs#interactionRepost',
+          feedContext,
+        })
         await queueRepost()
       } else {
         await queueUnrepost()
@@ -110,10 +132,24 @@ let PostCtrls = ({
         throw e
       }
     }
-  }, [closeModal, post.viewer?.repost, playHaptic, queueRepost, queueUnrepost])
+  }, [
+    closeModal,
+    post.uri,
+    post.viewer?.repost,
+    playHaptic,
+    queueRepost,
+    queueUnrepost,
+    sendInteraction,
+    feedContext,
+  ])
 
   const onQuote = useCallback(() => {
     closeModal()
+    sendInteraction({
+      item: post.uri,
+      event: 'app.bsky.feed.defs#interactionQuote',
+      feedContext,
+    })
     openComposer({
       quote: {
         uri: post.uri,
@@ -133,6 +169,8 @@ let PostCtrls = ({
     post.indexedAt,
     record.text,
     playHaptic,
+    sendInteraction,
+    feedContext,
   ])
 
   const onShare = useCallback(() => {
@@ -140,7 +178,12 @@ let PostCtrls = ({
     const href = makeProfileLink(post.author, 'post', urip.rkey)
     const url = toShareUrl(href)
     shareUrl(url)
-  }, [post.uri, post.author])
+    sendInteraction({
+      item: post.uri,
+      event: 'app.bsky.feed.defs#interactionShare',
+      feedContext,
+    })
+  }, [post.uri, post.author, sendInteraction, feedContext])
 
   return (
     <View style={[styles.ctrls, style]}>
@@ -268,6 +311,7 @@ let PostCtrls = ({
           postAuthor={post.author}
           postCid={post.cid}
           postUri={post.uri}
+          postFeedContext={feedContext}
           record={record}
           richText={richText}
           style={styles.btnPad}
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index b84c04b83..3b2a12c24 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -19,10 +19,12 @@ import {Text} from '../text/Text'
 
 export const ExternalLinkEmbed = ({
   link,
+  onOpen,
   style,
   hideAlt,
 }: {
   link: AppBskyEmbedExternal.ViewExternal
+  onOpen?: () => void
   style?: StyleProp<ViewStyle>
   hideAlt?: boolean
 }) => {
@@ -44,7 +46,7 @@ export const ExternalLinkEmbed = ({
 
   return (
     <View style={[a.flex_col, a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
-      <LinkWrapper link={link} style={style}>
+      <LinkWrapper link={link} onOpen={onOpen} style={style}>
         {link.thumb && !embedPlayerParams ? (
           <Image
             style={{
@@ -97,10 +99,12 @@ export const ExternalLinkEmbed = ({
 
 function LinkWrapper({
   link,
+  onOpen,
   style,
   children,
 }: {
   link: AppBskyEmbedExternal.ViewExternal
+  onOpen?: () => void
   style?: StyleProp<ViewStyle>
   children: React.ReactNode
 }) {
@@ -125,6 +129,7 @@ function LinkWrapper({
         style,
       ]}
       hoverStyle={t.atoms.border_contrast_high}
+      onBeforePress={onOpen}
       onLongPress={onShareExternal}>
       {children}
     </Link>
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index 0e19a6ccd..57f1d28ba 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -42,9 +42,11 @@ import {PostEmbeds} from '.'
 
 export function MaybeQuoteEmbed({
   embed,
+  onOpen,
   style,
 }: {
   embed: AppBskyEmbedRecord.View
+  onOpen?: () => void
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
@@ -57,6 +59,7 @@ export function MaybeQuoteEmbed({
       <QuoteEmbedModerated
         viewRecord={embed.record}
         postRecord={embed.record.value}
+        onOpen={onOpen}
         style={style}
       />
     )
@@ -85,10 +88,12 @@ export function MaybeQuoteEmbed({
 function QuoteEmbedModerated({
   viewRecord,
   postRecord,
+  onOpen,
   style,
 }: {
   viewRecord: AppBskyEmbedRecord.ViewRecord
   postRecord: AppBskyFeedPost.Record
+  onOpen?: () => void
   style?: StyleProp<ViewStyle>
 }) {
   const moderationOpts = useModerationOpts()
@@ -108,16 +113,25 @@ function QuoteEmbedModerated({
     embeds: viewRecord.embeds,
   }
 
-  return <QuoteEmbed quote={quote} moderation={moderation} style={style} />
+  return (
+    <QuoteEmbed
+      quote={quote}
+      moderation={moderation}
+      onOpen={onOpen}
+      style={style}
+    />
+  )
 }
 
 export function QuoteEmbed({
   quote,
   moderation,
+  onOpen,
   style,
 }: {
   quote: ComposerOptsQuote
   moderation?: ModerationDecision
+  onOpen?: () => void
   style?: StyleProp<ViewStyle>
 }) {
   const queryClient = useQueryClient()
@@ -150,7 +164,8 @@ export function QuoteEmbed({
 
   const onBeforePress = React.useCallback(() => {
     precacheProfile(queryClient, quote.author)
-  }, [queryClient, quote.author])
+    onOpen?.()
+  }, [queryClient, quote.author, onOpen])
 
   return (
     <ContentHider modui={moderation?.ui('contentList')}>
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 7ea5b55cf..eb9732ee8 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -38,10 +38,12 @@ type Embed =
 export function PostEmbeds({
   embed,
   moderation,
+  onOpen,
   style,
 }: {
   embed?: Embed
   moderation?: ModerationDecision
+  onOpen?: () => void
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
@@ -52,8 +54,12 @@ export function PostEmbeds({
   if (AppBskyEmbedRecordWithMedia.isView(embed)) {
     return (
       <View style={style}>
-        <PostEmbeds embed={embed.media} moderation={moderation} />
-        <MaybeQuoteEmbed embed={embed.record} />
+        <PostEmbeds
+          embed={embed.media}
+          moderation={moderation}
+          onOpen={onOpen}
+        />
+        <MaybeQuoteEmbed embed={embed.record} onOpen={onOpen} />
       </View>
     )
   }
@@ -80,7 +86,7 @@ export function PostEmbeds({
 
     // quote post
     // =
-    return <MaybeQuoteEmbed embed={embed} style={style} />
+    return <MaybeQuoteEmbed embed={embed} style={style} onOpen={onOpen} />
   }
 
   // image embed
@@ -151,7 +157,7 @@ export function PostEmbeds({
     const link = embed.external
     return (
       <ContentHider modui={moderation?.ui('contentMedia')}>
-        <ExternalLinkEmbed link={link} style={style} />
+        <ExternalLinkEmbed link={link} onOpen={onOpen} style={style} />
       </ContentHider>
     )
   }
diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx
index 442e33fd3..86c632194 100644
--- a/src/view/screens/DebugMod.tsx
+++ b/src/view/screens/DebugMod.tsx
@@ -804,6 +804,7 @@ function MockPostFeedItem({
       record={post.record as AppBskyFeedPost.Record}
       moderation={moderation}
       reason={undefined}
+      feedContext={''}
     />
   )
 }
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
index c6fac743a..f66231ab5 100644
--- a/src/view/screens/ProfileFeed.tsx
+++ b/src/view/screens/ProfileFeed.tsx
@@ -10,6 +10,7 @@ import {HITSLOP_20} from '#/lib/constants'
 import {logger} from '#/logger'
 import {isNative} from '#/platform/detection'
 import {listenSoftReset} from '#/state/events'
+import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
 import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
 import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
 import {FeedDescriptor} from '#/state/queries/post-feed'
@@ -462,6 +463,8 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
     const [isScrolledDown, setIsScrolledDown] = React.useState(false)
     const queryClient = useQueryClient()
     const isScreenFocused = useIsFocused()
+    const {hasSession} = useSession()
+    const feedFeedback = useFeedFeedback(feed, hasSession)
 
     const onScrollToTop = useCallback(() => {
       scrollElRef.current?.scrollToOffset({
@@ -489,17 +492,19 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
 
     return (
       <View>
-        <Feed
-          enabled={isFocused}
-          feed={feed}
-          pollInterval={60e3}
-          disablePoll={hasNew}
-          scrollElRef={scrollElRef}
-          onHasNew={setHasNew}
-          onScrolledDownChange={setIsScrolledDown}
-          renderEmptyState={renderPostsEmpty}
-          headerOffset={headerHeight}
-        />
+        <FeedFeedbackProvider value={feedFeedback}>
+          <Feed
+            enabled={isFocused}
+            feed={feed}
+            pollInterval={60e3}
+            disablePoll={hasNew}
+            scrollElRef={scrollElRef}
+            onHasNew={setHasNew}
+            onScrolledDownChange={setIsScrolledDown}
+            renderEmptyState={renderPostsEmpty}
+            headerOffset={headerHeight}
+          />
+        </FeedFeedbackProvider>
         {(isScrolledDown || hasNew) && (
           <LoadLatestBtn
             onPress={onScrollToTop}
diff --git a/yarn.lock b/yarn.lock
index 2f9b59ecf..4edc4ce43 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7729,6 +7729,13 @@
   dependencies:
     "@types/lodash" "*"
 
+"@types/lodash.throttle@^4.1.9":
+  version "4.1.9"
+  resolved "https://registry.yarnpkg.com/@types/lodash.throttle/-/lodash.throttle-4.1.9.tgz#f17a6ae084f7c0117bd7df145b379537bc9615c5"
+  integrity sha512-PCPVfpfueguWZQB7pJQK890F2scYKoDUL3iM522AptHWn7d5NQmeS/LTEHIcLr5PaTzl3dK2Z0xSUHHTHwaL5g==
+  dependencies:
+    "@types/lodash" "*"
+
 "@types/lodash@*":
   version "4.14.197"
   resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.197.tgz#e95c5ddcc814ec3e84c891910a01e0c8a378c54b"