about summary refs log tree commit diff
path: root/src/view/com/util
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/util')
-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
10 files changed, 235 insertions, 29 deletions
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>
     )
   }