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) && (