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

@ -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(() => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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