Implement FeedFeedback API (#3498)

* Implement onViewableItemsChanged on List.web.tsx

* Introduce onItemSeen to List API

* Add FeedFeedback tracker

* Add clickthrough interaction tracking

* Add engagement interaction tracking

* Reduce duplicate sends, introduce a flushAndReset to be triggered on refreshes, and modify the api design a bit

* Wire up SDK types and feedContext

* Avoid needless function allocations

* Fix schema usage

* Add show more / show less buttons

* Fix minor rendering issue on mobile menu

* Wire up sendInteractions()

* Fix logic error

* Fix: it's item not uri

* Update 'seen' to mean 3 seconds on-screen with some significant portion visible

* Fix non-reactive debounce

* Move methods out

* Use a WeakSet for deduping

* Reset timeout

* 3 -> 2 seconds

* Oopsie

* Throttle instead

* Fix divider

* Remove explicit flush calls

* Rm unused

---------

Co-authored-by: dan <dan.abramov@gmail.com>
This commit is contained in:
Paul Frazee 2024-05-06 19:08:33 -07:00 committed by GitHub
parent e264dfbb87
commit 4fad18b2fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 516 additions and 64 deletions

View file

@ -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>
)

View file

@ -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>

View file

@ -1,14 +1,15 @@
import React, {memo} from 'react'
import {StyleSheet, View} from 'react-native'
import {FeedPostSlice} from '#/state/queries/post-feed'
import {AtUri} from '@atproto/api'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import Svg, {Circle, Line} from 'react-native-svg'
import {FeedItem} from './FeedItem'
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 {Trans} from '@lingui/macro'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {FeedItem} from './FeedItem'
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)}