Merge branch 'pnbx/fix-consume' into pnbx/base

* pnbx/fix-consume: (59 commits)
  Part out the interstitials for perf, add view more
  follow button
  attempt to fix broken scrub on android (not working)
  fix interval + decel rate on interstitials
  android nav bar fixes + lower update speed
  Add feature gate
  Update events
  Only use grid placeholder on native
  Explore interstitial, handle dimissal, pinning, compact card
  Add Discover interstitial, settings, includes pin for now
  Fix grid layout trailing single item
  fix type error
  reduce update rate
  emoji in post text
  Add content hider to video card
  Maybe fix row generation
  mostly fix android flicker
  fix jitter fr this time
  link to profile on press
  fix jank
  ...
This commit is contained in:
Eric Bailey
2025-01-17 18:09:42 -06:00
30 changed files with 2495 additions and 97 deletions
+3
View File
@@ -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)
+9
View File
@@ -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,
}}
/>
</>
)
}
+1 -1
View File
@@ -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,
+3 -1
View File
@@ -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} =
+59
View File
@@ -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>
)
}
+5 -1
View File
@@ -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>
+10 -4
View File
@@ -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>
)
+9 -1
View File
@@ -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}
+509
View File
@@ -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<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()
/**
* 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 (
<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={moderation.ui('contentList')}>
<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={40}
/>
<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>
<VideoPostCardTextPlaceholder author={post.author} />
</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>
<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>
</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()
/**
* 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={moderation.ui('contentList')}>
<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={40}
/>
<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>
<VideoPostCardTextPlaceholder author={post.author} />
</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>
)
}
@@ -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 (
<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>
)
}
@@ -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 (
<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>
<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>
<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)
}, [])
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,
}}
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,
]}>
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<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>
</>
)
}
+2
View File
@@ -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,
+3
View File
@@ -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 & {
+15 -4
View File
@@ -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': {}
+1
View File
@@ -7,3 +7,4 @@ export type Gate =
| 'test_gate_1'
| 'test_gate_2'
| 'trending_topics_beta'
| 'yolo'
+1
View File
@@ -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',
})
+968
View File
@@ -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<RouteProp<CommonNavigatorParams, 'VideoFeed'>>()
const t = useTheme()
const setMinShellMode = useSetMinimalShellMode()
useFocusEffect(
useCallback(() => {
setMinShellMode(true)
setNavigationBar('lightbox', t)
return () => {
setMinShellMode(false)
setNavigationBar('theme', t)
}
}, [setMinShellMode, t]),
)
useSetLightStatusBar(true)
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>
<Inner />
</Layout.Screen>
</ThemeProvider>
)
}
function Inner() {
const {params} = useRoute<RouteProp<CommonNavigatorParams, 'VideoFeed'>>()
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<FeedPostSliceItem> = 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 (
<VideoItem
player={player}
post={post}
embed={post.embed}
active={
isFocused &&
index === currentIndex &&
currentSource === post.embed.playlist
}
scrollGesture={scrollGesture}
/>
)
},
[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 (
<GestureDetector gesture={scrollGesture}>
<List
data={videos}
renderItem={renderItem}
keyExtractor={keyExtractor}
pagingEnabled={true}
refreshing={isFetching}
onRefresh={refetch}
ListFooterComponent={
<ListFooter
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onRetry={refetch}
/>
}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}}
showsVerticalScrollIndicator={false}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={{itemVisiblePercentThreshold: 100}}
/>
</GestureDetector>
)
}
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 (
<View style={[a.relative, {height, width}]}>
<SafeAreaView edges={['left', 'right', 'bottom']} style={[a.flex_1]}>
{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} />
{player && (
<VideoItemInner player={player} embed={embed} active={active} />
)}
<Overlay
player={player}
post={postShadow}
active={active}
scrollGesture={scrollGesture}
/>
</>
)}
</SafeAreaView>
</View>
)
}
function VideoItemInner({
player,
embed,
active,
}: {
player: VideoPlayer
embed: AppBskyEmbedVideo.View
active: boolean
}) {
const {status} = useEvent(player, 'statusChange', {status: player.status})
return (
<>
{active && player && (
<VideoView
style={[
a.absolute,
a.inset_0,
isAndroid && status === 'loading' && {opacity: 0},
]}
player={player}
nativeControls={false}
contentFit={
isTallAspectRatio(embed.aspectRatio) ? 'cover' : 'contain'
}
accessibilityIgnoresInvertColors
/>
)}
</>
)
}
function Overlay({
player,
post,
active,
scrollGesture,
}: {
player?: VideoPlayer
post: Shadow<AppBskyFeedDefs.PostView>
active: boolean
scrollGesture: NativeGesture
}) {
const {_} = useLingui()
const t = useTheme()
const navigation = useNavigation<NavigationProp>()
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 (
<>
<View style={[a.absolute, a.inset_0, a.z_20]}>
<GestureDetector gesture={gesture}>
<View style={[a.flex_1]}>
<PlayPauseTapArea player={player} post={post} />
</View>
</GestureDetector>
<LinearGradient
colors={['rgba(0,0,0,0)', 'rgba(0,0,0,0.8)', '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.pb_sm,
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="outline"
color="secondary_inverted"
style={[a.mb_xs, a.bg_transparent]}
hoverStyle={[]}
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() && (
<View style={[a.pb_sm]}>
<ExpandableRichTextView
value={richText}
authorHandle={post.author.handle}
/>
</View>
)}
{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>
{player && active ? (
<Scrubber
player={player}
seekingAnimationSV={seekingAnimationSV}
scrollGesture={scrollGesture}
/>
) : (
<ScrubberPlaceholder />
)}
</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>
)}
*/}
</>
)
}
/**
* Magic number that matches the Scrubber height
*/
function ScrubberPlaceholder() {
const {bottom} = useSafeAreaInsets()
return (
<View
style={[
a.w_full,
{
// same as Scrubber
height: bottom + tokens.space.xl,
},
]}
/>
)
}
function Scrubber({
player,
seekingAnimationSV,
scrollGesture,
}: {
player: VideoPlayer
seekingAnimationSV: SharedValue<number>
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 (
<>
<Animated.View
style={[
a.absolute,
{
left: 0,
right: 0,
bottom: insets.bottom + 48,
},
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={gesture}>
<View
style={[
a.relative,
a.w_full,
a.justify_end,
{
paddingBottom: insets.bottom,
height:
// bottom padding
insets.bottom +
// actual height
tokens.space.xl,
},
a.z_10,
]}>
<Animated.View style={[{backgroundColor: 'white'}, barStyle]} />
</View>
</GestureDetector>
</>
)
}
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 (
<ScrollView
scrollEnabled={expanded}
onContentSizeChange={(_w, h) => {
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,
]}>
<RichText
value={value}
style={[a.text_sm, a.flex_1]}
authorHandle={authorHandle}
enableTags
numberOfLines={expanded ? undefined : constrained ? 1 : 2}
onTextLayout={evt => {
if (!constrained && evt.nativeEvent.lines.length > 1) {
setConstrained(true)
}
}}
/>
{constrained && (
<Button
label={expanded ? _(msg`Read less`) : _(msg`Read more`)}
hitSlop={HITSLOP_20}
onPress={() => setExpanded(prev => !prev)}>
<ButtonText>
{expanded ? <Trans>Read less</Trans> : <Trans>Read more</Trans>}
</ButtonText>
</Button>
)}
</ScrollView>
)
}
function VideoItemPlaceholder({
embed,
style,
}: {
embed: AppBskyEmbedVideo.View
style?: ImageStyle
}) {
const src = embed.thumbnail
return src ? (
<Image
accessibilityIgnoresInvertColors
source={{uri: src}}
style={[a.absolute, a.inset_0, style]}
contentFit={isTallAspectRatio(embed.aspectRatio) ? 'cover' : 'contain'}
/>
) : null
}
function PlayPauseTapArea({
player,
post,
}: {
player?: VideoPlayer
post: Shadow<AppBskyFeedDefs.PostView>
}) {
const doubleTapRef = useRef<ReturnType<typeof setTimeout> | 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 (
<Button
disabled={!player}
label="Toggle play/pause"
accessibilityHint="Double tap to like"
onPress={onPress}
style={[a.absolute, a.inset_0]}>
<View />
</Button>
)
}
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
}
@@ -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 (
<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>A new way to experience video on Bluesky</Trans>
</Text>
</View>
</View>
<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>
{!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)
}, [])
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,
}}
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,
]}>
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<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>
</>
)
}
@@ -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 (
<Layout.Screen>
@@ -138,6 +145,29 @@ export function ContentAndMediaSettingsScreen({}: Props) {
<Toggle.Platform />
</SettingsList.Item>
</Toggle.Item>
{areVideoFeedsEnabled && (
<Toggle.Item
name="show_trending_videos"
label={_(msg`Enable trending videos`)}
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</Trans>
</SettingsList.ItemText>
<Toggle.Platform />
</SettingsList.Item>
</Toggle.Item>
)}
</>
)}
</SettingsList.Container>
+126
View File
@@ -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 (
<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>
<Layout.Header.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 (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>
)
}
+1
View File
@@ -0,0 +1 @@
export {VideoFeed} from '#/screens/Feeds/VibeScreen'
+3
View File
@@ -0,0 +1,3 @@
export function VideoScreen() {
return null
}
+13
View File
@@ -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
}
+2
View File
@@ -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 {
+17 -3
View File
@@ -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 (
+1 -1
View File
@@ -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'
+183 -74
View File
@@ -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<number>(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 <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 +623,24 @@ 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
slices={row.slices}
sourceContext={{
type: 'feedgen',
uri: row.sourceFeedUri,
}}
/>
)
} else {
return null
}
+3 -3
View File
@@ -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}
+1 -1
View File
@@ -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()
+19 -1
View File
@@ -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 <ExploreTrendingTopics />
}
case 'trendingVideos': {
return <ExploreTrendingVideos />
}
case 'recommendations': {
return <ExploreRecommendations />
}