diff options
Diffstat (limited to 'src/view/com/util')
-rw-r--r-- | src/view/com/util/Link.tsx | 2 | ||||
-rw-r--r-- | src/view/com/util/List.tsx | 25 | ||||
-rw-r--r-- | src/view/com/util/List.web.tsx | 77 | ||||
-rw-r--r-- | src/view/com/util/PostMeta.tsx | 14 | ||||
-rw-r--r-- | src/view/com/util/UserAvatar.tsx | 5 | ||||
-rw-r--r-- | src/view/com/util/forms/PostDropdownBtn.tsx | 51 | ||||
-rw-r--r-- | src/view/com/util/post-ctrls/PostCtrls.tsx | 50 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/ExternalLinkEmbed.tsx | 7 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/QuoteEmbed.tsx | 19 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/index.tsx | 14 |
10 files changed, 235 insertions, 29 deletions
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 78d995ee8..df82124f9 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 84b401e63..194f81c5c 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<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} /> diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 9bea2d795..b6ecf02ec 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -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> ) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index e7ce18535..c0e4d8099 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<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> diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 45327669b..83c61a4f2 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -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}> diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index ac97f3da2..7a62ce7cb 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<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 && ( + {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 diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 7ebcde9a0..b6c07d573 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -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} diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index b84c04b83..3b2a12c24 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<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> diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index 0e19a6ccd..57f1d28ba 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<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')}> diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 7ea5b55cf..eb9732ee8 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<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> ) } |