about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-05-28 22:09:28 +0300
committerGitHub <noreply@github.com>2025-05-28 22:09:28 +0300
commitcf63c2ca07c9a77bb92449ea4f3d78b8dd54fb8f (patch)
tree6136c729a77ef8daf3cbece566f4221c1b6a8d47 /src
parent665a0430a3c04a3ad689954c5f930b4434daef79 (diff)
downloadvoidsky-cf63c2ca07c9a77bb92449ea4f3d78b8dd54fb8f.tar.zst
Send FeedFeedback interactions in thread view (#8414)
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx19
-rw-r--r--src/App.web.tsx11
-rw-r--r--src/components/PostControls/index.tsx9
-rw-r--r--src/screens/VideoFeed/index.tsx7
-rw-r--r--src/state/feed-feedback.tsx51
-rw-r--r--src/state/queries/notifications/types.ts2
-rw-r--r--src/state/queries/notifications/util.ts11
-rw-r--r--src/state/queries/post.ts22
-rw-r--r--src/state/unstable-post-source.tsx73
-rw-r--r--src/view/com/notifications/NotificationFeed.tsx4
-rw-r--r--src/view/com/notifications/NotificationFeedItem.tsx54
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx107
-rw-r--r--src/view/com/posts/PostFeedItem.tsx27
13 files changed, 319 insertions, 78 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index baab8c838..e3f85c0fe 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -58,6 +58,7 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
 import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
+import {Provider as UnstablePostSourceProvider} from '#/state/unstable-post-source'
 import {TestCtrls} from '#/view/com/testing/TestCtrls'
 import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
 import * as Toast from '#/view/com/util/Toast'
@@ -150,14 +151,16 @@ function InnerApp() {
                                           <MutedThreadsProvider>
                                             <ProgressGuideProvider>
                                               <ServiceAccountManager>
-                                                <GestureHandlerRootView
-                                                  style={s.h100pct}>
-                                                  <IntentDialogProvider>
-                                                    <TestCtrls />
-                                                    <Shell />
-                                                    <NuxDialogs />
-                                                  </IntentDialogProvider>
-                                                </GestureHandlerRootView>
+                                                <UnstablePostSourceProvider>
+                                                  <GestureHandlerRootView
+                                                    style={s.h100pct}>
+                                                    <IntentDialogProvider>
+                                                      <TestCtrls />
+                                                      <Shell />
+                                                      <NuxDialogs />
+                                                    </IntentDialogProvider>
+                                                  </GestureHandlerRootView>
+                                                </UnstablePostSourceProvider>
                                               </ServiceAccountManager>
                                             </ProgressGuideProvider>
                                           </MutedThreadsProvider>
diff --git a/src/App.web.tsx b/src/App.web.tsx
index c5ec0473c..97ada6148 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -48,6 +48,7 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
 import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
+import {Provider as UnstablePostSourceProvider} from '#/state/unstable-post-source'
 import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
 import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
 import * as Toast from '#/view/com/util/Toast'
@@ -131,10 +132,12 @@ function InnerApp() {
                                             <SafeAreaProvider>
                                               <ProgressGuideProvider>
                                                 <ServiceConfigProvider>
-                                                  <IntentDialogProvider>
-                                                    <Shell />
-                                                    <NuxDialogs />
-                                                  </IntentDialogProvider>
+                                                  <UnstablePostSourceProvider>
+                                                    <IntentDialogProvider>
+                                                      <Shell />
+                                                      <NuxDialogs />
+                                                    </IntentDialogProvider>
+                                                  </UnstablePostSourceProvider>
                                                 </ServiceConfigProvider>
                                               </ProgressGuideProvider>
                                             </SafeAreaProvider>
diff --git a/src/components/PostControls/index.tsx b/src/components/PostControls/index.tsx
index 7739da56b..f024928ee 100644
--- a/src/components/PostControls/index.tsx
+++ b/src/components/PostControls/index.tsx
@@ -50,6 +50,7 @@ let PostControls = ({
   logContext,
   threadgateRecord,
   onShowLess,
+  viaRepost,
 }: {
   big?: boolean
   post: Shadow<AppBskyFeedDefs.PostView>
@@ -63,13 +64,19 @@ let PostControls = ({
   logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
   threadgateRecord?: AppBskyFeedThreadgate.Record
   onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
+  viaRepost?: {uri: string; cid: string}
 }): React.ReactNode => {
   const {_, i18n} = useLingui()
   const {gtMobile} = useBreakpoints()
   const {openComposer} = useOpenComposer()
-  const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext)
+  const [queueLike, queueUnlike] = usePostLikeMutationQueue(
+    post,
+    viaRepost,
+    logContext,
+  )
   const [queueRepost, queueUnrepost] = usePostRepostMutationQueue(
     post,
+    viaRepost,
     logContext,
   )
   const requireAuth = useRequireAuth()
diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx
index 2a61db715..21eb53baf 100644
--- a/src/screens/VideoFeed/index.tsx
+++ b/src/screens/VideoFeed/index.tsx
@@ -1023,7 +1023,12 @@ function PlayPauseTapArea({
   const {_} = useLingui()
   const doubleTapRef = useRef<ReturnType<typeof setTimeout> | null>(null)
   const playHaptic = useHaptics()
-  const [queueLike] = usePostLikeMutationQueue(post, 'ImmersiveVideo')
+  // TODO: implement viaRepost -sfn
+  const [queueLike] = usePostLikeMutationQueue(
+    post,
+    undefined,
+    'ImmersiveVideo',
+  )
   const {sendInteraction} = useFeedFeedbackContext()
   const {isPlaying} = useEvent(player, 'playingChange', {
     isPlaying: player.playing,
diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx
index 8880cb6b3..225b495d3 100644
--- a/src/state/feed-feedback.tsx
+++ b/src/state/feed-feedback.tsx
@@ -1,4 +1,11 @@
-import React from 'react'
+import {
+  createContext,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+} from 'react'
 import {AppState, type AppStateStatus} from 'react-native'
 import {type AppBskyFeedDefs} from '@atproto/api'
 import throttle from 'lodash.throttle'
@@ -13,31 +20,36 @@ import {
 import {getItemsForFeedback} from '#/view/com/posts/PostFeed'
 import {useAgent} from './session'
 
-type StateContext = {
+export type StateContext = {
   enabled: boolean
   onItemSeen: (item: any) => void
   sendInteraction: (interaction: AppBskyFeedDefs.Interaction) => void
+  feedDescriptor: FeedDescriptor | undefined
 }
 
-const stateContext = React.createContext<StateContext>({
+const stateContext = createContext<StateContext>({
   enabled: false,
   onItemSeen: (_item: any) => {},
   sendInteraction: (_interaction: AppBskyFeedDefs.Interaction) => {},
+  feedDescriptor: undefined,
 })
 
-export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
+export function useFeedFeedback(
+  feed: FeedDescriptor | undefined,
+  hasSession: boolean,
+) {
   const agent = useAgent()
   const enabled = isDiscoverFeed(feed) && hasSession
 
-  const queue = React.useRef<Set<string>>(new Set())
-  const history = React.useRef<
+  const queue = useRef<Set<string>>(new Set())
+  const history = 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 aggregatedStats = React.useRef<AggregatedStats | null>(null)
-  const throttledFlushAggregatedStats = React.useMemo(
+  const aggregatedStats = useRef<AggregatedStats | null>(null)
+  const throttledFlushAggregatedStats = useMemo(
     () =>
       throttle(() => flushToStatsig(aggregatedStats.current), 45e3, {
         leading: true, // The outer call is already throttled somewhat.
@@ -46,12 +58,12 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
     [],
   )
 
-  const sendToFeedNoDelay = React.useCallback(() => {
+  const sendToFeedNoDelay = useCallback(() => {
     const interactions = Array.from(queue.current).map(toInteraction)
     queue.current.clear()
 
     let proxyDid = 'did:web:discover.bsky.app'
-    if (STAGING_FEEDS.includes(feed)) {
+    if (STAGING_FEEDS.includes(feed ?? '')) {
       proxyDid = 'did:web:algo.pop2.bsky.app'
     }
 
@@ -79,7 +91,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
     throttledFlushAggregatedStats()
   }, [agent, throttledFlushAggregatedStats, feed])
 
-  const sendToFeed = React.useMemo(
+  const sendToFeed = useMemo(
     () =>
       throttle(sendToFeedNoDelay, 10e3, {
         leading: false,
@@ -88,7 +100,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
     [sendToFeedNoDelay],
   )
 
-  React.useEffect(() => {
+  useEffect(() => {
     if (!enabled) {
       return
     }
@@ -100,7 +112,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
     return () => sub.remove()
   }, [enabled, sendToFeed])
 
-  const onItemSeen = React.useCallback(
+  const onItemSeen = useCallback(
     (feedItem: any) => {
       if (!enabled) {
         return
@@ -124,7 +136,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
     [enabled, sendToFeed],
   )
 
-  const sendInteraction = React.useCallback(
+  const sendInteraction = useCallback(
     (interaction: AppBskyFeedDefs.Interaction) => {
       if (!enabled) {
         return
@@ -138,7 +150,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
     [enabled, sendToFeed],
   )
 
-  return React.useMemo(() => {
+  return useMemo(() => {
     return {
       enabled,
       // pass this method to the <List> onItemSeen
@@ -146,14 +158,15 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
       // call on various events
       // queues the event to be sent with the throttled sendToFeed call
       sendInteraction,
+      feedDescriptor: feed,
     }
-  }, [enabled, onItemSeen, sendInteraction])
+  }, [enabled, onItemSeen, sendInteraction, feed])
 }
 
 export const FeedFeedbackProvider = stateContext.Provider
 
 export function useFeedFeedbackContext() {
-  return React.useContext(stateContext)
+  return useContext(stateContext)
 }
 
 // TODO
@@ -161,8 +174,8 @@ export function useFeedFeedbackContext() {
 // 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 FEEDBACK_FEEDS.includes(feed)
+function isDiscoverFeed(feed?: FeedDescriptor) {
+  return !!feed && FEEDBACK_FEEDS.includes(feed)
 }
 
 function toString(interaction: AppBskyFeedDefs.Interaction): string {
diff --git a/src/state/queries/notifications/types.ts b/src/state/queries/notifications/types.ts
index b3a972394..e05715f77 100644
--- a/src/state/queries/notifications/types.ts
+++ b/src/state/queries/notifications/types.ts
@@ -46,6 +46,8 @@ type OtherNotificationType =
   | 'feedgen-like'
   | 'verified'
   | 'unverified'
+  | 'like-via-repost'
+  | 'repost-via-repost'
   | 'unknown'
 
 type FeedNotificationBase = {
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index 6bbf9b250..569fbbd0f 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -244,7 +244,9 @@ function toKnownType(
     notif.reason === 'follow' ||
     notif.reason === 'starterpack-joined' ||
     notif.reason === 'verified' ||
-    notif.reason === 'unverified'
+    notif.reason === 'unverified' ||
+    notif.reason === 'like-via-repost' ||
+    notif.reason === 'repost-via-repost'
   ) {
     return notif.reason as NotificationType
   }
@@ -257,7 +259,12 @@ function getSubjectUri(
 ): string | undefined {
   if (type === 'reply' || type === 'quote' || type === 'mention') {
     return notif.uri
-  } else if (type === 'post-like' || type === 'repost') {
+  } else if (
+    type === 'post-like' ||
+    type === 'repost' ||
+    type === 'like-via-repost' ||
+    type === 'repost-via-repost'
+  ) {
     if (
       bsky.dangerousIsType<AppBskyFeedRepost.Record>(
         notif.record,
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index 7052590ca..4700a7fdc 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -1,11 +1,11 @@
 import {useCallback} from 'react'
-import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
+import {type AppBskyActorDefs, type AppBskyFeedDefs, AtUri} from '@atproto/api'
 import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
 
 import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
-import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig'
+import {logEvent, type LogEvents, toClout} from '#/lib/statsig/statsig'
 import {updatePostShadow} from '#/state/cache/post-shadow'
-import {Shadow} from '#/state/cache/types'
+import {type Shadow} from '#/state/cache/types'
 import {useAgent, useSession} from '#/state/session'
 import * as userActionHistory from '#/state/userActionHistory'
 import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes'
@@ -98,6 +98,7 @@ export function useGetPosts() {
 
 export function usePostLikeMutationQueue(
   post: Shadow<AppBskyFeedDefs.PostView>,
+  viaRepost: {uri: string; cid: string} | undefined,
   logContext: LogEvents['post:like']['logContext'] &
     LogEvents['post:unlike']['logContext'],
 ) {
@@ -115,6 +116,7 @@ export function usePostLikeMutationQueue(
         const {uri: likeUri} = await likeMutation.mutateAsync({
           uri: postUri,
           cid: postCid,
+          via: viaRepost,
         })
         userActionHistory.like([postUri])
         return likeUri
@@ -167,9 +169,9 @@ function usePostLikeMutation(
   return useMutation<
     {uri: string}, // responds with the uri of the like
     Error,
-    {uri: string; cid: string} // the post's uri and cid
+    {uri: string; cid: string; via?: {uri: string; cid: string}} // the post's uri and cid, and the repost uri/cid if present
   >({
-    mutationFn: ({uri, cid}) => {
+    mutationFn: ({uri, cid, via}) => {
       let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined
       if (currentAccount) {
         ownProfile = findProfileQueryData(queryClient, currentAccount.did)
@@ -190,7 +192,7 @@ function usePostLikeMutation(
             ? toClout(post.likeCount + post.repostCount + post.replyCount)
             : undefined,
       })
-      return agent.like(uri, cid)
+      return agent.like(uri, cid, via)
     },
   })
 }
@@ -209,6 +211,7 @@ function usePostUnlikeMutation(
 
 export function usePostRepostMutationQueue(
   post: Shadow<AppBskyFeedDefs.PostView>,
+  viaRepost: {uri: string; cid: string} | undefined,
   logContext: LogEvents['post:repost']['logContext'] &
     LogEvents['post:unrepost']['logContext'],
 ) {
@@ -226,6 +229,7 @@ export function usePostRepostMutationQueue(
         const {uri: repostUri} = await repostMutation.mutateAsync({
           uri: postUri,
           cid: postCid,
+          via: viaRepost,
         })
         return repostUri
       } else {
@@ -272,11 +276,11 @@ function usePostRepostMutation(
   return useMutation<
     {uri: string}, // responds with the uri of the repost
     Error,
-    {uri: string; cid: string} // the post's uri and cid
+    {uri: string; cid: string; via?: {uri: string; cid: string}} // the post's uri and cid, and the repost uri/cid if present
   >({
-    mutationFn: post => {
+    mutationFn: ({uri, cid, via}) => {
       logEvent('post:repost', {logContext})
-      return agent.repost(post.uri, post.cid)
+      return agent.repost(uri, cid, via)
     },
   })
 }
diff --git a/src/state/unstable-post-source.tsx b/src/state/unstable-post-source.tsx
new file mode 100644
index 000000000..1fb4af287
--- /dev/null
+++ b/src/state/unstable-post-source.tsx
@@ -0,0 +1,73 @@
+import {createContext, useCallback, useContext, useState} from 'react'
+import {type AppBskyFeedDefs} from '@atproto/api'
+
+import {type FeedDescriptor} from './queries/post-feed'
+
+/**
+ * For passing the source of the post (i.e. the original post, from the feed) to the threadview,
+ * without using query params. Deliberately unstable to avoid using query params, use for FeedFeedback
+ * and other ephemeral non-critical systems.
+ */
+
+type Source = {
+  post: AppBskyFeedDefs.FeedViewPost
+  feed?: FeedDescriptor
+}
+
+const SetUnstablePostSourceContext = createContext<
+  (key: string, source: Source) => void
+>(() => {})
+const ConsumeUnstablePostSourceContext = createContext<
+  (uri: string) => Source | undefined
+>(() => undefined)
+
+export function Provider({children}: {children: React.ReactNode}) {
+  const [sources, setSources] = useState<Map<string, Source>>(() => new Map())
+
+  const setUnstablePostSource = useCallback((key: string, source: Source) => {
+    setSources(prev => {
+      const newMap = new Map(prev)
+      newMap.set(key, source)
+      return newMap
+    })
+  }, [])
+
+  const consumeUnstablePostSource = useCallback(
+    (uri: string) => {
+      const source = sources.get(uri)
+      if (source) {
+        setSources(prev => {
+          const newMap = new Map(prev)
+          newMap.delete(uri)
+          return newMap
+        })
+      }
+      return source
+    },
+    [sources],
+  )
+
+  return (
+    <SetUnstablePostSourceContext.Provider value={setUnstablePostSource}>
+      <ConsumeUnstablePostSourceContext.Provider
+        value={consumeUnstablePostSource}>
+        {children}
+      </ConsumeUnstablePostSourceContext.Provider>
+    </SetUnstablePostSourceContext.Provider>
+  )
+}
+
+export function useSetUnstablePostSource() {
+  return useContext(SetUnstablePostSourceContext)
+}
+
+/**
+ * DANGER - This hook is unstable and should only be used for FeedFeedback
+ * and other ephemeral non-critical systems. Does not change when the URI changes.
+ */
+export function useUnstablePostSource(uri: string) {
+  const consume = useContext(ConsumeUnstablePostSourceContext)
+
+  const [source] = useState(() => consume(uri))
+  return source
+}
diff --git a/src/view/com/notifications/NotificationFeed.tsx b/src/view/com/notifications/NotificationFeed.tsx
index 73cebf868..1f87b3186 100644
--- a/src/view/com/notifications/NotificationFeed.tsx
+++ b/src/view/com/notifications/NotificationFeed.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
 import {
   ActivityIndicator,
-  ListRenderItemInfo,
+  type ListRenderItemInfo,
   StyleSheet,
   View,
 } from 'react-native'
@@ -16,7 +16,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
 import {EmptyState} from '#/view/com/util/EmptyState'
 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
-import {List, ListRef} from '#/view/com/util/List'
+import {List, type ListRef} from '#/view/com/util/List'
 import {NotificationFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
 import {NotificationFeedItem} from './NotificationFeedItem'
diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx
index a30aba7d8..1f99a3c34 100644
--- a/src/view/com/notifications/NotificationFeedItem.tsx
+++ b/src/view/com/notifications/NotificationFeedItem.tsx
@@ -446,6 +446,55 @@ let NotificationFeedItem = ({
       </Trans>
     )
     icon = <VerifiedCheck size="xl" fill={t.palette.contrast_500} />
+  } else if (item.type === 'like-via-repost') {
+    a11yLabel = hasMultipleAuthors
+      ? _(
+          msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
+            one: `${formattedAuthorsCount} other`,
+            other: `${formattedAuthorsCount} others`,
+          })} liked your repost`,
+        )
+      : _(msg`${firstAuthorName} liked your repost`)
+    notificationContent = hasMultipleAuthors ? (
+      <Trans>
+        {firstAuthorLink} and{' '}
+        <Text style={[a.text_md, a.font_bold, a.leading_snug]}>
+          <Plural
+            value={additionalAuthorsCount}
+            one={`${formattedAuthorsCount} other`}
+            other={`${formattedAuthorsCount} others`}
+          />
+        </Text>{' '}
+        liked your repost
+      </Trans>
+    ) : (
+      <Trans>{firstAuthorLink} liked your repost</Trans>
+    )
+  } else if (item.type === 'repost-via-repost') {
+    a11yLabel = hasMultipleAuthors
+      ? _(
+          msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
+            one: `${formattedAuthorsCount} other`,
+            other: `${formattedAuthorsCount} others`,
+          })} reposted your repost`,
+        )
+      : _(msg`${firstAuthorName} reposted your repost`)
+    notificationContent = hasMultipleAuthors ? (
+      <Trans>
+        {firstAuthorLink} and{' '}
+        <Text style={[a.text_md, a.font_bold, a.leading_snug]}>
+          <Plural
+            value={additionalAuthorsCount}
+            one={`${formattedAuthorsCount} other`}
+            other={`${formattedAuthorsCount} others`}
+          />
+        </Text>{' '}
+        reposted your repost
+      </Trans>
+    ) : (
+      <Trans>{firstAuthorLink} reposted your repost</Trans>
+    )
+    icon = <RepostIcon size="xl" style={{color: t.palette.positive_600}} />
   } else {
     return null
   }
@@ -553,7 +602,10 @@ let NotificationFeedItem = ({
                 </TimeElapsed>
               </Text>
             </ExpandListPressable>
-            {item.type === 'post-like' || item.type === 'repost' ? (
+            {item.type === 'post-like' ||
+            item.type === 'repost' ||
+            item.type === 'like-via-repost' ||
+            item.type === 'repost-via-repost' ? (
               <View style={[a.pt_2xs]}>
                 <AdditionalPostText post={item.subject} />
               </View>
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 82852aa62..77adebac9 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -1,4 +1,4 @@
-import React, {memo, useMemo} from 'react'
+import {memo, useCallback, useMemo, useState} from 'react'
 import {
   type GestureResponderEvent,
   StyleSheet,
@@ -6,7 +6,7 @@ import {
   View,
 } from 'react-native'
 import {
-  type AppBskyFeedDefs,
+  AppBskyFeedDefs,
   AppBskyFeedPost,
   type AppBskyFeedThreadgate,
   AtUri,
@@ -35,10 +35,12 @@ import {
   usePostShadow,
 } from '#/state/cache/post-shadow'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
 import {useLanguagePrefs} from '#/state/preferences'
 import {type ThreadPost} from '#/state/queries/post-thread'
 import {useSession} from '#/state/session'
 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
+import {useUnstablePostSource} from '#/state/unstable-post-source'
 import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
 import {Link, TextLink} from '#/view/com/util/Link'
@@ -201,18 +203,21 @@ let PostThreadItemLoaded = ({
   hideTopBorder?: boolean
   threadgateRecord?: AppBskyFeedThreadgate.Record
 }): React.ReactNode => {
+  const {currentAccount, hasSession} = useSession()
+  const source = useUnstablePostSource(post.uri)
+  const feedFeedback = useFeedFeedback(source?.feed, hasSession)
+
   const t = useTheme()
   const pal = usePalette('default')
   const {_, i18n} = useLingui()
   const langPrefs = useLanguagePrefs()
   const {openComposer} = useOpenComposer()
-  const [limitLines, setLimitLines] = React.useState(
+  const [limitLines, setLimitLines] = useState(
     () => countLines(richText?.text) >= MAX_POST_LINES,
   )
-  const {currentAccount} = useSession()
   const shadowedPostAuthor = useProfileShadow(post.author)
   const rootUri = record.reply?.root?.uri || post.uri
-  const postHref = React.useMemo(() => {
+  const postHref = useMemo(() => {
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey)
   }, [post.uri, post.author])
@@ -220,12 +225,12 @@ let PostThreadItemLoaded = ({
   const authorHref = makeProfileLink(post.author)
   const authorTitle = post.author.handle
   const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
-  const likesHref = React.useMemo(() => {
+  const likesHref = useMemo(() => {
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
   }, [post.uri, post.author])
   const likesTitle = _(msg`Likes on this post`)
-  const repostsHref = React.useMemo(() => {
+  const repostsHref = useMemo(() => {
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
   }, [post.uri, post.author])
@@ -233,7 +238,7 @@ let PostThreadItemLoaded = ({
   const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
     threadgateRecord,
   })
-  const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
+  const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
     const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
     const isControlledByViewer = new AtUri(rootUri).host === currentAccount?.did
     return isControlledByViewer && isPostHiddenByThreadgate
@@ -246,7 +251,7 @@ let PostThreadItemLoaded = ({
         ]
       : []
   }, [post, currentAccount?.did, threadgateHiddenReplies, rootUri])
-  const quotesHref = React.useMemo(() => {
+  const quotesHref = useMemo(() => {
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey, 'quotes')
   }, [post.uri, post.author])
@@ -270,7 +275,15 @@ let PostThreadItemLoaded = ({
     [post, langPrefs.primaryLanguage],
   )
 
-  const onPressReply = React.useCallback(() => {
+  const onPressReply = () => {
+    if (source) {
+      feedFeedback.sendInteraction({
+        item: post.uri,
+        event: 'app.bsky.feed.defs#interactionReply',
+        feedContext: source.post.feedContext,
+        reqId: source.post.reqId,
+      })
+    }
     openComposer({
       replyTo: {
         uri: post.uri,
@@ -282,14 +295,46 @@ let PostThreadItemLoaded = ({
       },
       onPost: onPostReply,
     })
-  }, [openComposer, post, record, onPostReply, moderation])
+  }
 
-  const onPressShowMore = React.useCallback(() => {
+  const onOpenAuthor = () => {
+    if (source) {
+      feedFeedback.sendInteraction({
+        item: post.uri,
+        event: 'app.bsky.feed.defs#clickthroughAuthor',
+        feedContext: source.post.feedContext,
+        reqId: source.post.reqId,
+      })
+    }
+  }
+
+  const onOpenEmbed = () => {
+    if (source) {
+      feedFeedback.sendInteraction({
+        item: post.uri,
+        event: 'app.bsky.feed.defs#clickthroughEmbed',
+        feedContext: source.post.feedContext,
+        reqId: source.post.reqId,
+      })
+    }
+  }
+
+  const onPressShowMore = useCallback(() => {
     setLimitLines(false)
   }, [setLimitLines])
 
   const {isActive: live} = useActorStatus(post.author)
 
+  const reason = source?.post.reason
+  const viaRepost = useMemo(() => {
+    if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) {
+      return {
+        uri: reason.uri,
+        cid: reason.cid,
+      }
+    }
+  }, [reason])
+
   if (!record) {
     return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} />
   }
@@ -309,10 +354,8 @@ let PostThreadItemLoaded = ({
               <View
                 style={[
                   styles.replyLine,
-                  {
-                    flexGrow: 1,
-                    backgroundColor: pal.colors.replyLine,
-                  },
+                  a.flex_grow,
+                  {backgroundColor: pal.colors.replyLine},
                 ]}
               />
             </View>
@@ -334,13 +377,15 @@ let PostThreadItemLoaded = ({
               moderation={moderation.ui('avatar')}
               type={post.author.associated?.labeler ? 'labeler' : 'user'}
               live={live}
+              onBeforePress={onOpenAuthor}
             />
             <View style={[a.flex_1]}>
               <View style={[a.flex_row, a.align_center]}>
                 <Link
                   style={[a.flex_shrink]}
                   href={authorHref}
-                  title={authorTitle}>
+                  title={authorTitle}
+                  onBeforePress={onOpenAuthor}>
                   <Text
                     emoji
                     style={[
@@ -413,6 +458,7 @@ let PostThreadItemLoaded = ({
                     embed={post.embed}
                     moderation={moderation}
                     viewContext={PostEmbedViewContext.ThreadHighlighted}
+                    onOpen={onOpenEmbed}
                   />
                 </View>
               )}
@@ -494,16 +540,21 @@ let PostThreadItemLoaded = ({
                   marginLeft: -5,
                 },
               ]}>
-              <PostControls
-                big
-                post={post}
-                record={record}
-                richText={richText}
-                onPressReply={onPressReply}
-                onPostReply={onPostReply}
-                logContext="PostThreadItem"
-                threadgateRecord={threadgateRecord}
-              />
+              <FeedFeedbackProvider value={feedFeedback}>
+                <PostControls
+                  big
+                  post={post}
+                  record={record}
+                  richText={richText}
+                  onPressReply={onPressReply}
+                  onPostReply={onPostReply}
+                  logContext="PostThreadItem"
+                  threadgateRecord={threadgateRecord}
+                  feedContext={source?.post?.feedContext}
+                  reqId={source?.post?.reqId}
+                  viaRepost={viaRepost}
+                />
+              </FeedFeedbackProvider>
             </View>
           </View>
         </View>
@@ -779,7 +830,7 @@ function ExpandedPostDetails({
   const isRootPost = !('reply' in post.record)
   const langPrefs = useLanguagePrefs()
 
-  const onTranslatePress = React.useCallback(
+  const onTranslatePress = useCallback(
     (e: GestureResponderEvent) => {
       e.preventDefault()
       openLink(translatorUrl, true)
diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx
index 3735bbb5a..b9aa67673 100644
--- a/src/view/com/posts/PostFeedItem.tsx
+++ b/src/view/com/posts/PostFeedItem.tsx
@@ -33,9 +33,10 @@ import {
   usePostShadow,
 } from '#/state/cache/post-shadow'
 import {useFeedFeedbackContext} from '#/state/feed-feedback'
-import {precacheProfile} from '#/state/queries/profile'
+import {unstableCacheProfileView} from '#/state/queries/profile'
 import {useSession} from '#/state/session'
 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
+import {useSetUnstablePostSource} from '#/state/unstable-post-source'
 import {FeedNameText} from '#/view/com/util/FeedInfoText'
 import {Link, TextLink, TextLinkOnWebOnly} from '#/view/com/util/Link'
 import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
@@ -174,7 +175,8 @@ let FeedItemInner = ({
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey)
   }, [post.uri, post.author])
-  const {sendInteraction} = useFeedFeedbackContext()
+  const {sendInteraction, feedDescriptor} = useFeedFeedbackContext()
+  const unstableSetPostSource = useSetUnstablePostSource()
 
   const onPressReply = () => {
     sendInteraction({
@@ -229,7 +231,16 @@ let FeedItemInner = ({
       feedContext,
       reqId,
     })
-    precacheProfile(queryClient, post.author)
+    unstableCacheProfileView(queryClient, post.author)
+    unstableSetPostSource(post.uri, {
+      feed: feedDescriptor,
+      post: {
+        post,
+        reason: AppBskyFeedDefs.isReasonRepost(reason) ? reason : undefined,
+        feedContext,
+        reqId,
+      },
+    })
   }
 
   const outerStyles = [
@@ -263,6 +274,15 @@ let FeedItemInner = ({
 
   const {isActive: live} = useActorStatus(post.author)
 
+  const viaRepost = useMemo(() => {
+    if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) {
+      return {
+        uri: reason.uri,
+        cid: reason.cid,
+      }
+    }
+  }, [reason])
+
   return (
     <Link
       testID={`feedItem-by-${post.author.handle}`}
@@ -450,6 +470,7 @@ let FeedItemInner = ({
             reqId={reqId}
             threadgateRecord={threadgateRecord}
             onShowLess={onShowLess}
+            viaRepost={viaRepost}
           />
         </View>