diff --git a/assets/icons/emojiSmile_stroke2_corner0_rounded.svg b/assets/icons/emojiSmile_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..fd329b5098
--- /dev/null
+++ b/assets/icons/emojiSmile_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/package.json b/package.json
index 39b90b3c32..62e5a50371 100644
--- a/package.json
+++ b/package.json
@@ -98,6 +98,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",
@@ -149,6 +150,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 ef7ffd435a..b7427c84b2 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 0000000000..5bfc77d0a5
--- /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({
+ 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>(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
+ >(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 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 827f8a2a86..dc86a9ba0d 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 4ebf64da9a..bb782809df 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(null)
const [hasNew, setHasNew] = React.useState(false)
const gate = useGate()
@@ -113,20 +115,22 @@ export function FeedPage({
return (
-
+
+
+
{(isScrolledDown || adjustedHasNew) && (
void) | null>(null)
const lastFetchRef = React.useRef(Date.now())
@@ -353,6 +355,7 @@ let Feed = ({
}
initialNumToRender={initialNumToRender}
windowSize={11}
+ onItemSeen={feedFeedback.onItemSeen}
/>
)
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 605dffde9d..5b4efe2af4 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
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}>
@@ -251,6 +294,7 @@ let FeedItemInner = ({
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
+ onBeforePress={onOpenAuthor}
/>
{isThreadParent && (
{!isThreadChild && replyAuthorDid !== '' && (
@@ -308,6 +353,7 @@ let FeedItemInner = ({
richText={richText}
postEmbed={post.embed}
postAuthor={post.author}
+ onOpenEmbed={onOpenEmbed}
/>
@@ -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 ? (
-
+
) : null}
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index 49e48aa202..27a9ff8c06 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 78d995ee82..df82124f92 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 84b401e636..194f81c5c1 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 = Omit<
headerOffset?: number
refreshing?: boolean
onRefresh?: () => void
+ onItemSeen?: (item: ItemT) => void
containWeb?: boolean
}
export type ListRef = React.MutableRefObject
@@ -34,6 +35,7 @@ function ListImpl(
onScrolledDownChange,
refreshing,
onRefresh,
+ onItemSeen,
headerOffset,
style,
...props
@@ -73,6 +75,25 @@ function ListImpl(
},
})
+ const [onViewableItemsChanged, viewabilityConfig] = React.useMemo(() => {
+ if (!onItemSeen) {
+ return [undefined, undefined]
+ }
+ return [
+ (info: {viewableItems: Array; changed: Array}) => {
+ 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(
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 9bea2d7951..b6ecf02ec8 100644
--- a/src/view/com/util/List.web.tsx
+++ b/src/view/com/util/List.web.tsx
@@ -20,11 +20,17 @@ export type ListProps = Omit<
headerOffset?: number
refreshing?: boolean
onRefresh?: () => void
+ onItemSeen?: (item: ItemT) => void
desktopFixedHeight: any // TODO: Better types.
containWeb?: boolean
}
export type ListRef = React.MutableRefObject // 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(
{
ListHeaderComponent,
@@ -43,6 +49,7 @@ function ListImpl(
onRefresh: _unsupportedOnRefresh,
onScrolledDownChange,
onContentSizeChange,
+ onItemSeen,
renderItem,
extraData,
style,
@@ -319,15 +326,19 @@ function ListImpl(
/>
)}
{header}
- {(data as Array).map((item, index) => (
-
- key={keyExtractor!(item, index)}
- item={item}
- index={index}
- renderItem={renderItem}
- extraData={extraData}
- />
- ))}
+ {(data as Array).map((item, index) => {
+ const key = keyExtractor!(item, index)
+ return (
+
+ key={key}
+ item={item}
+ index={index}
+ renderItem={renderItem}
+ extraData={extraData}
+ onItemSeen={onItemSeen}
+ />
+ )
+ })}
{onEndReached && (
({
index,
renderItem,
extraData: _unused,
+ onItemSeen,
}: {
item: ItemT
index: number
@@ -380,12 +392,57 @@ let Row = function RowImpl({
| 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(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 (
-
+
{renderItem({item, index, separators: null as any})}
)
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index e7ce18535e..c0e4d80991 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
+ onOpenAuthor?: () => void
style?: StyleProp
}
@@ -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}
/>
{
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}
/>
)}
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 118e2ce2be..faff24fd40 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -49,6 +49,7 @@ interface EditableUserAvatarProps extends BaseUserAvatarProps {
interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
moderation?: ModerationUI
+ onBeforePress?: () => void
profile: AppBskyActorDefs.ProfileViewBasic
}
@@ -375,14 +376,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 (
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index ac97f3da24..7a62ce7cbc 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
@@ -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 = ({
)}
- {hasSession && (
+ {hasSession && feedFeedback.enabled && (
<>
+
+
+ {_(msg`Show more like this`)}
+
+
+
+
+ {_(msg`Show less like this`)}
+
+
+
+ >
+ )}
+ {hasSession && (
+ <>
+
-
{!isAuthor && (
record: AppBskyFeedPost.Record
richText: RichTextAPI
+ feedContext?: string | undefined
style?: StyleProp
onPressReply: () => void
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
@@ -67,6 +70,7 @@ let PostCtrls = ({
)
const requireAuth = useRequireAuth()
const loggedOutWarningPromptControl = useDialogControl()
+ const {sendInteraction} = useFeedFeedbackContext()
const playHaptic = useHaptics()
const shouldShowLoggedOutWarning = React.useMemo(() => {
@@ -86,6 +90,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()
@@ -95,13 +104,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()
@@ -111,10 +133,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,
@@ -134,6 +170,8 @@ let PostCtrls = ({
post.indexedAt,
record.text,
playHaptic,
+ sendInteraction,
+ feedContext,
])
const onShare = useCallback(() => {
@@ -141,7 +179,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 (
@@ -260,6 +303,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 b84c04b831..3b2a12c24b 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
hideAlt?: boolean
}) => {
@@ -44,7 +46,7 @@ export const ExternalLinkEmbed = ({
return (
-
+
{link.thumb && !embedPlayerParams ? (
void
style?: StyleProp
children: React.ReactNode
}) {
@@ -125,6 +129,7 @@ function LinkWrapper({
style,
]}
hoverStyle={t.atoms.border_contrast_high}
+ onBeforePress={onOpen}
onLongPress={onShareExternal}>
{children}
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index 0e19a6ccde..57f1d28ba6 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
}) {
const pal = usePalette('default')
@@ -57,6 +59,7 @@ export function MaybeQuoteEmbed({
)
@@ -85,10 +88,12 @@ export function MaybeQuoteEmbed({
function QuoteEmbedModerated({
viewRecord,
postRecord,
+ onOpen,
style,
}: {
viewRecord: AppBskyEmbedRecord.ViewRecord
postRecord: AppBskyFeedPost.Record
+ onOpen?: () => void
style?: StyleProp
}) {
const moderationOpts = useModerationOpts()
@@ -108,16 +113,25 @@ function QuoteEmbedModerated({
embeds: viewRecord.embeds,
}
- return
+ return (
+
+ )
}
export function QuoteEmbed({
quote,
moderation,
+ onOpen,
style,
}: {
quote: ComposerOptsQuote
moderation?: ModerationDecision
+ onOpen?: () => void
style?: StyleProp
}) {
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 (
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 7ea5b55cfe..eb9732ee8e 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
}) {
const pal = usePalette('default')
@@ -52,8 +54,12 @@ export function PostEmbeds({
if (AppBskyEmbedRecordWithMedia.isView(embed)) {
return (
-
-
+
+
)
}
@@ -80,7 +86,7 @@ export function PostEmbeds({
// quote post
// =
- return
+ return
}
// image embed
@@ -151,7 +157,7 @@ export function PostEmbeds({
const link = embed.external
return (
-
+
)
}
diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx
index 442e33fd30..86c6321948 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 814c1e8558..b9042ff327 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'
@@ -463,6 +464,8 @@ const FeedSection = React.forwardRef(
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({
@@ -490,17 +493,19 @@ const FeedSection = React.forwardRef(
return (
-
+
+
+
{(isScrolledDown || hasNew) && (