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", server.WebGeneric)
|
||||||
e.GET("/messages/:conversation", server.WebGeneric)
|
e.GET("/messages/:conversation", server.WebGeneric)
|
||||||
|
|
||||||
|
// temp
|
||||||
|
e.GET("/temp-vibe", server.WebGeneric)
|
||||||
|
|
||||||
// profile endpoints; only first populates info
|
// profile endpoints; only first populates info
|
||||||
e.GET("/profile/:handleOrDID", server.WebProfile)
|
e.GET("/profile/:handleOrDID", server.WebProfile)
|
||||||
e.GET("/profile/:handleOrDID/follows", server.WebGeneric)
|
e.GET("/profile/:handleOrDID/follows", server.WebGeneric)
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ import {
|
|||||||
StarterPackScreenShort,
|
StarterPackScreenShort,
|
||||||
} from '#/screens/StarterPack/StarterPackScreen'
|
} from '#/screens/StarterPack/StarterPackScreen'
|
||||||
import {Wizard} from '#/screens/StarterPack/Wizard'
|
import {Wizard} from '#/screens/StarterPack/Wizard'
|
||||||
|
import {VideoFeed} from '#/screens/VideoFeed'
|
||||||
import {useTheme} from '#/alf'
|
import {useTheme} from '#/alf'
|
||||||
import {router} from '#/routes'
|
import {router} from '#/routes'
|
||||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||||
@@ -422,6 +423,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
|||||||
getComponent={() => Wizard}
|
getComponent={() => Wizard}
|
||||||
options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
|
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,
|
color: dimPalette.contrast_400,
|
||||||
},
|
},
|
||||||
text_contrast_medium: {
|
text_contrast_medium: {
|
||||||
color: dimPalette.contrast_700,
|
color: dimPalette.contrast_600,
|
||||||
},
|
},
|
||||||
text_contrast_high: {
|
text_contrast_high: {
|
||||||
color: dimPalette.contrast_900,
|
color: dimPalette.contrast_900,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {useA11y} from '#/state/a11y'
|
|||||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||||
import {List, ListMethods, ListProps} from '#/view/com/util/List'
|
import {List, ListMethods, ListProps} from '#/view/com/util/List'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {useThemeName} from '#/alf/util/useColorModeTheme'
|
||||||
import {Context, useDialogContext} from '#/components/Dialog/context'
|
import {Context, useDialogContext} from '#/components/Dialog/context'
|
||||||
import {
|
import {
|
||||||
DialogControlProps,
|
DialogControlProps,
|
||||||
@@ -55,7 +56,8 @@ export function Outer({
|
|||||||
nativeOptions,
|
nativeOptions,
|
||||||
testID,
|
testID,
|
||||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||||
const t = useTheme()
|
const themeName = useThemeName()
|
||||||
|
const t = useTheme(themeName)
|
||||||
const ref = React.useRef<BottomSheetNativeComponent>(null)
|
const ref = React.useRef<BottomSheetNativeComponent>(null)
|
||||||
const closeCallbacks = React.useRef<(() => void)[]>([])
|
const closeCallbacks = React.useRef<(() => void)[]>([])
|
||||||
const {setDialogIsOpen, setFullyExpandedCount} =
|
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"
|
shape="square"
|
||||||
onPress={onPressBack}
|
onPress={onPressBack}
|
||||||
hitSlop={HITSLOP_30}
|
hitSlop={HITSLOP_30}
|
||||||
style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, style]}
|
style={[
|
||||||
|
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
|
||||||
|
a.bg_transparent,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
{...props}>
|
{...props}>
|
||||||
<ButtonIcon icon={ArrowLeft} size="lg" />
|
<ButtonIcon icon={ArrowLeft} size="lg" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,12 +6,18 @@ import {gradients} from '#/alf/tokens'
|
|||||||
|
|
||||||
export function LinearGradientBackground({
|
export function LinearGradientBackground({
|
||||||
style,
|
style,
|
||||||
|
gradient = 'sky',
|
||||||
children,
|
children,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
}: {
|
}: {
|
||||||
style: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
children: React.ReactNode
|
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
|
return color
|
||||||
}) as [string, string, ...string[]]
|
}) as [string, string, ...string[]]
|
||||||
|
|
||||||
@@ -20,7 +26,7 @@ export function LinearGradientBackground({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LinearGradient colors={gradient} style={style}>
|
<LinearGradient colors={colors} style={style} start={start} end={end}>
|
||||||
{children}
|
{children}
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {Text, TextProps} from '#/components/Typography'
|
|||||||
const WORD_WRAP = {wordWrap: 1}
|
const WORD_WRAP = {wordWrap: 1}
|
||||||
|
|
||||||
export type RichTextProps = TextStyleProp &
|
export type RichTextProps = TextStyleProp &
|
||||||
Pick<TextProps, 'selectable'> & {
|
Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
|
||||||
value: RichTextAPI | string
|
value: RichTextAPI | string
|
||||||
testID?: string
|
testID?: string
|
||||||
numberOfLines?: number
|
numberOfLines?: number
|
||||||
@@ -43,6 +43,8 @@ export function RichText({
|
|||||||
onLinkPress,
|
onLinkPress,
|
||||||
interactiveStyle,
|
interactiveStyle,
|
||||||
emojiMultiplier = 1.85,
|
emojiMultiplier = 1.85,
|
||||||
|
onLayout,
|
||||||
|
onTextLayout,
|
||||||
}: RichTextProps) {
|
}: RichTextProps) {
|
||||||
const richText = React.useMemo(
|
const richText = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -70,6 +72,8 @@ export function RichText({
|
|||||||
selectable={selectable}
|
selectable={selectable}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
style={[plainStyles, {fontSize}]}
|
style={[plainStyles, {fontSize}]}
|
||||||
|
onLayout={onLayout}
|
||||||
|
onTextLayout={onTextLayout}
|
||||||
// @ts-ignore web only -prf
|
// @ts-ignore web only -prf
|
||||||
dataSet={WORD_WRAP}>
|
dataSet={WORD_WRAP}>
|
||||||
{text}
|
{text}
|
||||||
@@ -83,6 +87,8 @@ export function RichText({
|
|||||||
testID={testID}
|
testID={testID}
|
||||||
style={plainStyles}
|
style={plainStyles}
|
||||||
numberOfLines={numberOfLines}
|
numberOfLines={numberOfLines}
|
||||||
|
onLayout={onLayout}
|
||||||
|
onTextLayout={onTextLayout}
|
||||||
// @ts-ignore web only -prf
|
// @ts-ignore web only -prf
|
||||||
dataSet={WORD_WRAP}>
|
dataSet={WORD_WRAP}>
|
||||||
{text}
|
{text}
|
||||||
@@ -163,6 +169,8 @@ export function RichText({
|
|||||||
testID={testID}
|
testID={testID}
|
||||||
style={plainStyles}
|
style={plainStyles}
|
||||||
numberOfLines={numberOfLines}
|
numberOfLines={numberOfLines}
|
||||||
|
onLayout={onLayout}
|
||||||
|
onTextLayout={onTextLayout}
|
||||||
// @ts-ignore web only -prf
|
// @ts-ignore web only -prf
|
||||||
dataSet={WORD_WRAP}>
|
dataSet={WORD_WRAP}>
|
||||||
{els}
|
{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 =
|
export const DISCOVER_FEED_URI =
|
||||||
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot'
|
'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 = {
|
export const DISCOVER_SAVED_FEED = {
|
||||||
type: 'feed',
|
type: 'feed',
|
||||||
value: DISCOVER_FEED_URI,
|
value: DISCOVER_FEED_URI,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {NavigationState, PartialState} from '@react-navigation/native'
|
import {NavigationState, PartialState} from '@react-navigation/native'
|
||||||
import type {NativeStackNavigationProp} from '@react-navigation/native-stack'
|
import type {NativeStackNavigationProp} from '@react-navigation/native-stack'
|
||||||
|
|
||||||
|
import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
|
||||||
|
|
||||||
export type {NativeStackScreenProps} from '@react-navigation/native-stack'
|
export type {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||||
|
|
||||||
export type CommonNavigatorParams = {
|
export type CommonNavigatorParams = {
|
||||||
@@ -57,6 +59,7 @@ export type CommonNavigatorParams = {
|
|||||||
StarterPackShort: {code: string}
|
StarterPackShort: {code: string}
|
||||||
StarterPackWizard: undefined
|
StarterPackWizard: undefined
|
||||||
StarterPackEdit: {rkey?: string}
|
StarterPackEdit: {rkey?: string}
|
||||||
|
VideoFeed: VideoFeedSourceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||||
|
|||||||
@@ -131,16 +131,16 @@ export type LogEvents = {
|
|||||||
doesPosterFollowLiker: boolean | undefined
|
doesPosterFollowLiker: boolean | undefined
|
||||||
likerClout: number | undefined
|
likerClout: number | undefined
|
||||||
postClout: number | undefined
|
postClout: number | undefined
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||||
}
|
}
|
||||||
'post:repost': {
|
'post:repost': {
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||||
}
|
}
|
||||||
'post:unlike': {
|
'post:unlike': {
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||||
}
|
}
|
||||||
'post:unrepost': {
|
'post:unrepost': {
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||||
}
|
}
|
||||||
'post:mute': {}
|
'post:mute': {}
|
||||||
'post:unmute': {}
|
'post:unmute': {}
|
||||||
@@ -163,6 +163,7 @@ export type LogEvents = {
|
|||||||
| 'FeedInterstitial'
|
| 'FeedInterstitial'
|
||||||
| 'ProfileHeaderSuggestedFollows'
|
| 'ProfileHeaderSuggestedFollows'
|
||||||
| 'PostOnboardingFindFollows'
|
| 'PostOnboardingFindFollows'
|
||||||
|
| 'ImmersiveVideo'
|
||||||
}
|
}
|
||||||
'profile:unfollow': {
|
'profile:unfollow': {
|
||||||
logContext:
|
logContext:
|
||||||
@@ -179,6 +180,7 @@ export type LogEvents = {
|
|||||||
| 'FeedInterstitial'
|
| 'FeedInterstitial'
|
||||||
| 'ProfileHeaderSuggestedFollows'
|
| 'ProfileHeaderSuggestedFollows'
|
||||||
| 'PostOnboardingFindFollows'
|
| 'PostOnboardingFindFollows'
|
||||||
|
| 'ImmersiveVideo'
|
||||||
}
|
}
|
||||||
'chat:create': {
|
'chat:create': {
|
||||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
||||||
@@ -249,6 +251,15 @@ export type LogEvents = {
|
|||||||
'recommendedTopic:click': {
|
'recommendedTopic:click': {
|
||||||
context: 'explore'
|
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:hide': {}
|
||||||
'progressGuide:followDialog:open': {}
|
'progressGuide:followDialog:open': {}
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export type Gate =
|
|||||||
| 'test_gate_1'
|
| 'test_gate_1'
|
||||||
| 'test_gate_2'
|
| 'test_gate_2'
|
||||||
| 'trending_topics_beta'
|
| 'trending_topics_beta'
|
||||||
|
| 'yolo'
|
||||||
|
|||||||
@@ -64,4 +64,5 @@ export const router = new Router({
|
|||||||
StarterPack: '/starter-pack/:name/:rkey',
|
StarterPack: '/starter-pack/:name/:rkey',
|
||||||
StarterPackShort: '/starter-pack-short/:code',
|
StarterPackShort: '/starter-pack-short/:code',
|
||||||
StarterPackWizard: '/starter-pack/create',
|
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 {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||||
|
|
||||||
import {CommonNavigatorParams} from '#/lib/routes/types'
|
import {CommonNavigatorParams} from '#/lib/routes/types'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
|
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
|
||||||
import {
|
import {
|
||||||
@@ -37,8 +39,13 @@ export function ContentAndMediaSettingsScreen({}: Props) {
|
|||||||
const inAppBrowserPref = useInAppBrowser()
|
const inAppBrowserPref = useInAppBrowser()
|
||||||
const setUseInAppBrowser = useSetInAppBrowser()
|
const setUseInAppBrowser = useSetInAppBrowser()
|
||||||
const {enabled: trendingEnabled} = useTrendingConfig()
|
const {enabled: trendingEnabled} = useTrendingConfig()
|
||||||
const {trendingDisabled} = useTrendingSettings()
|
const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
|
||||||
const {setTrendingDisabled} = useTrendingSettingsApi()
|
const {setTrendingDisabled, setTrendingVideoDisabled} =
|
||||||
|
useTrendingSettingsApi()
|
||||||
|
const gate = useGate()
|
||||||
|
const areVideoFeedsEnabled = React.useMemo(() => {
|
||||||
|
return gate('yolo')
|
||||||
|
}, [gate])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout.Screen>
|
<Layout.Screen>
|
||||||
@@ -138,6 +145,29 @@ export function ContentAndMediaSettingsScreen({}: Props) {
|
|||||||
<Toggle.Platform />
|
<Toggle.Platform />
|
||||||
</SettingsList.Item>
|
</SettingsList.Item>
|
||||||
</Toggle.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>
|
</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 */
|
/** @deprecated */
|
||||||
mutedThreads: z.array(z.string()),
|
mutedThreads: z.array(z.string()),
|
||||||
trendingDisabled: z.boolean().optional(),
|
trendingDisabled: z.boolean().optional(),
|
||||||
|
trendingVideoDisabled: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
export type Schema = z.infer<typeof schema>
|
export type Schema = z.infer<typeof schema>
|
||||||
|
|
||||||
@@ -172,6 +173,7 @@ export const defaults: Schema = {
|
|||||||
hasCheckedForStarterPack: false,
|
hasCheckedForStarterPack: false,
|
||||||
subtitlesEnabled: true,
|
subtitlesEnabled: true,
|
||||||
trendingDisabled: false,
|
trendingDisabled: false,
|
||||||
|
trendingVideoDisabled: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tryParse(rawData: string): Schema | undefined {
|
export function tryParse(rawData: string): Schema | undefined {
|
||||||
|
|||||||
@@ -4,18 +4,27 @@ import * as persisted from '#/state/persisted'
|
|||||||
|
|
||||||
type StateContext = {
|
type StateContext = {
|
||||||
trendingDisabled: Exclude<persisted.Schema['trendingDisabled'], undefined>
|
trendingDisabled: Exclude<persisted.Schema['trendingDisabled'], undefined>
|
||||||
|
trendingVideoDisabled: Exclude<
|
||||||
|
persisted.Schema['trendingVideoDisabled'],
|
||||||
|
undefined
|
||||||
|
>
|
||||||
}
|
}
|
||||||
type ApiContext = {
|
type ApiContext = {
|
||||||
setTrendingDisabled(
|
setTrendingDisabled(
|
||||||
hidden: Exclude<persisted.Schema['trendingDisabled'], undefined>,
|
hidden: Exclude<persisted.Schema['trendingDisabled'], undefined>,
|
||||||
): void
|
): void
|
||||||
|
setTrendingVideoDisabled(
|
||||||
|
hidden: Exclude<persisted.Schema['trendingVideoDisabled'], undefined>,
|
||||||
|
): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const StateContext = React.createContext<StateContext>({
|
const StateContext = React.createContext<StateContext>({
|
||||||
trendingDisabled: Boolean(persisted.defaults.trendingDisabled),
|
trendingDisabled: Boolean(persisted.defaults.trendingDisabled),
|
||||||
|
trendingVideoDisabled: Boolean(persisted.defaults.trendingVideoDisabled),
|
||||||
})
|
})
|
||||||
const ApiContext = React.createContext<ApiContext>({
|
const ApiContext = React.createContext<ApiContext>({
|
||||||
setTrendingDisabled() {},
|
setTrendingDisabled() {},
|
||||||
|
setTrendingVideoDisabled() {},
|
||||||
})
|
})
|
||||||
|
|
||||||
function usePersistedBooleanValue<T extends keyof persisted.Schema>(key: T) {
|
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<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const [trendingDisabled, setTrendingDisabled] =
|
const [trendingDisabled, setTrendingDisabled] =
|
||||||
usePersistedBooleanValue('trendingDisabled')
|
usePersistedBooleanValue('trendingDisabled')
|
||||||
|
const [trendingVideoDisabled, setTrendingVideoDisabled] =
|
||||||
|
usePersistedBooleanValue('trendingVideoDisabled')
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Context
|
* Context
|
||||||
*/
|
*/
|
||||||
const state = React.useMemo(() => ({trendingDisabled}), [trendingDisabled])
|
const state = React.useMemo(
|
||||||
|
() => ({trendingDisabled, trendingVideoDisabled}),
|
||||||
|
[trendingDisabled, trendingVideoDisabled],
|
||||||
|
)
|
||||||
const api = React.useMemo(
|
const api = React.useMemo(
|
||||||
() => ({setTrendingDisabled}),
|
() => ({setTrendingDisabled, setTrendingVideoDisabled}),
|
||||||
[setTrendingDisabled],
|
[setTrendingDisabled, setTrendingVideoDisabled],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import {
|
|||||||
} from './util'
|
} from './util'
|
||||||
|
|
||||||
type ActorDid = string
|
type ActorDid = string
|
||||||
type AuthorFilter =
|
export type AuthorFilter =
|
||||||
| 'posts_with_replies'
|
| 'posts_with_replies'
|
||||||
| 'posts_no_replies'
|
| 'posts_no_replies'
|
||||||
| 'posts_and_author_threads'
|
| 'posts_and_author_threads'
|
||||||
|
|||||||
+120
-11
@@ -9,18 +9,23 @@ import {
|
|||||||
View,
|
View,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {AppBskyActorDefs} from '@atproto/api'
|
import {AppBskyActorDefs, AppBskyEmbedVideo} from '@atproto/api'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
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 {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {useTheme} from '#/lib/ThemeContext'
|
import {useTheme} from '#/lib/ThemeContext'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isIOS, isWeb} from '#/platform/detection'
|
import {isIOS, isNative, isWeb} from '#/platform/detection'
|
||||||
import {listenPostCreated} from '#/state/events'
|
import {listenPostCreated} from '#/state/events'
|
||||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||||
import {useTrendingSettings} from '#/state/preferences/trending'
|
import {useTrendingSettings} from '#/state/preferences/trending'
|
||||||
@@ -29,18 +34,24 @@ import {
|
|||||||
FeedDescriptor,
|
FeedDescriptor,
|
||||||
FeedParams,
|
FeedParams,
|
||||||
FeedPostSlice,
|
FeedPostSlice,
|
||||||
|
FeedPostSliceItem,
|
||||||
pollLatest,
|
pollLatest,
|
||||||
RQKEY,
|
RQKEY,
|
||||||
usePostFeedQuery,
|
usePostFeedQuery,
|
||||||
} from '#/state/queries/post-feed'
|
} from '#/state/queries/post-feed'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useProgressGuide} from '#/state/shell/progress-guide'
|
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 {useBreakpoints} from '#/alf'
|
||||||
import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials'
|
import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials'
|
||||||
|
import {
|
||||||
|
PostFeedVideoGridRow,
|
||||||
|
PostFeedVideoGridRowPlaceholder,
|
||||||
|
} from '#/components/feeds/PostFeedVideoGridRow'
|
||||||
import {TrendingInterstitial} from '#/components/interstitials/Trending'
|
import {TrendingInterstitial} from '#/components/interstitials/Trending'
|
||||||
import {List, ListRef} from '../util/List'
|
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
|
||||||
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
|
||||||
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
|
||||||
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
|
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
|
||||||
import {FeedShutdownMsg} from './FeedShutdownMsg'
|
import {FeedShutdownMsg} from './FeedShutdownMsg'
|
||||||
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
|
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
|
||||||
@@ -69,7 +80,7 @@ type FeedRow =
|
|||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'slice'
|
type: 'slice' // TODO can we remove?
|
||||||
key: string
|
key: string
|
||||||
slice: FeedPostSlice
|
slice: FeedPostSlice
|
||||||
}
|
}
|
||||||
@@ -80,6 +91,16 @@ type FeedRow =
|
|||||||
indexInSlice: number
|
indexInSlice: number
|
||||||
showReplyTo: boolean
|
showReplyTo: boolean
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'videoGridRowPlaceholder'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'videoGridRow'
|
||||||
|
key: string
|
||||||
|
slices: FeedPostSliceItem[]
|
||||||
|
sourceFeedUri: string
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'sliceViewFullThread'
|
type: 'sliceViewFullThread'
|
||||||
key: string
|
key: string
|
||||||
@@ -97,6 +118,10 @@ type FeedRow =
|
|||||||
type: 'interstitialTrending'
|
type: 'interstitialTrending'
|
||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'interstitialTrendingVideos'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
export function getFeedPostSlice(feedRow: FeedRow): FeedPostSlice | null {
|
export function getFeedPostSlice(feedRow: FeedRow): FeedPostSlice | null {
|
||||||
if (feedRow.type === 'sliceItem') {
|
if (feedRow.type === 'sliceItem') {
|
||||||
@@ -163,7 +188,14 @@ let PostFeed = ({
|
|||||||
const checkForNewRef = React.useRef<(() => void) | null>(null)
|
const checkForNewRef = React.useRef<(() => void) | null>(null)
|
||||||
const lastFetchRef = React.useRef<number>(Date.now())
|
const lastFetchRef = React.useRef<number>(Date.now())
|
||||||
const [feedType, feedUri, feedTab] = feed.split('|')
|
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(
|
const opts = React.useMemo(
|
||||||
() => ({enabled, ignoreFilterFor}),
|
() => ({enabled, ignoreFilterFor}),
|
||||||
@@ -267,10 +299,10 @@ let PostFeed = ({
|
|||||||
const showProgressIntersitial =
|
const showProgressIntersitial =
|
||||||
(followProgressGuide || followAndLikeProgressGuide) && !isDesktop
|
(followProgressGuide || followAndLikeProgressGuide) && !isDesktop
|
||||||
|
|
||||||
const {trendingDisabled} = useTrendingSettings()
|
const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
|
||||||
|
|
||||||
const feedItems: FeedRow[] = React.useMemo(() => {
|
const feedItems: FeedRow[] = React.useMemo(() => {
|
||||||
let feedKind: 'following' | 'discover' | 'profile' | undefined
|
let feedKind: 'following' | 'discover' | 'profile' | 'thevids' | undefined
|
||||||
if (feedType === 'following') {
|
if (feedType === 'following') {
|
||||||
feedKind = 'following'
|
feedKind = 'following'
|
||||||
} else if (feedUri === DISCOVER_FEED_URI) {
|
} else if (feedUri === DISCOVER_FEED_URI) {
|
||||||
@@ -303,6 +335,43 @@ let PostFeed = ({
|
|||||||
})
|
})
|
||||||
} else if (data) {
|
} else if (data) {
|
||||||
let sliceIndex = -1
|
let sliceIndex = -1
|
||||||
|
|
||||||
|
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 page of data?.pages) {
|
||||||
for (const slice of page.slices) {
|
for (const slice of page.slices) {
|
||||||
sliceIndex++
|
sliceIndex++
|
||||||
@@ -319,7 +388,15 @@ let PostFeed = ({
|
|||||||
if (!gtTablet && !trendingDisabled) {
|
if (!gtTablet && !trendingDisabled) {
|
||||||
arr.push({
|
arr.push({
|
||||||
type: 'interstitialTrending',
|
type: 'interstitialTrending',
|
||||||
key: 'interstitial2-' + sliceIndex + '-' + lastFetchedAt,
|
key:
|
||||||
|
'interstitial2-' + sliceIndex + '-' + lastFetchedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (sliceIndex === 15) {
|
||||||
|
if (areVideoFeedsEnabled && !trendingVideoDisabled) {
|
||||||
|
arr.push({
|
||||||
|
type: 'interstitialTrendingVideos',
|
||||||
|
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (sliceIndex === 30) {
|
} else if (sliceIndex === 30) {
|
||||||
@@ -383,18 +460,26 @@ let PostFeed = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (isError && !isEmpty) {
|
if (isError && !isEmpty) {
|
||||||
arr.push({
|
arr.push({
|
||||||
type: 'loadMoreError',
|
type: 'loadMoreError',
|
||||||
key: 'loadMoreError',
|
key: 'loadMoreError',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (isVideoFeedAndIsEnabled) {
|
||||||
|
arr.push({
|
||||||
|
type: 'videoGridRowPlaceholder',
|
||||||
|
key: 'videoGridRowPlaceholder',
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
arr.push({
|
arr.push({
|
||||||
type: 'loading',
|
type: 'loading',
|
||||||
key: 'loading',
|
key: 'loading',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return arr
|
return arr
|
||||||
}, [
|
}, [
|
||||||
@@ -409,7 +494,11 @@ let PostFeed = ({
|
|||||||
hasSession,
|
hasSession,
|
||||||
showProgressIntersitial,
|
showProgressIntersitial,
|
||||||
trendingDisabled,
|
trendingDisabled,
|
||||||
|
trendingVideoDisabled,
|
||||||
gtTablet,
|
gtTablet,
|
||||||
|
gtMobile,
|
||||||
|
isVideoFeedAndIsEnabled,
|
||||||
|
areVideoFeedsEnabled,
|
||||||
])
|
])
|
||||||
|
|
||||||
// events
|
// events
|
||||||
@@ -498,6 +587,8 @@ let PostFeed = ({
|
|||||||
return <ProgressGuide />
|
return <ProgressGuide />
|
||||||
} else if (row.type === 'interstitialTrending') {
|
} else if (row.type === 'interstitialTrending') {
|
||||||
return <TrendingInterstitial />
|
return <TrendingInterstitial />
|
||||||
|
} else if (row.type === 'interstitialTrendingVideos') {
|
||||||
|
return <TrendingVideosInterstitial />
|
||||||
} else if (row.type === 'sliceItem') {
|
} else if (row.type === 'sliceItem') {
|
||||||
const slice = row.slice
|
const slice = row.slice
|
||||||
if (slice.isFallbackMarker) {
|
if (slice.isFallbackMarker) {
|
||||||
@@ -532,6 +623,24 @@ let PostFeed = ({
|
|||||||
)
|
)
|
||||||
} else if (row.type === 'sliceViewFullThread') {
|
} else if (row.type === 'sliceViewFullThread') {
|
||||||
return <ViewFullThread uri={row.uri} />
|
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 {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ let List = React.forwardRef<ListMethods, ListProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatList_INTERNAL
|
<FlatList_INTERNAL
|
||||||
|
showsVerticalScrollIndicator={!isAndroid} // overridable
|
||||||
|
onViewableItemsChanged={onViewableItemsChanged}
|
||||||
|
viewabilityConfig={viewabilityConfig}
|
||||||
{...props}
|
{...props}
|
||||||
automaticallyAdjustsScrollIndicatorInsets={
|
automaticallyAdjustsScrollIndicatorInsets={
|
||||||
automaticallyAdjustsScrollIndicatorInsets
|
automaticallyAdjustsScrollIndicatorInsets
|
||||||
@@ -166,9 +169,6 @@ let List = React.forwardRef<ListMethods, ListProps>(
|
|||||||
onScroll={scrollHandler}
|
onScroll={scrollHandler}
|
||||||
scrollsToTop={!activeLightbox}
|
scrollsToTop={!activeLightbox}
|
||||||
scrollEventThrottle={1}
|
scrollEventThrottle={1}
|
||||||
onViewableItemsChanged={onViewableItemsChanged}
|
|
||||||
viewabilityConfig={viewabilityConfig}
|
|
||||||
showsVerticalScrollIndicator={!isAndroid}
|
|
||||||
style={style}
|
style={style}
|
||||||
// @ts-expect-error FlatList_INTERNAL ref type is wrong -sfn
|
// @ts-expect-error FlatList_INTERNAL ref type is wrong -sfn
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ let PostCtrls = ({
|
|||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
onPressReply: () => void
|
onPressReply: () => void
|
||||||
onPostReply?: (postUri: string | undefined) => void
|
onPostReply?: (postUri: string | undefined) => void
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import {
|
|||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isNative, isWeb} from '#/platform/detection'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {ExploreRecommendations} from '#/screens/Search/components/ExploreRecommendations'
|
import {ExploreRecommendations} from '#/screens/Search/components/ExploreRecommendations'
|
||||||
import {ExploreTrendingTopics} from '#/screens/Search/components/ExploreTrendingTopics'
|
import {ExploreTrendingTopics} from '#/screens/Search/components/ExploreTrendingTopics'
|
||||||
|
import {ExploreTrendingVideos} from '#/screens/Search/components/ExploreTrendingVideos'
|
||||||
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import * as FeedCard from '#/components/FeedCard'
|
import * as FeedCard from '#/components/FeedCard'
|
||||||
@@ -246,6 +248,10 @@ type ExploreScreenItems =
|
|||||||
type: 'trendingTopics'
|
type: 'trendingTopics'
|
||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'trendingVideos'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'recommendations'
|
type: 'recommendations'
|
||||||
key: string
|
key: string
|
||||||
@@ -303,6 +309,7 @@ export function Explore() {
|
|||||||
error: feedsError,
|
error: feedsError,
|
||||||
fetchNextPage: fetchNextFeedsPage,
|
fetchNextPage: fetchNextFeedsPage,
|
||||||
} = useGetPopularFeedsQuery({limit: 10})
|
} = useGetPopularFeedsQuery({limit: 10})
|
||||||
|
const gate = useGate()
|
||||||
|
|
||||||
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
|
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
|
||||||
const onLoadMoreProfiles = React.useCallback(async () => {
|
const onLoadMoreProfiles = React.useCallback(async () => {
|
||||||
@@ -343,6 +350,13 @@ export function Explore() {
|
|||||||
key: `trending-topics`,
|
key: `trending-topics`,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (isNative && gate('yolo')) {
|
||||||
|
i.push({
|
||||||
|
type: 'trendingVideos',
|
||||||
|
key: `trending-videos`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
i.push({
|
i.push({
|
||||||
type: 'recommendations',
|
type: 'recommendations',
|
||||||
key: `recommendations`,
|
key: `recommendations`,
|
||||||
@@ -496,6 +510,7 @@ export function Explore() {
|
|||||||
preferencesError,
|
preferencesError,
|
||||||
hasNextProfilesPage,
|
hasNextProfilesPage,
|
||||||
hasNextFeedsPage,
|
hasNextFeedsPage,
|
||||||
|
gate,
|
||||||
])
|
])
|
||||||
|
|
||||||
const renderItem = React.useCallback(
|
const renderItem = React.useCallback(
|
||||||
@@ -514,6 +529,9 @@ export function Explore() {
|
|||||||
case 'trendingTopics': {
|
case 'trendingTopics': {
|
||||||
return <ExploreTrendingTopics />
|
return <ExploreTrendingTopics />
|
||||||
}
|
}
|
||||||
|
case 'trendingVideos': {
|
||||||
|
return <ExploreTrendingVideos />
|
||||||
|
}
|
||||||
case 'recommendations': {
|
case 'recommendations': {
|
||||||
return <ExploreRecommendations />
|
return <ExploreRecommendations />
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user