diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 3ef19fc462..12ba3a15eb 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -272,6 +272,9 @@ func serve(cctx *cli.Context) error { e.GET("/messages", server.WebGeneric) e.GET("/messages/:conversation", server.WebGeneric) + // temp + e.GET("/temp-vibe", server.WebGeneric) + // profile endpoints; only first populates info e.GET("/profile/:handleOrDID", server.WebProfile) e.GET("/profile/:handleOrDID/follows", server.WebGeneric) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 18705c5ffb..a6332c5d88 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}} /> + VideoFeed} + options={{ + title: title(msg`Video Feed`), + requireAuth: true, + }} + /> ) } diff --git a/src/alf/themes.ts b/src/alf/themes.ts index cb97a7065b..82b2e1b405 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 c424321be7..597964e291 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) { - const t = useTheme() + const themeName = useThemeName() + const t = useTheme(themeName) const ref = React.useRef(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 0000000000..d424634de4 --- /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 ( + ({gap}), [gap])}> + + {children} + + + ) +} + +export function Col({ + children, + width = 1, + style, +}: ViewStyleProp & { + children: React.ReactNode + width?: number +}) { + const {gap} = useContext(Context) + return ( + + {children} + + ) +} diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx index 2d0fc149ef..d38cf9d94c 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) { 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}> diff --git a/src/components/LinearGradientBackground.tsx b/src/components/LinearGradientBackground.tsx index 724df43f31..9b28b897c0 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 - children: React.ReactNode + style?: StyleProp + 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 ( - + {children} ) diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 6d7e50e480..4edd9f88ee 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 & { + Pick & { 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 0000000000..b487b0d8ac --- /dev/null +++ b/src/components/VideoPostCard.tsx @@ -0,0 +1,509 @@ +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) { + 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() + + /** + * 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 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) + + return ( + { + onInteract?.() + }} + onPressIn={onPressIn} + onPressOut={onPressOut} + style={[ + a.flex_col, + { + alignItems: undefined, + justifyContent: undefined, + }, + ]}> + + + + + + + + + + + {_(msg`Hidden`)} + + + + + + + + + + + + + + + + + {likeCount > 0 && ( + + + + {formatCount(i18n, likeCount)} + + + )} + {repostCount > 0 && ( + + + + {formatCount(i18n, repostCount)} + + + )} + + + + + + {text && ( + + {text} + + )} + + + + + + + {sanitizeHandle(post.author.handle, '@')} + + + + + + + ) +} + +export function VideoPostCardPlaceholder() { + const t = useTheme() + const black = getBlackColor(t) + + return ( + + + + + + + ) +} + +export function VideoPostCardTextPlaceholder({ + author, +}: { + author?: AppBskyActorDefs.ProfileViewBasic +}) { + const t = useTheme() + + return ( + + + + + {author ? ( + + + + + + + {sanitizeHandle(author.handle, '@')} + + + ) : ( + + + + + )} + + + ) +} + +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() + + /** + * 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 ( + { + onInteract?.() + }} + onPressIn={onPressIn} + onPressOut={onPressOut} + style={[ + a.flex_col, + { + alignItems: undefined, + justifyContent: undefined, + }, + ]}> + + + + + + + + + + + {_(msg`Hidden`)} + + + + + + + + + + + + + + + + + + + + + + + {likeCount > 0 && ( + + + + {formatCount(i18n, likeCount)} + + + )} + + + + + + + + ) +} + +export function CompactVideoPostCardPlaceholder() { + const t = useTheme() + const black = getBlackColor(t) + + return ( + + + + + + ) +} diff --git a/src/components/feeds/PostFeedVideoGridRow.tsx b/src/components/feeds/PostFeedVideoGridRow.tsx new file mode 100644 index 0000000000..7200afc0c1 --- /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({ + slices, + sourceContext, +}: { + slices: 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 ( + + + + {posts.map(post => ( + + { + logEvent('videoCard:click', {context: 'feed'}) + }} + /> + + ))} + + + + ) +} + +export function PostFeedVideoGridRowPlaceholder() { + const gutters = useGutters(['base', 'base', 0, 'base']) + return ( + + + + + + + ) +} diff --git a/src/components/interstitials/TrendingVideos.tsx b/src/components/interstitials/TrendingVideos.tsx new file mode 100644 index 0000000000..d41371bcb7 --- /dev/null +++ b/src/components/interstitials/TrendingVideos.tsx @@ -0,0 +1,194 @@ +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 {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 {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 + +export function TrendingVideos() { + const t = useTheme() + const {_} = useLingui() + const gutters = useGutters([0, 'base']) + const {data, isLoading, error} = usePostFeedQuery(`feedgen|${VIDEO_FEED_URI}`) + 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 ( + + + + + + Trending Videos + + + + + + + + {isLoading ? ( + Array(10) + .fill(0) + .map((_, i) => ( + + + + )) + ) : error || !data ? ( + + Whoops! Trending videos failed to load. + + ) : ( + + )} + + + + + + ) +} + +function VideoCards({ + data, +}: { + data: Exclude['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) + }, []) + + return ( + <> + {items.map(item => ( + + { + logEvent('videoCard:click', { + context: 'interstitial:discover', + }) + }} + /> + + ))} + + + + + + View more + + + + + + + + + ) +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 5ae000f729..bc099dab98 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -124,6 +124,8 @@ 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:yofh3kx63drvfljkibw5zuxo/app.bsky.feed.generator/thevids' export const DISCOVER_SAVED_FEED = { type: 'feed', value: DISCOVER_FEED_URI, diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index d720886e9f..fbade8223d 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 = { @@ -57,6 +59,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 19bf06ba98..af759e94e2 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/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index d2caa47f2a..7fbbe8d24e 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -7,3 +7,4 @@ export type Gate = | 'test_gate_1' | 'test_gate_2' | 'trending_topics_beta' + | 'yolo' diff --git a/src/routes.ts b/src/routes.ts index 7cd7c0880d..8541d4254d 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/Feeds/VibeScreen.tsx b/src/screens/Feeds/VibeScreen.tsx new file mode 100644 index 0000000000..f59a0e858b --- /dev/null +++ b/src/screens/Feeds/VibeScreen.tsx @@ -0,0 +1,968 @@ +import {useCallback, useMemo, useRef, useState} from 'react' +import { + LayoutAnimation, + ListRenderItem, + ScrollView, + View, + ViewToken, +} from 'react-native' +import { + Gesture, + GestureDetector, + NativeGesture, +} from 'react-native-gesture-handler' +import Animated, { + runOnJS, + runOnUI, + SharedValue, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' +import { + SafeAreaView, + useSafeAreaFrame, + useSafeAreaInsets, +} from 'react-native-safe-area-context' +import {useEvent, 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, + 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 {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +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 {usePostLikeMutationQueue} from '#/state/queries/post' +import { + AuthorFilter, + FeedPostSliceItem, + usePostFeedQuery, +} from '#/state/queries/post-feed' +import {useProfileFollowMutationQueue} from '#/state/queries/profile' +import {useSetMinimalShellMode} from '#/state/shell' +import {useSetLightStatusBar} from '#/state/shell/light-status-bar' +import {List} from '#/view/com/util/List' +import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' +import {formatTime} from '#/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {Header} from '#/screens/VideoFeed/Header' +import {atoms as a, platform, ThemeProvider, tokens, useTheme} from '#/alf' +import {setNavigationBar} from '#/alf/util/navigationBar' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' +import * as Layout from '#/components/Layout' +import {Link} from '#/components/Link' +import {ListFooter} from '#/components/Lists' +import {RichText} from '#/components/RichText' +import {Text} from '#/components/Typography' + +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>() + + const t = useTheme() + const setMinShellMode = useSetMinimalShellMode() + useFocusEffect( + useCallback(() => { + setMinShellMode(true) + setNavigationBar('lightbox', t) + return () => { + setMinShellMode(false) + setNavigationBar('theme', t) + } + }, [setMinShellMode, t]), + ) + + useSetLightStatusBar(true) + + return ( + + + +
+ + + + + ) +} + +function Inner() { + const {params} = useRoute>() + const isFocused = useIsFocused() + 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 { + data, + isFetching, + refetch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = usePostFeedQuery(feedDesc) + + let videos = data?.pages.flatMap(page => + page.slices.flatMap(slice => slice.items), + ) + const startingVideoIndex = videos?.findIndex(video => { + return video.post.uri === params.initialPostUri + }) + if (videos && startingVideoIndex && startingVideoIndex > -1) { + videos = videos.slice(startingVideoIndex) + } + + const [currentSources, setCurrentSources] = useState< + [string | null, string | null, string | null] + >([null, null, null]) + + const [players, setPlayers] = useState< + [VideoPlayer, VideoPlayer, VideoPlayer] | null + >(createThreeVideoPlayers) + + const [currentIndex, setCurrentIndex] = useState(0) + + const scrollGesture = useMemo(() => Gesture.Native(), []) + + const renderItem: ListRenderItem = useCallback( + ({item, index}) => { + const {post} = item + if (!post.embed || !AppBskyEmbedVideo.isView(post.embed)) { + return null + } + + const player = players?.[index % 3] + const currentSource = currentSources[index % 3] + + return ( + + ) + }, + [players, currentIndex, isFocused, currentSources, scrollGesture], + ) + + const updateVideoState = useNonReactiveCallback((index?: number) => { + if (!videos) return + + if (index === undefined) { + index = currentIndex + } else { + setCurrentIndex(index) + } + + setCurrentSources(oldSources => { + const currentSources = [...oldSources] as [ + string | null, + string | null, + string | null, + ] + + const prevEmbed = videos[index - 1]?.post.embed + const prevVideo = + prevEmbed && AppBskyEmbedVideo.isView(prevEmbed) + ? prevEmbed.playlist + : null + const currEmbed = videos[index]?.post.embed + const currVideo = + currEmbed && AppBskyEmbedVideo.isView(currEmbed) + ? currEmbed.playlist + : null + const nextEmbed = videos[index + 1]?.post.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) { + prevPlayer.replace(prevVideo) + } + prevPlayer.pause() + + if (currVideo) { + if (currVideo !== currPlayerCurrentSource) { + currPlayer.replace(currVideo) + } + currPlayer.play() + } + + if (nextVideo && nextVideo !== nextPlayerCurrentSource) { + nextPlayer.replace(nextVideo) + } + nextPlayer.pause() + } + + if (prevVideo && prevVideo !== prevPlayerCurrentSource) { + currentSources[(index + 2) % 3] = prevVideo + } + + if (currVideo && currVideo !== currPlayerCurrentSource) { + currentSources[index % 3] = currVideo + } + + if (nextVideo && nextVideo !== nextPlayerCurrentSource) { + currentSources[(index + 1) % 3] = nextVideo + } + + // use old array if no changes + if ( + oldSources[0] === currentSources[0] && + oldSources[1] === currentSources[1] && + oldSources[2] === currentSources[2] + ) { + return oldSources + } + return currentSources + }) + }) + + useFocusEffect( + useCallback(() => { + if (!players) { + // create players, set sources, start playing + updateVideoState() + } + return () => { + if (players) { + // manually release players when offscreen + players.forEach(p => p.release()) + setPlayers(null) + } + } + }, [players, updateVideoState]), + ) + + const onViewableItemsChanged = useCallback( + ({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { + if (viewableItems[0] && viewableItems[0].index !== null) { + updateVideoState(viewableItems[0].index) + } + }, + [updateVideoState], + ) + + return ( + + + } + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }} + showsVerticalScrollIndicator={false} + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={{itemVisiblePercentThreshold: 100}} + /> + + ) +} + +function keyExtractor(item: FeedPostSliceItem) { + return item._reactKey +} + +function VideoItem({ + player, + post, + embed, + active, + scrollGesture, +}: { + player?: VideoPlayer + post: AppBskyFeedDefs.PostView + embed: AppBskyEmbedVideo.View + active: boolean + scrollGesture: NativeGesture +}) { + const postShadow = usePostShadow(post) + const {width, height} = useSafeAreaFrame() + + return ( + + + {postShadow === POST_TOMBSTONE ? ( + + + Post has been deleted + + + ) : ( + <> + + {player && ( + + )} + + + )} + + + ) +} + +function VideoItemInner({ + player, + embed, + active, +}: { + player: VideoPlayer + embed: AppBskyEmbedVideo.View + active: boolean +}) { + const {status} = useEvent(player, 'statusChange', {status: player.status}) + + return ( + <> + {active && player && ( + + )} + + ) +} + +function Overlay({ + player, + post, + active, + scrollGesture, +}: { + player?: VideoPlayer + post: Shadow + active: boolean + scrollGesture: NativeGesture +}) { + const {_} = useLingui() + const t = useTheme() + const navigation = useNavigation() + const seekingAnimationSV = useSharedValue(0) + + const profile = useProfileShadow(post.author) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( + profile, + 'ImmersiveVideo', + ) + + const pushToProfile = useNonReactiveCallback(() => { + navigation.navigate('Profile', {name: post.author.did}) + }) + + const gesture = useMemo(() => { + const dragLeftGesture = Gesture.Pan() + .simultaneousWithExternalGesture(scrollGesture) + .activeOffsetX([0, 10]) + .failOffsetX([-10, 0]) + .failOffsetY([-5, 5]) + .maxPointers(1) + .onEnd(evt => { + 'worklet' + if (evt.translationX < -50 && evt.velocityX < -300) { + runOnJS(pushToProfile)() + } + }) + + return dragLeftGesture + }, [pushToProfile, scrollGesture]) + + 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(), + })) + + return ( + <> + + + + + + + + + + + + + + + {sanitizeDisplayName( + post.author.displayName || post.author.handle, + )} + + + {sanitizeHandle(post.author.handle, '@')} + + + + {/* show button based on non-reactive version, so it doesn't hide on press */} + {!post.author.viewer?.following && ( + + )} + + {record?.text?.trim() && ( + + + + )} + {record && ( + + + navigation.navigate('PostThread', { + name: post.author.did, + rkey, + }) + } + big + /> + + )} + + + {player && active ? ( + + ) : ( + + )} + + + {/* + {isAndroid && status === 'loading' && ( + + + + )} + */} + + ) +} + +/** + * Magic number that matches the Scrubber height + */ +function ScrubberPlaceholder() { + const {bottom} = useSafeAreaInsets() + return ( + + ) +} + +function Scrubber({ + player, + seekingAnimationSV, + scrollGesture, +}: { + player: VideoPlayer + seekingAnimationSV: SharedValue + scrollGesture: NativeGesture +}) { + 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) + durationSV.set(duration) + } + + useEventListener(player, 'timeUpdate', evt => { + runOnUI(updateTime)(evt.currentTime, player.duration) + }) + + const isSeekingSV = useSharedValue(false) + const seekProgressSV = useSharedValue(0) + + useAnimatedReaction( + () => Math.round(seekProgressSV.get()), + (progress, prevProgress) => { + if (progress !== prevProgress) { + runOnJS(setCurrentSeekTime)(progress) + } + }, + ) + + useAnimatedReaction( + () => Math.round(durationSV.get()), + (duration, prevDuration) => { + if (duration !== prevDuration) { + runOnJS(setDuration)(duration) + } + }, + ) + + const seekBy = useCallback( + (time: number) => { + player.seekBy(time) + }, + [player], + ) + + const gesture = useMemo(() => { + return Gesture.Pan() + .blocksExternalGesture(scrollGesture) + .failOffsetY([-10, 10]) + .onBegin(() => { + 'worklet' + console.log('begin') + }) + .onStart(() => { + 'worklet' + console.log('start') + 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()) + + // it's seek by, so offset by the current time + runOnJS(seekBy)(newTime - currentTimeSV.get()) + + isSeekingSV.set(false) + seekingAnimationSV.set(withTiming(0, {duration: 500})) + }) + }, [ + 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() + return { + height: seekingAnimationSV.get() * 3 + 1, + width: `${progress * 100}%`, + } + }) + + return ( + <> + + + + {formatTime(currentSeekTime)} + + {' / '} + + {formatTime(duration)} + + + + + + + + + + + ) +} + +function ExpandableRichTextView({ + value, + authorHandle, +}: { + value: RichTextAPI + authorHandle?: string +}) { + const {height: screenHeight} = useSafeAreaFrame() + const [expanded, setExpanded] = useState(false) + const [constrained, setConstrained] = useState(false) + const [contentHeight, setContentHeight] = useState(0) + const {_} = useLingui() + + return ( + { + if (expanded) { + LayoutAnimation.configureNext({ + duration: 500, + update: {type: 'spring', springDamping: 0.6}, + }) + } + setContentHeight(h) + }} + style={{height: Math.min(contentHeight, screenHeight * 0.5)}} + contentContainerStyle={[ + a.gap_xs, + expanded ? [a.align_start] : a.flex_row, + ]}> + { + if (!constrained && evt.nativeEvent.lines.length > 1) { + setConstrained(true) + } + }} + /> + {constrained && ( + + )} + + ) +} + +function VideoItemPlaceholder({ + embed, + style, +}: { + embed: AppBskyEmbedVideo.View + style?: ImageStyle +}) { + const src = embed.thumbnail + return src ? ( + + ) : null +} + +function PlayPauseTapArea({ + player, + post, +}: { + player?: VideoPlayer + post: Shadow +}) { + const doubleTapRef = useRef | null>(null) + const [queueLike] = usePostLikeMutationQueue(post, 'ImmersiveVideo') + 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 + queueLike() + } else { + doubleTapRef.current = setTimeout(togglePlayPause, 200) + } + } + + return ( + + ) +} + +function clamp(num: number, min: number, max: number) { + 'worklet' + return Math.min(Math.max(num, min), max) +} + +/* + * 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/Search/components/ExploreTrendingVideos.tsx b/src/screens/Search/components/ExploreTrendingVideos.tsx new file mode 100644 index 0000000000..475a6ba366 --- /dev/null +++ b/src/screens/Search/components/ExploreTrendingVideos.tsx @@ -0,0 +1,235 @@ +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 {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 {usePostFeedQuery} from '#/state/queries/post-feed' +import {useAddSavedFeedsMutation} from '#/state/queries/preferences' +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 + +export function ExploreTrendingVideos() { + const t = useTheme() + const {_} = useLingui() + const gutters = useGutters([0, 'base']) + const {data, isLoading, error} = usePostFeedQuery(`feedgen|${VIDEO_FEED_URI}`) + + 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 ( + + + + + + + Trending Videos + + + + + BETA + + + + + A new way to experience video on Bluesky + + + + + + + {isLoading ? ( + Array(10) + .fill(0) + .map((_, i) => ( + + + + )) + ) : error || !data ? ( + + Whoops! Trending videos failed to load. + + ) : ( + + )} + + + + {!isSavedAlready && ( + + + + Pin the trending videos feed to your home screen for easy access + + + + + )} + + ) +} + +function VideoCards({ + data, +}: { + data: Exclude['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) + }, []) + + return ( + <> + {items.map(item => ( + + { + logEvent('videoCard:click', { + context: 'interstitial:discover', + }) + }} + /> + + ))} + + + + + + View more + + + + + + + + + ) +} diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx index 4a9354bb84..6ebb083b95 100644 --- a/src/screens/Settings/ContentAndMediaSettings.tsx +++ b/src/screens/Settings/ContentAndMediaSettings.tsx @@ -1,9 +1,11 @@ +import React from 'react' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {CommonNavigatorParams} from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' +import {useGate} from '#/lib/statsig/statsig' import {isNative} from '#/platform/detection' import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences' import { @@ -37,8 +39,13 @@ 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() + const gate = useGate() + const areVideoFeedsEnabled = React.useMemo(() => { + return gate('yolo') + }, [gate]) return ( @@ -138,6 +145,29 @@ export function ContentAndMediaSettingsScreen({}: Props) { + {areVideoFeedsEnabled && ( + { + const hide = Boolean(!value) + if (hide) { + logEvent('trendingVideos:hide', {context: 'settings'}) + } else { + logEvent('trendingVideos:show', {context: 'settings'}) + } + setTrendingVideoDisabled(hide) + }}> + + + + Enable trending videos + + + + + )} )} diff --git a/src/screens/VideoFeed/Header.tsx b/src/screens/VideoFeed/Header.tsx new file mode 100644 index 0000000000..b40f9865b6 --- /dev/null +++ b/src/screens/VideoFeed/Header.tsx @@ -0,0 +1,126 @@ +import {View} from 'react-native' + +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 * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' + +export function HeaderPlaceholder() { + return ( + + + + + + + + + ) +} + +export function Header({ + sourceContext, +}: { + sourceContext: VideoFeedSourceContext +}) { + let content = null + switch (sourceContext.type) { + case 'feedgen': { + content = + break + } + case 'author': + // TODO + default: { + break + } + } + + return ( + + + {content} + + ) +} + +export function FeedHeader({ + sourceContext, +}: { + sourceContext: Exclude +}) { + const {gtMobile} = useBreakpoints() + + const { + data: info, + isLoading, + error, + } = useFeedSourceInfoQuery({uri: sourceContext.uri}) + + if (isLoading) { + return + } else if (error || !info) { + return null + } + + return ( + + {info.avatar && } + + + + {info.displayName} + + + + {sanitizeHandle(info.creatorHandle, '@')} + + + + + ) +} diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx new file mode 100644 index 0000000000..b9412343e4 --- /dev/null +++ b/src/screens/VideoFeed/index.tsx @@ -0,0 +1 @@ +export {VideoFeed} from '#/screens/Feeds/VibeScreen' diff --git a/src/screens/VideoFeed/index.web.tsx b/src/screens/VideoFeed/index.web.tsx new file mode 100644 index 0000000000..38ec8cc0ad --- /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 0000000000..22723bcc29 --- /dev/null +++ b/src/screens/VideoFeed/types.ts @@ -0,0 +1,13 @@ +import {AuthorFilter} from '#/state/queries/post-feed' + +/** + * Kind of like `FeedDescriptor` but not + */ +export type VideoFeedSourceContext = + | {type: 'feedgen'; uri: string; initialPostUri?: string} + | { + type: 'author' + did: string + filter: AuthorFilter + initialPostUri?: string + } diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 0a9e5b2c07..f840081f3d 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 @@ -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 bf5d8f13cc..87ec687712 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 + trendingVideoDisabled: Exclude< + persisted.Schema['trendingVideoDisabled'], + undefined + > } type ApiContext = { setTrendingDisabled( hidden: Exclude, ): void + setTrendingVideoDisabled( + hidden: Exclude, + ): void } const StateContext = React.createContext({ trendingDisabled: Boolean(persisted.defaults.trendingDisabled), + trendingVideoDisabled: Boolean(persisted.defaults.trendingVideoDisabled), }) const ApiContext = React.createContext({ setTrendingDisabled() {}, + setTrendingVideoDisabled() {}, }) function usePersistedBooleanValue(key: T) { @@ -43,14 +52,19 @@ function usePersistedBooleanValue(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/post-feed.ts b/src/state/queries/post-feed.ts index 2eb604627e..61dabf5f10 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' diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index f9b2e6e76b..d16ce8a0c4 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -9,18 +9,23 @@ 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' -import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' +import { + DISCOVER_FEED_URI, + KNOWN_SHUTDOWN_FEEDS, + VIDEO_FEED_URI, +} from '#/lib/constants' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {useGate} from '#/lib/statsig/statsig' 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 +34,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 +80,7 @@ type FeedRow = key: string } | { - type: 'slice' + type: 'slice' // TODO can we remove? key: string slice: FeedPostSlice } @@ -80,6 +91,16 @@ type FeedRow = indexInSlice: number showReplyTo: boolean } + | { + type: 'videoGridRowPlaceholder' + key: string + } + | { + type: 'videoGridRow' + key: string + slices: FeedPostSliceItem[] + sourceFeedUri: string + } | { type: 'sliceViewFullThread' key: string @@ -97,6 +118,10 @@ type FeedRow = type: 'interstitialTrending' key: string } + | { + type: 'interstitialTrendingVideos' + key: string + } export function getFeedPostSlice(feedRow: FeedRow): FeedPostSlice | null { if (feedRow.type === 'sliceItem') { @@ -163,7 +188,14 @@ let PostFeed = ({ const checkForNewRef = React.useRef<(() => void) | null>(null) const lastFetchRef = React.useRef(Date.now()) const [feedType, feedUri, feedTab] = feed.split('|') - const {gtTablet} = useBreakpoints() + const {gtMobile, gtTablet} = useBreakpoints() + const gate = useGate() + const areVideoFeedsEnabled = React.useMemo(() => { + return isNative && gate('yolo') + }, [gate]) + const isVideoFeedAndIsEnabled = React.useMemo(() => { + return feedUri === VIDEO_FEED_URI && areVideoFeedsEnabled + }, [feedUri, areVideoFeedsEnabled]) const opts = React.useMemo( () => ({enabled, ignoreFilterFor}), @@ -267,10 +299,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 +335,127 @@ let PostFeed = ({ }) } else if (data) { let sliceIndex = -1 - for (const page of data?.pages) { - for (const slice of page.slices) { - sliceIndex++ - if (hasSession) { - if (feedKind === 'discover') { - if (sliceIndex === 0) { - if (showProgressIntersitial) { + if (isVideoFeedAndIsEnabled) { + const rows: FeedPostSliceItem[][] = [] + let slices: {slice: FeedPostSlice; index: number}[] = [] + for (const page of data.pages) { + for (const slice of page.slices) { + slices.push({slice, index: sliceIndex++}) + } + } + + for (let i = 0; i < slices.length; i++) { + const slice = slices[i] + const root = slice.slice.items.at(0) + if (!root) continue + // TODO test this + if (!AppBskyEmbedVideo.isView(root.post.embed)) { + i-- + continue + } + const cols = gtMobile ? 3 : 2 + if (i % cols === 0) { + rows.push([root]) + } else { + rows[rows.length - 1].push(root) + } + } + + for (const row of rows) { + sliceIndex++ + arr.push({ + type: 'videoGridRow', + key: row.map(r => r._reactKey).join('-'), + slices: row, + sourceFeedUri: feedUri, + }) + } + } else { + for (const page of data?.pages) { + for (const slice of page.slices) { + sliceIndex++ + + 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[i]._reactKey, + key: slice.items[0]._reactKey, slice: slice, - indexInSlice: i, - showReplyTo: i === 0, + 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++) { + arr.push({ + type: 'sliceItem', + key: slice.items[i]._reactKey, + slice: slice, + indexInSlice: i, + showReplyTo: i === 0, + }) + } } } } @@ -390,10 +468,17 @@ let PostFeed = ({ }) } } else { - arr.push({ - type: 'loading', - key: 'loading', - }) + if (isVideoFeedAndIsEnabled) { + arr.push({ + type: 'videoGridRowPlaceholder', + key: 'videoGridRowPlaceholder', + }) + } else { + arr.push({ + type: 'loading', + key: 'loading', + }) + } } return arr @@ -409,7 +494,11 @@ let PostFeed = ({ hasSession, showProgressIntersitial, trendingDisabled, + trendingVideoDisabled, gtTablet, + gtMobile, + isVideoFeedAndIsEnabled, + areVideoFeedsEnabled, ]) // events @@ -498,6 +587,8 @@ let PostFeed = ({ return } else if (row.type === 'interstitialTrending') { return + } else if (row.type === 'interstitialTrendingVideos') { + return } else if (row.type === 'sliceItem') { const slice = row.slice if (slice.isFallbackMarker) { @@ -532,6 +623,24 @@ let PostFeed = ({ ) } else if (row.type === 'sliceViewFullThread') { return + } else if (row.type === 'videoGridRowPlaceholder') { + return ( + + + + + + ) + } else if (row.type === 'videoGridRow') { + return ( + + ) } else { return null } diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index a4e1a0947b..41ca5b5725 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -152,6 +152,9 @@ let List = React.forwardRef( return ( ( 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 607a480ff2..f73cede352 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 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/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx index e27435c35c..3b8386263b 100644 --- a/src/view/screens/Search/Explore.tsx +++ b/src/view/screens/Search/Explore.tsx @@ -10,9 +10,10 @@ import { import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useGate} from '#/lib/statsig/statsig' 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 +27,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' @@ -246,6 +248,10 @@ type ExploreScreenItems = type: 'trendingTopics' key: string } + | { + type: 'trendingVideos' + key: string + } | { type: 'recommendations' key: string @@ -303,6 +309,7 @@ export function Explore() { error: feedsError, fetchNextPage: fetchNextFeedsPage, } = useGetPopularFeedsQuery({limit: 10}) + const gate = useGate() const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles const onLoadMoreProfiles = React.useCallback(async () => { @@ -343,6 +350,13 @@ export function Explore() { key: `trending-topics`, }) + if (isNative && gate('yolo')) { + i.push({ + type: 'trendingVideos', + key: `trending-videos`, + }) + } + i.push({ type: 'recommendations', key: `recommendations`, @@ -496,6 +510,7 @@ export function Explore() { preferencesError, hasNextProfilesPage, hasNextFeedsPage, + gate, ]) const renderItem = React.useCallback( @@ -514,6 +529,9 @@ export function Explore() { case 'trendingTopics': { return } + case 'trendingVideos': { + return + } case 'recommendations': { return }