yolo (#7499)
* tweaks to constants (#7478) * add did * use correct did * typo * tweak * Prevent Drawer gesture conflicting with Suggestions scroll (#7468) * Extract BlockDrawerGeesture * Block drawer when scrolling interstitials (cherry picked from commit9e3f2f4374) * yolo interstitial * yolo mode * right swipe * fix nav gesture * vibe controls * collapsible post text * rm blurview, cover for tall videos * smarter video source handling * use thumbnails, improve perf significantly * better android loading * improve aspect ratio * optimize source changes * rm spinner on ios * whoops, remove debug `false` * FIX WRONG VIDEOS SHOWING UP * don't spring on way down * release video players when leaving screen * remove jank animation * Add grid * improve contract, fix double tap * Filter out posts without videos * Only do grid on native * Pipe through feedSourceUri and link to feed * Handle passed through params * Partial revert, just filter posts to start at index * Clean up cards, remove entry interstitial * Tweak handle * Change constant name * Rename some things * Make types legit * Clean up more naming * Add placeholder for grid view * Handle web, set up new organization * Begin work on Header * Replace types * Squashed commit of the following: commit3d1be4c0f1Author: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 01:08:05 2025 +0000 extend animation commitc9f199413bAuthor: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 01:01:24 2025 +0000 fix gap commit22e520795fAuthor: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 00:50:16 2025 +0000 thinner bar, format time commitc32427f214Author: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 00:47:57 2025 +0000 fix 2 in 3 screens commitcbf84c08d6Author: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 00:45:46 2025 +0000 rm unneeded var commit7e0e100177Author: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 00:41:18 2025 +0000 scrubberrrrr * use white with opacity rather than gray * Simultaneous gesture * cleanup attempt * fix jank * link to profile on press * fix jitter fr this time * mostly fix android flicker * Maybe fix row generation * Add content hider to video card * emoji in post text * reduce update rate * fix type error * Fix grid layout trailing single item * Add Discover interstitial, settings, includes pin for now * Explore interstitial, handle dimissal, pinning, compact card * Only use grid placeholder on native * Update events * Add feature gate * android nav bar fixes + lower update speed * fix interval + decel rate on interstitials * attempt to fix broken scrub on android (not working) * follow button * Part out the interstitials for perf, add view more * Remove prod web route * Wrap interstitials with BlockDrawerGesture * Bring video cropping in line with images (#7462) * Mimic image cropping for videos on web * Same on native * Rename variables for clarity * Fix Android scrubbing * Add FeedFeedbackProvider * Remove swipe gesture * fix light status bar behaviour * bump * feedback * Copy pasta to new location * Copy pasta part deux * Filter only videos * Make whole text clickable to expand (cherry picked from commit 4cf31120779f4e06eb4c296b3d4b53814d432b07) * move scrubber to own file * end card * add icon to end card * add min view time to viewability config * play haptic on like * tweak feedback * tweak feedback again * Moderation (cherry picked from commit 6b6b471cfb363031284b3e7a1f6e0ade3ac4ae47) * remove bad check * fix feedback for new video grid * change prop name to items as well * Simplify logic * Fix mod footer * Give scrubber more space on android * Add subtle track behind scrubber, adjust opacity * wire in feed context again... * Add better a11y desc to card * Fix key issue * Update a11y copy * Fix scrubber height * improve scrubber animation * Make follow button more obvious * Make header back button more clear * Disable interactions with actual video el * keep content away from the bottom safe area * fix blur * fix moderation issue * improve contrast on mod screen * Make moderation static per item * Memoize rows * Optimizations * Take video moderation into account * Only blur titles for list blur * Change copy * Bump blur radius * animate text in both directions * Rm unused field * Filter by root early * Refactor for clarity * add compose prompt to scrubber * rm log * tweak gradient * Bump SDK, use contentMode to power video feed * Ensure ProfileFeed view also supports video feed * improve scrubber on android * rm border from footer * Update prod video feed did * Separate caches * Add lil hover to View More * Fix undefined logic, remove header for interstitial * Ungate * Fix stuckness * remove extra useless map * Fix effect cleanup * Send seen without cleanup * Simplify react stuff * Earlier early return to avoid loading flash * remove scrubber placeholder * Remove opacity hack * Render useEvent conditionally * Fix Android flash --------- Co-authored-by: dan <dan.abramov@gmail.com> Co-authored-by: Samuel Newman <mozzius@protonmail.com> Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "1.96.5",
|
"version": "1.96.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
"icons:optimize": "svgo -f ./assets/icons"
|
"icons:optimize": "svgo -f ./assets/icons"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.13.21",
|
"@atproto/api": "^0.13.28",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function ListFooter({
|
|||||||
style,
|
style,
|
||||||
showEndMessage = false,
|
showEndMessage = false,
|
||||||
endMessageText,
|
endMessageText,
|
||||||
|
renderEndMessage,
|
||||||
}: {
|
}: {
|
||||||
isFetchingNextPage?: boolean
|
isFetchingNextPage?: boolean
|
||||||
hasNextPage?: boolean
|
hasNextPage?: boolean
|
||||||
@@ -29,6 +30,7 @@ export function ListFooter({
|
|||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
showEndMessage?: boolean
|
showEndMessage?: boolean
|
||||||
endMessageText?: string
|
endMessageText?: string
|
||||||
|
renderEndMessage?: () => React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
@@ -48,9 +50,13 @@ export function ListFooter({
|
|||||||
) : error ? (
|
) : error ? (
|
||||||
<ListFooterMaybeError error={error} onRetry={onRetry} />
|
<ListFooterMaybeError error={error} onRetry={onRetry} />
|
||||||
) : !hasNextPage && showEndMessage ? (
|
) : !hasNextPage && showEndMessage ? (
|
||||||
|
renderEndMessage ? (
|
||||||
|
renderEndMessage()
|
||||||
|
) : (
|
||||||
<Text style={[a.text_sm, t.atoms.text_contrast_low]}>
|
<Text style={[a.text_sm, t.atoms.text_contrast_low]}>
|
||||||
{endMessageText ?? <Trans>You have reached the end</Trans>}
|
{endMessageText ?? <Trans>You have reached the end</Trans>}
|
||||||
</Text>
|
</Text>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,540 @@
|
|||||||
|
import {useMemo} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {LinearGradient} from 'expo-linear-gradient'
|
||||||
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
|
AppBskyEmbedVideo,
|
||||||
|
AppBskyFeedDefs,
|
||||||
|
AppBskyFeedPost,
|
||||||
|
ModerationDecision,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
import {formatCount} from '#/view/com/util/numeric/format'
|
||||||
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
|
import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {BLUE_HUE} from '#/alf/util/colorGeneration'
|
||||||
|
import {select} from '#/alf/util/themeSelector'
|
||||||
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
|
import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash'
|
||||||
|
import {Heart2_Stroke2_Corner0_Rounded as Heart} from '#/components/icons/Heart2'
|
||||||
|
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
|
import * as Hider from '#/components/moderation/Hider'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
function getBlackColor(t: ReturnType<typeof useTheme>) {
|
||||||
|
return select(t.name, {
|
||||||
|
light: t.palette.black,
|
||||||
|
dark: t.atoms.bg_contrast_25.backgroundColor,
|
||||||
|
dim: `hsl(${BLUE_HUE}, 28%, 6%)`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoPostCard({
|
||||||
|
post,
|
||||||
|
sourceContext,
|
||||||
|
moderation,
|
||||||
|
onInteract,
|
||||||
|
}: {
|
||||||
|
post: AppBskyFeedDefs.PostView
|
||||||
|
sourceContext: VideoFeedSourceContext
|
||||||
|
moderation: ModerationDecision
|
||||||
|
/**
|
||||||
|
* Callback for metrics etc
|
||||||
|
*/
|
||||||
|
onInteract?: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_, i18n} = useLingui()
|
||||||
|
const embed = post.embed
|
||||||
|
const {
|
||||||
|
state: pressed,
|
||||||
|
onIn: onPressIn,
|
||||||
|
onOut: onPressOut,
|
||||||
|
} = useInteractionState()
|
||||||
|
|
||||||
|
const listModUi = moderation.ui('contentList')
|
||||||
|
|
||||||
|
const mergedModui = useMemo(() => {
|
||||||
|
const modui = moderation.ui('contentList')
|
||||||
|
const mediaModui = moderation.ui('contentMedia')
|
||||||
|
modui.alerts = [...modui.alerts, ...mediaModui.alerts]
|
||||||
|
modui.blurs = [...modui.blurs, ...mediaModui.blurs]
|
||||||
|
modui.filters = [...modui.filters, ...mediaModui.filters]
|
||||||
|
modui.informs = [...modui.informs, ...mediaModui.informs]
|
||||||
|
return modui
|
||||||
|
}, [moderation])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtering should be done at a higher level, such as `PostFeed` or
|
||||||
|
* `PostFeedVideoGridRow`, but we need to protect here as well.
|
||||||
|
*/
|
||||||
|
if (!AppBskyEmbedVideo.isView(embed)) return null
|
||||||
|
|
||||||
|
const author = post.author
|
||||||
|
const text = AppBskyFeedPost.isRecord(post.record) ? post.record?.text : ''
|
||||||
|
const likeCount = post?.likeCount ?? 0
|
||||||
|
const repostCount = post?.repostCount ?? 0
|
||||||
|
const {thumbnail} = embed
|
||||||
|
const black = getBlackColor(t)
|
||||||
|
|
||||||
|
const textAndAuthor = (
|
||||||
|
<View style={[a.pr_xs, {paddingTop: 6, gap: 4}]}>
|
||||||
|
{text && (
|
||||||
|
<Text style={[a.text_md, a.leading_snug]} numberOfLines={2} emoji>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
|
||||||
|
<View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
|
||||||
|
<UserAvatar type="user" size={20} avatar={post.author.avatar} />
|
||||||
|
<MediaInsetBorder />
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.text_sm,
|
||||||
|
a.leading_tight,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{sanitizeHandle(post.author.handle, '@')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
accessibilityHint={_(msg`Tap to view video in immersive mode.`)}
|
||||||
|
label={_(msg`Video from ${author.handle}: ${text}`)}
|
||||||
|
to={{
|
||||||
|
screen: 'VideoFeed',
|
||||||
|
params: {
|
||||||
|
...sourceContext,
|
||||||
|
initialPostUri: post.uri,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onPress={() => {
|
||||||
|
onInteract?.()
|
||||||
|
}}
|
||||||
|
onPressIn={onPressIn}
|
||||||
|
onPressOut={onPressOut}
|
||||||
|
style={[
|
||||||
|
a.flex_col,
|
||||||
|
{
|
||||||
|
alignItems: undefined,
|
||||||
|
justifyContent: undefined,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Hider.Outer modui={mergedModui}>
|
||||||
|
<Hider.Mask>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.justify_center,
|
||||||
|
a.rounded_md,
|
||||||
|
a.overflow_hidden,
|
||||||
|
{
|
||||||
|
backgroundColor: black,
|
||||||
|
aspectRatio: 9 / 16,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Image
|
||||||
|
source={{uri: thumbnail}}
|
||||||
|
style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
blurRadius={100}
|
||||||
|
/>
|
||||||
|
<MediaInsetBorder />
|
||||||
|
<View
|
||||||
|
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.inset_0,
|
||||||
|
a.justify_center,
|
||||||
|
a.align_center,
|
||||||
|
{
|
||||||
|
backgroundColor: 'black',
|
||||||
|
opacity: 0.2,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View style={[a.align_center, a.gap_xs]}>
|
||||||
|
<Eye size="lg" fill="white" />
|
||||||
|
<Text style={[a.text_sm, {color: 'white'}]}>
|
||||||
|
{_(msg`Hidden`)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{listModUi.blur ? (
|
||||||
|
<VideoPostCardTextPlaceholder author={post.author} />
|
||||||
|
) : (
|
||||||
|
textAndAuthor
|
||||||
|
)}
|
||||||
|
</Hider.Mask>
|
||||||
|
<Hider.Content>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.justify_center,
|
||||||
|
a.rounded_md,
|
||||||
|
a.overflow_hidden,
|
||||||
|
{
|
||||||
|
backgroundColor: black,
|
||||||
|
aspectRatio: 9 / 16,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Image
|
||||||
|
source={{uri: thumbnail}}
|
||||||
|
style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
/>
|
||||||
|
<MediaInsetBorder />
|
||||||
|
|
||||||
|
<View style={[a.absolute, a.inset_0]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.inset_0,
|
||||||
|
a.pt_2xl,
|
||||||
|
{
|
||||||
|
top: 'auto',
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[black, 'rgba(0, 0, 0, 0)']}
|
||||||
|
locations={[0.02, 1]}
|
||||||
|
start={{x: 0, y: 1}}
|
||||||
|
end={{x: 0, y: 0}}
|
||||||
|
style={[a.absolute, a.inset_0, {opacity: 0.9}]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[a.relative, a.z_10, a.p_md, a.flex_row, a.gap_md]}>
|
||||||
|
{likeCount > 0 && (
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
|
<Heart size="sm" fill="white" />
|
||||||
|
<Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
|
||||||
|
{formatCount(i18n, likeCount)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{repostCount > 0 && (
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
|
<Repost size="sm" fill="white" />
|
||||||
|
<Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
|
||||||
|
{formatCount(i18n, repostCount)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{textAndAuthor}
|
||||||
|
</Hider.Content>
|
||||||
|
</Hider.Outer>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoPostCardPlaceholder() {
|
||||||
|
const t = useTheme()
|
||||||
|
const black = getBlackColor(t)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_md,
|
||||||
|
a.overflow_hidden,
|
||||||
|
{
|
||||||
|
backgroundColor: black,
|
||||||
|
aspectRatio: 9 / 16,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<MediaInsetBorder />
|
||||||
|
</View>
|
||||||
|
<VideoPostCardTextPlaceholder />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoPostCardTextPlaceholder({
|
||||||
|
author,
|
||||||
|
}: {
|
||||||
|
author?: AppBskyActorDefs.ProfileViewBasic
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<View style={[a.pr_xs, {paddingTop: 8, gap: 6}]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.rounded_xs,
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
{
|
||||||
|
height: 14,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.rounded_xs,
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
{
|
||||||
|
height: 14,
|
||||||
|
width: '70%',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{author ? (
|
||||||
|
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
|
||||||
|
<View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
|
||||||
|
<UserAvatar type="user" size={20} avatar={author.avatar} />
|
||||||
|
<MediaInsetBorder />
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.text_sm,
|
||||||
|
a.leading_tight,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{sanitizeHandle(author.handle, '@')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
{
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_xs,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
{
|
||||||
|
height: 12,
|
||||||
|
width: '75%',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompactVideoPostCard({
|
||||||
|
post,
|
||||||
|
sourceContext,
|
||||||
|
moderation,
|
||||||
|
onInteract,
|
||||||
|
}: {
|
||||||
|
post: AppBskyFeedDefs.PostView
|
||||||
|
sourceContext: VideoFeedSourceContext
|
||||||
|
moderation: ModerationDecision
|
||||||
|
/**
|
||||||
|
* Callback for metrics etc
|
||||||
|
*/
|
||||||
|
onInteract?: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_, i18n} = useLingui()
|
||||||
|
const embed = post.embed
|
||||||
|
const {
|
||||||
|
state: pressed,
|
||||||
|
onIn: onPressIn,
|
||||||
|
onOut: onPressOut,
|
||||||
|
} = useInteractionState()
|
||||||
|
|
||||||
|
const mergedModui = useMemo(() => {
|
||||||
|
const modui = moderation.ui('contentList')
|
||||||
|
const mediaModui = moderation.ui('contentMedia')
|
||||||
|
modui.alerts = [...modui.alerts, ...mediaModui.alerts]
|
||||||
|
modui.blurs = [...modui.blurs, ...mediaModui.blurs]
|
||||||
|
modui.filters = [...modui.filters, ...mediaModui.filters]
|
||||||
|
modui.informs = [...modui.informs, ...mediaModui.informs]
|
||||||
|
return modui
|
||||||
|
}, [moderation])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtering should be done at a higher level, such as `PostFeed` or
|
||||||
|
* `PostFeedVideoGridRow`, but we need to protect here as well.
|
||||||
|
*/
|
||||||
|
if (!AppBskyEmbedVideo.isView(embed)) return null
|
||||||
|
|
||||||
|
const likeCount = post?.likeCount ?? 0
|
||||||
|
const {thumbnail} = embed
|
||||||
|
const black = getBlackColor(t)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
label={_(msg`View video`)}
|
||||||
|
to={{
|
||||||
|
screen: 'VideoFeed',
|
||||||
|
params: {
|
||||||
|
...sourceContext,
|
||||||
|
initialPostUri: post.uri,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onPress={() => {
|
||||||
|
onInteract?.()
|
||||||
|
}}
|
||||||
|
onPressIn={onPressIn}
|
||||||
|
onPressOut={onPressOut}
|
||||||
|
style={[
|
||||||
|
a.flex_col,
|
||||||
|
{
|
||||||
|
alignItems: undefined,
|
||||||
|
justifyContent: undefined,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Hider.Outer modui={mergedModui}>
|
||||||
|
<Hider.Mask>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.justify_center,
|
||||||
|
a.rounded_md,
|
||||||
|
a.overflow_hidden,
|
||||||
|
{
|
||||||
|
backgroundColor: black,
|
||||||
|
aspectRatio: 9 / 16,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Image
|
||||||
|
source={{uri: thumbnail}}
|
||||||
|
style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
blurRadius={100}
|
||||||
|
/>
|
||||||
|
<MediaInsetBorder />
|
||||||
|
<View
|
||||||
|
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.inset_0,
|
||||||
|
a.justify_center,
|
||||||
|
a.align_center,
|
||||||
|
{
|
||||||
|
backgroundColor: 'black',
|
||||||
|
opacity: 0.2,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View style={[a.align_center, a.gap_xs]}>
|
||||||
|
<Eye size="lg" fill="white" />
|
||||||
|
<Text style={[a.text_sm, {color: 'white'}]}>
|
||||||
|
{_(msg`Hidden`)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Hider.Mask>
|
||||||
|
<Hider.Content>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.justify_center,
|
||||||
|
a.rounded_md,
|
||||||
|
a.overflow_hidden,
|
||||||
|
{
|
||||||
|
backgroundColor: black,
|
||||||
|
aspectRatio: 9 / 16,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Image
|
||||||
|
source={{uri: thumbnail}}
|
||||||
|
style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
/>
|
||||||
|
<MediaInsetBorder />
|
||||||
|
|
||||||
|
<View style={[a.absolute, a.inset_0]}>
|
||||||
|
<View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}>
|
||||||
|
<View
|
||||||
|
style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
|
||||||
|
<UserAvatar
|
||||||
|
type="user"
|
||||||
|
size={20}
|
||||||
|
avatar={post.author.avatar}
|
||||||
|
/>
|
||||||
|
<MediaInsetBorder />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.inset_0,
|
||||||
|
a.pt_2xl,
|
||||||
|
{
|
||||||
|
top: 'auto',
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[black, 'rgba(0, 0, 0, 0)']}
|
||||||
|
locations={[0.02, 1]}
|
||||||
|
start={{x: 0, y: 1}}
|
||||||
|
end={{x: 0, y: 0}}
|
||||||
|
style={[a.absolute, a.inset_0, {opacity: 0.9}]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}>
|
||||||
|
{likeCount > 0 && (
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
|
<Heart size="sm" fill="white" />
|
||||||
|
<Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
|
||||||
|
{formatCount(i18n, likeCount)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Hider.Content>
|
||||||
|
</Hider.Outer>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompactVideoPostCardPlaceholder() {
|
||||||
|
const t = useTheme()
|
||||||
|
const black = getBlackColor(t)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_md,
|
||||||
|
a.overflow_hidden,
|
||||||
|
{
|
||||||
|
backgroundColor: black,
|
||||||
|
aspectRatio: 9 / 16,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<MediaInsetBorder />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||||
|
|
||||||
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
|
import {FeedPostSliceItem} from '#/state/queries/post-feed'
|
||||||
|
import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
|
||||||
|
import {atoms as a, useGutters} from '#/alf'
|
||||||
|
import * as Grid from '#/components/Grid'
|
||||||
|
import {
|
||||||
|
VideoPostCard,
|
||||||
|
VideoPostCardPlaceholder,
|
||||||
|
} from '#/components/VideoPostCard'
|
||||||
|
|
||||||
|
export function PostFeedVideoGridRow({
|
||||||
|
items: slices,
|
||||||
|
sourceContext,
|
||||||
|
}: {
|
||||||
|
items: FeedPostSliceItem[]
|
||||||
|
sourceContext: VideoFeedSourceContext
|
||||||
|
}) {
|
||||||
|
const gutters = useGutters(['base', 'base', 0, 'base'])
|
||||||
|
const posts = slices
|
||||||
|
.filter(slice => AppBskyEmbedVideo.isView(slice.post.embed))
|
||||||
|
.map(slice => ({
|
||||||
|
post: slice.post,
|
||||||
|
moderation: slice.moderation,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This should not happen because we should be filtering out posts without
|
||||||
|
* videos within the `PostFeed` component.
|
||||||
|
*/
|
||||||
|
if (posts.length !== slices.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[gutters]}>
|
||||||
|
<View style={[a.flex_row, a.gap_sm]}>
|
||||||
|
<Grid.Row gap={a.gap_sm.gap}>
|
||||||
|
{posts.map(post => (
|
||||||
|
<Grid.Col key={post.post.uri} width={1 / 2}>
|
||||||
|
<VideoPostCard
|
||||||
|
post={post.post}
|
||||||
|
sourceContext={sourceContext}
|
||||||
|
moderation={post.moderation}
|
||||||
|
onInteract={() => {
|
||||||
|
logEvent('videoCard:click', {context: 'feed'})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid.Row>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostFeedVideoGridRowPlaceholder() {
|
||||||
|
const gutters = useGutters(['base', 'base', 0, 'base'])
|
||||||
|
return (
|
||||||
|
<View style={[gutters]}>
|
||||||
|
<View style={[a.flex_row, a.gap_sm]}>
|
||||||
|
<VideoPostCardPlaceholder />
|
||||||
|
<VideoPostCardPlaceholder />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import React, {useEffect} from 'react'
|
||||||
|
import {ScrollView, View} from 'react-native'
|
||||||
|
import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {VIDEO_FEED_URI} from '#/lib/constants'
|
||||||
|
import {makeCustomFeedLink} from '#/lib/routes/links'
|
||||||
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
|
import {useTrendingSettingsApi} from '#/state/preferences/trending'
|
||||||
|
import {usePostFeedQuery} from '#/state/queries/post-feed'
|
||||||
|
import {RQKEY} from '#/state/queries/post-feed'
|
||||||
|
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||||
|
import {atoms as a, useGutters, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
|
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||||
|
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
|
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {
|
||||||
|
CompactVideoPostCard,
|
||||||
|
CompactVideoPostCardPlaceholder,
|
||||||
|
} from '#/components/VideoPostCard'
|
||||||
|
|
||||||
|
const CARD_WIDTH = 100
|
||||||
|
|
||||||
|
const FEED_DESC = `feedgen|${VIDEO_FEED_URI}`
|
||||||
|
const FEED_PARAMS: {
|
||||||
|
feedCacheKey: 'discover'
|
||||||
|
} = {
|
||||||
|
feedCacheKey: 'discover',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrendingVideos() {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const gutters = useGutters([0, 'base'])
|
||||||
|
const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
|
||||||
|
|
||||||
|
// Refetch on unmount if nothing else is using this query.
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const query = queryClient
|
||||||
|
.getQueryCache()
|
||||||
|
.find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)})
|
||||||
|
if (query && query.getObserversCount() <= 1) {
|
||||||
|
query.fetch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [queryClient])
|
||||||
|
|
||||||
|
const {setTrendingVideoDisabled} = useTrendingSettingsApi()
|
||||||
|
const trendingPrompt = Prompt.usePromptControl()
|
||||||
|
|
||||||
|
const onConfirmHide = React.useCallback(() => {
|
||||||
|
setTrendingVideoDisabled(true)
|
||||||
|
logEvent('trendingVideos:hide', {context: 'interstitial:discover'})
|
||||||
|
}, [setTrendingVideoDisabled])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.pt_lg,
|
||||||
|
a.pb_lg,
|
||||||
|
a.border_t,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
gutters,
|
||||||
|
a.pb_sm,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
|
<Graph />
|
||||||
|
<Text style={[a.text_md, a.font_bold, a.leading_snug]}>
|
||||||
|
<Trans>Trending Videos</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Dismiss this section`)}
|
||||||
|
size="tiny"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
shape="round"
|
||||||
|
onPress={() => trendingPrompt.open()}>
|
||||||
|
<ButtonIcon icon={X} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<BlockDrawerGesture>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
decelerationRate="fast"
|
||||||
|
snapToInterval={CARD_WIDTH + a.gap_sm.gap}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.gap_sm,
|
||||||
|
{
|
||||||
|
paddingLeft: gutters.paddingLeft,
|
||||||
|
paddingRight: gutters.paddingRight,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{isLoading ? (
|
||||||
|
Array(10)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, i) => (
|
||||||
|
<View key={i} style={[{width: CARD_WIDTH}]}>
|
||||||
|
<CompactVideoPostCardPlaceholder />
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : error || !data ? (
|
||||||
|
<Text>
|
||||||
|
<Trans>Whoops! Trending videos failed to load.</Trans>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<VideoCards data={data} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</BlockDrawerGesture>
|
||||||
|
|
||||||
|
<Prompt.Basic
|
||||||
|
control={trendingPrompt}
|
||||||
|
title={_(msg`Hide trending videos?`)}
|
||||||
|
description={_(msg`You can update this later from your settings.`)}
|
||||||
|
confirmButtonCta={_(msg`Hide`)}
|
||||||
|
onConfirm={onConfirmHide}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoCards({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined>
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const items = React.useMemo(() => {
|
||||||
|
return data.pages
|
||||||
|
.flatMap(page => page.slices)
|
||||||
|
.map(slice => slice.items[0])
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter(item => AppBskyEmbedVideo.isView(item.post.embed))
|
||||||
|
.slice(0, 8)
|
||||||
|
}, [data])
|
||||||
|
const href = React.useMemo(() => {
|
||||||
|
const urip = new AtUri(VIDEO_FEED_URI)
|
||||||
|
return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'discover')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.map(item => (
|
||||||
|
<View key={item.post.uri} style={[{width: CARD_WIDTH}]}>
|
||||||
|
<CompactVideoPostCard
|
||||||
|
post={item.post}
|
||||||
|
moderation={item.moderation}
|
||||||
|
sourceContext={{
|
||||||
|
type: 'feedgen',
|
||||||
|
uri: VIDEO_FEED_URI,
|
||||||
|
sourceInterstitial: 'discover',
|
||||||
|
}}
|
||||||
|
onInteract={() => {
|
||||||
|
logEvent('videoCard:click', {
|
||||||
|
context: 'interstitial:discover',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View style={[{width: CARD_WIDTH * 2}]}>
|
||||||
|
<Link
|
||||||
|
to={href}
|
||||||
|
label={_(msg`View more`)}
|
||||||
|
style={[
|
||||||
|
a.justify_center,
|
||||||
|
a.align_center,
|
||||||
|
a.flex_1,
|
||||||
|
a.rounded_md,
|
||||||
|
t.atoms.bg,
|
||||||
|
]}>
|
||||||
|
{({pressed}) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_md,
|
||||||
|
{
|
||||||
|
opacity: pressed ? 0.6 : 1,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Text style={[a.text_md]}>
|
||||||
|
<Trans>View more</Trans>
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
a.rounded_full,
|
||||||
|
{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
backgroundColor: t.palette.primary_500,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<ButtonIcon icon={ChevronRight} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -124,6 +124,11 @@ 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:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/thevids'
|
||||||
|
export const STAGING_VIDEO_FEED_URI =
|
||||||
|
'at://did:plc:yofh3kx63drvfljkibw5zuxo/app.bsky.feed.generator/thevids'
|
||||||
|
export const VIDEO_FEED_URIS = [VIDEO_FEED_URI, STAGING_VIDEO_FEED_URI]
|
||||||
export const DISCOVER_SAVED_FEED = {
|
export const DISCOVER_SAVED_FEED = {
|
||||||
type: 'feed',
|
type: 'feed',
|
||||||
value: DISCOVER_FEED_URI,
|
value: DISCOVER_FEED_URI,
|
||||||
|
|||||||
@@ -19,9 +19,13 @@ export function makeProfileLink(
|
|||||||
export function makeCustomFeedLink(
|
export function makeCustomFeedLink(
|
||||||
did: string,
|
did: string,
|
||||||
rkey: string,
|
rkey: string,
|
||||||
...segments: string[]
|
segment?: string | undefined,
|
||||||
|
feedCacheKey?: 'discover' | 'explore' | undefined,
|
||||||
) {
|
) {
|
||||||
return [`/profile`, did, 'feed', rkey, ...segments].join('/')
|
return (
|
||||||
|
[`/profile`, did, 'feed', rkey, ...(segment ? [segment] : [])].join('/') +
|
||||||
|
(feedCacheKey ? `?feedCacheKey=${encodeURIComponent(feedCacheKey)}` : '')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeListLink(did: string, rkey: string, ...segments: string[]) {
|
export function makeListLink(did: string, rkey: string, ...segments: string[]) {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
@@ -20,7 +22,11 @@ export type CommonNavigatorParams = {
|
|||||||
PostLikedBy: {name: string; rkey: string}
|
PostLikedBy: {name: string; rkey: string}
|
||||||
PostRepostedBy: {name: string; rkey: string}
|
PostRepostedBy: {name: string; rkey: string}
|
||||||
PostQuotes: {name: string; rkey: string}
|
PostQuotes: {name: string; rkey: string}
|
||||||
ProfileFeed: {name: string; rkey: string}
|
ProfileFeed: {
|
||||||
|
name: string
|
||||||
|
rkey: string
|
||||||
|
feedCacheKey?: 'discover' | 'explore' | undefined
|
||||||
|
}
|
||||||
ProfileFeedLikedBy: {name: string; rkey: string}
|
ProfileFeedLikedBy: {name: string; rkey: string}
|
||||||
ProfileLabelerLikedBy: {name: string}
|
ProfileLabelerLikedBy: {name: string}
|
||||||
Debug: undefined
|
Debug: undefined
|
||||||
@@ -57,6 +63,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': {}
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import React, {useCallback, useMemo} from 'react'
|
import React, {useCallback, useMemo} from 'react'
|
||||||
import {StyleSheet, View} from 'react-native'
|
import {StyleSheet, View} from 'react-native'
|
||||||
import {useAnimatedRef} from 'react-native-reanimated'
|
import {useAnimatedRef} from 'react-native-reanimated'
|
||||||
|
import {AppBskyFeedDefs} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useIsFocused, useNavigation} from '@react-navigation/native'
|
import {useIsFocused, useNavigation} from '@react-navigation/native'
|
||||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {VIDEO_FEED_URIS} from '#/lib/constants'
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
||||||
import {ComposeIcon2} from '#/lib/icons'
|
import {ComposeIcon2} from '#/lib/icons'
|
||||||
@@ -18,7 +20,7 @@ import {isNative} from '#/platform/detection'
|
|||||||
import {listenSoftReset} from '#/state/events'
|
import {listenSoftReset} from '#/state/events'
|
||||||
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
|
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
|
||||||
import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
|
import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
|
||||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
|
||||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||||
import {
|
import {
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
@@ -46,6 +48,11 @@ type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'>
|
|||||||
export function ProfileFeedScreen(props: Props) {
|
export function ProfileFeedScreen(props: Props) {
|
||||||
const {rkey, name: handleOrDid} = props.route.params
|
const {rkey, name: handleOrDid} = props.route.params
|
||||||
|
|
||||||
|
const feedParams: FeedParams | undefined = props.route.params.feedCacheKey
|
||||||
|
? {
|
||||||
|
feedCacheKey: props.route.params.feedCacheKey,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
@@ -96,7 +103,10 @@ export function ProfileFeedScreen(props: Props) {
|
|||||||
|
|
||||||
return resolvedUri ? (
|
return resolvedUri ? (
|
||||||
<Layout.Screen>
|
<Layout.Screen>
|
||||||
<ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} />
|
<ProfileFeedScreenIntermediate
|
||||||
|
feedUri={resolvedUri.uri}
|
||||||
|
feedParams={feedParams}
|
||||||
|
/>
|
||||||
</Layout.Screen>
|
</Layout.Screen>
|
||||||
) : (
|
) : (
|
||||||
<Layout.Screen>
|
<Layout.Screen>
|
||||||
@@ -108,7 +118,13 @@ export function ProfileFeedScreen(props: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
|
function ProfileFeedScreenIntermediate({
|
||||||
|
feedUri,
|
||||||
|
feedParams,
|
||||||
|
}: {
|
||||||
|
feedUri: string
|
||||||
|
feedParams: FeedParams | undefined
|
||||||
|
}) {
|
||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
const {data: info} = useFeedSourceInfoQuery({uri: feedUri})
|
const {data: info} = useFeedSourceInfoQuery({uri: feedUri})
|
||||||
|
|
||||||
@@ -125,15 +141,18 @@ function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
|
|||||||
<ProfileFeedScreenInner
|
<ProfileFeedScreenInner
|
||||||
preferences={preferences}
|
preferences={preferences}
|
||||||
feedInfo={info as FeedSourceFeedInfo}
|
feedInfo={info as FeedSourceFeedInfo}
|
||||||
|
feedParams={feedParams}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileFeedScreenInner({
|
export function ProfileFeedScreenInner({
|
||||||
feedInfo,
|
feedInfo,
|
||||||
|
feedParams,
|
||||||
}: {
|
}: {
|
||||||
preferences: UsePreferencesQueryResponse
|
preferences: UsePreferencesQueryResponse
|
||||||
feedInfo: FeedSourceFeedInfo
|
feedInfo: FeedSourceFeedInfo
|
||||||
|
feedParams: FeedParams | undefined
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
@@ -170,6 +189,14 @@ export function ProfileFeedScreenInner({
|
|||||||
return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} />
|
return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} />
|
||||||
}, [_])
|
}, [_])
|
||||||
|
|
||||||
|
const isVideoFeed = React.useMemo(() => {
|
||||||
|
const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri)
|
||||||
|
const feedIsVideoMode =
|
||||||
|
feedInfo.contentMode === AppBskyFeedDefs.CONTENTMODEVIDEO
|
||||||
|
const _isVideoFeed = isBskyVideoFeed || feedIsVideoMode
|
||||||
|
return isNative && _isVideoFeed
|
||||||
|
}, [feedInfo])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProfileFeedHeader info={feedInfo} />
|
<ProfileFeedHeader info={feedInfo} />
|
||||||
@@ -177,12 +204,14 @@ export function ProfileFeedScreenInner({
|
|||||||
<FeedFeedbackProvider value={feedFeedback}>
|
<FeedFeedbackProvider value={feedFeedback}>
|
||||||
<PostFeed
|
<PostFeed
|
||||||
feed={feed}
|
feed={feed}
|
||||||
|
feedParams={feedParams}
|
||||||
pollInterval={60e3}
|
pollInterval={60e3}
|
||||||
disablePoll={hasNew}
|
disablePoll={hasNew}
|
||||||
onHasNew={setHasNew}
|
onHasNew={setHasNew}
|
||||||
scrollElRef={scrollElRef}
|
scrollElRef={scrollElRef}
|
||||||
onScrolledDownChange={setIsScrolledDown}
|
onScrolledDownChange={setIsScrolledDown}
|
||||||
renderEmptyState={renderPostsEmpty}
|
renderEmptyState={renderPostsEmpty}
|
||||||
|
isVideoFeed={isVideoFeed}
|
||||||
/>
|
/>
|
||||||
</FeedFeedbackProvider>
|
</FeedFeedbackProvider>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {ScrollView, View} from 'react-native'
|
||||||
|
import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {VIDEO_FEED_URI} from '#/lib/constants'
|
||||||
|
import {makeCustomFeedLink} from '#/lib/routes/links'
|
||||||
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {useSavedFeeds} from '#/state/queries/feed'
|
||||||
|
import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed'
|
||||||
|
import {useAddSavedFeedsMutation} from '#/state/queries/preferences'
|
||||||
|
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||||
|
import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import {GradientFill} from '#/components/GradientFill'
|
||||||
|
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||||
|
import {Pin_Stroke2_Corner0_Rounded as Pin} from '#/components/icons/Pin'
|
||||||
|
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {
|
||||||
|
CompactVideoPostCard,
|
||||||
|
CompactVideoPostCardPlaceholder,
|
||||||
|
} from '#/components/VideoPostCard'
|
||||||
|
|
||||||
|
const CARD_WIDTH = 100
|
||||||
|
|
||||||
|
const FEED_DESC = `feedgen|${VIDEO_FEED_URI}`
|
||||||
|
const FEED_PARAMS: {
|
||||||
|
feedCacheKey: 'explore'
|
||||||
|
} = {
|
||||||
|
feedCacheKey: 'explore',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExploreTrendingVideos() {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const gutters = useGutters([0, 'base'])
|
||||||
|
const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
|
||||||
|
|
||||||
|
// Refetch on tab change if nothing else is using this query.
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
useFocusEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const query = queryClient
|
||||||
|
.getQueryCache()
|
||||||
|
.find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)})
|
||||||
|
if (query && query.getObserversCount() <= 1) {
|
||||||
|
query.fetch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const {data: saved} = useSavedFeeds()
|
||||||
|
const isSavedAlready = React.useMemo(() => {
|
||||||
|
return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI)
|
||||||
|
}, [saved])
|
||||||
|
|
||||||
|
const {mutateAsync: addSavedFeeds, isPending: isPinPending} =
|
||||||
|
useAddSavedFeedsMutation()
|
||||||
|
const pinFeed = React.useCallback(
|
||||||
|
(e: any) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
addSavedFeeds([
|
||||||
|
{
|
||||||
|
type: 'feed',
|
||||||
|
value: VIDEO_FEED_URI,
|
||||||
|
pinned: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// prevent navigation
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
[addSavedFeeds],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.pb_xl]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
isWeb
|
||||||
|
? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
|
||||||
|
: [a.p_lg, a.pt_xl, a.gap_md],
|
||||||
|
a.border_b,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_1, a.gap_sm]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Graph
|
||||||
|
size="lg"
|
||||||
|
fill={t.palette.primary_500}
|
||||||
|
style={{marginLeft: -2}}
|
||||||
|
/>
|
||||||
|
<Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>
|
||||||
|
<Trans>Trending Videos</Trans>
|
||||||
|
</Text>
|
||||||
|
<View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}>
|
||||||
|
<GradientFill gradient={tokens.gradients.primary} />
|
||||||
|
<Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}>
|
||||||
|
<Trans>BETA</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
|
||||||
|
<Trans>Popular videos in your network.</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<BlockDrawerGesture>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
decelerationRate="fast"
|
||||||
|
snapToInterval={CARD_WIDTH + tokens.space.sm}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.pt_lg,
|
||||||
|
a.flex_row,
|
||||||
|
a.gap_sm,
|
||||||
|
{
|
||||||
|
paddingLeft: gutters.paddingLeft,
|
||||||
|
paddingRight: gutters.paddingRight,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{isLoading ? (
|
||||||
|
Array(10)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, i) => (
|
||||||
|
<View key={i} style={[{width: CARD_WIDTH}]}>
|
||||||
|
<CompactVideoPostCardPlaceholder />
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : error || !data ? (
|
||||||
|
<Text>
|
||||||
|
<Trans>Whoops! Trending videos failed to load.</Trans>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<VideoCards data={data} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</BlockDrawerGesture>
|
||||||
|
|
||||||
|
{!isSavedAlready && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
gutters,
|
||||||
|
a.pt_lg,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
a.gap_xl,
|
||||||
|
]}>
|
||||||
|
<Text style={[a.flex_1, a.text_sm, a.leading_snug]}>
|
||||||
|
<Trans>
|
||||||
|
Pin the trending videos feed to your home screen for easy access
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
disabled={isPinPending}
|
||||||
|
label={_(msg`Pin`)}
|
||||||
|
size="small"
|
||||||
|
variant="outline"
|
||||||
|
color="secondary"
|
||||||
|
onPress={pinFeed}>
|
||||||
|
<ButtonText>{_(msg`Pin`)}</ButtonText>
|
||||||
|
<ButtonIcon icon={Pin} position="right" />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoCards({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined>
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const items = React.useMemo(() => {
|
||||||
|
return data.pages
|
||||||
|
.flatMap(page => page.slices)
|
||||||
|
.map(slice => slice.items[0])
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter(item => AppBskyEmbedVideo.isView(item.post.embed))
|
||||||
|
.slice(0, 8)
|
||||||
|
}, [data])
|
||||||
|
const href = React.useMemo(() => {
|
||||||
|
const urip = new AtUri(VIDEO_FEED_URI)
|
||||||
|
return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.map(item => (
|
||||||
|
<View key={item.post.uri} style={[{width: CARD_WIDTH}]}>
|
||||||
|
<CompactVideoPostCard
|
||||||
|
post={item.post}
|
||||||
|
moderation={item.moderation}
|
||||||
|
sourceContext={{
|
||||||
|
type: 'feedgen',
|
||||||
|
uri: VIDEO_FEED_URI,
|
||||||
|
sourceInterstitial: 'explore',
|
||||||
|
}}
|
||||||
|
onInteract={() => {
|
||||||
|
logEvent('videoCard:click', {
|
||||||
|
context: 'interstitial:discover',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View style={[{width: CARD_WIDTH * 2}]}>
|
||||||
|
<Link
|
||||||
|
to={href}
|
||||||
|
label={_(msg`View more`)}
|
||||||
|
style={[
|
||||||
|
a.justify_center,
|
||||||
|
a.align_center,
|
||||||
|
a.flex_1,
|
||||||
|
a.rounded_md,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
{({pressed}) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_md,
|
||||||
|
{
|
||||||
|
opacity: pressed ? 0.6 : 1,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Text style={[a.text_md]}>
|
||||||
|
<Trans>View more</Trans>
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
a.rounded_full,
|
||||||
|
{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
backgroundColor: t.palette.primary_500,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<ButtonIcon icon={ChevronRight} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -37,8 +37,9 @@ 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()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout.Screen>
|
<Layout.Screen>
|
||||||
@@ -138,6 +139,27 @@ export function ContentAndMediaSettingsScreen({}: Props) {
|
|||||||
<Toggle.Platform />
|
<Toggle.Platform />
|
||||||
</SettingsList.Item>
|
</SettingsList.Item>
|
||||||
</Toggle.Item>
|
</Toggle.Item>
|
||||||
|
<Toggle.Item
|
||||||
|
name="show_trending_videos"
|
||||||
|
label={_(msg`Enable trending videos in your Discover feed.`)}
|
||||||
|
value={!trendingVideoDisabled}
|
||||||
|
onChange={value => {
|
||||||
|
const hide = Boolean(!value)
|
||||||
|
if (hide) {
|
||||||
|
logEvent('trendingVideos:hide', {context: 'settings'})
|
||||||
|
} else {
|
||||||
|
logEvent('trendingVideos:show', {context: 'settings'})
|
||||||
|
}
|
||||||
|
setTrendingVideoDisabled(hide)
|
||||||
|
}}>
|
||||||
|
<SettingsList.Item>
|
||||||
|
<SettingsList.ItemIcon icon={Graph} />
|
||||||
|
<SettingsList.ItemText>
|
||||||
|
<Trans>Enable trending videos in your Discover feed</Trans>
|
||||||
|
</SettingsList.ItemText>
|
||||||
|
<Toggle.Platform />
|
||||||
|
</SettingsList.Item>
|
||||||
|
</Toggle.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SettingsList.Container>
|
</SettingsList.Container>
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import {useCallback} from 'react'
|
||||||
|
import {GestureResponderEvent, View} from 'react-native'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {HITSLOP_30} from '#/lib/constants'
|
||||||
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
import {useFeedSourceInfoQuery} from '#/state/queries/feed'
|
||||||
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
|
import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
|
||||||
|
import {atoms as a, useBreakpoints} from '#/alf'
|
||||||
|
import {Button, ButtonProps} from '#/components/Button'
|
||||||
|
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
|
||||||
|
import * as Layout from '#/components/Layout'
|
||||||
|
import {BUTTON_VISUAL_ALIGNMENT_OFFSET} from '#/components/Layout/const'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function HeaderPlaceholder() {
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_sm,
|
||||||
|
{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={[a.flex_1, a.gap_xs]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.rounded_xs,
|
||||||
|
{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
height: 14,
|
||||||
|
width: 80,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.rounded_xs,
|
||||||
|
{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
height: 10,
|
||||||
|
width: 140,
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({
|
||||||
|
sourceContext,
|
||||||
|
}: {
|
||||||
|
sourceContext: VideoFeedSourceContext
|
||||||
|
}) {
|
||||||
|
let content = null
|
||||||
|
switch (sourceContext.type) {
|
||||||
|
case 'feedgen': {
|
||||||
|
content = <FeedHeader sourceContext={sourceContext} />
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'author':
|
||||||
|
// TODO
|
||||||
|
default: {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout.Header.Outer noBottomBorder>
|
||||||
|
<BackButton />
|
||||||
|
<Layout.Header.Content align="left">{content}</Layout.Header.Content>
|
||||||
|
</Layout.Header.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedHeader({
|
||||||
|
sourceContext,
|
||||||
|
}: {
|
||||||
|
sourceContext: Exclude<VideoFeedSourceContext, {type: 'author'}>
|
||||||
|
}) {
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: info,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useFeedSourceInfoQuery({uri: sourceContext.uri})
|
||||||
|
|
||||||
|
if (sourceContext.sourceInterstitial !== undefined) {
|
||||||
|
// For now, don't show the header if coming from an interstitial.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <HeaderPlaceholder />
|
||||||
|
} else if (error || !info) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
{info.avatar && <UserAvatar size={36} type="algo" avatar={info.avatar} />}
|
||||||
|
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.font_heavy,
|
||||||
|
a.leading_tight,
|
||||||
|
gtMobile && a.text_lg,
|
||||||
|
]}
|
||||||
|
numberOfLines={2}>
|
||||||
|
{info.displayName}
|
||||||
|
</Text>
|
||||||
|
<View style={[a.flex_row, {gap: 6}]}>
|
||||||
|
<Text
|
||||||
|
style={[a.flex_shrink, a.text_sm, a.leading_snug]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{sanitizeHandle(info.creatorHandle, '@')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: This customization should be a part of the layout component
|
||||||
|
export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
const onPressBack = useCallback(
|
||||||
|
(evt: GestureResponderEvent) => {
|
||||||
|
onPress?.(evt)
|
||||||
|
if (evt.defaultPrevented) return
|
||||||
|
if (navigation.canGoBack()) {
|
||||||
|
navigation.goBack()
|
||||||
|
} else {
|
||||||
|
navigation.navigate('Home')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onPress, navigation],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout.Header.Slot>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Go back`)}
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
shape="round"
|
||||||
|
onPress={onPressBack}
|
||||||
|
hitSlop={HITSLOP_30}
|
||||||
|
style={[
|
||||||
|
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
|
||||||
|
a.bg_transparent,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
{...props}>
|
||||||
|
<ArrowLeft size="lg" fill="white" />
|
||||||
|
</Button>
|
||||||
|
</Layout.Header.Slot>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import {useCallback, useMemo, useState} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {
|
||||||
|
Gesture,
|
||||||
|
GestureDetector,
|
||||||
|
NativeGesture,
|
||||||
|
} from 'react-native-gesture-handler'
|
||||||
|
import Animated, {
|
||||||
|
interpolate,
|
||||||
|
runOnJS,
|
||||||
|
runOnUI,
|
||||||
|
SharedValue,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
import {
|
||||||
|
useSafeAreaFrame,
|
||||||
|
useSafeAreaInsets,
|
||||||
|
} from 'react-native-safe-area-context'
|
||||||
|
import {useEventListener} from 'expo'
|
||||||
|
import {VideoPlayer} from 'expo-video'
|
||||||
|
|
||||||
|
import {formatTime} from '#/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils'
|
||||||
|
import {tokens} from '#/alf'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
// magic number that is roughly the min height of the write reply button
|
||||||
|
// we inset the video by this amount
|
||||||
|
export const VIDEO_PLAYER_BOTTOM_INSET = 57
|
||||||
|
|
||||||
|
export function Scrubber({
|
||||||
|
active,
|
||||||
|
player,
|
||||||
|
seekingAnimationSV,
|
||||||
|
scrollGesture,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
player?: VideoPlayer
|
||||||
|
seekingAnimationSV: SharedValue<number>
|
||||||
|
scrollGesture: NativeGesture
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const {width: screenWidth} = useSafeAreaFrame()
|
||||||
|
const insets = useSafeAreaInsets()
|
||||||
|
const currentTimeSV = useSharedValue(0)
|
||||||
|
const durationSV = useSharedValue(0)
|
||||||
|
const [currentSeekTime, setCurrentSeekTime] = useState(0)
|
||||||
|
const [duration, setDuration] = useState(0)
|
||||||
|
|
||||||
|
const updateTime = (currentTime: number, duration: number) => {
|
||||||
|
'worklet'
|
||||||
|
currentTimeSV.set(currentTime)
|
||||||
|
if (duration !== 0) {
|
||||||
|
durationSV.set(duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSeekingSV = useSharedValue(false)
|
||||||
|
const seekProgressSV = useSharedValue(0)
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => Math.round(seekProgressSV.get()),
|
||||||
|
(progress, prevProgress) => {
|
||||||
|
if (progress !== prevProgress) {
|
||||||
|
runOnJS(setCurrentSeekTime)(progress)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const seekBy = useCallback(
|
||||||
|
(time: number) => {
|
||||||
|
player?.seekBy(time)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
runOnUI(() => {
|
||||||
|
'worklet'
|
||||||
|
isSeekingSV.set(false)
|
||||||
|
seekingAnimationSV.set(withTiming(0, {duration: 500}))
|
||||||
|
})()
|
||||||
|
}, 50)
|
||||||
|
},
|
||||||
|
[player, isSeekingSV, seekingAnimationSV],
|
||||||
|
)
|
||||||
|
|
||||||
|
const scrubPanGesture = useMemo(() => {
|
||||||
|
return Gesture.Pan()
|
||||||
|
.blocksExternalGesture(scrollGesture)
|
||||||
|
.activeOffsetX([-10, 10])
|
||||||
|
.failOffsetY([-10, 10])
|
||||||
|
.onStart(() => {
|
||||||
|
'worklet'
|
||||||
|
seekProgressSV.set(currentTimeSV.get())
|
||||||
|
isSeekingSV.set(true)
|
||||||
|
seekingAnimationSV.set(withTiming(1, {duration: 500}))
|
||||||
|
})
|
||||||
|
.onUpdate(evt => {
|
||||||
|
'worklet'
|
||||||
|
const progress = evt.x / screenWidth
|
||||||
|
seekProgressSV.set(
|
||||||
|
clamp(progress * durationSV.get(), 0, durationSV.get()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.onEnd(evt => {
|
||||||
|
'worklet'
|
||||||
|
isSeekingSV.get()
|
||||||
|
|
||||||
|
const progress = evt.x / screenWidth
|
||||||
|
const newTime = clamp(progress * durationSV.get(), 0, durationSV.get())
|
||||||
|
|
||||||
|
// optimisically set the progress bar
|
||||||
|
seekProgressSV.set(newTime)
|
||||||
|
|
||||||
|
// it's seek by, so offset by the current time
|
||||||
|
// seekBy sets isSeekingSV back to false, so no need to do that here
|
||||||
|
runOnJS(seekBy)(newTime - currentTimeSV.get())
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
scrollGesture,
|
||||||
|
seekingAnimationSV,
|
||||||
|
seekBy,
|
||||||
|
screenWidth,
|
||||||
|
currentTimeSV,
|
||||||
|
durationSV,
|
||||||
|
isSeekingSV,
|
||||||
|
seekProgressSV,
|
||||||
|
])
|
||||||
|
|
||||||
|
const timeStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
display: seekingAnimationSV.get() === 0 ? 'none' : 'flex',
|
||||||
|
opacity: seekingAnimationSV.get(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const barStyle = useAnimatedStyle(() => {
|
||||||
|
const currentTime = isSeekingSV.get()
|
||||||
|
? seekProgressSV.get()
|
||||||
|
: currentTimeSV.get()
|
||||||
|
const progress = currentTime === 0 ? 0 : currentTime / durationSV.get()
|
||||||
|
const isSeeking = seekingAnimationSV.get()
|
||||||
|
return {
|
||||||
|
height: isSeeking * 3 + 1,
|
||||||
|
opacity: interpolate(isSeeking, [0, 1], [0.4, 0.6]),
|
||||||
|
width: `${progress * 100}%`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const trackStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
height: seekingAnimationSV.get() * 3 + 1,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const childrenStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
opacity: 1 - seekingAnimationSV.get(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{player && active && (
|
||||||
|
<PlayerListener
|
||||||
|
player={player}
|
||||||
|
setDuration={setDuration}
|
||||||
|
updateTime={updateTime}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
{
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: insets.bottom + 80,
|
||||||
|
},
|
||||||
|
timeStyle,
|
||||||
|
]}
|
||||||
|
pointerEvents="none">
|
||||||
|
<Text style={[a.text_center, a.font_bold]}>
|
||||||
|
<Text style={[a.text_5xl, {fontVariant: ['tabular-nums']}]}>
|
||||||
|
{formatTime(currentSeekTime)}
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_2xl, {opacity: 0.8}]}>{' / '}</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_5xl,
|
||||||
|
{opacity: 0.8},
|
||||||
|
{fontVariant: ['tabular-nums']},
|
||||||
|
]}>
|
||||||
|
{formatTime(duration)}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<GestureDetector gesture={scrubPanGesture}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.relative,
|
||||||
|
a.w_full,
|
||||||
|
a.justify_end,
|
||||||
|
{
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
|
minHeight:
|
||||||
|
// bottom padding
|
||||||
|
insets.bottom +
|
||||||
|
// scrubber height
|
||||||
|
tokens.space.lg +
|
||||||
|
// write reply height
|
||||||
|
VIDEO_PLAYER_BOTTOM_INSET,
|
||||||
|
},
|
||||||
|
a.z_10,
|
||||||
|
]}>
|
||||||
|
<View style={[a.w_full, a.relative]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
{backgroundColor: 'white', opacity: 0.2},
|
||||||
|
trackStyle,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
{top: 0, left: 0, backgroundColor: 'white'},
|
||||||
|
barStyle,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Animated.View
|
||||||
|
style={[{minHeight: VIDEO_PLAYER_BOTTOM_INSET}, childrenStyle]}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</GestureDetector>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerListener({
|
||||||
|
player,
|
||||||
|
setDuration,
|
||||||
|
updateTime,
|
||||||
|
}: {
|
||||||
|
player: VideoPlayer
|
||||||
|
setDuration: (duration: number) => void
|
||||||
|
updateTime: (currentTime: number, duration: number) => void
|
||||||
|
}) {
|
||||||
|
useEventListener(player, 'timeUpdate', evt => {
|
||||||
|
const duration = player.duration
|
||||||
|
if (duration !== 0) {
|
||||||
|
setDuration(Math.round(duration))
|
||||||
|
}
|
||||||
|
runOnUI(updateTime)(evt.currentTime, duration)
|
||||||
|
})
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(num: number, min: number, max: number) {
|
||||||
|
'worklet'
|
||||||
|
return Math.min(Math.max(num, min), max)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
export function VideoScreen() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import {AuthorFilter} from '#/state/queries/post-feed'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kind of like `FeedDescriptor` but not
|
||||||
|
*/
|
||||||
|
export type VideoFeedSourceContext =
|
||||||
|
| {
|
||||||
|
type: 'feedgen'
|
||||||
|
uri: string
|
||||||
|
sourceInterstitial: 'discover' | 'explore' | 'none'
|
||||||
|
initialPostUri?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'author'
|
||||||
|
did: string
|
||||||
|
filter: AuthorFilter
|
||||||
|
initialPostUri?: string
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import {FEEDBACK_FEEDS, STAGING_FEEDS} from '#/lib/constants'
|
|||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed'
|
import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed'
|
||||||
import {getFeedPostSlice} from '#/view/com/posts/PostFeed'
|
import {getItemsForFeedback} from '#/view/com/posts/PostFeed'
|
||||||
import {useAgent} from './session'
|
import {useAgent} from './session'
|
||||||
|
|
||||||
type StateContext = {
|
type StateContext = {
|
||||||
@@ -102,18 +102,15 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
|||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const slice = getFeedPostSlice(feedItem)
|
const items = getItemsForFeedback(feedItem)
|
||||||
if (slice === null) {
|
for (const {item: postItem, feedContext} of items) {
|
||||||
return
|
|
||||||
}
|
|
||||||
for (const postItem of slice.items) {
|
|
||||||
if (!history.current.has(postItem)) {
|
if (!history.current.has(postItem)) {
|
||||||
history.current.add(postItem)
|
history.current.add(postItem)
|
||||||
queue.current.add(
|
queue.current.add(
|
||||||
toString({
|
toString({
|
||||||
item: postItem.uri,
|
item: postItem.uri,
|
||||||
event: 'app.bsky.feed.defs#interactionSeen',
|
event: 'app.bsky.feed.defs#interactionSeen',
|
||||||
feedContext: slice.feedContext,
|
feedContext,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
sendToFeed()
|
sendToFeed()
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export type FeedSourceFeedInfo = {
|
|||||||
creatorHandle: string
|
creatorHandle: string
|
||||||
likeCount: number | undefined
|
likeCount: number | undefined
|
||||||
likeUri: string | undefined
|
likeUri: string | undefined
|
||||||
|
contentMode: AppBskyFeedDefs.GeneratorView['contentMode']
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeedSourceListInfo = {
|
export type FeedSourceListInfo = {
|
||||||
@@ -65,6 +66,7 @@ export type FeedSourceListInfo = {
|
|||||||
description: RichText
|
description: RichText
|
||||||
creatorDid: string
|
creatorDid: string
|
||||||
creatorHandle: string
|
creatorHandle: string
|
||||||
|
contentMode: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeedSourceInfo = FeedSourceFeedInfo | FeedSourceListInfo
|
export type FeedSourceInfo = FeedSourceFeedInfo | FeedSourceListInfo
|
||||||
@@ -111,6 +113,7 @@ export function hydrateFeedGenerator(
|
|||||||
creatorHandle: view.creator.handle,
|
creatorHandle: view.creator.handle,
|
||||||
likeCount: view.likeCount,
|
likeCount: view.likeCount,
|
||||||
likeUri: view.viewer?.like,
|
likeUri: view.viewer?.like,
|
||||||
|
contentMode: view.contentMode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +144,7 @@ export function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo {
|
|||||||
displayName: view.name
|
displayName: view.name
|
||||||
? sanitizeDisplayName(view.name)
|
? sanitizeDisplayName(view.name)
|
||||||
: `User List by ${sanitizeHandle(view.creator.handle, '@')}`,
|
: `User List by ${sanitizeHandle(view.creator.handle, '@')}`,
|
||||||
|
contentMode: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +403,7 @@ const PWI_DISCOVER_FEED_STUB: SavedFeedSourceInfo = {
|
|||||||
id: 'pwi-discover',
|
id: 'pwi-discover',
|
||||||
...DISCOVER_SAVED_FEED,
|
...DISCOVER_SAVED_FEED,
|
||||||
},
|
},
|
||||||
|
contentMode: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos'
|
const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos'
|
||||||
@@ -485,6 +490,7 @@ export function usePinnedFeedsInfos() {
|
|||||||
likeCount: 0,
|
likeCount: 0,
|
||||||
likeUri: '',
|
likeUri: '',
|
||||||
savedFeed: pinnedItem,
|
savedFeed: pinnedItem,
|
||||||
|
contentMode: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -61,6 +61,7 @@ export type FeedDescriptor =
|
|||||||
export interface FeedParams {
|
export interface FeedParams {
|
||||||
mergeFeedEnabled?: boolean
|
mergeFeedEnabled?: boolean
|
||||||
mergeFeedSources?: string[]
|
mergeFeedSources?: string[]
|
||||||
|
feedCacheKey?: 'discover' | 'explore' | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined
|
type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {AppBskyActorDefs} from '@atproto/api'
|
import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {NavigationProp, useNavigation} from '@react-navigation/native'
|
import {NavigationProp, useNavigation} from '@react-navigation/native'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {VIDEO_FEED_URIS} from '#/lib/constants'
|
||||||
import {ComposeIcon2} from '#/lib/icons'
|
import {ComposeIcon2} from '#/lib/icons'
|
||||||
import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers'
|
import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers'
|
||||||
import {AllNavigatorParams} from '#/lib/routes/types'
|
import {AllNavigatorParams} from '#/lib/routes/types'
|
||||||
@@ -15,6 +16,7 @@ import {isNative} from '#/platform/detection'
|
|||||||
import {listenSoftReset} from '#/state/events'
|
import {listenSoftReset} from '#/state/events'
|
||||||
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
|
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
|
||||||
import {useSetHomeBadge} from '#/state/home-badge'
|
import {useSetHomeBadge} from '#/state/home-badge'
|
||||||
|
import {SavedFeedSourceInfo} from '#/state/queries/feed'
|
||||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||||
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
|
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
|
||||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||||
@@ -39,6 +41,7 @@ export function FeedPage({
|
|||||||
renderEmptyState,
|
renderEmptyState,
|
||||||
renderEndOfFeed,
|
renderEndOfFeed,
|
||||||
savedFeedConfig,
|
savedFeedConfig,
|
||||||
|
feedInfo,
|
||||||
}: {
|
}: {
|
||||||
testID?: string
|
testID?: string
|
||||||
feed: FeedDescriptor
|
feed: FeedDescriptor
|
||||||
@@ -48,6 +51,7 @@ export function FeedPage({
|
|||||||
renderEmptyState: () => JSX.Element
|
renderEmptyState: () => JSX.Element
|
||||||
renderEndOfFeed?: () => JSX.Element
|
renderEndOfFeed?: () => JSX.Element
|
||||||
savedFeedConfig?: AppBskyActorDefs.SavedFeed
|
savedFeedConfig?: AppBskyActorDefs.SavedFeed
|
||||||
|
feedInfo: SavedFeedSourceInfo
|
||||||
}) {
|
}) {
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
@@ -61,6 +65,13 @@ export function FeedPage({
|
|||||||
const scrollElRef = React.useRef<ListMethods>(null)
|
const scrollElRef = React.useRef<ListMethods>(null)
|
||||||
const [hasNew, setHasNew] = React.useState(false)
|
const [hasNew, setHasNew] = React.useState(false)
|
||||||
const setHomeBadge = useSetHomeBadge()
|
const setHomeBadge = useSetHomeBadge()
|
||||||
|
const isVideoFeed = React.useMemo(() => {
|
||||||
|
const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri)
|
||||||
|
const feedIsVideoMode =
|
||||||
|
feedInfo.contentMode === AppBskyFeedDefs.CONTENTMODEVIDEO
|
||||||
|
const _isVideoFeed = isBskyVideoFeed || feedIsVideoMode
|
||||||
|
return isNative && _isVideoFeed
|
||||||
|
}, [feedInfo])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isPageFocused) {
|
if (isPageFocused) {
|
||||||
@@ -134,6 +145,7 @@ export function FeedPage({
|
|||||||
renderEndOfFeed={renderEndOfFeed}
|
renderEndOfFeed={renderEndOfFeed}
|
||||||
headerOffset={headerOffset}
|
headerOffset={headerOffset}
|
||||||
savedFeedConfig={savedFeedConfig}
|
savedFeedConfig={savedFeedConfig}
|
||||||
|
isVideoFeed={isVideoFeed}
|
||||||
/>
|
/>
|
||||||
</FeedFeedbackProvider>
|
</FeedFeedbackProvider>
|
||||||
</MainScrollProvider>
|
</MainScrollProvider>
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export function PostThreadComposePrompt({
|
|||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
]}
|
]}
|
||||||
onPressIn={ios(() => playHaptic('Light'))}
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onPressCompose()
|
onPressCompose()
|
||||||
playHaptic('Light')
|
playHaptic('Light')
|
||||||
|
|||||||
+134
-13
@@ -9,7 +9,7 @@ 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'
|
||||||
@@ -20,7 +20,7 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
|||||||
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 +29,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 +75,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 +86,17 @@ type FeedRow =
|
|||||||
indexInSlice: number
|
indexInSlice: number
|
||||||
showReplyTo: boolean
|
showReplyTo: boolean
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'videoGridRowPlaceholder'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'videoGridRow'
|
||||||
|
key: string
|
||||||
|
items: FeedPostSliceItem[]
|
||||||
|
sourceFeedUri: string
|
||||||
|
feedContexts: (string | undefined)[]
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'sliceViewFullThread'
|
type: 'sliceViewFullThread'
|
||||||
key: string
|
key: string
|
||||||
@@ -97,12 +114,28 @@ type FeedRow =
|
|||||||
type: 'interstitialTrending'
|
type: 'interstitialTrending'
|
||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'interstitialTrendingVideos'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
export function getFeedPostSlice(feedRow: FeedRow): FeedPostSlice | null {
|
export function getItemsForFeedback(feedRow: FeedRow):
|
||||||
|
| {
|
||||||
|
item: FeedPostSliceItem
|
||||||
|
feedContext: string | undefined
|
||||||
|
}[] {
|
||||||
if (feedRow.type === 'sliceItem') {
|
if (feedRow.type === 'sliceItem') {
|
||||||
return feedRow.slice
|
return feedRow.slice.items.map(item => ({
|
||||||
|
item,
|
||||||
|
feedContext: feedRow.slice.feedContext,
|
||||||
|
}))
|
||||||
|
} else if (feedRow.type === 'videoGridRow') {
|
||||||
|
return feedRow.items.map((item, i) => ({
|
||||||
|
item,
|
||||||
|
feedContext: feedRow.feedContexts[i],
|
||||||
|
}))
|
||||||
} else {
|
} else {
|
||||||
return null
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +164,7 @@ let PostFeed = ({
|
|||||||
extraData,
|
extraData,
|
||||||
savedFeedConfig,
|
savedFeedConfig,
|
||||||
initialNumToRender: initialNumToRenderOverride,
|
initialNumToRender: initialNumToRenderOverride,
|
||||||
|
isVideoFeed = false,
|
||||||
}: {
|
}: {
|
||||||
feed: FeedDescriptor
|
feed: FeedDescriptor
|
||||||
feedParams?: FeedParams
|
feedParams?: FeedParams
|
||||||
@@ -152,6 +186,7 @@ let PostFeed = ({
|
|||||||
extraData?: any
|
extraData?: any
|
||||||
savedFeedConfig?: AppBskyActorDefs.SavedFeed
|
savedFeedConfig?: AppBskyActorDefs.SavedFeed
|
||||||
initialNumToRender?: number
|
initialNumToRender?: number
|
||||||
|
isVideoFeed?: boolean
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
@@ -163,8 +198,10 @@ 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 areVideoFeedsEnabled = isNative
|
||||||
|
|
||||||
|
const feedCacheKey = feedParams?.feedCacheKey
|
||||||
const opts = React.useMemo(
|
const opts = React.useMemo(
|
||||||
() => ({enabled, ignoreFilterFor}),
|
() => ({enabled, ignoreFilterFor}),
|
||||||
[enabled, ignoreFilterFor],
|
[enabled, ignoreFilterFor],
|
||||||
@@ -267,10 +304,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 +340,48 @@ let PostFeed = ({
|
|||||||
})
|
})
|
||||||
} else if (data) {
|
} else if (data) {
|
||||||
let sliceIndex = -1
|
let sliceIndex = -1
|
||||||
|
|
||||||
|
if (isVideoFeed) {
|
||||||
|
const videos: {
|
||||||
|
item: FeedPostSliceItem
|
||||||
|
feedContext: string | undefined
|
||||||
|
}[] = []
|
||||||
|
for (const page of data.pages) {
|
||||||
|
for (const slice of page.slices) {
|
||||||
|
const item = slice.items.at(0)
|
||||||
|
if (item && AppBskyEmbedVideo.isView(item.post.embed)) {
|
||||||
|
videos.push({item, feedContext: slice.feedContext})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: {
|
||||||
|
item: FeedPostSliceItem
|
||||||
|
feedContext: string | undefined
|
||||||
|
}[][] = []
|
||||||
|
for (let i = 0; i < videos.length; i++) {
|
||||||
|
const video = videos[i]
|
||||||
|
const item = video.item
|
||||||
|
const cols = gtMobile ? 3 : 2
|
||||||
|
const rowItem = {item, feedContext: video.feedContext}
|
||||||
|
if (i % cols === 0) {
|
||||||
|
rows.push([rowItem])
|
||||||
|
} else {
|
||||||
|
rows[rows.length - 1].push(rowItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
sliceIndex++
|
||||||
|
arr.push({
|
||||||
|
type: 'videoGridRow',
|
||||||
|
key: row.map(r => r.item._reactKey).join('-'),
|
||||||
|
items: row.map(r => r.item),
|
||||||
|
sourceFeedUri: feedUri,
|
||||||
|
feedContexts: row.map(r => r.feedContext),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
for (const page of data?.pages) {
|
for (const page of data?.pages) {
|
||||||
for (const slice of page.slices) {
|
for (const slice of page.slices) {
|
||||||
sliceIndex++
|
sliceIndex++
|
||||||
@@ -319,7 +398,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 +470,26 @@ let PostFeed = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (isError && !isEmpty) {
|
if (isError && !isEmpty) {
|
||||||
arr.push({
|
arr.push({
|
||||||
type: 'loadMoreError',
|
type: 'loadMoreError',
|
||||||
key: 'loadMoreError',
|
key: 'loadMoreError',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (isVideoFeed) {
|
||||||
|
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 +504,11 @@ let PostFeed = ({
|
|||||||
hasSession,
|
hasSession,
|
||||||
showProgressIntersitial,
|
showProgressIntersitial,
|
||||||
trendingDisabled,
|
trendingDisabled,
|
||||||
|
trendingVideoDisabled,
|
||||||
gtTablet,
|
gtTablet,
|
||||||
|
gtMobile,
|
||||||
|
isVideoFeed,
|
||||||
|
areVideoFeedsEnabled,
|
||||||
])
|
])
|
||||||
|
|
||||||
// events
|
// events
|
||||||
@@ -498,6 +597,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 +633,25 @@ 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
|
||||||
|
items={row.items}
|
||||||
|
sourceContext={{
|
||||||
|
type: 'feedgen',
|
||||||
|
uri: row.sourceFeedUri,
|
||||||
|
sourceInterstitial: feedCacheKey ?? 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -545,6 +665,7 @@ let PostFeed = ({
|
|||||||
_,
|
_,
|
||||||
onPressRetryLoadMore,
|
onPressRetryLoadMore,
|
||||||
feedUri,
|
feedUri,
|
||||||
|
feedCacheKey,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ function HomeScreenReady({
|
|||||||
feedParams={homeFeedParams}
|
feedParams={homeFeedParams}
|
||||||
renderEmptyState={renderFollowingEmptyState}
|
renderEmptyState={renderFollowingEmptyState}
|
||||||
renderEndOfFeed={FollowingEndOfFeed}
|
renderEndOfFeed={FollowingEndOfFeed}
|
||||||
|
feedInfo={feedInfo}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -247,6 +248,7 @@ function HomeScreenReady({
|
|||||||
feed={feed}
|
feed={feed}
|
||||||
renderEmptyState={renderCustomFeedEmptyState}
|
renderEmptyState={renderCustomFeedEmptyState}
|
||||||
savedFeedConfig={savedFeedConfig}
|
savedFeedConfig={savedFeedConfig}
|
||||||
|
feedInfo={feedInfo}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
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 +26,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 +247,10 @@ type ExploreScreenItems =
|
|||||||
type: 'trendingTopics'
|
type: 'trendingTopics'
|
||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'trendingVideos'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'recommendations'
|
type: 'recommendations'
|
||||||
key: string
|
key: string
|
||||||
@@ -343,6 +348,13 @@ export function Explore() {
|
|||||||
key: `trending-topics`,
|
key: `trending-topics`,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (isNative) {
|
||||||
|
i.push({
|
||||||
|
type: 'trendingVideos',
|
||||||
|
key: `trending-videos`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
i.push({
|
i.push({
|
||||||
type: 'recommendations',
|
type: 'recommendations',
|
||||||
key: `recommendations`,
|
key: `recommendations`,
|
||||||
@@ -514,6 +526,9 @@ export function Explore() {
|
|||||||
case 'trendingTopics': {
|
case 'trendingTopics': {
|
||||||
return <ExploreTrendingTopics />
|
return <ExploreTrendingTopics />
|
||||||
}
|
}
|
||||||
|
case 'trendingVideos': {
|
||||||
|
return <ExploreTrendingVideos />
|
||||||
|
}
|
||||||
case 'recommendations': {
|
case 'recommendations': {
|
||||||
return <ExploreRecommendations />
|
return <ExploreRecommendations />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,15 +72,15 @@
|
|||||||
tlds "^1.234.0"
|
tlds "^1.234.0"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/api@^0.13.21":
|
"@atproto/api@^0.13.28":
|
||||||
version "0.13.21"
|
version "0.13.28"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.21.tgz#8ee27a07e5a024b5bf32408d9bd623dd598ad1cc"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.28.tgz#b36d4ad9485724ec030e7292599f048ab62a9fcc"
|
||||||
integrity sha512-iOxSj2YS3Fx9IPz1NivKrSsdYPNbBgpnUH7+WhKYAMvDFDUe2PZe7taau8wsUjJAu/H3S0Mk2TDh5e/7tCRwHA==
|
integrity sha512-qBuEI5aNe2/KjmtmtLMilnpZc+FRAsAM3/5nFOQPEudUk388ctNsmKdz2Nti4OvCebn+50EB6V3lju596CTUNA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.3.1"
|
"@atproto/common-web" "^0.3.2"
|
||||||
"@atproto/lexicon" "^0.4.4"
|
"@atproto/lexicon" "^0.4.5"
|
||||||
"@atproto/syntax" "^0.3.1"
|
"@atproto/syntax" "^0.3.1"
|
||||||
"@atproto/xrpc" "^0.6.5"
|
"@atproto/xrpc" "^0.6.6"
|
||||||
await-lock "^2.2.2"
|
await-lock "^2.2.2"
|
||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
tlds "^1.234.0"
|
tlds "^1.234.0"
|
||||||
@@ -169,6 +169,16 @@
|
|||||||
uint8arrays "3.0.0"
|
uint8arrays "3.0.0"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
|
"@atproto/common-web@^0.3.2":
|
||||||
|
version "0.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.3.2.tgz#4cf78ad4d24fed801882f3d35afc39bceccdff51"
|
||||||
|
integrity sha512-Vx0JtL1/CssJbFAb0UOdvTrkbUautsDfHNOXNTcX2vyPIxH9xOameSqLLunM1hZnOQbJwyjmQCt6TV+bhnanDg==
|
||||||
|
dependencies:
|
||||||
|
graphemer "^1.4.0"
|
||||||
|
multiformats "^9.9.0"
|
||||||
|
uint8arrays "3.0.0"
|
||||||
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/common@0.1.0":
|
"@atproto/common@0.1.0":
|
||||||
version "0.1.0"
|
version "0.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210"
|
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210"
|
||||||
@@ -283,6 +293,17 @@
|
|||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
|
"@atproto/lexicon@^0.4.5":
|
||||||
|
version "0.4.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.5.tgz#4fcf3731193c674286e9e8d677bbab5dd530b817"
|
||||||
|
integrity sha512-fljWqMGKn+XWtTprBcS3F1hGBREnQYh6qYHv2sjENucc7REms1gtmZXSerB9N6pVeHVNOnXiILdukeAcic5OEw==
|
||||||
|
dependencies:
|
||||||
|
"@atproto/common-web" "^0.3.2"
|
||||||
|
"@atproto/syntax" "^0.3.1"
|
||||||
|
iso-datestring-validator "^2.2.2"
|
||||||
|
multiformats "^9.9.0"
|
||||||
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/oauth-provider@^0.2.10":
|
"@atproto/oauth-provider@^0.2.10":
|
||||||
version "0.2.10"
|
version "0.2.10"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.2.10.tgz#f9820d7f82c33d3b74e81a75873f50e1e654b901"
|
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.2.10.tgz#f9820d7f82c33d3b74e81a75873f50e1e654b901"
|
||||||
@@ -452,6 +473,14 @@
|
|||||||
"@atproto/lexicon" "^0.4.4"
|
"@atproto/lexicon" "^0.4.4"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
|
"@atproto/xrpc@^0.6.6":
|
||||||
|
version "0.6.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.6.tgz#28f58270ef4a8056f7f718bd52512e74bcd3702f"
|
||||||
|
integrity sha512-umXEYVMo9/pyIBoKmIAIi64RXDW9tSXY+wqztlQ6I2GZtjLfNZqmAWU+wADk3SxUe54mvjxxGyA4TtyGtDMfhA==
|
||||||
|
dependencies:
|
||||||
|
"@atproto/lexicon" "^0.4.5"
|
||||||
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@aws-crypto/crc32@3.0.0":
|
"@aws-crypto/crc32@3.0.0":
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-3.0.0.tgz#07300eca214409c33e3ff769cd5697b57fdd38fa"
|
resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-3.0.0.tgz#07300eca214409c33e3ff769cd5697b57fdd38fa"
|
||||||
|
|||||||
Reference in New Issue
Block a user