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>
zio/stable
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

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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.415Z M10.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" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 623 B

View File

@ -100,6 +100,7 @@
"@tiptap/react": "^2.0.0-beta.220", "@tiptap/react": "^2.0.0-beta.220",
"@tiptap/suggestion": "^2.0.0-beta.220", "@tiptap/suggestion": "^2.0.0-beta.220",
"@types/invariant": "^2.2.37", "@types/invariant": "^2.2.37",
"@types/lodash.throttle": "^4.1.9",
"@types/node": "^18.16.2", "@types/node": "^18.16.2",
"@zxing/text-encoding": "^0.9.0", "@zxing/text-encoding": "^0.9.0",
"array.prototype.findlast": "^1.2.3", "array.prototype.findlast": "^1.2.3",
@ -151,6 +152,7 @@
"lodash.samplesize": "^4.2.0", "lodash.samplesize": "^4.2.0",
"lodash.set": "^4.3.2", "lodash.set": "^4.3.2",
"lodash.shuffle": "^4.2.0", "lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1",
"mobx": "^6.6.1", "mobx": "^6.6.1",
"mobx-react-lite": "^3.4.0", "mobx-react-lite": "^3.4.0",
"mobx-utils": "^6.0.6", "mobx-utils": "^6.0.6",

View File

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

View File

@ -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<StateContext>({
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<Set<string>>(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<FeedPostSliceItem | AppBskyFeedDefs.Interaction>
>(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 <List> 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}
}

View File

@ -70,10 +70,12 @@ export interface FeedPostSliceItem {
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
feedContext: string | undefined
moderation: ModerationDecision moderation: ModerationDecision
} }
export interface FeedPostSlice { export interface FeedPostSlice {
_isFeedPostSlice: boolean
_reactKey: string _reactKey: string
rootUri: string rootUri: string
isThread: boolean isThread: boolean
@ -276,6 +278,7 @@ export function usePostFeedQuery(
return { return {
_reactKey: slice._reactKey, _reactKey: slice._reactKey,
_isFeedPostSlice: true,
rootUri: slice.rootItem.post.uri, rootUri: slice.rootItem.post.uri,
isThread: isThread:
slice.items.length > 1 && slice.items.length > 1 &&
@ -300,6 +303,7 @@ export function usePostFeedQuery(
i === 0 && slice.source i === 0 && slice.source
? slice.source ? slice.source
: item.reason, : item.reason,
feedContext: item.feedContext,
moderation: moderations[i], moderation: moderations[i],
} }
} }
@ -507,3 +511,9 @@ export function resetProfilePostsQueries(
}) })
}, timeout) }, timeout)
} }
export function isFeedPostSlice(v: any): v is FeedPostSlice {
return (
v && typeof v === 'object' && '_isFeedPostSlice' in v && v._isFeedPostSlice
)
}

View File

@ -9,6 +9,7 @@ import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers'
import {logEvent, useGate} from '#/lib/statsig/statsig' import {logEvent, useGate} from '#/lib/statsig/statsig'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {listenSoftReset} from '#/state/events' import {listenSoftReset} from '#/state/events'
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
import {truncateAndInvalidate} from '#/state/queries/util' import {truncateAndInvalidate} from '#/state/queries/util'
@ -51,6 +52,7 @@ export function FeedPage({
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const {screen, track} = useAnalytics() const {screen, track} = useAnalytics()
const headerOffset = useHeaderOffset() const headerOffset = useHeaderOffset()
const feedFeedback = useFeedFeedback(feed, hasSession)
const scrollElRef = React.useRef<ListMethods>(null) const scrollElRef = React.useRef<ListMethods>(null)
const [hasNew, setHasNew] = React.useState(false) const [hasNew, setHasNew] = React.useState(false)
const gate = useGate() const gate = useGate()
@ -113,20 +115,22 @@ export function FeedPage({
return ( return (
<View testID={testID} style={s.h100pct}> <View testID={testID} style={s.h100pct}>
<MainScrollProvider> <MainScrollProvider>
<Feed <FeedFeedbackProvider value={feedFeedback}>
testID={testID ? `${testID}-feed` : undefined} <Feed
enabled={isPageFocused} testID={testID ? `${testID}-feed` : undefined}
feed={feed} enabled={isPageFocused}
feedParams={feedParams} feed={feed}
pollInterval={POLL_FREQ} feedParams={feedParams}
disablePoll={hasNew} pollInterval={POLL_FREQ}
scrollElRef={scrollElRef} disablePoll={hasNew}
onScrolledDownChange={setIsScrolledDown} scrollElRef={scrollElRef}
onHasNew={setHasNew} onScrolledDownChange={setIsScrolledDown}
renderEmptyState={renderEmptyState} onHasNew={setHasNew}
renderEndOfFeed={renderEndOfFeed} renderEmptyState={renderEmptyState}
headerOffset={headerOffset} renderEndOfFeed={renderEndOfFeed}
/> headerOffset={headerOffset}
/>
</FeedFeedbackProvider>
</MainScrollProvider> </MainScrollProvider>
{(isScrolledDown || adjustedHasNew) && ( {(isScrolledDown || adjustedHasNew) && (
<LoadLatestBtn <LoadLatestBtn

View File

@ -17,6 +17,7 @@ import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {listenPostCreated} from '#/state/events' import {listenPostCreated} from '#/state/events'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import { import {
FeedDescriptor, FeedDescriptor,
@ -88,6 +89,7 @@ let Feed = ({
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const initialNumToRender = useInitialNumToRender() const initialNumToRender = useInitialNumToRender()
const feedFeedback = useFeedFeedbackContext()
const [isPTRing, setIsPTRing] = React.useState(false) const [isPTRing, setIsPTRing] = React.useState(false)
const checkForNewRef = React.useRef<(() => void) | null>(null) const checkForNewRef = React.useRef<(() => void) | null>(null)
const lastFetchRef = React.useRef<number>(Date.now()) const lastFetchRef = React.useRef<number>(Date.now())
@ -353,6 +355,7 @@ let Feed = ({
} }
initialNumToRender={initialNumToRender} initialNumToRender={initialNumToRender}
windowSize={11} windowSize={11}
onItemSeen={feedFeedback.onItemSeen}
/> />
</View> </View>
) )

View File

@ -16,6 +16,7 @@ import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types' import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
import {MAX_POST_LINES} from 'lib/constants' import {MAX_POST_LINES} from 'lib/constants'
@ -45,6 +46,7 @@ export function FeedItem({
post, post,
record, record,
reason, reason,
feedContext,
moderation, moderation,
isThreadChild, isThreadChild,
isThreadLastChild, isThreadLastChild,
@ -53,6 +55,7 @@ export function FeedItem({
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
feedContext: string | undefined
moderation: ModerationDecision moderation: ModerationDecision
isThreadChild?: boolean isThreadChild?: boolean
isThreadLastChild?: boolean isThreadLastChild?: boolean
@ -78,6 +81,7 @@ export function FeedItem({
post={postShadowed} post={postShadowed}
record={record} record={record}
reason={reason} reason={reason}
feedContext={feedContext}
richText={richText} richText={richText}
moderation={moderation} moderation={moderation}
isThreadChild={isThreadChild} isThreadChild={isThreadChild}
@ -93,6 +97,7 @@ let FeedItemInner = ({
post, post,
record, record,
reason, reason,
feedContext,
richText, richText,
moderation, moderation,
isThreadChild, isThreadChild,
@ -102,6 +107,7 @@ let FeedItemInner = ({
post: Shadow<AppBskyFeedDefs.PostView> post: Shadow<AppBskyFeedDefs.PostView>
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
feedContext: string | undefined
richText: RichTextAPI richText: RichTextAPI
moderation: ModerationDecision moderation: ModerationDecision
isThreadChild?: boolean isThreadChild?: boolean
@ -116,6 +122,7 @@ let FeedItemInner = ({
const urip = new AtUri(post.uri) const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey) return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author]) }, [post.uri, post.author])
const {sendInteraction} = useFeedFeedbackContext()
const replyAuthorDid = useMemo(() => { const replyAuthorDid = useMemo(() => {
if (!record?.reply) { if (!record?.reply) {
@ -126,6 +133,11 @@ let FeedItemInner = ({
}, [record?.reply]) }, [record?.reply])
const onPressReply = React.useCallback(() => { const onPressReply = React.useCallback(() => {
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionReply',
feedContext,
})
openComposer({ openComposer({
replyTo: { replyTo: {
uri: post.uri, uri: post.uri,
@ -136,11 +148,40 @@ let FeedItemInner = ({
moderation, 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(() => { const onBeforePress = React.useCallback(() => {
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#clickthroughItem',
feedContext,
})
precacheProfile(queryClient, post.author) precacheProfile(queryClient, post.author)
}, [queryClient, post.author]) }, [queryClient, post, sendInteraction, feedContext])
const outerStyles = [ const outerStyles = [
styles.outer, styles.outer,
@ -207,7 +248,8 @@ let FeedItemInner = ({
msg`Reposted by ${sanitizeDisplayName( msg`Reposted by ${sanitizeDisplayName(
reason.by.displayName || reason.by.handle, reason.by.displayName || reason.by.handle,
)}`, )}`,
)}> )}
onBeforePress={onOpenReposter}>
<FontAwesomeIcon <FontAwesomeIcon
icon="retweet" icon="retweet"
style={{ style={{
@ -235,6 +277,7 @@ let FeedItemInner = ({
moderation.ui('displayName'), moderation.ui('displayName'),
)} )}
href={makeProfileLink(reason.by)} href={makeProfileLink(reason.by)}
onBeforePress={onOpenReposter}
/> />
</ProfileHoverCard> </ProfileHoverCard>
</Trans> </Trans>
@ -251,6 +294,7 @@ let FeedItemInner = ({
profile={post.author} profile={post.author}
moderation={moderation.ui('avatar')} moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'} type={post.author.associated?.labeler ? 'labeler' : 'user'}
onBeforePress={onOpenAuthor}
/> />
{isThreadParent && ( {isThreadParent && (
<View <View
@ -272,6 +316,7 @@ let FeedItemInner = ({
authorHasWarning={!!post.author.labels?.length} authorHasWarning={!!post.author.labels?.length}
timestamp={post.indexedAt} timestamp={post.indexedAt}
postHref={href} postHref={href}
onOpenAuthor={onOpenAuthor}
/> />
{!isThreadChild && replyAuthorDid !== '' && ( {!isThreadChild && replyAuthorDid !== '' && (
<View style={[s.flexRow, s.mb2, s.alignCenter]}> <View style={[s.flexRow, s.mb2, s.alignCenter]}>
@ -308,6 +353,7 @@ let FeedItemInner = ({
richText={richText} richText={richText}
postEmbed={post.embed} postEmbed={post.embed}
postAuthor={post.author} postAuthor={post.author}
onOpenEmbed={onOpenEmbed}
/> />
<PostCtrls <PostCtrls
post={post} post={post}
@ -315,6 +361,7 @@ let FeedItemInner = ({
richText={richText} richText={richText}
onPressReply={onPressReply} onPressReply={onPressReply}
logContext="FeedItem" logContext="FeedItem"
feedContext={feedContext}
/> />
</View> </View>
</View> </View>
@ -328,11 +375,13 @@ let PostContent = ({
richText, richText,
postEmbed, postEmbed,
postAuthor, postAuthor,
onOpenEmbed,
}: { }: {
moderation: ModerationDecision moderation: ModerationDecision
richText: RichTextAPI richText: RichTextAPI
postEmbed: AppBskyFeedDefs.PostView['embed'] postEmbed: AppBskyFeedDefs.PostView['embed']
postAuthor: AppBskyFeedDefs.PostView['author'] postAuthor: AppBskyFeedDefs.PostView['author']
onOpenEmbed: () => void
}): React.ReactNode => { }): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
@ -373,7 +422,11 @@ let PostContent = ({
) : undefined} ) : undefined}
{postEmbed ? ( {postEmbed ? (
<View style={[a.pb_sm]}> <View style={[a.pb_sm]}>
<PostEmbeds embed={postEmbed} moderation={moderation} /> <PostEmbeds
embed={postEmbed}
moderation={moderation}
onOpen={onOpenEmbed}
/>
</View> </View>
) : null} ) : null}
</ContentHider> </ContentHider>

View File

@ -1,14 +1,15 @@
import React, {memo} from 'react' import React, {memo} from 'react'
import {StyleSheet, View} from 'react-native' 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 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 {usePalette} from 'lib/hooks/usePalette'
import {makeProfileLink} from 'lib/routes/links' 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 => { let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
if (slice.isThread && slice.items.length > 3) { if (slice.isThread && slice.items.length > 3) {
@ -20,6 +21,7 @@ let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
post={slice.items[0].post} post={slice.items[0].post}
record={slice.items[0].record} record={slice.items[0].record}
reason={slice.items[0].reason} reason={slice.items[0].reason}
feedContext={slice.items[0].feedContext}
moderation={slice.items[0].moderation} moderation={slice.items[0].moderation}
isThreadParent={isThreadParentAt(slice.items, 0)} isThreadParent={isThreadParentAt(slice.items, 0)}
isThreadChild={isThreadChildAt(slice.items, 0)} isThreadChild={isThreadChildAt(slice.items, 0)}
@ -29,6 +31,7 @@ let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
post={slice.items[1].post} post={slice.items[1].post}
record={slice.items[1].record} record={slice.items[1].record}
reason={slice.items[1].reason} reason={slice.items[1].reason}
feedContext={slice.items[1].feedContext}
moderation={slice.items[1].moderation} moderation={slice.items[1].moderation}
isThreadParent={isThreadParentAt(slice.items, 1)} isThreadParent={isThreadParentAt(slice.items, 1)}
isThreadChild={isThreadChildAt(slice.items, 1)} isThreadChild={isThreadChildAt(slice.items, 1)}
@ -39,6 +42,7 @@ let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
post={slice.items[last].post} post={slice.items[last].post}
record={slice.items[last].record} record={slice.items[last].record}
reason={slice.items[last].reason} reason={slice.items[last].reason}
feedContext={slice.items[last].feedContext}
moderation={slice.items[last].moderation} moderation={slice.items[last].moderation}
isThreadParent={isThreadParentAt(slice.items, last)} isThreadParent={isThreadParentAt(slice.items, last)}
isThreadChild={isThreadChildAt(slice.items, last)} isThreadChild={isThreadChildAt(slice.items, last)}
@ -56,6 +60,7 @@ let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
post={slice.items[i].post} post={slice.items[i].post}
record={slice.items[i].record} record={slice.items[i].record}
reason={slice.items[i].reason} reason={slice.items[i].reason}
feedContext={slice.items[i].feedContext}
moderation={slice.items[i].moderation} moderation={slice.items[i].moderation}
isThreadParent={isThreadParentAt(slice.items, i)} isThreadParent={isThreadParentAt(slice.items, i)}
isThreadChild={isThreadChildAt(slice.items, i)} isThreadChild={isThreadChildAt(slice.items, i)}

View File

@ -220,6 +220,7 @@ export const TextLink = memo(function TextLink({
) )
}, },
[ [
onBeforePress,
onPress, onPress,
closeModal, closeModal,
openModal, openModal,
@ -229,7 +230,6 @@ export const TextLink = memo(function TextLink({
disableMismatchWarning, disableMismatchWarning,
navigationAction, navigationAction,
openLink, openLink,
onBeforePress,
], ],
) )
const hrefAttrs = useMemo(() => { const hrefAttrs = useMemo(() => {

View File

@ -1,5 +1,5 @@
import React, {memo} from 'react' 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 {runOnJS, useSharedValue} from 'react-native-reanimated'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
@ -23,6 +23,7 @@ export type ListProps<ItemT> = Omit<
headerOffset?: number headerOffset?: number
refreshing?: boolean refreshing?: boolean
onRefresh?: () => void onRefresh?: () => void
onItemSeen?: (item: ItemT) => void
containWeb?: boolean containWeb?: boolean
} }
export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null> export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
@ -34,6 +35,7 @@ function ListImpl<ItemT>(
onScrolledDownChange, onScrolledDownChange,
refreshing, refreshing,
onRefresh, onRefresh,
onItemSeen,
headerOffset, headerOffset,
style, style,
...props ...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 let refreshControl
if (refreshing !== undefined || onRefresh !== undefined) { if (refreshing !== undefined || onRefresh !== undefined) {
refreshControl = ( refreshControl = (
@ -102,6 +123,8 @@ function ListImpl<ItemT>(
refreshControl={refreshControl} refreshControl={refreshControl}
onScroll={scrollHandler} onScroll={scrollHandler}
scrollEventThrottle={1} scrollEventThrottle={1}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
style={style} style={style}
ref={ref} ref={ref}
/> />

View File

@ -20,11 +20,17 @@ export type ListProps<ItemT> = Omit<
headerOffset?: number headerOffset?: number
refreshing?: boolean refreshing?: boolean
onRefresh?: () => void onRefresh?: () => void
onItemSeen?: (item: ItemT) => void
desktopFixedHeight: any // TODO: Better types. desktopFixedHeight: any // TODO: Better types.
containWeb?: boolean containWeb?: boolean
} }
export type ListRef = React.MutableRefObject<any | null> // TODO: Better types. 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>( function ListImpl<ItemT>(
{ {
ListHeaderComponent, ListHeaderComponent,
@ -43,6 +49,7 @@ function ListImpl<ItemT>(
onRefresh: _unsupportedOnRefresh, onRefresh: _unsupportedOnRefresh,
onScrolledDownChange, onScrolledDownChange,
onContentSizeChange, onContentSizeChange,
onItemSeen,
renderItem, renderItem,
extraData, extraData,
style, style,
@ -319,15 +326,19 @@ function ListImpl<ItemT>(
/> />
)} )}
{header} {header}
{(data as Array<ItemT>).map((item, index) => ( {(data as Array<ItemT>).map((item, index) => {
<Row<ItemT> const key = keyExtractor!(item, index)
key={keyExtractor!(item, index)} return (
item={item} <Row<ItemT>
index={index} key={key}
renderItem={renderItem} item={item}
extraData={extraData} index={index}
/> renderItem={renderItem}
))} extraData={extraData}
onItemSeen={onItemSeen}
/>
)
})}
{onEndReached && ( {onEndReached && (
<Visibility <Visibility
root={containWeb ? nativeRef : null} root={containWeb ? nativeRef : null}
@ -372,6 +383,7 @@ let Row = function RowImpl<ItemT>({
index, index,
renderItem, renderItem,
extraData: _unused, extraData: _unused,
onItemSeen,
}: { }: {
item: ItemT item: ItemT
index: number index: number
@ -380,12 +392,57 @@ let Row = function RowImpl<ItemT>({
| undefined | undefined
| ((data: {index: number; item: any; separators: any}) => React.ReactNode) | ((data: {index: number; item: any; separators: any}) => React.ReactNode)
extraData: any extraData: any
onItemSeen: ((item: any) => void) | undefined
}): React.ReactNode { }): 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) { if (!renderItem) {
return null return null
} }
return ( return (
<View style={styles.row}> <View style={styles.row} ref={rowRef}>
{renderItem({item, index, separators: null as any})} {renderItem({item, index, separators: null as any})}
</View> </View>
) )

View File

@ -28,6 +28,7 @@ interface PostMetaOpts {
avatarSize?: number avatarSize?: number
displayNameType?: TypographyVariant displayNameType?: TypographyVariant
displayNameStyle?: StyleProp<TextStyle> displayNameStyle?: StyleProp<TextStyle>
onOpenAuthor?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
} }
@ -43,7 +44,12 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
: undefined : undefined
const queryClient = useQueryClient() 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) precacheProfile(queryClient, opts.author)
}, [queryClient, opts.author]) }, [queryClient, opts.author])
@ -77,7 +83,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
</> </>
} }
href={profileLink} href={profileLink}
onBeforePress={onBeforePress} onBeforePress={onBeforePressAuthor}
onPointerEnter={onPointerEnter} onPointerEnter={onPointerEnter}
/> />
<TextLinkOnWebOnly <TextLinkOnWebOnly
@ -86,7 +92,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
style={[pal.textLight, {flexShrink: 4}]} style={[pal.textLight, {flexShrink: 4}]}
text={'\xa0' + sanitizeHandle(handle, '@')} text={'\xa0' + sanitizeHandle(handle, '@')}
href={profileLink} href={profileLink}
onBeforePress={onBeforePress} onBeforePress={onBeforePressAuthor}
onPointerEnter={onPointerEnter} onPointerEnter={onPointerEnter}
anchorNoUnderline anchorNoUnderline
/> />
@ -112,7 +118,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
title={niceDate(opts.timestamp)} title={niceDate(opts.timestamp)}
accessibilityHint="" accessibilityHint=""
href={opts.postHref} href={opts.postHref}
onBeforePress={onBeforePress} onBeforePress={onBeforePressPost}
/> />
)} )}
</TimeElapsed> </TimeElapsed>

View File

@ -50,6 +50,7 @@ interface EditableUserAvatarProps extends BaseUserAvatarProps {
interface PreviewableUserAvatarProps extends BaseUserAvatarProps { interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
moderation?: ModerationUI moderation?: ModerationUI
onBeforePress?: () => void
profile: AppBskyActorDefs.ProfileViewBasic profile: AppBskyActorDefs.ProfileViewBasic
} }
@ -382,14 +383,16 @@ export {EditableUserAvatar}
let PreviewableUserAvatar = ({ let PreviewableUserAvatar = ({
moderation, moderation,
profile, profile,
onBeforePress,
...rest ...rest
}: PreviewableUserAvatarProps): React.ReactNode => { }: PreviewableUserAvatarProps): React.ReactNode => {
const {_} = useLingui() const {_} = useLingui()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const onPress = React.useCallback(() => { const onPress = React.useCallback(() => {
onBeforePress?.()
precacheProfile(queryClient, profile) precacheProfile(queryClient, profile)
}, [profile, queryClient]) }, [profile, queryClient, onBeforePress])
return ( return (
<ProfileHoverCard did={profile.did}> <ProfileHoverCard did={profile.did}>

View File

@ -18,6 +18,7 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers' import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {useHiddenPosts, useHiddenPostsApi} 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 {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets' 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 {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
@ -53,6 +58,7 @@ let PostDropdownBtn = ({
postAuthor, postAuthor,
postCid, postCid,
postUri, postUri,
postFeedContext,
record, record,
richText, richText,
style, style,
@ -63,6 +69,7 @@ let PostDropdownBtn = ({
postAuthor: AppBskyActorDefs.ProfileViewBasic postAuthor: AppBskyActorDefs.ProfileViewBasic
postCid: string postCid: string
postUri: string postUri: string
postFeedContext: string | undefined
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
richText: RichTextAPI richText: RichTextAPI
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
@ -81,6 +88,7 @@ let PostDropdownBtn = ({
const postDeleteMutation = usePostDeleteMutation() const postDeleteMutation = usePostDeleteMutation()
const hiddenPosts = useHiddenPosts() const hiddenPosts = useHiddenPosts()
const {hidePost} = useHiddenPostsApi() const {hidePost} = useHiddenPostsApi()
const feedFeedback = useFeedFeedbackContext()
const openLink = useOpenLink() const openLink = useOpenLink()
const navigation = useNavigation() const navigation = useNavigation()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext() const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
@ -183,6 +191,24 @@ let PostDropdownBtn = ({
shareUrl(url) shareUrl(url)
}, [href]) }, [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 const canEmbed = isWeb && gtMobile && !hideInPWI
return ( return (
@ -262,10 +288,32 @@ let PostDropdownBtn = ({
)} )}
</Menu.Group> </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 && ( {hasSession && (
<> <>
<Menu.Divider /> <Menu.Divider />
<Menu.Group> <Menu.Group>
<Menu.Item <Menu.Item
testID="postDropdownMuteThreadBtn" testID="postDropdownMuteThreadBtn"
@ -308,7 +356,6 @@ let PostDropdownBtn = ({
{hasSession && ( {hasSession && (
<> <>
<Menu.Divider /> <Menu.Divider />
<Menu.Group> <Menu.Group>
{!isAuthor && ( {!isAuthor && (
<Menu.Item <Menu.Item

View File

@ -23,6 +23,7 @@ import {toShareUrl} from '#/lib/strings/url-helpers'
import {s} from '#/lib/styles' import {s} from '#/lib/styles'
import {useTheme} from '#/lib/ThemeContext' import {useTheme} from '#/lib/ThemeContext'
import {Shadow} from '#/state/cache/types' import {Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import { import {
usePostLikeMutationQueue, usePostLikeMutationQueue,
@ -43,6 +44,7 @@ let PostCtrls = ({
post, post,
record, record,
richText, richText,
feedContext,
style, style,
onPressReply, onPressReply,
logContext, logContext,
@ -51,6 +53,7 @@ let PostCtrls = ({
post: Shadow<AppBskyFeedDefs.PostView> post: Shadow<AppBskyFeedDefs.PostView>
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
richText: RichTextAPI richText: RichTextAPI
feedContext?: string | undefined
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
onPressReply: () => void onPressReply: () => void
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
@ -66,6 +69,7 @@ let PostCtrls = ({
) )
const requireAuth = useRequireAuth() const requireAuth = useRequireAuth()
const loggedOutWarningPromptControl = useDialogControl() const loggedOutWarningPromptControl = useDialogControl()
const {sendInteraction} = useFeedFeedbackContext()
const playHaptic = useHaptics() const playHaptic = useHaptics()
const shouldShowLoggedOutWarning = React.useMemo(() => { const shouldShowLoggedOutWarning = React.useMemo(() => {
@ -85,6 +89,11 @@ let PostCtrls = ({
try { try {
if (!post.viewer?.like) { if (!post.viewer?.like) {
playHaptic() playHaptic()
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionLike',
feedContext,
})
await queueLike() await queueLike()
} else { } else {
await queueUnlike() await queueUnlike()
@ -94,13 +103,26 @@ let PostCtrls = ({
throw e throw e
} }
} }
}, [playHaptic, post.viewer?.like, queueLike, queueUnlike]) }, [
playHaptic,
post.uri,
post.viewer?.like,
queueLike,
queueUnlike,
sendInteraction,
feedContext,
])
const onRepost = useCallback(async () => { const onRepost = useCallback(async () => {
closeModal() closeModal()
try { try {
if (!post.viewer?.repost) { if (!post.viewer?.repost) {
playHaptic() playHaptic()
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionRepost',
feedContext,
})
await queueRepost() await queueRepost()
} else { } else {
await queueUnrepost() await queueUnrepost()
@ -110,10 +132,24 @@ let PostCtrls = ({
throw e throw e
} }
} }
}, [closeModal, post.viewer?.repost, playHaptic, queueRepost, queueUnrepost]) }, [
closeModal,
post.uri,
post.viewer?.repost,
playHaptic,
queueRepost,
queueUnrepost,
sendInteraction,
feedContext,
])
const onQuote = useCallback(() => { const onQuote = useCallback(() => {
closeModal() closeModal()
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionQuote',
feedContext,
})
openComposer({ openComposer({
quote: { quote: {
uri: post.uri, uri: post.uri,
@ -133,6 +169,8 @@ let PostCtrls = ({
post.indexedAt, post.indexedAt,
record.text, record.text,
playHaptic, playHaptic,
sendInteraction,
feedContext,
]) ])
const onShare = useCallback(() => { const onShare = useCallback(() => {
@ -140,7 +178,12 @@ let PostCtrls = ({
const href = makeProfileLink(post.author, 'post', urip.rkey) const href = makeProfileLink(post.author, 'post', urip.rkey)
const url = toShareUrl(href) const url = toShareUrl(href)
shareUrl(url) shareUrl(url)
}, [post.uri, post.author]) sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionShare',
feedContext,
})
}, [post.uri, post.author, sendInteraction, feedContext])
return ( return (
<View style={[styles.ctrls, style]}> <View style={[styles.ctrls, style]}>
@ -268,6 +311,7 @@ let PostCtrls = ({
postAuthor={post.author} postAuthor={post.author}
postCid={post.cid} postCid={post.cid}
postUri={post.uri} postUri={post.uri}
postFeedContext={feedContext}
record={record} record={record}
richText={richText} richText={richText}
style={styles.btnPad} style={styles.btnPad}

View File

@ -19,10 +19,12 @@ import {Text} from '../text/Text'
export const ExternalLinkEmbed = ({ export const ExternalLinkEmbed = ({
link, link,
onOpen,
style, style,
hideAlt, hideAlt,
}: { }: {
link: AppBskyEmbedExternal.ViewExternal link: AppBskyEmbedExternal.ViewExternal
onOpen?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
hideAlt?: boolean hideAlt?: boolean
}) => { }) => {
@ -44,7 +46,7 @@ export const ExternalLinkEmbed = ({
return ( return (
<View style={[a.flex_col, a.rounded_sm, a.overflow_hidden, a.mt_sm]}> <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 ? ( {link.thumb && !embedPlayerParams ? (
<Image <Image
style={{ style={{
@ -97,10 +99,12 @@ export const ExternalLinkEmbed = ({
function LinkWrapper({ function LinkWrapper({
link, link,
onOpen,
style, style,
children, children,
}: { }: {
link: AppBskyEmbedExternal.ViewExternal link: AppBskyEmbedExternal.ViewExternal
onOpen?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
children: React.ReactNode children: React.ReactNode
}) { }) {
@ -125,6 +129,7 @@ function LinkWrapper({
style, style,
]} ]}
hoverStyle={t.atoms.border_contrast_high} hoverStyle={t.atoms.border_contrast_high}
onBeforePress={onOpen}
onLongPress={onShareExternal}> onLongPress={onShareExternal}>
{children} {children}
</Link> </Link>

View File

@ -42,9 +42,11 @@ import {PostEmbeds} from '.'
export function MaybeQuoteEmbed({ export function MaybeQuoteEmbed({
embed, embed,
onOpen,
style, style,
}: { }: {
embed: AppBskyEmbedRecord.View embed: AppBskyEmbedRecord.View
onOpen?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
@ -57,6 +59,7 @@ export function MaybeQuoteEmbed({
<QuoteEmbedModerated <QuoteEmbedModerated
viewRecord={embed.record} viewRecord={embed.record}
postRecord={embed.record.value} postRecord={embed.record.value}
onOpen={onOpen}
style={style} style={style}
/> />
) )
@ -85,10 +88,12 @@ export function MaybeQuoteEmbed({
function QuoteEmbedModerated({ function QuoteEmbedModerated({
viewRecord, viewRecord,
postRecord, postRecord,
onOpen,
style, style,
}: { }: {
viewRecord: AppBskyEmbedRecord.ViewRecord viewRecord: AppBskyEmbedRecord.ViewRecord
postRecord: AppBskyFeedPost.Record postRecord: AppBskyFeedPost.Record
onOpen?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
@ -108,16 +113,25 @@ function QuoteEmbedModerated({
embeds: viewRecord.embeds, embeds: viewRecord.embeds,
} }
return <QuoteEmbed quote={quote} moderation={moderation} style={style} /> return (
<QuoteEmbed
quote={quote}
moderation={moderation}
onOpen={onOpen}
style={style}
/>
)
} }
export function QuoteEmbed({ export function QuoteEmbed({
quote, quote,
moderation, moderation,
onOpen,
style, style,
}: { }: {
quote: ComposerOptsQuote quote: ComposerOptsQuote
moderation?: ModerationDecision moderation?: ModerationDecision
onOpen?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@ -150,7 +164,8 @@ export function QuoteEmbed({
const onBeforePress = React.useCallback(() => { const onBeforePress = React.useCallback(() => {
precacheProfile(queryClient, quote.author) precacheProfile(queryClient, quote.author)
}, [queryClient, quote.author]) onOpen?.()
}, [queryClient, quote.author, onOpen])
return ( return (
<ContentHider modui={moderation?.ui('contentList')}> <ContentHider modui={moderation?.ui('contentList')}>

View File

@ -38,10 +38,12 @@ type Embed =
export function PostEmbeds({ export function PostEmbeds({
embed, embed,
moderation, moderation,
onOpen,
style, style,
}: { }: {
embed?: Embed embed?: Embed
moderation?: ModerationDecision moderation?: ModerationDecision
onOpen?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
@ -52,8 +54,12 @@ export function PostEmbeds({
if (AppBskyEmbedRecordWithMedia.isView(embed)) { if (AppBskyEmbedRecordWithMedia.isView(embed)) {
return ( return (
<View style={style}> <View style={style}>
<PostEmbeds embed={embed.media} moderation={moderation} /> <PostEmbeds
<MaybeQuoteEmbed embed={embed.record} /> embed={embed.media}
moderation={moderation}
onOpen={onOpen}
/>
<MaybeQuoteEmbed embed={embed.record} onOpen={onOpen} />
</View> </View>
) )
} }
@ -80,7 +86,7 @@ export function PostEmbeds({
// quote post // quote post
// = // =
return <MaybeQuoteEmbed embed={embed} style={style} /> return <MaybeQuoteEmbed embed={embed} style={style} onOpen={onOpen} />
} }
// image embed // image embed
@ -151,7 +157,7 @@ export function PostEmbeds({
const link = embed.external const link = embed.external
return ( return (
<ContentHider modui={moderation?.ui('contentMedia')}> <ContentHider modui={moderation?.ui('contentMedia')}>
<ExternalLinkEmbed link={link} style={style} /> <ExternalLinkEmbed link={link} onOpen={onOpen} style={style} />
</ContentHider> </ContentHider>
) )
} }

View File

@ -804,6 +804,7 @@ function MockPostFeedItem({
record={post.record as AppBskyFeedPost.Record} record={post.record as AppBskyFeedPost.Record}
moderation={moderation} moderation={moderation}
reason={undefined} reason={undefined}
feedContext={''}
/> />
) )
} }

View File

@ -10,6 +10,7 @@ import {HITSLOP_20} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {listenSoftReset} from '#/state/events' import {listenSoftReset} from '#/state/events'
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
import {FeedDescriptor} from '#/state/queries/post-feed' import {FeedDescriptor} from '#/state/queries/post-feed'
@ -462,6 +463,8 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
const [isScrolledDown, setIsScrolledDown] = React.useState(false) const [isScrolledDown, setIsScrolledDown] = React.useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const isScreenFocused = useIsFocused() const isScreenFocused = useIsFocused()
const {hasSession} = useSession()
const feedFeedback = useFeedFeedback(feed, hasSession)
const onScrollToTop = useCallback(() => { const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({ scrollElRef.current?.scrollToOffset({
@ -489,17 +492,19 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
return ( return (
<View> <View>
<Feed <FeedFeedbackProvider value={feedFeedback}>
enabled={isFocused} <Feed
feed={feed} enabled={isFocused}
pollInterval={60e3} feed={feed}
disablePoll={hasNew} pollInterval={60e3}
scrollElRef={scrollElRef} disablePoll={hasNew}
onHasNew={setHasNew} scrollElRef={scrollElRef}
onScrolledDownChange={setIsScrolledDown} onHasNew={setHasNew}
renderEmptyState={renderPostsEmpty} onScrolledDownChange={setIsScrolledDown}
headerOffset={headerHeight} renderEmptyState={renderPostsEmpty}
/> headerOffset={headerHeight}
/>
</FeedFeedbackProvider>
{(isScrolledDown || hasNew) && ( {(isScrolledDown || hasNew) && (
<LoadLatestBtn <LoadLatestBtn
onPress={onScrollToTop} onPress={onScrollToTop}

View File

@ -7729,6 +7729,13 @@
dependencies: dependencies:
"@types/lodash" "*" "@types/lodash" "*"
"@types/lodash.throttle@^4.1.9":
version "4.1.9"
resolved "https://registry.yarnpkg.com/@types/lodash.throttle/-/lodash.throttle-4.1.9.tgz#f17a6ae084f7c0117bd7df145b379537bc9615c5"
integrity sha512-PCPVfpfueguWZQB7pJQK890F2scYKoDUL3iM522AptHWn7d5NQmeS/LTEHIcLr5PaTzl3dK2Z0xSUHHTHwaL5g==
dependencies:
"@types/lodash" "*"
"@types/lodash@*": "@types/lodash@*":
version "4.14.197" version "4.14.197"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.197.tgz#e95c5ddcc814ec3e84c891910a01e0c8a378c54b" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.197.tgz#e95c5ddcc814ec3e84c891910a01e0c8a378c54b"