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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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} =
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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': {}
|
||||
|
||||
@@ -7,3 +7,4 @@ export type Gate =
|
||||
| 'test_gate_1'
|
||||
| 'test_gate_2'
|
||||
| 'trending_topics_beta'
|
||||
| 'yolo'
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {VideoFeed} from '#/screens/Feeds/VibeScreen'
|
||||
@@ -0,0 +1,3 @@
|
||||
export function VideoScreen() {
|
||||
return null
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user