diff options
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/Dialog/index.tsx | 4 | ||||
-rw-r--r-- | src/components/Grid.tsx | 59 | ||||
-rw-r--r-- | src/components/Layout/Header/index.tsx | 6 | ||||
-rw-r--r-- | src/components/LinearGradientBackground.tsx | 14 | ||||
-rw-r--r-- | src/components/Lists.tsx | 12 | ||||
-rw-r--r-- | src/components/RichText.tsx | 10 | ||||
-rw-r--r-- | src/components/VideoPostCard.tsx | 540 | ||||
-rw-r--r-- | src/components/feeds/PostFeedVideoGridRow.tsx | 67 | ||||
-rw-r--r-- | src/components/interstitials/TrendingVideos.tsx | 231 |
9 files changed, 933 insertions, 10 deletions
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index c424321be..597964e29 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -27,6 +27,7 @@ import {useA11y} from '#/state/a11y' import {useDialogStateControlContext} from '#/state/dialogs' import {List, ListMethods, ListProps} from '#/view/com/util/List' import {atoms as a, useTheme} from '#/alf' +import {useThemeName} from '#/alf/util/useColorModeTheme' import {Context, useDialogContext} from '#/components/Dialog/context' import { DialogControlProps, @@ -55,7 +56,8 @@ export function Outer({ nativeOptions, testID, }: React.PropsWithChildren<DialogOuterProps>) { - const t = useTheme() + const themeName = useThemeName() + const t = useTheme(themeName) const ref = React.useRef<BottomSheetNativeComponent>(null) const closeCallbacks = React.useRef<(() => void)[]>([]) const {setDialogIsOpen, setFullyExpandedCount} = diff --git a/src/components/Grid.tsx b/src/components/Grid.tsx new file mode 100644 index 000000000..d424634de --- /dev/null +++ b/src/components/Grid.tsx @@ -0,0 +1,59 @@ +import {createContext, useContext, useMemo} from 'react' +import {View} from 'react-native' + +import {atoms as a, ViewStyleProp} from '#/alf' + +const Context = createContext({ + gap: 0, +}) + +export function Row({ + children, + gap = 0, + style, +}: ViewStyleProp & { + children: React.ReactNode + gap?: number +}) { + return ( + <Context.Provider value={useMemo(() => ({gap}), [gap])}> + <View + style={[ + a.flex_row, + a.flex_1, + { + marginLeft: -gap / 2, + marginRight: -gap / 2, + }, + style, + ]}> + {children} + </View> + </Context.Provider> + ) +} + +export function Col({ + children, + width = 1, + style, +}: ViewStyleProp & { + children: React.ReactNode + width?: number +}) { + const {gap} = useContext(Context) + return ( + <View + style={[ + a.flex_col, + { + paddingLeft: gap / 2, + paddingRight: gap / 2, + width: `${width * 100}%`, + }, + style, + ]}> + {children} + </View> + ) +} diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx index 2d0fc149e..d38cf9d94 100644 --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -122,7 +122,11 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) { shape="square" onPress={onPressBack} hitSlop={HITSLOP_30} - style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, style]} + style={[ + {marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, + a.bg_transparent, + style, + ]} {...props}> <ButtonIcon icon={ArrowLeft} size="lg" /> </Button> diff --git a/src/components/LinearGradientBackground.tsx b/src/components/LinearGradientBackground.tsx index 724df43f3..9b28b897c 100644 --- a/src/components/LinearGradientBackground.tsx +++ b/src/components/LinearGradientBackground.tsx @@ -6,12 +6,18 @@ import {gradients} from '#/alf/tokens' export function LinearGradientBackground({ style, + gradient = 'sky', children, + start, + end, }: { - style: StyleProp<ViewStyle> - children: React.ReactNode + style?: StyleProp<ViewStyle> + gradient?: keyof typeof gradients + children?: React.ReactNode + start?: [number, number] + end?: [number, number] }) { - const gradient = gradients.sky.values.map(([_, color]) => { + const colors = gradients[gradient].values.map(([_, color]) => { return color }) as [string, string, ...string[]] @@ -20,7 +26,7 @@ export function LinearGradientBackground({ } return ( - <LinearGradient colors={gradient} style={style}> + <LinearGradient colors={colors} style={style} start={start} end={end}> {children} </LinearGradient> ) diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx index 2d7b13b25..5c602249b 100644 --- a/src/components/Lists.tsx +++ b/src/components/Lists.tsx @@ -20,6 +20,7 @@ export function ListFooter({ style, showEndMessage = false, endMessageText, + renderEndMessage, }: { isFetchingNextPage?: boolean hasNextPage?: boolean @@ -29,6 +30,7 @@ export function ListFooter({ style?: StyleProp<ViewStyle> showEndMessage?: boolean endMessageText?: string + renderEndMessage?: () => React.ReactNode }) { const t = useTheme() @@ -48,9 +50,13 @@ export function ListFooter({ ) : error ? ( <ListFooterMaybeError error={error} onRetry={onRetry} /> ) : !hasNextPage && showEndMessage ? ( - <Text style={[a.text_sm, t.atoms.text_contrast_low]}> - {endMessageText ?? <Trans>You have reached the end</Trans>} - </Text> + renderEndMessage ? ( + renderEndMessage() + ) : ( + <Text style={[a.text_sm, t.atoms.text_contrast_low]}> + {endMessageText ?? <Trans>You have reached the end</Trans>} + </Text> + ) ) : null} </View> ) diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 6d7e50e48..4edd9f88e 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -19,7 +19,7 @@ import {Text, TextProps} from '#/components/Typography' const WORD_WRAP = {wordWrap: 1} export type RichTextProps = TextStyleProp & - Pick<TextProps, 'selectable'> & { + Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & { value: RichTextAPI | string testID?: string numberOfLines?: number @@ -43,6 +43,8 @@ export function RichText({ onLinkPress, interactiveStyle, emojiMultiplier = 1.85, + onLayout, + onTextLayout, }: RichTextProps) { const richText = React.useMemo( () => @@ -70,6 +72,8 @@ export function RichText({ selectable={selectable} testID={testID} style={[plainStyles, {fontSize}]} + onLayout={onLayout} + onTextLayout={onTextLayout} // @ts-ignore web only -prf dataSet={WORD_WRAP}> {text} @@ -83,6 +87,8 @@ export function RichText({ testID={testID} style={plainStyles} numberOfLines={numberOfLines} + onLayout={onLayout} + onTextLayout={onTextLayout} // @ts-ignore web only -prf dataSet={WORD_WRAP}> {text} @@ -163,6 +169,8 @@ export function RichText({ testID={testID} style={plainStyles} numberOfLines={numberOfLines} + onLayout={onLayout} + onTextLayout={onTextLayout} // @ts-ignore web only -prf dataSet={WORD_WRAP}> {els} diff --git a/src/components/VideoPostCard.tsx b/src/components/VideoPostCard.tsx new file mode 100644 index 000000000..008274969 --- /dev/null +++ b/src/components/VideoPostCard.tsx @@ -0,0 +1,540 @@ +import {useMemo} from 'react' +import {View} from 'react-native' +import {Image} from 'expo-image' +import {LinearGradient} from 'expo-linear-gradient' +import { + AppBskyActorDefs, + AppBskyEmbedVideo, + AppBskyFeedDefs, + AppBskyFeedPost, + ModerationDecision, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeHandle} from '#/lib/strings/handles' +import {formatCount} from '#/view/com/util/numeric/format' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {VideoFeedSourceContext} from '#/screens/VideoFeed/types' +import {atoms as a, useTheme} from '#/alf' +import {BLUE_HUE} from '#/alf/util/colorGeneration' +import {select} from '#/alf/util/themeSelector' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash' +import {Heart2_Stroke2_Corner0_Rounded as Heart} from '#/components/icons/Heart2' +import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' +import {Link} from '#/components/Link' +import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import * as Hider from '#/components/moderation/Hider' +import {Text} from '#/components/Typography' + +function getBlackColor(t: ReturnType<typeof useTheme>) { + return select(t.name, { + light: t.palette.black, + dark: t.atoms.bg_contrast_25.backgroundColor, + dim: `hsl(${BLUE_HUE}, 28%, 6%)`, + }) +} + +export function VideoPostCard({ + post, + sourceContext, + moderation, + onInteract, +}: { + post: AppBskyFeedDefs.PostView + sourceContext: VideoFeedSourceContext + moderation: ModerationDecision + /** + * Callback for metrics etc + */ + onInteract?: () => void +}) { + const t = useTheme() + const {_, i18n} = useLingui() + const embed = post.embed + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + + const listModUi = moderation.ui('contentList') + + const mergedModui = useMemo(() => { + const modui = moderation.ui('contentList') + const mediaModui = moderation.ui('contentMedia') + modui.alerts = [...modui.alerts, ...mediaModui.alerts] + modui.blurs = [...modui.blurs, ...mediaModui.blurs] + modui.filters = [...modui.filters, ...mediaModui.filters] + modui.informs = [...modui.informs, ...mediaModui.informs] + return modui + }, [moderation]) + + /** + * Filtering should be done at a higher level, such as `PostFeed` or + * `PostFeedVideoGridRow`, but we need to protect here as well. + */ + if (!AppBskyEmbedVideo.isView(embed)) return null + + const author = post.author + const text = AppBskyFeedPost.isRecord(post.record) ? post.record?.text : '' + const likeCount = post?.likeCount ?? 0 + const repostCount = post?.repostCount ?? 0 + const {thumbnail} = embed + const black = getBlackColor(t) + + const textAndAuthor = ( + <View style={[a.pr_xs, {paddingTop: 6, gap: 4}]}> + {text && ( + <Text style={[a.text_md, a.leading_snug]} numberOfLines={2} emoji> + {text} + </Text> + )} + <View style={[a.flex_row, a.gap_xs, a.align_center]}> + <View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}> + <UserAvatar type="user" size={20} avatar={post.author.avatar} /> + <MediaInsetBorder /> + </View> + <Text + style={[ + a.flex_1, + a.text_sm, + a.leading_tight, + t.atoms.text_contrast_medium, + ]} + numberOfLines={1}> + {sanitizeHandle(post.author.handle, '@')} + </Text> + </View> + </View> + ) + + return ( + <Link + accessibilityHint={_(msg`Tap to view video in immersive mode.`)} + label={_(msg`Video from ${author.handle}: ${text}`)} + to={{ + screen: 'VideoFeed', + params: { + ...sourceContext, + initialPostUri: post.uri, + }, + }} + onPress={() => { + onInteract?.() + }} + onPressIn={onPressIn} + onPressOut={onPressOut} + style={[ + a.flex_col, + { + alignItems: undefined, + justifyContent: undefined, + }, + ]}> + <Hider.Outer modui={mergedModui}> + <Hider.Mask> + <View + style={[ + a.justify_center, + a.rounded_md, + a.overflow_hidden, + { + backgroundColor: black, + aspectRatio: 9 / 16, + }, + ]}> + <Image + source={{uri: thumbnail}} + style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]} + accessibilityIgnoresInvertColors + blurRadius={100} + /> + <MediaInsetBorder /> + <View + style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> + <View + style={[ + a.absolute, + a.inset_0, + a.justify_center, + a.align_center, + { + backgroundColor: 'black', + opacity: 0.2, + }, + ]} + /> + <View style={[a.align_center, a.gap_xs]}> + <Eye size="lg" fill="white" /> + <Text style={[a.text_sm, {color: 'white'}]}> + {_(msg`Hidden`)} + </Text> + </View> + </View> + </View> + {listModUi.blur ? ( + <VideoPostCardTextPlaceholder author={post.author} /> + ) : ( + textAndAuthor + )} + </Hider.Mask> + <Hider.Content> + <View + style={[ + a.justify_center, + a.rounded_md, + a.overflow_hidden, + { + backgroundColor: black, + aspectRatio: 9 / 16, + }, + ]}> + <Image + source={{uri: thumbnail}} + style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]} + accessibilityIgnoresInvertColors + /> + <MediaInsetBorder /> + + <View style={[a.absolute, a.inset_0]}> + <View + style={[ + a.absolute, + a.inset_0, + a.pt_2xl, + { + top: 'auto', + }, + ]}> + <LinearGradient + colors={[black, 'rgba(0, 0, 0, 0)']} + locations={[0.02, 1]} + start={{x: 0, y: 1}} + end={{x: 0, y: 0}} + style={[a.absolute, a.inset_0, {opacity: 0.9}]} + /> + + <View + style={[a.relative, a.z_10, a.p_md, a.flex_row, a.gap_md]}> + {likeCount > 0 && ( + <View style={[a.flex_row, a.align_center, a.gap_xs]}> + <Heart size="sm" fill="white" /> + <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}> + {formatCount(i18n, likeCount)} + </Text> + </View> + )} + {repostCount > 0 && ( + <View style={[a.flex_row, a.align_center, a.gap_xs]}> + <Repost size="sm" fill="white" /> + <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}> + {formatCount(i18n, repostCount)} + </Text> + </View> + )} + </View> + </View> + </View> + </View> + {textAndAuthor} + </Hider.Content> + </Hider.Outer> + </Link> + ) +} + +export function VideoPostCardPlaceholder() { + const t = useTheme() + const black = getBlackColor(t) + + return ( + <View style={[a.flex_1]}> + <View + style={[ + a.rounded_md, + a.overflow_hidden, + { + backgroundColor: black, + aspectRatio: 9 / 16, + }, + ]}> + <MediaInsetBorder /> + </View> + <VideoPostCardTextPlaceholder /> + </View> + ) +} + +export function VideoPostCardTextPlaceholder({ + author, +}: { + author?: AppBskyActorDefs.ProfileViewBasic +}) { + const t = useTheme() + + return ( + <View style={[a.flex_1]}> + <View style={[a.pr_xs, {paddingTop: 8, gap: 6}]}> + <View + style={[ + a.w_full, + a.rounded_xs, + t.atoms.bg_contrast_50, + { + height: 14, + }, + ]} + /> + <View + style={[ + a.w_full, + a.rounded_xs, + t.atoms.bg_contrast_50, + { + height: 14, + width: '70%', + }, + ]} + /> + {author ? ( + <View style={[a.flex_row, a.gap_xs, a.align_center]}> + <View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}> + <UserAvatar type="user" size={20} avatar={author.avatar} /> + <MediaInsetBorder /> + </View> + <Text + style={[ + a.flex_1, + a.text_sm, + a.leading_tight, + t.atoms.text_contrast_medium, + ]} + numberOfLines={1}> + {sanitizeHandle(author.handle, '@')} + </Text> + </View> + ) : ( + <View style={[a.flex_row, a.gap_xs, a.align_center]}> + <View + style={[ + a.rounded_full, + t.atoms.bg_contrast_50, + { + width: 20, + height: 20, + }, + ]} + /> + <View + style={[ + a.rounded_xs, + t.atoms.bg_contrast_25, + { + height: 12, + width: '75%', + }, + ]} + /> + </View> + )} + </View> + </View> + ) +} + +export function CompactVideoPostCard({ + post, + sourceContext, + moderation, + onInteract, +}: { + post: AppBskyFeedDefs.PostView + sourceContext: VideoFeedSourceContext + moderation: ModerationDecision + /** + * Callback for metrics etc + */ + onInteract?: () => void +}) { + const t = useTheme() + const {_, i18n} = useLingui() + const embed = post.embed + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + + const mergedModui = useMemo(() => { + const modui = moderation.ui('contentList') + const mediaModui = moderation.ui('contentMedia') + modui.alerts = [...modui.alerts, ...mediaModui.alerts] + modui.blurs = [...modui.blurs, ...mediaModui.blurs] + modui.filters = [...modui.filters, ...mediaModui.filters] + modui.informs = [...modui.informs, ...mediaModui.informs] + return modui + }, [moderation]) + + /** + * Filtering should be done at a higher level, such as `PostFeed` or + * `PostFeedVideoGridRow`, but we need to protect here as well. + */ + if (!AppBskyEmbedVideo.isView(embed)) return null + + const likeCount = post?.likeCount ?? 0 + const {thumbnail} = embed + const black = getBlackColor(t) + + return ( + <Link + label={_(msg`View video`)} + to={{ + screen: 'VideoFeed', + params: { + ...sourceContext, + initialPostUri: post.uri, + }, + }} + onPress={() => { + onInteract?.() + }} + onPressIn={onPressIn} + onPressOut={onPressOut} + style={[ + a.flex_col, + { + alignItems: undefined, + justifyContent: undefined, + }, + ]}> + <Hider.Outer modui={mergedModui}> + <Hider.Mask> + <View + style={[ + a.justify_center, + a.rounded_md, + a.overflow_hidden, + { + backgroundColor: black, + aspectRatio: 9 / 16, + }, + ]}> + <Image + source={{uri: thumbnail}} + style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]} + accessibilityIgnoresInvertColors + blurRadius={100} + /> + <MediaInsetBorder /> + <View + style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> + <View + style={[ + a.absolute, + a.inset_0, + a.justify_center, + a.align_center, + { + backgroundColor: 'black', + opacity: 0.2, + }, + ]} + /> + <View style={[a.align_center, a.gap_xs]}> + <Eye size="lg" fill="white" /> + <Text style={[a.text_sm, {color: 'white'}]}> + {_(msg`Hidden`)} + </Text> + </View> + </View> + </View> + </Hider.Mask> + <Hider.Content> + <View + style={[ + a.justify_center, + a.rounded_md, + a.overflow_hidden, + { + backgroundColor: black, + aspectRatio: 9 / 16, + }, + ]}> + <Image + source={{uri: thumbnail}} + style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]} + accessibilityIgnoresInvertColors + /> + <MediaInsetBorder /> + + <View style={[a.absolute, a.inset_0]}> + <View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}> + <View + style={[a.relative, a.rounded_full, {width: 20, height: 20}]}> + <UserAvatar + type="user" + size={20} + avatar={post.author.avatar} + /> + <MediaInsetBorder /> + </View> + </View> + <View + style={[ + a.absolute, + a.inset_0, + a.pt_2xl, + { + top: 'auto', + }, + ]}> + <LinearGradient + colors={[black, 'rgba(0, 0, 0, 0)']} + locations={[0.02, 1]} + start={{x: 0, y: 1}} + end={{x: 0, y: 0}} + style={[a.absolute, a.inset_0, {opacity: 0.9}]} + /> + + <View + style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}> + {likeCount > 0 && ( + <View style={[a.flex_row, a.align_center, a.gap_xs]}> + <Heart size="sm" fill="white" /> + <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}> + {formatCount(i18n, likeCount)} + </Text> + </View> + )} + </View> + </View> + </View> + </View> + </Hider.Content> + </Hider.Outer> + </Link> + ) +} + +export function CompactVideoPostCardPlaceholder() { + const t = useTheme() + const black = getBlackColor(t) + + return ( + <View style={[a.flex_1]}> + <View + style={[ + a.rounded_md, + a.overflow_hidden, + { + backgroundColor: black, + aspectRatio: 9 / 16, + }, + ]}> + <MediaInsetBorder /> + </View> + </View> + ) +} diff --git a/src/components/feeds/PostFeedVideoGridRow.tsx b/src/components/feeds/PostFeedVideoGridRow.tsx new file mode 100644 index 000000000..7f9898083 --- /dev/null +++ b/src/components/feeds/PostFeedVideoGridRow.tsx @@ -0,0 +1,67 @@ +import {View} from 'react-native' +import {AppBskyEmbedVideo} from '@atproto/api' + +import {logEvent} from '#/lib/statsig/statsig' +import {FeedPostSliceItem} from '#/state/queries/post-feed' +import {VideoFeedSourceContext} from '#/screens/VideoFeed/types' +import {atoms as a, useGutters} from '#/alf' +import * as Grid from '#/components/Grid' +import { + VideoPostCard, + VideoPostCardPlaceholder, +} from '#/components/VideoPostCard' + +export function PostFeedVideoGridRow({ + items: slices, + sourceContext, +}: { + items: FeedPostSliceItem[] + sourceContext: VideoFeedSourceContext +}) { + const gutters = useGutters(['base', 'base', 0, 'base']) + const posts = slices + .filter(slice => AppBskyEmbedVideo.isView(slice.post.embed)) + .map(slice => ({ + post: slice.post, + moderation: slice.moderation, + })) + + /** + * This should not happen because we should be filtering out posts without + * videos within the `PostFeed` component. + */ + if (posts.length !== slices.length) return null + + return ( + <View style={[gutters]}> + <View style={[a.flex_row, a.gap_sm]}> + <Grid.Row gap={a.gap_sm.gap}> + {posts.map(post => ( + <Grid.Col key={post.post.uri} width={1 / 2}> + <VideoPostCard + post={post.post} + sourceContext={sourceContext} + moderation={post.moderation} + onInteract={() => { + logEvent('videoCard:click', {context: 'feed'}) + }} + /> + </Grid.Col> + ))} + </Grid.Row> + </View> + </View> + ) +} + +export function PostFeedVideoGridRowPlaceholder() { + const gutters = useGutters(['base', 'base', 0, 'base']) + return ( + <View style={[gutters]}> + <View style={[a.flex_row, a.gap_sm]}> + <VideoPostCardPlaceholder /> + <VideoPostCardPlaceholder /> + </View> + </View> + ) +} diff --git a/src/components/interstitials/TrendingVideos.tsx b/src/components/interstitials/TrendingVideos.tsx new file mode 100644 index 000000000..126d6f417 --- /dev/null +++ b/src/components/interstitials/TrendingVideos.tsx @@ -0,0 +1,231 @@ +import React, {useEffect} from 'react' +import {ScrollView, View} from 'react-native' +import {AppBskyEmbedVideo, AtUri} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' + +import {VIDEO_FEED_URI} from '#/lib/constants' +import {makeCustomFeedLink} from '#/lib/routes/links' +import {logEvent} from '#/lib/statsig/statsig' +import {useTrendingSettingsApi} from '#/state/preferences/trending' +import {usePostFeedQuery} from '#/state/queries/post-feed' +import {RQKEY} from '#/state/queries/post-feed' +import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' +import {atoms as a, useGutters, useTheme} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' +import {Link} from '#/components/Link' +import * as Prompt from '#/components/Prompt' +import {Text} from '#/components/Typography' +import { + CompactVideoPostCard, + CompactVideoPostCardPlaceholder, +} from '#/components/VideoPostCard' + +const CARD_WIDTH = 100 + +const FEED_DESC = `feedgen|${VIDEO_FEED_URI}` +const FEED_PARAMS: { + feedCacheKey: 'discover' +} = { + feedCacheKey: 'discover', +} + +export function TrendingVideos() { + const t = useTheme() + const {_} = useLingui() + const gutters = useGutters([0, 'base']) + const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) + + // Refetch on unmount if nothing else is using this query. + const queryClient = useQueryClient() + useEffect(() => { + return () => { + const query = queryClient + .getQueryCache() + .find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)}) + if (query && query.getObserversCount() <= 1) { + query.fetch() + } + } + }, [queryClient]) + + const {setTrendingVideoDisabled} = useTrendingSettingsApi() + const trendingPrompt = Prompt.usePromptControl() + + const onConfirmHide = React.useCallback(() => { + setTrendingVideoDisabled(true) + logEvent('trendingVideos:hide', {context: 'interstitial:discover'}) + }, [setTrendingVideoDisabled]) + + if (error) { + return null + } + + return ( + <View + style={[ + a.pt_lg, + a.pb_lg, + a.border_t, + t.atoms.border_contrast_low, + t.atoms.bg_contrast_25, + ]}> + <View + style={[ + gutters, + a.pb_sm, + a.flex_row, + a.align_center, + a.justify_between, + ]}> + <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_xs]}> + <Graph /> + <Text style={[a.text_md, a.font_bold, a.leading_snug]}> + <Trans>Trending Videos</Trans> + </Text> + </View> + <Button + label={_(msg`Dismiss this section`)} + size="tiny" + variant="ghost" + color="secondary" + shape="round" + onPress={() => trendingPrompt.open()}> + <ButtonIcon icon={X} /> + </Button> + </View> + + <BlockDrawerGesture> + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + decelerationRate="fast" + snapToInterval={CARD_WIDTH + a.gap_sm.gap}> + <View + style={[ + a.flex_row, + a.gap_sm, + { + paddingLeft: gutters.paddingLeft, + paddingRight: gutters.paddingRight, + }, + ]}> + {isLoading ? ( + Array(10) + .fill(0) + .map((_, i) => ( + <View key={i} style={[{width: CARD_WIDTH}]}> + <CompactVideoPostCardPlaceholder /> + </View> + )) + ) : error || !data ? ( + <Text> + <Trans>Whoops! Trending videos failed to load.</Trans> + </Text> + ) : ( + <VideoCards data={data} /> + )} + </View> + </ScrollView> + </BlockDrawerGesture> + + <Prompt.Basic + control={trendingPrompt} + title={_(msg`Hide trending videos?`)} + description={_(msg`You can update this later from your settings.`)} + confirmButtonCta={_(msg`Hide`)} + onConfirm={onConfirmHide} + /> + </View> + ) +} + +function VideoCards({ + data, +}: { + data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined> +}) { + const t = useTheme() + const {_} = useLingui() + const items = React.useMemo(() => { + return data.pages + .flatMap(page => page.slices) + .map(slice => slice.items[0]) + .filter(Boolean) + .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) + .slice(0, 8) + }, [data]) + const href = React.useMemo(() => { + const urip = new AtUri(VIDEO_FEED_URI) + return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'discover') + }, []) + + return ( + <> + {items.map(item => ( + <View key={item.post.uri} style={[{width: CARD_WIDTH}]}> + <CompactVideoPostCard + post={item.post} + moderation={item.moderation} + sourceContext={{ + type: 'feedgen', + uri: VIDEO_FEED_URI, + sourceInterstitial: 'discover', + }} + onInteract={() => { + logEvent('videoCard:click', { + context: 'interstitial:discover', + }) + }} + /> + </View> + ))} + + <View style={[{width: CARD_WIDTH * 2}]}> + <Link + to={href} + label={_(msg`View more`)} + style={[ + a.justify_center, + a.align_center, + a.flex_1, + a.rounded_md, + t.atoms.bg, + ]}> + {({pressed}) => ( + <View + style={[ + a.flex_row, + a.align_center, + a.gap_md, + { + opacity: pressed ? 0.6 : 1, + }, + ]}> + <Text style={[a.text_md]}> + <Trans>View more</Trans> + </Text> + <View + style={[ + a.align_center, + a.justify_center, + a.rounded_full, + { + width: 34, + height: 34, + backgroundColor: t.palette.primary_500, + }, + ]}> + <ButtonIcon icon={ChevronRight} /> + </View> + </View> + )} + </Link> + </View> + </> + ) +} |