diff options
author | Hailey <me@haileyok.com> | 2025-01-19 17:17:41 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-01-19 17:17:41 -0800 |
commit | 34582edf3ea17789684100172d6dd496220482b0 (patch) | |
tree | ca29a927bf015107a60a867d8266274c8a78de49 /src | |
parent | cb020655504dd0d39f8e91fd517f14dc4a82c307 (diff) | |
download | voidsky-34582edf3ea17789684100172d6dd496220482b0.tar.zst |
yolo (#7499)
* tweaks to constants (#7478) * add did * use correct did * typo * tweak * Prevent Drawer gesture conflicting with Suggestions scroll (#7468) * Extract BlockDrawerGeesture * Block drawer when scrolling interstitials (cherry picked from commit 9e3f2f43745eed9c71cb985e48135b7363d91aa9) * yolo interstitial * yolo mode * right swipe * fix nav gesture * vibe controls * collapsible post text * rm blurview, cover for tall videos * smarter video source handling * use thumbnails, improve perf significantly * better android loading * improve aspect ratio * optimize source changes * rm spinner on ios * whoops, remove debug `false` * FIX WRONG VIDEOS SHOWING UP * don't spring on way down * release video players when leaving screen * remove jank animation * Add grid * improve contract, fix double tap * Filter out posts without videos * Only do grid on native * Pipe through feedSourceUri and link to feed * Handle passed through params * Partial revert, just filter posts to start at index * Clean up cards, remove entry interstitial * Tweak handle * Change constant name * Rename some things * Make types legit * Clean up more naming * Add placeholder for grid view * Handle web, set up new organization * Begin work on Header * Replace types * Squashed commit of the following: commit 3d1be4c0f19789dd3c5a3572ec1acd744a2edb80 Author: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 01:08:05 2025 +0000 extend animation commit c9f199413b018efcbd9d8d2a58dd05eb41e7acb7 Author: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 01:01:24 2025 +0000 fix gap commit 22e520795f50efda176f21a5e967cb27d0cdd907 Author: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 00:50:16 2025 +0000 thinner bar, format time commit c32427f21405294ed3567545629a2964c4af59fe Author: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 00:47:57 2025 +0000 fix 2 in 3 screens commit cbf84c08d64ca0a08ba9070ef5db918f89aa4296 Author: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 00:45:46 2025 +0000 rm unneeded var commit 7e0e100177bb1cd0e64c0841bb7685c7f1eb857f Author: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 00:41:18 2025 +0000 scrubberrrrr * use white with opacity rather than gray * Simultaneous gesture * cleanup attempt * fix jank * link to profile on press * fix jitter fr this time * mostly fix android flicker * Maybe fix row generation * Add content hider to video card * emoji in post text * reduce update rate * fix type error * Fix grid layout trailing single item * Add Discover interstitial, settings, includes pin for now * Explore interstitial, handle dimissal, pinning, compact card * Only use grid placeholder on native * Update events * Add feature gate * android nav bar fixes + lower update speed * fix interval + decel rate on interstitials * attempt to fix broken scrub on android (not working) * follow button * Part out the interstitials for perf, add view more * Remove prod web route * Wrap interstitials with BlockDrawerGesture * Bring video cropping in line with images (#7462) * Mimic image cropping for videos on web * Same on native * Rename variables for clarity * Fix Android scrubbing * Add FeedFeedbackProvider * Remove swipe gesture * fix light status bar behaviour * bump * feedback * Copy pasta to new location * Copy pasta part deux * Filter only videos * Make whole text clickable to expand (cherry picked from commit 4cf31120779f4e06eb4c296b3d4b53814d432b07) * move scrubber to own file * end card * add icon to end card * add min view time to viewability config * play haptic on like * tweak feedback * tweak feedback again * Moderation (cherry picked from commit 6b6b471cfb363031284b3e7a1f6e0ade3ac4ae47) * remove bad check * fix feedback for new video grid * change prop name to items as well * Simplify logic * Fix mod footer * Give scrubber more space on android * Add subtle track behind scrubber, adjust opacity * wire in feed context again... * Add better a11y desc to card * Fix key issue * Update a11y copy * Fix scrubber height * improve scrubber animation * Make follow button more obvious * Make header back button more clear * Disable interactions with actual video el * keep content away from the bottom safe area * fix blur * fix moderation issue * improve contrast on mod screen * Make moderation static per item * Memoize rows * Optimizations * Take video moderation into account * Only blur titles for list blur * Change copy * Bump blur radius * animate text in both directions * Rm unused field * Filter by root early * Refactor for clarity * add compose prompt to scrubber * rm log * tweak gradient * Bump SDK, use contentMode to power video feed * Ensure ProfileFeed view also supports video feed * improve scrubber on android * rm border from footer * Update prod video feed did * Separate caches * Add lil hover to View More * Fix undefined logic, remove header for interstitial * Ungate * Fix stuckness * remove extra useless map * Fix effect cleanup * Send seen without cleanup * Simplify react stuff * Earlier early return to avoid loading flash * remove scrubber placeholder * Remove opacity hack * Render useEvent conditionally * Fix Android flash --------- Co-authored-by: dan <dan.abramov@gmail.com> Co-authored-by: Samuel Newman <mozzius@protonmail.com> Co-authored-by: Eric Bailey <git@esb.lol>
Diffstat (limited to 'src')
36 files changed, 3126 insertions, 116 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 18705c5ff..a6332c5d8 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -86,6 +86,7 @@ import { StarterPackScreenShort, } from '#/screens/StarterPack/StarterPackScreen' import {Wizard} from '#/screens/StarterPack/Wizard' +import {VideoFeed} from '#/screens/VideoFeed' import {useTheme} from '#/alf' import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' @@ -422,6 +423,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => Wizard} options={{title: title(msg`Edit your starter pack`), requireAuth: true}} /> + <Stack.Screen + name="VideoFeed" + getComponent={() => VideoFeed} + options={{ + title: title(msg`Video Feed`), + requireAuth: true, + }} + /> </> ) } diff --git a/src/alf/themes.ts b/src/alf/themes.ts index cb97a7065..82b2e1b40 100644 --- a/src/alf/themes.ts +++ b/src/alf/themes.ts @@ -497,7 +497,7 @@ export function createThemes({ color: dimPalette.contrast_400, }, text_contrast_medium: { - color: dimPalette.contrast_700, + color: dimPalette.contrast_600, }, text_contrast_high: { color: dimPalette.contrast_900, 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> + </> + ) +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 5ae000f72..945e61c99 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -124,6 +124,11 @@ export const BSKY_FEED_OWNER_DIDS = [ export const DISCOVER_FEED_URI = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot' +export const VIDEO_FEED_URI = + 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/thevids' +export const STAGING_VIDEO_FEED_URI = + 'at://did:plc:yofh3kx63drvfljkibw5zuxo/app.bsky.feed.generator/thevids' +export const VIDEO_FEED_URIS = [VIDEO_FEED_URI, STAGING_VIDEO_FEED_URI] export const DISCOVER_SAVED_FEED = { type: 'feed', value: DISCOVER_FEED_URI, diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts index 8a9950262..10c99b62d 100644 --- a/src/lib/routes/links.ts +++ b/src/lib/routes/links.ts @@ -19,9 +19,13 @@ export function makeProfileLink( export function makeCustomFeedLink( did: string, rkey: string, - ...segments: string[] + segment?: string | undefined, + feedCacheKey?: 'discover' | 'explore' | undefined, ) { - return [`/profile`, did, 'feed', rkey, ...segments].join('/') + return ( + [`/profile`, did, 'feed', rkey, ...(segment ? [segment] : [])].join('/') + + (feedCacheKey ? `?feedCacheKey=${encodeURIComponent(feedCacheKey)}` : '') + ) } export function makeListLink(did: string, rkey: string, ...segments: string[]) { diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index d720886e9..66ee7bffa 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -1,6 +1,8 @@ import {NavigationState, PartialState} from '@react-navigation/native' import type {NativeStackNavigationProp} from '@react-navigation/native-stack' +import {VideoFeedSourceContext} from '#/screens/VideoFeed/types' + export type {NativeStackScreenProps} from '@react-navigation/native-stack' export type CommonNavigatorParams = { @@ -20,7 +22,11 @@ export type CommonNavigatorParams = { PostLikedBy: {name: string; rkey: string} PostRepostedBy: {name: string; rkey: string} PostQuotes: {name: string; rkey: string} - ProfileFeed: {name: string; rkey: string} + ProfileFeed: { + name: string + rkey: string + feedCacheKey?: 'discover' | 'explore' | undefined + } ProfileFeedLikedBy: {name: string; rkey: string} ProfileLabelerLikedBy: {name: string} Debug: undefined @@ -57,6 +63,7 @@ export type CommonNavigatorParams = { StarterPackShort: {code: string} StarterPackWizard: undefined StarterPackEdit: {rkey?: string} + VideoFeed: VideoFeedSourceContext } export type BottomTabNavigatorParams = CommonNavigatorParams & { diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 19bf06ba9..af759e94e 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -131,16 +131,16 @@ export type LogEvents = { doesPosterFollowLiker: boolean | undefined likerClout: number | undefined postClout: number | undefined - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' } 'post:repost': { - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' } 'post:unlike': { - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' } 'post:unrepost': { - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' } 'post:mute': {} 'post:unmute': {} @@ -163,6 +163,7 @@ export type LogEvents = { | 'FeedInterstitial' | 'ProfileHeaderSuggestedFollows' | 'PostOnboardingFindFollows' + | 'ImmersiveVideo' } 'profile:unfollow': { logContext: @@ -179,6 +180,7 @@ export type LogEvents = { | 'FeedInterstitial' | 'ProfileHeaderSuggestedFollows' | 'PostOnboardingFindFollows' + | 'ImmersiveVideo' } 'chat:create': { logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' @@ -249,6 +251,15 @@ export type LogEvents = { 'recommendedTopic:click': { context: 'explore' } + 'trendingVideos:show': { + context: 'settings' + } + 'trendingVideos:hide': { + context: 'settings' | 'interstitial:discover' | 'interstitial:explore' + } + 'videoCard:click': { + context: 'interstitial:discover' | 'interstitial:explore' | 'feed' + } 'progressGuide:hide': {} 'progressGuide:followDialog:open': {} diff --git a/src/routes.ts b/src/routes.ts index 7cd7c0880..8541d4254 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -64,4 +64,5 @@ export const router = new Router({ StarterPack: '/starter-pack/:name/:rkey', StarterPackShort: '/starter-pack-short/:code', StarterPackWizard: '/starter-pack/create', + VideoFeed: '/video-feed', }) diff --git a/src/screens/Profile/ProfileFeed/index.tsx b/src/screens/Profile/ProfileFeed/index.tsx index 3a8686a7d..8751ba3d9 100644 --- a/src/screens/Profile/ProfileFeed/index.tsx +++ b/src/screens/Profile/ProfileFeed/index.tsx @@ -1,12 +1,14 @@ import React, {useCallback, useMemo} from 'react' import {StyleSheet, View} from 'react-native' import {useAnimatedRef} from 'react-native-reanimated' +import {AppBskyFeedDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useIsFocused, useNavigation} from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {useQueryClient} from '@tanstack/react-query' +import {VIDEO_FEED_URIS} from '#/lib/constants' import {usePalette} from '#/lib/hooks/usePalette' import {useSetTitle} from '#/lib/hooks/useSetTitle' import {ComposeIcon2} from '#/lib/icons' @@ -18,7 +20,7 @@ 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 {FeedDescriptor} from '#/state/queries/post-feed' +import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import { usePreferencesQuery, @@ -46,6 +48,11 @@ type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'> export function ProfileFeedScreen(props: Props) { const {rkey, name: handleOrDid} = props.route.params + const feedParams: FeedParams | undefined = props.route.params.feedCacheKey + ? { + feedCacheKey: props.route.params.feedCacheKey, + } + : undefined const pal = usePalette('default') const {_} = useLingui() const navigation = useNavigation<NavigationProp>() @@ -96,7 +103,10 @@ export function ProfileFeedScreen(props: Props) { return resolvedUri ? ( <Layout.Screen> - <ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} /> + <ProfileFeedScreenIntermediate + feedUri={resolvedUri.uri} + feedParams={feedParams} + /> </Layout.Screen> ) : ( <Layout.Screen> @@ -108,7 +118,13 @@ export function ProfileFeedScreen(props: Props) { ) } -function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { +function ProfileFeedScreenIntermediate({ + feedUri, + feedParams, +}: { + feedUri: string + feedParams: FeedParams | undefined +}) { const {data: preferences} = usePreferencesQuery() const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) @@ -125,15 +141,18 @@ function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { <ProfileFeedScreenInner preferences={preferences} feedInfo={info as FeedSourceFeedInfo} + feedParams={feedParams} /> ) } export function ProfileFeedScreenInner({ feedInfo, + feedParams, }: { preferences: UsePreferencesQueryResponse feedInfo: FeedSourceFeedInfo + feedParams: FeedParams | undefined }) { const {_} = useLingui() const {hasSession} = useSession() @@ -170,6 +189,14 @@ export function ProfileFeedScreenInner({ return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} /> }, [_]) + const isVideoFeed = React.useMemo(() => { + const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri) + const feedIsVideoMode = + feedInfo.contentMode === AppBskyFeedDefs.CONTENTMODEVIDEO + const _isVideoFeed = isBskyVideoFeed || feedIsVideoMode + return isNative && _isVideoFeed + }, [feedInfo]) + return ( <> <ProfileFeedHeader info={feedInfo} /> @@ -177,12 +204,14 @@ export function ProfileFeedScreenInner({ <FeedFeedbackProvider value={feedFeedback}> <PostFeed feed={feed} + feedParams={feedParams} pollInterval={60e3} disablePoll={hasNew} onHasNew={setHasNew} scrollElRef={scrollElRef} onScrolledDownChange={setIsScrolledDown} renderEmptyState={renderPostsEmpty} + isVideoFeed={isVideoFeed} /> </FeedFeedbackProvider> diff --git a/src/screens/Search/components/ExploreTrendingVideos.tsx b/src/screens/Search/components/ExploreTrendingVideos.tsx new file mode 100644 index 000000000..daceb9acd --- /dev/null +++ b/src/screens/Search/components/ExploreTrendingVideos.tsx @@ -0,0 +1,271 @@ +import React 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 {useFocusEffect} from '@react-navigation/native' +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 {isWeb} from '#/platform/detection' +import {useSavedFeeds} from '#/state/queries/feed' +import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed' +import {useAddSavedFeedsMutation} from '#/state/queries/preferences' +import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' +import {atoms as a, tokens, useGutters, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {GradientFill} from '#/components/GradientFill' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {Pin_Stroke2_Corner0_Rounded as Pin} from '#/components/icons/Pin' +import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' +import {Link} from '#/components/Link' +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: 'explore' +} = { + feedCacheKey: 'explore', +} + +export function ExploreTrendingVideos() { + const t = useTheme() + const {_} = useLingui() + const gutters = useGutters([0, 'base']) + const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) + + // Refetch on tab change if nothing else is using this query. + const queryClient = useQueryClient() + useFocusEffect(() => { + return () => { + const query = queryClient + .getQueryCache() + .find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)}) + if (query && query.getObserversCount() <= 1) { + query.fetch() + } + } + }) + + const {data: saved} = useSavedFeeds() + const isSavedAlready = React.useMemo(() => { + return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI) + }, [saved]) + + const {mutateAsync: addSavedFeeds, isPending: isPinPending} = + useAddSavedFeedsMutation() + const pinFeed = React.useCallback( + (e: any) => { + e.preventDefault() + + addSavedFeeds([ + { + type: 'feed', + value: VIDEO_FEED_URI, + pinned: true, + }, + ]) + + // prevent navigation + return false + }, + [addSavedFeeds], + ) + + if (error) { + return null + } + + return ( + <View style={[a.pb_xl]}> + <View + style={[ + a.flex_row, + isWeb + ? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md] + : [a.p_lg, a.pt_xl, a.gap_md], + a.border_b, + t.atoms.border_contrast_low, + ]}> + <View style={[a.flex_1, a.gap_sm]}> + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + <Graph + size="lg" + fill={t.palette.primary_500} + style={{marginLeft: -2}} + /> + <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}> + <Trans>Trending Videos</Trans> + </Text> + <View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}> + <GradientFill gradient={tokens.gradients.primary} /> + <Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}> + <Trans>BETA</Trans> + </Text> + </View> + </View> + <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> + <Trans>Popular videos in your network.</Trans> + </Text> + </View> + </View> + + <BlockDrawerGesture> + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + decelerationRate="fast" + snapToInterval={CARD_WIDTH + tokens.space.sm}> + <View + style={[ + a.pt_lg, + 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> + + {!isSavedAlready && ( + <View + style={[ + gutters, + a.pt_lg, + a.flex_row, + a.align_center, + a.justify_between, + a.gap_xl, + ]}> + <Text style={[a.flex_1, a.text_sm, a.leading_snug]}> + <Trans> + Pin the trending videos feed to your home screen for easy access + </Trans> + </Text> + <Button + disabled={isPinPending} + label={_(msg`Pin`)} + size="small" + variant="outline" + color="secondary" + onPress={pinFeed}> + <ButtonText>{_(msg`Pin`)}</ButtonText> + <ButtonIcon icon={Pin} position="right" /> + </Button> + </View> + )} + </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, 'explore') + }, []) + + 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: 'explore', + }} + 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_contrast_25, + ]}> + {({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> + </> + ) +} diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx index 4a9354bb8..e28c98803 100644 --- a/src/screens/Settings/ContentAndMediaSettings.tsx +++ b/src/screens/Settings/ContentAndMediaSettings.tsx @@ -37,8 +37,9 @@ export function ContentAndMediaSettingsScreen({}: Props) { const inAppBrowserPref = useInAppBrowser() const setUseInAppBrowser = useSetInAppBrowser() const {enabled: trendingEnabled} = useTrendingConfig() - const {trendingDisabled} = useTrendingSettings() - const {setTrendingDisabled} = useTrendingSettingsApi() + const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings() + const {setTrendingDisabled, setTrendingVideoDisabled} = + useTrendingSettingsApi() return ( <Layout.Screen> @@ -138,6 +139,27 @@ export function ContentAndMediaSettingsScreen({}: Props) { <Toggle.Platform /> </SettingsList.Item> </Toggle.Item> + <Toggle.Item + name="show_trending_videos" + label={_(msg`Enable trending videos in your Discover feed.`)} + value={!trendingVideoDisabled} + onChange={value => { + const hide = Boolean(!value) + if (hide) { + logEvent('trendingVideos:hide', {context: 'settings'}) + } else { + logEvent('trendingVideos:show', {context: 'settings'}) + } + setTrendingVideoDisabled(hide) + }}> + <SettingsList.Item> + <SettingsList.ItemIcon icon={Graph} /> + <SettingsList.ItemText> + <Trans>Enable trending videos in your Discover feed</Trans> + </SettingsList.ItemText> + <Toggle.Platform /> + </SettingsList.Item> + </Toggle.Item> </> )} </SettingsList.Container> diff --git a/src/screens/VideoFeed/components/Header.tsx b/src/screens/VideoFeed/components/Header.tsx new file mode 100644 index 000000000..66c932119 --- /dev/null +++ b/src/screens/VideoFeed/components/Header.tsx @@ -0,0 +1,180 @@ +import {useCallback} from 'react' +import {GestureResponderEvent, View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {HITSLOP_30} from '#/lib/constants' +import {NavigationProp} from '#/lib/routes/types' +import {sanitizeHandle} from '#/lib/strings/handles' +import {useFeedSourceInfoQuery} from '#/state/queries/feed' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {VideoFeedSourceContext} from '#/screens/VideoFeed/types' +import {atoms as a, useBreakpoints} from '#/alf' +import {Button, ButtonProps} from '#/components/Button' +import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow' +import * as Layout from '#/components/Layout' +import {BUTTON_VISUAL_ALIGNMENT_OFFSET} from '#/components/Layout/const' +import {Text} from '#/components/Typography' + +export function HeaderPlaceholder() { + return ( + <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> + <View + style={[ + a.rounded_sm, + { + width: 36, + height: 36, + backgroundColor: 'white', + opacity: 0.8, + }, + ]} + /> + + <View style={[a.flex_1, a.gap_xs]}> + <View + style={[ + a.w_full, + a.rounded_xs, + { + backgroundColor: 'white', + height: 14, + width: 80, + opacity: 0.8, + }, + ]} + /> + <View + style={[ + a.w_full, + a.rounded_xs, + { + backgroundColor: 'white', + height: 10, + width: 140, + opacity: 0.6, + }, + ]} + /> + </View> + </View> + ) +} + +export function Header({ + sourceContext, +}: { + sourceContext: VideoFeedSourceContext +}) { + let content = null + switch (sourceContext.type) { + case 'feedgen': { + content = <FeedHeader sourceContext={sourceContext} /> + break + } + case 'author': + // TODO + default: { + break + } + } + + return ( + <Layout.Header.Outer noBottomBorder> + <BackButton /> + <Layout.Header.Content align="left">{content}</Layout.Header.Content> + </Layout.Header.Outer> + ) +} + +export function FeedHeader({ + sourceContext, +}: { + sourceContext: Exclude<VideoFeedSourceContext, {type: 'author'}> +}) { + const {gtMobile} = useBreakpoints() + + const { + data: info, + isLoading, + error, + } = useFeedSourceInfoQuery({uri: sourceContext.uri}) + + if (sourceContext.sourceInterstitial !== undefined) { + // For now, don't show the header if coming from an interstitial. + return null + } + + if (isLoading) { + return <HeaderPlaceholder /> + } else if (error || !info) { + return null + } + + return ( + <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> + {info.avatar && <UserAvatar size={36} type="algo" avatar={info.avatar} />} + + <View style={[a.flex_1]}> + <Text + style={[ + a.text_md, + a.font_heavy, + a.leading_tight, + gtMobile && a.text_lg, + ]} + numberOfLines={2}> + {info.displayName} + </Text> + <View style={[a.flex_row, {gap: 6}]}> + <Text + style={[a.flex_shrink, a.text_sm, a.leading_snug]} + numberOfLines={1}> + {sanitizeHandle(info.creatorHandle, '@')} + </Text> + </View> + </View> + </View> + ) +} + +// TODO: This customization should be a part of the layout component +export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) { + const {_} = useLingui() + const navigation = useNavigation<NavigationProp>() + + const onPressBack = useCallback( + (evt: GestureResponderEvent) => { + onPress?.(evt) + if (evt.defaultPrevented) return + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, + [onPress, navigation], + ) + + return ( + <Layout.Header.Slot> + <Button + label={_(msg`Go back`)} + size="small" + variant="ghost" + color="secondary" + shape="round" + onPress={onPressBack} + hitSlop={HITSLOP_30} + style={[ + {marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, + a.bg_transparent, + style, + ]} + {...props}> + <ArrowLeft size="lg" fill="white" /> + </Button> + </Layout.Header.Slot> + ) +} diff --git a/src/screens/VideoFeed/components/Scrubber.tsx b/src/screens/VideoFeed/components/Scrubber.tsx new file mode 100644 index 000000000..ef3190526 --- /dev/null +++ b/src/screens/VideoFeed/components/Scrubber.tsx @@ -0,0 +1,265 @@ +import {useCallback, useMemo, useState} from 'react' +import {View} from 'react-native' +import { + Gesture, + GestureDetector, + NativeGesture, +} from 'react-native-gesture-handler' +import Animated, { + interpolate, + runOnJS, + runOnUI, + SharedValue, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' +import { + useSafeAreaFrame, + useSafeAreaInsets, +} from 'react-native-safe-area-context' +import {useEventListener} from 'expo' +import {VideoPlayer} from 'expo-video' + +import {formatTime} from '#/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils' +import {tokens} from '#/alf' +import {atoms as a} from '#/alf' +import {Text} from '#/components/Typography' + +// magic number that is roughly the min height of the write reply button +// we inset the video by this amount +export const VIDEO_PLAYER_BOTTOM_INSET = 57 + +export function Scrubber({ + active, + player, + seekingAnimationSV, + scrollGesture, + children, +}: { + active: boolean + player?: VideoPlayer + seekingAnimationSV: SharedValue<number> + scrollGesture: NativeGesture + children?: React.ReactNode +}) { + const {width: screenWidth} = useSafeAreaFrame() + const insets = useSafeAreaInsets() + const currentTimeSV = useSharedValue(0) + const durationSV = useSharedValue(0) + const [currentSeekTime, setCurrentSeekTime] = useState(0) + const [duration, setDuration] = useState(0) + + const updateTime = (currentTime: number, duration: number) => { + 'worklet' + currentTimeSV.set(currentTime) + if (duration !== 0) { + durationSV.set(duration) + } + } + + const isSeekingSV = useSharedValue(false) + const seekProgressSV = useSharedValue(0) + + useAnimatedReaction( + () => Math.round(seekProgressSV.get()), + (progress, prevProgress) => { + if (progress !== prevProgress) { + runOnJS(setCurrentSeekTime)(progress) + } + }, + ) + + const seekBy = useCallback( + (time: number) => { + player?.seekBy(time) + + setTimeout(() => { + runOnUI(() => { + 'worklet' + isSeekingSV.set(false) + seekingAnimationSV.set(withTiming(0, {duration: 500})) + })() + }, 50) + }, + [player, isSeekingSV, seekingAnimationSV], + ) + + const scrubPanGesture = useMemo(() => { + return Gesture.Pan() + .blocksExternalGesture(scrollGesture) + .activeOffsetX([-10, 10]) + .failOffsetY([-10, 10]) + .onStart(() => { + 'worklet' + seekProgressSV.set(currentTimeSV.get()) + isSeekingSV.set(true) + seekingAnimationSV.set(withTiming(1, {duration: 500})) + }) + .onUpdate(evt => { + 'worklet' + const progress = evt.x / screenWidth + seekProgressSV.set( + clamp(progress * durationSV.get(), 0, durationSV.get()), + ) + }) + .onEnd(evt => { + 'worklet' + isSeekingSV.get() + + const progress = evt.x / screenWidth + const newTime = clamp(progress * durationSV.get(), 0, durationSV.get()) + + // optimisically set the progress bar + seekProgressSV.set(newTime) + + // it's seek by, so offset by the current time + // seekBy sets isSeekingSV back to false, so no need to do that here + runOnJS(seekBy)(newTime - currentTimeSV.get()) + }) + }, [ + scrollGesture, + seekingAnimationSV, + seekBy, + screenWidth, + currentTimeSV, + durationSV, + isSeekingSV, + seekProgressSV, + ]) + + const timeStyle = useAnimatedStyle(() => { + return { + display: seekingAnimationSV.get() === 0 ? 'none' : 'flex', + opacity: seekingAnimationSV.get(), + } + }) + + const barStyle = useAnimatedStyle(() => { + const currentTime = isSeekingSV.get() + ? seekProgressSV.get() + : currentTimeSV.get() + const progress = currentTime === 0 ? 0 : currentTime / durationSV.get() + const isSeeking = seekingAnimationSV.get() + return { + height: isSeeking * 3 + 1, + opacity: interpolate(isSeeking, [0, 1], [0.4, 0.6]), + width: `${progress * 100}%`, + } + }) + const trackStyle = useAnimatedStyle(() => { + return { + height: seekingAnimationSV.get() * 3 + 1, + } + }) + const childrenStyle = useAnimatedStyle(() => { + return { + opacity: 1 - seekingAnimationSV.get(), + } + }) + + return ( + <> + {player && active && ( + <PlayerListener + player={player} + setDuration={setDuration} + updateTime={updateTime} + /> + )} + <Animated.View + style={[ + a.absolute, + { + left: 0, + right: 0, + bottom: insets.bottom + 80, + }, + timeStyle, + ]} + pointerEvents="none"> + <Text style={[a.text_center, a.font_bold]}> + <Text style={[a.text_5xl, {fontVariant: ['tabular-nums']}]}> + {formatTime(currentSeekTime)} + </Text> + <Text style={[a.text_2xl, {opacity: 0.8}]}>{' / '}</Text> + <Text + style={[ + a.text_5xl, + {opacity: 0.8}, + {fontVariant: ['tabular-nums']}, + ]}> + {formatTime(duration)} + </Text> + </Text> + </Animated.View> + + <GestureDetector gesture={scrubPanGesture}> + <View + style={[ + a.relative, + a.w_full, + a.justify_end, + { + paddingBottom: insets.bottom, + minHeight: + // bottom padding + insets.bottom + + // scrubber height + tokens.space.lg + + // write reply height + VIDEO_PLAYER_BOTTOM_INSET, + }, + a.z_10, + ]}> + <View style={[a.w_full, a.relative]}> + <Animated.View + style={[ + a.w_full, + {backgroundColor: 'white', opacity: 0.2}, + trackStyle, + ]} + /> + <Animated.View + style={[ + a.absolute, + {top: 0, left: 0, backgroundColor: 'white'}, + barStyle, + ]} + /> + </View> + <Animated.View + style={[{minHeight: VIDEO_PLAYER_BOTTOM_INSET}, childrenStyle]}> + {children} + </Animated.View> + </View> + </GestureDetector> + </> + ) +} + +function PlayerListener({ + player, + setDuration, + updateTime, +}: { + player: VideoPlayer + setDuration: (duration: number) => void + updateTime: (currentTime: number, duration: number) => void +}) { + useEventListener(player, 'timeUpdate', evt => { + const duration = player.duration + if (duration !== 0) { + setDuration(Math.round(duration)) + } + runOnUI(updateTime)(evt.currentTime, duration) + }) + + return null +} + +function clamp(num: number, min: number, max: number) { + 'worklet' + return Math.min(Math.max(num, min), max) +} diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx new file mode 100644 index 000000000..21b2ec5be --- /dev/null +++ b/src/screens/VideoFeed/index.tsx @@ -0,0 +1,1093 @@ +import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' +import { + LayoutAnimation, + ListRenderItem, + Pressable, + ScrollView, + View, + ViewabilityConfig, + ViewToken, +} from 'react-native' +import { + Gesture, + GestureDetector, + NativeGesture, +} from 'react-native-gesture-handler' +import Animated, { + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' +import { + useSafeAreaFrame, + useSafeAreaInsets, +} from 'react-native-safe-area-context' +import {useEventListener} from 'expo' +import {Image, ImageStyle} from 'expo-image' +import {LinearGradient} from 'expo-linear-gradient' +import {createVideoPlayer, VideoPlayer, VideoView} from 'expo-video' +import { + AppBskyEmbedVideo, + AppBskyFeedDefs, + AppBskyFeedPost, + AtUri, + ModerationDecision, + RichText as RichTextAPI, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + RouteProp, + useFocusEffect, + useIsFocused, + useNavigation, + useRoute, +} from '@react-navigation/native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {HITSLOP_20} from '#/lib/constants' +import {useHaptics} from '#/lib/haptics' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {cleanError} from '#/lib/strings/errors' +import {sanitizeHandle} from '#/lib/strings/handles' +import {isAndroid} from '#/platform/detection' +import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import { + FeedFeedbackProvider, + useFeedFeedbackContext, +} from '#/state/feed-feedback' +import {useFeedFeedback} from '#/state/feed-feedback' +import {usePostLikeMutationQueue} from '#/state/queries/post' +import { + AuthorFilter, + FeedPostSliceItem, + usePostFeedQuery, +} from '#/state/queries/post-feed' +import {useProfileFollowMutationQueue} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {useComposerControls, useSetMinimalShellMode} from '#/state/shell' +import {useSetLightStatusBar} from '#/state/shell/light-status-bar' +import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' +import {List} from '#/view/com/util/List' +import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {Header} from '#/screens/VideoFeed/components/Header' +import {atoms as a, platform, ThemeProvider, useTheme} from '#/alf' +import {setNavigationBar} from '#/alf/util/navigationBar' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Divider} from '#/components/Divider' +import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' +import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' +import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash' +import {Leaf_Stroke2_Corner0_Rounded as LeafIcon} from '#/components/icons/Leaf' +import * as Layout from '#/components/Layout' +import {Link} from '#/components/Link' +import {ListFooter} from '#/components/Lists' +import * as Hider from '#/components/moderation/Hider' +import {RichText} from '#/components/RichText' +import {Text} from '#/components/Typography' +import {Scrubber, VIDEO_PLAYER_BOTTOM_INSET} from './components/Scrubber' + +function createThreeVideoPlayers( + sources?: [string, string, string], +): [VideoPlayer, VideoPlayer, VideoPlayer] { + // android is typically slower and can't keep up with a 0.1 interval + const eventInterval = platform({ + ios: 0.2, + android: 0.5, + default: 0, + }) + const p1 = createVideoPlayer(sources?.[0] ?? '') + p1.loop = true + p1.timeUpdateEventInterval = eventInterval + const p2 = createVideoPlayer(sources?.[1] ?? '') + p2.loop = true + p2.timeUpdateEventInterval = eventInterval + const p3 = createVideoPlayer(sources?.[2] ?? '') + p3.loop = true + p3.timeUpdateEventInterval = eventInterval + return [p1, p2, p3] +} + +export function VideoFeed({}: NativeStackScreenProps< + CommonNavigatorParams, + 'VideoFeed' +>) { + const {top} = useSafeAreaInsets() + const {params} = useRoute<RouteProp<CommonNavigatorParams, 'VideoFeed'>>() + + const t = useTheme() + const setMinShellMode = useSetMinimalShellMode() + useFocusEffect( + useCallback(() => { + setMinShellMode(true) + setNavigationBar('lightbox', t) + return () => { + setMinShellMode(false) + setNavigationBar('theme', t) + } + }, [setMinShellMode, t]), + ) + + const isFocused = useIsFocused() + useSetLightStatusBar(isFocused) + + return ( + <ThemeProvider theme="dark"> + <Layout.Screen noInsetTop style={{backgroundColor: 'black'}}> + <View + style={[ + a.absolute, + a.z_30, + {top: 0, left: 0, right: 0, paddingTop: top}, + ]}> + <Header sourceContext={params} /> + </View> + <Feed /> + </Layout.Screen> + </ThemeProvider> + ) +} + +const viewabilityConfig = { + itemVisiblePercentThreshold: 100, + minimumViewTime: 0, +} satisfies ViewabilityConfig + +type CurrentSource = { + source: string +} | null + +type VideoItem = { + moderation: ModerationDecision + post: AppBskyFeedDefs.PostView + feedContext: string | undefined +} + +function Feed() { + const {params} = useRoute<RouteProp<CommonNavigatorParams, 'VideoFeed'>>() + const isFocused = useIsFocused() + const {hasSession} = useSession() + const {height} = useSafeAreaFrame() + + const feedDesc = useMemo(() => { + switch (params.type) { + case 'feedgen': + return `feedgen|${params.uri as string}` as const + case 'author': + return `author|${params.did as string}|${ + params.filter as AuthorFilter + }` as const + default: + throw new Error(`Invalid video feed params ${JSON.stringify(params)}`) + } + }, [params]) + const feedFeedback = useFeedFeedback(feedDesc, hasSession) + const {data, error, hasNextPage, isFetchingNextPage, fetchNextPage} = + usePostFeedQuery( + feedDesc, + params.type === 'feedgen' && params.sourceInterstitial !== 'none' + ? {feedCacheKey: params.sourceInterstitial} + : undefined, + ) + + const videos = useMemo(() => { + let vids = + data?.pages + .flatMap(page => { + const items: { + _reactKey: string + moderation: ModerationDecision + post: AppBskyFeedDefs.PostView + feedContext: string | undefined + }[] = [] + for (const slice of page.slices) { + for (const i of slice.items) { + items.push({ + _reactKey: i._reactKey, + moderation: i.moderation, + post: i.post, + feedContext: slice.feedContext, + }) + } + } + return items + }) + .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) || [] + const startingVideoIndex = vids?.findIndex(video => { + return video.post.uri === params.initialPostUri + }) + if (vids && startingVideoIndex && startingVideoIndex > -1) { + vids = vids.slice(startingVideoIndex) + } + return vids + }, [data, params.initialPostUri]) + + const [currentSources, setCurrentSources] = useState< + [CurrentSource, CurrentSource, CurrentSource] + >([null, null, null]) + + const [players, setPlayers] = useState< + [VideoPlayer, VideoPlayer, VideoPlayer] | null + >(null) + + const [currentIndex, setCurrentIndex] = useState(0) + + const scrollGesture = useMemo(() => Gesture.Native(), []) + + const renderItem: ListRenderItem<VideoItem> = useCallback( + ({item, index}) => { + const {post} = item + + // filtered above, here for TS + if (!post.embed || !AppBskyEmbedVideo.isView(post.embed)) { + return null + } + + const player = players?.[index % 3] + const currentSource = currentSources[index % 3] + + return ( + <VideoItem + player={player} + post={post} + embed={post.embed} + active={ + isFocused && + index === currentIndex && + currentSource?.source === post.embed.playlist + } + moderation={item.moderation} + scrollGesture={scrollGesture} + feedContext={item.feedContext} + /> + ) + }, + [players, currentIndex, isFocused, currentSources, scrollGesture], + ) + + const updateVideoState = useCallback( + (index?: number) => { + if (!videos.length) return + + if (index === undefined) { + index = currentIndex + } else { + setCurrentIndex(index) + } + + const prevSlice = videos.at(index - 1) + const prevPost = prevSlice?.post + const prevEmbed = prevPost?.embed + const prevVideo = + prevEmbed && AppBskyEmbedVideo.isView(prevEmbed) + ? prevEmbed.playlist + : null + const currSlice = videos.at(index) + const currPost = currSlice?.post + const currEmbed = currPost?.embed + const currVideo = + currEmbed && AppBskyEmbedVideo.isView(currEmbed) + ? currEmbed.playlist + : null + const currVideoModeration = currSlice?.moderation + const nextSlice = videos.at(index + 1) + const nextPost = nextSlice?.post + const nextEmbed = nextPost?.embed + const nextVideo = + nextEmbed && AppBskyEmbedVideo.isView(nextEmbed) + ? nextEmbed.playlist + : null + + const prevPlayerCurrentSource = currentSources[(index + 2) % 3] + const currPlayerCurrentSource = currentSources[index % 3] + const nextPlayerCurrentSource = currentSources[(index + 1) % 3] + + if (!players) { + const args = ['', '', ''] satisfies [string, string, string] + if (prevVideo) args[(index + 2) % 3] = prevVideo + if (currVideo) args[index % 3] = currVideo + if (nextVideo) args[(index + 1) % 3] = nextVideo + const [player1, player2, player3] = createThreeVideoPlayers(args) + + setPlayers([player1, player2, player3]) + + if (currVideo) { + const currPlayer = [player1, player2, player3][index % 3] + currPlayer.play() + } + } else { + const [player1, player2, player3] = players + + const prevPlayer = [player1, player2, player3][(index + 2) % 3] + const currPlayer = [player1, player2, player3][index % 3] + const nextPlayer = [player1, player2, player3][(index + 1) % 3] + + if (prevVideo && prevVideo !== prevPlayerCurrentSource?.source) { + prevPlayer.replace(prevVideo) + } + prevPlayer.pause() + + if (currVideo) { + if (currVideo !== currPlayerCurrentSource?.source) { + currPlayer.replace(currVideo) + } + if ( + currVideoModeration && + (currVideoModeration.ui('contentView').blur || + currVideoModeration.ui('contentMedia').blur) + ) { + currPlayer.pause() + } else { + currPlayer.play() + } + } + + if (nextVideo && nextVideo !== nextPlayerCurrentSource?.source) { + nextPlayer.replace(nextVideo) + } + nextPlayer.pause() + } + + const updatedSources: [CurrentSource, CurrentSource, CurrentSource] = [ + ...currentSources, + ] + if (prevVideo && prevVideo !== prevPlayerCurrentSource?.source) { + updatedSources[(index + 2) % 3] = { + source: prevVideo, + } + } + if (currVideo && currVideo !== currPlayerCurrentSource?.source) { + updatedSources[index % 3] = { + source: currVideo, + } + } + if (nextVideo && nextVideo !== nextPlayerCurrentSource?.source) { + updatedSources[(index + 1) % 3] = { + source: nextVideo, + } + } + + if ( + updatedSources[0]?.source !== currentSources[0]?.source || + updatedSources[1]?.source !== currentSources[1]?.source || + updatedSources[2]?.source !== currentSources[2]?.source + ) { + setCurrentSources(updatedSources) + } + }, + [videos, currentSources, currentIndex, players], + ) + + const updateVideoStateInitially = useNonReactiveCallback(() => { + updateVideoState() + }) + + useFocusEffect( + useCallback(() => { + if (!players) { + // create players, set sources, start playing + updateVideoStateInitially() + } + return () => { + if (players) { + // manually release players when offscreen + players.forEach(p => p.release()) + setPlayers(null) + } + } + }, [players, updateVideoStateInitially]), + ) + + const onViewableItemsChanged = useCallback( + ({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { + if (viewableItems[0] && viewableItems[0].index !== null) { + updateVideoState(viewableItems[0].index) + } + }, + [updateVideoState], + ) + + const renderEndMessage = useCallback(() => <EndMessage />, []) + + return ( + <FeedFeedbackProvider value={feedFeedback}> + <GestureDetector gesture={scrollGesture}> + <List + data={videos} + renderItem={renderItem} + keyExtractor={keyExtractor} + initialNumToRender={3} + maxToRenderPerBatch={3} + windowSize={6} + pagingEnabled={true} + ListFooterComponent={ + <ListFooter + hasNextPage={hasNextPage} + isFetchingNextPage={isFetchingNextPage} + error={cleanError(error)} + onRetry={fetchNextPage} + height={height} + showEndMessage + renderEndMessage={renderEndMessage} + style={[a.justify_center, a.border_0]} + /> + } + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }} + showsVerticalScrollIndicator={false} + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={viewabilityConfig} + /> + </GestureDetector> + </FeedFeedbackProvider> + ) +} + +function keyExtractor(item: FeedPostSliceItem) { + return item._reactKey +} + +let VideoItem = ({ + player, + post, + embed, + active, + scrollGesture, + moderation, + feedContext, +}: { + player?: VideoPlayer + post: AppBskyFeedDefs.PostView + embed: AppBskyEmbedVideo.View + active: boolean + scrollGesture: NativeGesture + moderation?: ModerationDecision + feedContext: string | undefined +}): React.ReactNode => { + const postShadow = usePostShadow(post) + const {width, height} = useSafeAreaFrame() + const {sendInteraction} = useFeedFeedbackContext() + + useEffect(() => { + if (active) { + sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#interactionSeen', + feedContext, + }) + } + }, [active, post.uri, feedContext, sendInteraction]) + + return ( + <View style={[a.relative, {height, width}]}> + {postShadow === POST_TOMBSTONE ? ( + <View + style={[ + a.absolute, + a.inset_0, + a.z_20, + a.align_center, + a.justify_center, + {backgroundColor: 'rgba(0, 0, 0, 0.8)'}, + ]}> + <Text + style={[ + a.text_2xl, + a.font_heavy, + a.text_center, + a.leading_tight, + a.mx_xl, + ]}> + <Trans>Post has been deleted</Trans> + </Text> + </View> + ) : ( + <> + <VideoItemPlaceholder embed={embed} /> + {active && player && <VideoItemInner player={player} embed={embed} />} + {moderation && ( + <Overlay + player={player} + post={postShadow} + embed={embed} + active={active} + scrollGesture={scrollGesture} + moderation={moderation} + feedContext={feedContext} + /> + )} + </> + )} + </View> + ) +} +VideoItem = memo(VideoItem) + +function VideoItemInner({ + player, + embed, +}: { + player: VideoPlayer + embed: AppBskyEmbedVideo.View +}) { + const {bottom} = useSafeAreaInsets() + const [isReady, setIsReady] = useState(!isAndroid) + + useEventListener(player, 'timeUpdate', evt => { + if (isAndroid && !isReady && evt.currentTime >= 0.05) { + setIsReady(true) + } + }) + + return ( + <VideoView + accessible={false} + style={[ + a.absolute, + { + top: 0, + left: 0, + right: 0, + bottom: bottom + VIDEO_PLAYER_BOTTOM_INSET, + }, + !isReady && {opacity: 0}, + ]} + player={player} + nativeControls={false} + contentFit={isTallAspectRatio(embed.aspectRatio) ? 'cover' : 'contain'} + accessibilityIgnoresInvertColors + /> + ) +} + +function ModerationOverlay({ + embed, + onPressShow, +}: { + embed: AppBskyEmbedVideo.View + onPressShow: () => void +}) { + const {_} = useLingui() + const hider = Hider.useHider() + const {bottom} = useSafeAreaInsets() + + const onShow = useCallback(() => { + hider.setIsContentVisible(true) + onPressShow() + }, [hider, onPressShow]) + + return ( + <View style={[a.absolute, a.inset_0, a.z_20]}> + <VideoItemPlaceholder blur embed={embed} /> + <View + style={[ + a.absolute, + a.inset_0, + a.z_20, + a.justify_center, + a.align_center, + {backgroundColor: 'rgba(0, 0, 0, 0.8)'}, + ]}> + <View style={[a.align_center, a.gap_sm]}> + <Eye width={36} fill="white" /> + <Text style={[a.text_center, a.leading_snug, a.pb_xs]}> + <Trans>Hidden by your moderation settings.</Trans> + </Text> + <Button + label={_(msg`Show anyway`)} + size="small" + variant="solid" + color="secondary_inverted" + onPress={onShow}> + <ButtonText> + <Trans>Show anyway</Trans> + </ButtonText> + </Button> + </View> + <View + style={[ + a.absolute, + a.inset_0, + a.px_xl, + a.pt_4xl, + { + top: 'auto', + paddingBottom: bottom, + }, + ]}> + <LinearGradient + colors={['rgba(0,0,0,0)', 'rgba(0,0,0,0.4)']} + style={[a.absolute, a.inset_0]} + /> + <Divider style={{borderColor: 'white'}} /> + <View> + <Button + label={_(msg`View details`)} + onPress={() => { + hider.showInfoDialog() + }} + style={[ + a.w_full, + { + height: 60, + }, + ]}> + {({pressed}) => ( + <Text + style={[ + a.text_sm, + a.font_bold, + a.text_center, + {opacity: pressed ? 0.5 : 1}, + ]}> + <Trans>View details</Trans> + </Text> + )} + </Button> + </View> + </View> + </View> + </View> + ) +} + +function Overlay({ + player, + post, + embed, + active, + scrollGesture, + moderation, + feedContext, +}: { + player?: VideoPlayer + post: Shadow<AppBskyFeedDefs.PostView> + embed: AppBskyEmbedVideo.View + active: boolean + scrollGesture: NativeGesture + moderation: ModerationDecision + feedContext: string | undefined +}) { + const {_} = useLingui() + const t = useTheme() + const {openComposer} = useComposerControls() + const navigation = useNavigation<NavigationProp>() + const seekingAnimationSV = useSharedValue(0) + + const profile = useProfileShadow(post.author) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( + profile, + 'ImmersiveVideo', + ) + + const rkey = new AtUri(post.uri).rkey + const record = AppBskyFeedPost.isRecord(post.record) ? post.record : undefined + const richText = new RichTextAPI({ + text: record?.text || '', + facets: record?.facets, + }) + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: 1 - seekingAnimationSV.get(), + })) + + const onPressShow = useCallback(() => { + player?.play() + }, [player]) + + const mergedModui = useMemo(() => { + const modui = moderation.ui('contentView') + 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]) + + const onPressReply = useCallback(() => { + openComposer({ + replyTo: { + uri: post.uri, + cid: post.cid, + text: record?.text || '', + author: post.author, + embed: post.embed, + }, + }) + }, [openComposer, post, record]) + + return ( + <Hider.Outer modui={mergedModui}> + <Hider.Mask> + <ModerationOverlay embed={embed} onPressShow={onPressShow} /> + </Hider.Mask> + <Hider.Content> + <View style={[a.absolute, a.inset_0, a.z_20]}> + <View style={[a.flex_1]}> + <PlayPauseTapArea + player={player} + post={post} + feedContext={feedContext} + /> + </View> + + <LinearGradient + colors={[ + 'rgba(0,0,0,0)', + 'rgba(0,0,0,0.7)', + 'rgba(0,0,0,0.95)', + 'rgba(0,0,0,0.95)', + ]} + style={[a.w_full, a.pt_md]}> + <Animated.View style={[a.px_xl, animatedStyle]}> + <View style={[a.w_full, a.flex_row, a.align_center, a.gap_md]}> + <Link + label={_( + msg`View ${sanitizeDisplayName( + post.author.displayName || post.author.handle, + )}'s profile`, + )} + to={{ + screen: 'Profile', + params: {name: post.author.did}, + }} + style={[a.flex_1, a.flex_row, a.gap_md, a.align_center]}> + <UserAvatar + type="user" + avatar={post.author.avatar} + size={32} + /> + <View style={[a.flex_1]}> + <Text + style={[a.text_md, a.font_heavy]} + emoji + numberOfLines={1}> + {sanitizeDisplayName( + post.author.displayName || post.author.handle, + )} + </Text> + <Text + style={[a.text_sm, t.atoms.text_contrast_high]} + numberOfLines={1}> + {sanitizeHandle(post.author.handle, '@')} + </Text> + </View> + </Link> + {/* show button based on non-reactive version, so it doesn't hide on press */} + {!post.author.viewer?.following && ( + <Button + label={ + profile.viewer?.following + ? _(msg`Following`) + : _(msg`Follow`) + } + accessibilityHint={ + profile.viewer?.following ? _(msg`Unfollow user`) : '' + } + size="small" + variant="solid" + color="secondary_inverted" + style={[a.mb_xs]} + onPress={() => + profile.viewer?.following + ? queueUnfollow() + : queueFollow() + }> + {!!profile.viewer?.following && ( + <ButtonIcon icon={CheckIcon} /> + )} + <ButtonText> + {profile.viewer?.following ? ( + <Trans>Following</Trans> + ) : ( + <Trans>Follow</Trans> + )} + </ButtonText> + </Button> + )} + </View> + {record?.text?.trim() && ( + <ExpandableRichTextView + value={richText} + authorHandle={post.author.handle} + /> + )} + {record && ( + <View style={[{left: -5}]}> + <PostCtrls + richText={richText} + post={post} + record={record} + logContext="FeedItem" + onPressReply={() => + navigation.navigate('PostThread', { + name: post.author.did, + rkey, + }) + } + big + /> + </View> + )} + </Animated.View> + <Scrubber + active={active} + player={player} + seekingAnimationSV={seekingAnimationSV} + scrollGesture={scrollGesture}> + <PostThreadComposePrompt onPressCompose={onPressReply} /> + </Scrubber> + </LinearGradient> + </View> + {/* + {isAndroid && status === 'loading' && ( + <View + style={[ + a.absolute, + a.inset_0, + a.align_center, + a.justify_center, + a.z_10, + ]} + pointerEvents="none"> + <Loader size="2xl" /> + </View> + )} + */} + </Hider.Content> + </Hider.Outer> + ) +} + +function ExpandableRichTextView({ + value, + authorHandle, +}: { + value: RichTextAPI + authorHandle?: string +}) { + const {height: screenHeight} = useSafeAreaFrame() + const [expanded, setExpanded] = useState(false) + const [hasBeenExpanded, setHasBeenExpanded] = useState(false) + const [constrained, setConstrained] = useState(false) + const [contentHeight, setContentHeight] = useState(0) + const {_} = useLingui() + + if (expanded && !hasBeenExpanded) { + setHasBeenExpanded(true) + } + + return ( + <ScrollView + scrollEnabled={expanded} + onContentSizeChange={(_w, h) => { + if (hasBeenExpanded) { + LayoutAnimation.configureNext({ + duration: 500, + update: {type: 'spring', springDamping: 0.6}, + }) + } + setContentHeight(h) + }} + style={{height: Math.min(contentHeight, screenHeight * 0.5)}} + contentContainerStyle={[ + a.py_sm, + a.gap_xs, + expanded ? [a.align_start] : a.flex_row, + ]}> + <RichText + value={value} + style={[a.text_sm, a.flex_1, a.leading_normal]} + authorHandle={authorHandle} + enableTags + numberOfLines={expanded ? undefined : constrained ? 2 : 2} + onTextLayout={evt => { + if (!constrained && evt.nativeEvent.lines.length > 1) { + setConstrained(true) + } + }} + /> + {constrained && ( + <Pressable + accessibilityHint={_(msg`Tap to expand or collapse post text.`)} + accessibilityLabel={expanded ? _(msg`Read less`) : _(msg`Read more`)} + hitSlop={HITSLOP_20} + onPress={() => setExpanded(prev => !prev)} + style={[a.absolute, a.inset_0]} + /> + )} + </ScrollView> + ) +} + +function VideoItemPlaceholder({ + embed, + style, + blur, +}: { + embed: AppBskyEmbedVideo.View + style?: ImageStyle + blur?: boolean +}) { + const {bottom} = useSafeAreaInsets() + const src = embed.thumbnail + let contentFit = isTallAspectRatio(embed.aspectRatio) + ? ('cover' as const) + : ('contain' as const) + if (blur) { + contentFit = 'cover' as const + } + return src ? ( + <Image + accessibilityIgnoresInvertColors + source={{uri: src}} + style={[ + a.absolute, + blur + ? a.inset_0 + : { + top: 0, + left: 0, + right: 0, + bottom: bottom + VIDEO_PLAYER_BOTTOM_INSET, + }, + style, + ]} + contentFit={contentFit} + blurRadius={blur ? 100 : 0} + /> + ) : null +} + +function PlayPauseTapArea({ + player, + post, + feedContext, +}: { + player?: VideoPlayer + post: Shadow<AppBskyFeedDefs.PostView> + feedContext: string | undefined +}) { + const {_} = useLingui() + const doubleTapRef = useRef<ReturnType<typeof setTimeout> | null>(null) + const playHaptic = useHaptics() + const [queueLike] = usePostLikeMutationQueue(post, 'ImmersiveVideo') + const {sendInteraction} = useFeedFeedbackContext() + + const togglePlayPause = () => { + if (!player) return + doubleTapRef.current = null + if (player.playing) { + player.pause() + } else { + player.play() + } + } + + const onPress = () => { + if (doubleTapRef.current) { + clearTimeout(doubleTapRef.current) + doubleTapRef.current = null + playHaptic('Light') + queueLike() + sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#interactionLike', + feedContext, + }) + } else { + doubleTapRef.current = setTimeout(togglePlayPause, 200) + } + } + + return ( + <Button + disabled={!player} + label={_(`Tap to play or pause the video`)} + accessibilityHint={_(msg`Double tap to like`)} + onPress={onPress} + style={[a.absolute, a.inset_0]}> + <View /> + </Button> + ) +} + +function EndMessage() { + const navigation = useNavigation<NavigationProp>() + const {_} = useLingui() + const t = useTheme() + return ( + <View + style={[ + a.w_full, + a.gap_3xl, + a.px_lg, + a.mx_auto, + a.align_center, + {maxWidth: 350}, + ]}> + <View + style={[ + {height: 100, width: 100}, + a.rounded_full, + t.atoms.bg_contrast_700, + a.align_center, + a.justify_center, + ]}> + <LeafIcon width={64} fill="black" /> + </View> + <View style={[a.w_full, a.gap_md]}> + <Text style={[a.text_3xl, a.text_center, a.font_heavy]}> + <Trans>That's everything!</Trans> + </Text> + <Text + style={[ + a.text_lg, + a.text_center, + t.atoms.text_contrast_high, + a.leading_snug, + ]}> + <Trans> + You've run out of videos to watch. Maybe it's a good time to take a + break? + </Trans> + </Text> + </View> + <Button + testID="videoFeedGoBackButton" + onPress={() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }} + variant="solid" + color="secondary_inverted" + size="small" + label={_(msg`Go back`)} + accessibilityHint={_(msg`Returns to previous page`)}> + <ButtonIcon icon={ArrowLeftIcon} /> + <ButtonText> + <Trans>Go back</Trans> + </ButtonText> + </Button> + </View> + ) +} + +/* + * If the video is taller than 9:16 + */ +function isTallAspectRatio(aspectRatio: AppBskyEmbedVideo.View['aspectRatio']) { + const videoAspectRatio = + (aspectRatio?.width ?? 1) / (aspectRatio?.height ?? 1) + return videoAspectRatio <= 9 / 16 +} diff --git a/src/screens/VideoFeed/index.web.tsx b/src/screens/VideoFeed/index.web.tsx new file mode 100644 index 000000000..38ec8cc0a --- /dev/null +++ b/src/screens/VideoFeed/index.web.tsx @@ -0,0 +1,3 @@ +export function VideoScreen() { + return null +} diff --git a/src/screens/VideoFeed/types.ts b/src/screens/VideoFeed/types.ts new file mode 100644 index 000000000..2ab854bb3 --- /dev/null +++ b/src/screens/VideoFeed/types.ts @@ -0,0 +1,18 @@ +import {AuthorFilter} from '#/state/queries/post-feed' + +/** + * Kind of like `FeedDescriptor` but not + */ +export type VideoFeedSourceContext = + | { + type: 'feedgen' + uri: string + sourceInterstitial: 'discover' | 'explore' | 'none' + initialPostUri?: string + } + | { + type: 'author' + did: string + filter: AuthorFilter + initialPostUri?: string + } diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx index de5157a54..2ad5ff91a 100644 --- a/src/state/feed-feedback.tsx +++ b/src/state/feed-feedback.tsx @@ -7,7 +7,7 @@ import {FEEDBACK_FEEDS, STAGING_FEEDS} from '#/lib/constants' import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed' -import {getFeedPostSlice} from '#/view/com/posts/PostFeed' +import {getItemsForFeedback} from '#/view/com/posts/PostFeed' import {useAgent} from './session' type StateContext = { @@ -102,18 +102,15 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) { if (!enabled) { return } - const slice = getFeedPostSlice(feedItem) - if (slice === null) { - return - } - for (const postItem of slice.items) { + const items = getItemsForFeedback(feedItem) + for (const {item: postItem, feedContext} of items) { if (!history.current.has(postItem)) { history.current.add(postItem) queue.current.add( toString({ item: postItem.uri, event: 'app.bsky.feed.defs#interactionSeen', - feedContext: slice.feedContext, + feedContext, }), ) sendToFeed() diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 0a9e5b2c0..f840081f3 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -126,6 +126,7 @@ const schema = z.object({ /** @deprecated */ mutedThreads: z.array(z.string()), trendingDisabled: z.boolean().optional(), + trendingVideoDisabled: z.boolean().optional(), }) export type Schema = z.infer<typeof schema> @@ -172,6 +173,7 @@ export const defaults: Schema = { hasCheckedForStarterPack: false, subtitlesEnabled: true, trendingDisabled: false, + trendingVideoDisabled: false, } export function tryParse(rawData: string): Schema | undefined { diff --git a/src/state/preferences/trending.tsx b/src/state/preferences/trending.tsx index bf5d8f13c..87ec68771 100644 --- a/src/state/preferences/trending.tsx +++ b/src/state/preferences/trending.tsx @@ -4,18 +4,27 @@ import * as persisted from '#/state/persisted' type StateContext = { trendingDisabled: Exclude<persisted.Schema['trendingDisabled'], undefined> + trendingVideoDisabled: Exclude< + persisted.Schema['trendingVideoDisabled'], + undefined + > } type ApiContext = { setTrendingDisabled( hidden: Exclude<persisted.Schema['trendingDisabled'], undefined>, ): void + setTrendingVideoDisabled( + hidden: Exclude<persisted.Schema['trendingVideoDisabled'], undefined>, + ): void } const StateContext = React.createContext<StateContext>({ trendingDisabled: Boolean(persisted.defaults.trendingDisabled), + trendingVideoDisabled: Boolean(persisted.defaults.trendingVideoDisabled), }) const ApiContext = React.createContext<ApiContext>({ setTrendingDisabled() {}, + setTrendingVideoDisabled() {}, }) function usePersistedBooleanValue<T extends keyof persisted.Schema>(key: T) { @@ -43,14 +52,19 @@ function usePersistedBooleanValue<T extends keyof persisted.Schema>(key: T) { export function Provider({children}: React.PropsWithChildren<{}>) { const [trendingDisabled, setTrendingDisabled] = usePersistedBooleanValue('trendingDisabled') + const [trendingVideoDisabled, setTrendingVideoDisabled] = + usePersistedBooleanValue('trendingVideoDisabled') /* * Context */ - const state = React.useMemo(() => ({trendingDisabled}), [trendingDisabled]) + const state = React.useMemo( + () => ({trendingDisabled, trendingVideoDisabled}), + [trendingDisabled, trendingVideoDisabled], + ) const api = React.useMemo( - () => ({setTrendingDisabled}), - [setTrendingDisabled], + () => ({setTrendingDisabled, setTrendingVideoDisabled}), + [setTrendingDisabled, setTrendingVideoDisabled], ) return ( diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index e5ce19a9a..500cfea54 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -48,6 +48,7 @@ export type FeedSourceFeedInfo = { creatorHandle: string likeCount: number | undefined likeUri: string | undefined + contentMode: AppBskyFeedDefs.GeneratorView['contentMode'] } export type FeedSourceListInfo = { @@ -65,6 +66,7 @@ export type FeedSourceListInfo = { description: RichText creatorDid: string creatorHandle: string + contentMode: undefined } export type FeedSourceInfo = FeedSourceFeedInfo | FeedSourceListInfo @@ -111,6 +113,7 @@ export function hydrateFeedGenerator( creatorHandle: view.creator.handle, likeCount: view.likeCount, likeUri: view.viewer?.like, + contentMode: view.contentMode, } } @@ -141,6 +144,7 @@ export function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo { displayName: view.name ? sanitizeDisplayName(view.name) : `User List by ${sanitizeHandle(view.creator.handle, '@')}`, + contentMode: undefined, } } @@ -399,6 +403,7 @@ const PWI_DISCOVER_FEED_STUB: SavedFeedSourceInfo = { id: 'pwi-discover', ...DISCOVER_SAVED_FEED, }, + contentMode: undefined, } const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos' @@ -485,6 +490,7 @@ export function usePinnedFeedsInfos() { likeCount: 0, likeUri: '', savedFeed: pinnedItem, + contentMode: undefined, }) } } diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 2eb604627..6f9af18f0 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -44,7 +44,7 @@ import { } from './util' type ActorDid = string -type AuthorFilter = +export type AuthorFilter = | 'posts_with_replies' | 'posts_no_replies' | 'posts_and_author_threads' @@ -61,6 +61,7 @@ export type FeedDescriptor = export interface FeedParams { mergeFeedEnabled?: boolean mergeFeedSources?: string[] + feedCacheKey?: 'discover' | 'explore' | undefined } type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 10ed60212..f643adaf9 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -1,11 +1,12 @@ import React from 'react' import {View} from 'react-native' -import {AppBskyActorDefs} from '@atproto/api' +import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {NavigationProp, useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' +import {VIDEO_FEED_URIS} from '#/lib/constants' import {ComposeIcon2} from '#/lib/icons' import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers' import {AllNavigatorParams} from '#/lib/routes/types' @@ -15,6 +16,7 @@ import {isNative} from '#/platform/detection' import {listenSoftReset} from '#/state/events' import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' import {useSetHomeBadge} from '#/state/home-badge' +import {SavedFeedSourceInfo} from '#/state/queries/feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {truncateAndInvalidate} from '#/state/queries/util' @@ -39,6 +41,7 @@ export function FeedPage({ renderEmptyState, renderEndOfFeed, savedFeedConfig, + feedInfo, }: { testID?: string feed: FeedDescriptor @@ -48,6 +51,7 @@ export function FeedPage({ renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element savedFeedConfig?: AppBskyActorDefs.SavedFeed + feedInfo: SavedFeedSourceInfo }) { const {hasSession} = useSession() const {_} = useLingui() @@ -61,6 +65,13 @@ export function FeedPage({ const scrollElRef = React.useRef<ListMethods>(null) const [hasNew, setHasNew] = React.useState(false) const setHomeBadge = useSetHomeBadge() + const isVideoFeed = React.useMemo(() => { + const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri) + const feedIsVideoMode = + feedInfo.contentMode === AppBskyFeedDefs.CONTENTMODEVIDEO + const _isVideoFeed = isBskyVideoFeed || feedIsVideoMode + return isNative && _isVideoFeed + }, [feedInfo]) React.useEffect(() => { if (isPageFocused) { @@ -134,6 +145,7 @@ export function FeedPage({ renderEndOfFeed={renderEndOfFeed} headerOffset={headerOffset} savedFeedConfig={savedFeedConfig} + isVideoFeed={isVideoFeed} /> </FeedFeedbackProvider> </MainScrollProvider> diff --git a/src/view/com/post-thread/PostThreadComposePrompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx index 705572c06..40acff376 100644 --- a/src/view/com/post-thread/PostThreadComposePrompt.tsx +++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx @@ -40,7 +40,6 @@ export function PostThreadComposePrompt({ t.atoms.border_contrast_low, t.atoms.bg, ]} - onPressIn={ios(() => playHaptic('Light'))} onPress={() => { onPressCompose() playHaptic('Light') diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index f9b2e6e76..554415faf 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -9,7 +9,7 @@ import { View, ViewStyle, } from 'react-native' -import {AppBskyActorDefs} from '@atproto/api' +import {AppBskyActorDefs, AppBskyEmbedVideo} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' @@ -20,7 +20,7 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {logEvent} from '#/lib/statsig/statsig' import {useTheme} from '#/lib/ThemeContext' import {logger} from '#/logger' -import {isIOS, isWeb} from '#/platform/detection' +import {isIOS, isNative, isWeb} from '#/platform/detection' import {listenPostCreated} from '#/state/events' import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useTrendingSettings} from '#/state/preferences/trending' @@ -29,18 +29,24 @@ import { FeedDescriptor, FeedParams, FeedPostSlice, + FeedPostSliceItem, pollLatest, RQKEY, usePostFeedQuery, } from '#/state/queries/post-feed' import {useSession} from '#/state/session' import {useProgressGuide} from '#/state/shell/progress-guide' +import {List, ListRef} from '#/view/com/util/List' +import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' import {useBreakpoints} from '#/alf' import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials' +import { + PostFeedVideoGridRow, + PostFeedVideoGridRowPlaceholder, +} from '#/components/feeds/PostFeedVideoGridRow' import {TrendingInterstitial} from '#/components/interstitials/Trending' -import {List, ListRef} from '../util/List' -import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' -import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' +import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' import {FeedShutdownMsg} from './FeedShutdownMsg' import {PostFeedErrorMessage} from './PostFeedErrorMessage' @@ -69,7 +75,7 @@ type FeedRow = key: string } | { - type: 'slice' + type: 'slice' // TODO can we remove? key: string slice: FeedPostSlice } @@ -81,6 +87,17 @@ type FeedRow = showReplyTo: boolean } | { + type: 'videoGridRowPlaceholder' + key: string + } + | { + type: 'videoGridRow' + key: string + items: FeedPostSliceItem[] + sourceFeedUri: string + feedContexts: (string | undefined)[] + } + | { type: 'sliceViewFullThread' key: string uri: string @@ -97,12 +114,28 @@ type FeedRow = type: 'interstitialTrending' key: string } + | { + type: 'interstitialTrendingVideos' + key: string + } -export function getFeedPostSlice(feedRow: FeedRow): FeedPostSlice | null { +export function getItemsForFeedback(feedRow: FeedRow): + | { + item: FeedPostSliceItem + feedContext: string | undefined + }[] { if (feedRow.type === 'sliceItem') { - return feedRow.slice + return feedRow.slice.items.map(item => ({ + item, + feedContext: feedRow.slice.feedContext, + })) + } else if (feedRow.type === 'videoGridRow') { + return feedRow.items.map((item, i) => ({ + item, + feedContext: feedRow.feedContexts[i], + })) } else { - return null + return [] } } @@ -131,6 +164,7 @@ let PostFeed = ({ extraData, savedFeedConfig, initialNumToRender: initialNumToRenderOverride, + isVideoFeed = false, }: { feed: FeedDescriptor feedParams?: FeedParams @@ -152,6 +186,7 @@ let PostFeed = ({ extraData?: any savedFeedConfig?: AppBskyActorDefs.SavedFeed initialNumToRender?: number + isVideoFeed?: boolean }): React.ReactNode => { const theme = useTheme() const {_} = useLingui() @@ -163,8 +198,10 @@ let PostFeed = ({ const checkForNewRef = React.useRef<(() => void) | null>(null) const lastFetchRef = React.useRef<number>(Date.now()) const [feedType, feedUri, feedTab] = feed.split('|') - const {gtTablet} = useBreakpoints() + const {gtMobile, gtTablet} = useBreakpoints() + const areVideoFeedsEnabled = isNative + const feedCacheKey = feedParams?.feedCacheKey const opts = React.useMemo( () => ({enabled, ignoreFilterFor}), [enabled, ignoreFilterFor], @@ -267,10 +304,10 @@ let PostFeed = ({ const showProgressIntersitial = (followProgressGuide || followAndLikeProgressGuide) && !isDesktop - const {trendingDisabled} = useTrendingSettings() + const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings() const feedItems: FeedRow[] = React.useMemo(() => { - let feedKind: 'following' | 'discover' | 'profile' | undefined + let feedKind: 'following' | 'discover' | 'profile' | 'thevids' | undefined if (feedType === 'following') { feedKind = 'following' } else if (feedUri === DISCOVER_FEED_URI) { @@ -303,81 +340,132 @@ let PostFeed = ({ }) } else if (data) { let sliceIndex = -1 - for (const page of data?.pages) { - for (const slice of page.slices) { + + if (isVideoFeed) { + const videos: { + item: FeedPostSliceItem + feedContext: string | undefined + }[] = [] + for (const page of data.pages) { + for (const slice of page.slices) { + const item = slice.items.at(0) + if (item && AppBskyEmbedVideo.isView(item.post.embed)) { + videos.push({item, feedContext: slice.feedContext}) + } + } + } + + const rows: { + item: FeedPostSliceItem + feedContext: string | undefined + }[][] = [] + for (let i = 0; i < videos.length; i++) { + const video = videos[i] + const item = video.item + const cols = gtMobile ? 3 : 2 + const rowItem = {item, feedContext: video.feedContext} + if (i % cols === 0) { + rows.push([rowItem]) + } else { + rows[rows.length - 1].push(rowItem) + } + } + + for (const row of rows) { sliceIndex++ + arr.push({ + type: 'videoGridRow', + key: row.map(r => r.item._reactKey).join('-'), + items: row.map(r => r.item), + sourceFeedUri: feedUri, + feedContexts: row.map(r => r.feedContext), + }) + } + } else { + for (const page of data?.pages) { + for (const slice of page.slices) { + sliceIndex++ - if (hasSession) { - if (feedKind === 'discover') { - if (sliceIndex === 0) { - if (showProgressIntersitial) { + if (hasSession) { + if (feedKind === 'discover') { + if (sliceIndex === 0) { + if (showProgressIntersitial) { + arr.push({ + type: 'interstitialProgressGuide', + key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, + }) + } + if (!gtTablet && !trendingDisabled) { + arr.push({ + type: 'interstitialTrending', + key: + 'interstitial2-' + sliceIndex + '-' + lastFetchedAt, + }) + } + } else if (sliceIndex === 15) { + if (areVideoFeedsEnabled && !trendingVideoDisabled) { + arr.push({ + type: 'interstitialTrendingVideos', + key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, + }) + } + } else if (sliceIndex === 30) { arr.push({ - type: 'interstitialProgressGuide', + type: 'interstitialFollows', key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, }) } - if (!gtTablet && !trendingDisabled) { + } else if (feedKind === 'profile') { + if (sliceIndex === 5) { arr.push({ - type: 'interstitialTrending', - key: 'interstitial2-' + sliceIndex + '-' + lastFetchedAt, + type: 'interstitialFollows', + key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, }) } - } else if (sliceIndex === 30) { - arr.push({ - type: 'interstitialFollows', - key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, - }) - } - } else if (feedKind === 'profile') { - if (sliceIndex === 5) { - arr.push({ - type: 'interstitialFollows', - key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, - }) } } - } - if (slice.isIncompleteThread && slice.items.length >= 3) { - const beforeLast = slice.items.length - 2 - const last = slice.items.length - 1 - arr.push({ - type: 'sliceItem', - key: slice.items[0]._reactKey, - slice: slice, - indexInSlice: 0, - showReplyTo: false, - }) - arr.push({ - type: 'sliceViewFullThread', - key: slice._reactKey + '-viewFullThread', - uri: slice.items[0].uri, - }) - arr.push({ - type: 'sliceItem', - key: slice.items[beforeLast]._reactKey, - slice: slice, - indexInSlice: beforeLast, - showReplyTo: - slice.items[beforeLast].parentAuthor?.did !== - slice.items[beforeLast].post.author.did, - }) - arr.push({ - type: 'sliceItem', - key: slice.items[last]._reactKey, - slice: slice, - indexInSlice: last, - showReplyTo: false, - }) - } else { - for (let i = 0; i < slice.items.length; i++) { + if (slice.isIncompleteThread && slice.items.length >= 3) { + const beforeLast = slice.items.length - 2 + const last = slice.items.length - 1 + arr.push({ + type: 'sliceItem', + key: slice.items[0]._reactKey, + slice: slice, + indexInSlice: 0, + showReplyTo: false, + }) + arr.push({ + type: 'sliceViewFullThread', + key: slice._reactKey + '-viewFullThread', + uri: slice.items[0].uri, + }) arr.push({ type: 'sliceItem', - key: slice.items[i]._reactKey, + key: slice.items[beforeLast]._reactKey, slice: slice, - indexInSlice: i, - showReplyTo: i === 0, + indexInSlice: beforeLast, + showReplyTo: + slice.items[beforeLast].parentAuthor?.did !== + slice.items[beforeLast].post.author.did, }) + arr.push({ + type: 'sliceItem', + key: slice.items[last]._reactKey, + slice: slice, + indexInSlice: last, + showReplyTo: false, + }) + } else { + for (let i = 0; i < slice.items.length; i++) { + arr.push({ + type: 'sliceItem', + key: slice.items[i]._reactKey, + slice: slice, + indexInSlice: i, + showReplyTo: i === 0, + }) + } } } } @@ -390,10 +478,17 @@ let PostFeed = ({ }) } } else { - arr.push({ - type: 'loading', - key: 'loading', - }) + if (isVideoFeed) { + arr.push({ + type: 'videoGridRowPlaceholder', + key: 'videoGridRowPlaceholder', + }) + } else { + arr.push({ + type: 'loading', + key: 'loading', + }) + } } return arr @@ -409,7 +504,11 @@ let PostFeed = ({ hasSession, showProgressIntersitial, trendingDisabled, + trendingVideoDisabled, gtTablet, + gtMobile, + isVideoFeed, + areVideoFeedsEnabled, ]) // events @@ -498,6 +597,8 @@ let PostFeed = ({ return <ProgressGuide /> } else if (row.type === 'interstitialTrending') { return <TrendingInterstitial /> + } else if (row.type === 'interstitialTrendingVideos') { + return <TrendingVideosInterstitial /> } else if (row.type === 'sliceItem') { const slice = row.slice if (slice.isFallbackMarker) { @@ -532,6 +633,25 @@ let PostFeed = ({ ) } else if (row.type === 'sliceViewFullThread') { return <ViewFullThread uri={row.uri} /> + } else if (row.type === 'videoGridRowPlaceholder') { + return ( + <View> + <PostFeedVideoGridRowPlaceholder /> + <PostFeedVideoGridRowPlaceholder /> + <PostFeedVideoGridRowPlaceholder /> + </View> + ) + } else if (row.type === 'videoGridRow') { + return ( + <PostFeedVideoGridRow + items={row.items} + sourceContext={{ + type: 'feedgen', + uri: row.sourceFeedUri, + sourceInterstitial: feedCacheKey ?? 'none', + }} + /> + ) } else { return null } @@ -545,6 +665,7 @@ let PostFeed = ({ _, onPressRetryLoadMore, feedUri, + feedCacheKey, ], ) diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index a4e1a0947..41ca5b572 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -152,6 +152,9 @@ let List = React.forwardRef<ListMethods, ListProps>( return ( <FlatList_INTERNAL + showsVerticalScrollIndicator={!isAndroid} // overridable + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={viewabilityConfig} {...props} automaticallyAdjustsScrollIndicatorInsets={ automaticallyAdjustsScrollIndicatorInsets @@ -166,9 +169,6 @@ let List = React.forwardRef<ListMethods, ListProps>( onScroll={scrollHandler} scrollsToTop={!activeLightbox} scrollEventThrottle={1} - onViewableItemsChanged={onViewableItemsChanged} - viewabilityConfig={viewabilityConfig} - showsVerticalScrollIndicator={!isAndroid} style={style} // @ts-expect-error FlatList_INTERNAL ref type is wrong -sfn ref={ref} diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 607a480ff..f73cede35 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -69,7 +69,7 @@ let PostCtrls = ({ style?: StyleProp<ViewStyle> onPressReply: () => void onPostReply?: (postUri: string | undefined) => void - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' threadgateRecord?: AppBskyFeedThreadgate.Record }): React.ReactNode => { const t = useTheme() diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 59b296730..9043e2fdf 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -234,6 +234,7 @@ function HomeScreenReady({ feedParams={homeFeedParams} renderEmptyState={renderFollowingEmptyState} renderEndOfFeed={FollowingEndOfFeed} + feedInfo={feedInfo} /> ) } @@ -247,6 +248,7 @@ function HomeScreenReady({ feed={feed} renderEmptyState={renderCustomFeedEmptyState} savedFeedConfig={savedFeedConfig} + feedInfo={feedInfo} /> ) }) diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx index e27435c35..c5af9607b 100644 --- a/src/view/screens/Search/Explore.tsx +++ b/src/view/screens/Search/Explore.tsx @@ -12,7 +12,7 @@ import {useLingui} from '@lingui/react' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' +import {isNative, isWeb} from '#/platform/detection' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {usePreferencesQuery} from '#/state/queries/preferences' @@ -26,6 +26,7 @@ import { import {UserAvatar} from '#/view/com/util/UserAvatar' import {ExploreRecommendations} from '#/screens/Search/components/ExploreRecommendations' import {ExploreTrendingTopics} from '#/screens/Search/components/ExploreTrendingTopics' +import {ExploreTrendingVideos} from '#/screens/Search/components/ExploreTrendingVideos' import {atoms as a, useTheme, ViewStyleProp} from '#/alf' import {Button} from '#/components/Button' import * as FeedCard from '#/components/FeedCard' @@ -247,6 +248,10 @@ type ExploreScreenItems = key: string } | { + type: 'trendingVideos' + key: string + } + | { type: 'recommendations' key: string } @@ -343,6 +348,13 @@ export function Explore() { key: `trending-topics`, }) + if (isNative) { + i.push({ + type: 'trendingVideos', + key: `trending-videos`, + }) + } + i.push({ type: 'recommendations', key: `recommendations`, @@ -514,6 +526,9 @@ export function Explore() { case 'trendingTopics': { return <ExploreTrendingTopics /> } + case 'trendingVideos': { + return <ExploreTrendingVideos /> + } case 'recommendations': { return <ExploreRecommendations /> } |