* 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 commit 9e3f2f4374)

* 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:

commit 3d1be4c0f1
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 01:08:05 2025 +0000

    extend animation

commit c9f199413b
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 01:01:24 2025 +0000

    fix gap

commit 22e520795f
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 00:50:16 2025 +0000

    thinner bar, format time

commit c32427f214
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 00:47:57 2025 +0000

    fix 2 in 3 screens

commit cbf84c08d6
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 00:45:46 2025 +0000

    rm unneeded var

commit 7e0e100177
Author: 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:
Hailey
2025-01-19 17:17:41 -08:00
committed by GitHub
parent cb02065550
commit 34582edf3e
38 changed files with 3165 additions and 126 deletions
+3 -1
View File
@@ -27,6 +27,7 @@ import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs'
import {List, ListMethods, ListProps} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import {useThemeName} from '#/alf/util/useColorModeTheme'
import {Context, useDialogContext} from '#/components/Dialog/context'
import {
DialogControlProps,
@@ -55,7 +56,8 @@ export function Outer({
nativeOptions,
testID,
}: React.PropsWithChildren<DialogOuterProps>) {
const t = useTheme()
const themeName = useThemeName()
const t = useTheme(themeName)
const ref = React.useRef<BottomSheetNativeComponent>(null)
const closeCallbacks = React.useRef<(() => void)[]>([])
const {setDialogIsOpen, setFullyExpandedCount} =
+59
View File
@@ -0,0 +1,59 @@
import {createContext, useContext, useMemo} from 'react'
import {View} from 'react-native'
import {atoms as a, ViewStyleProp} from '#/alf'
const Context = createContext({
gap: 0,
})
export function Row({
children,
gap = 0,
style,
}: ViewStyleProp & {
children: React.ReactNode
gap?: number
}) {
return (
<Context.Provider value={useMemo(() => ({gap}), [gap])}>
<View
style={[
a.flex_row,
a.flex_1,
{
marginLeft: -gap / 2,
marginRight: -gap / 2,
},
style,
]}>
{children}
</View>
</Context.Provider>
)
}
export function Col({
children,
width = 1,
style,
}: ViewStyleProp & {
children: React.ReactNode
width?: number
}) {
const {gap} = useContext(Context)
return (
<View
style={[
a.flex_col,
{
paddingLeft: gap / 2,
paddingRight: gap / 2,
width: `${width * 100}%`,
},
style,
]}>
{children}
</View>
)
}
+5 -1
View File
@@ -122,7 +122,11 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
shape="square"
onPress={onPressBack}
hitSlop={HITSLOP_30}
style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, style]}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
style,
]}
{...props}>
<ButtonIcon icon={ArrowLeft} size="lg" />
</Button>
+10 -4
View File
@@ -6,12 +6,18 @@ import {gradients} from '#/alf/tokens'
export function LinearGradientBackground({
style,
gradient = 'sky',
children,
start,
end,
}: {
style: StyleProp<ViewStyle>
children: React.ReactNode
style?: StyleProp<ViewStyle>
gradient?: keyof typeof gradients
children?: React.ReactNode
start?: [number, number]
end?: [number, number]
}) {
const gradient = gradients.sky.values.map(([_, color]) => {
const colors = gradients[gradient].values.map(([_, color]) => {
return color
}) as [string, string, ...string[]]
@@ -20,7 +26,7 @@ export function LinearGradientBackground({
}
return (
<LinearGradient colors={gradient} style={style}>
<LinearGradient colors={colors} style={style} start={start} end={end}>
{children}
</LinearGradient>
)
+9 -3
View File
@@ -20,6 +20,7 @@ export function ListFooter({
style,
showEndMessage = false,
endMessageText,
renderEndMessage,
}: {
isFetchingNextPage?: boolean
hasNextPage?: boolean
@@ -29,6 +30,7 @@ export function ListFooter({
style?: StyleProp<ViewStyle>
showEndMessage?: boolean
endMessageText?: string
renderEndMessage?: () => React.ReactNode
}) {
const t = useTheme()
@@ -48,9 +50,13 @@ export function ListFooter({
) : error ? (
<ListFooterMaybeError error={error} onRetry={onRetry} />
) : !hasNextPage && showEndMessage ? (
<Text style={[a.text_sm, t.atoms.text_contrast_low]}>
{endMessageText ?? <Trans>You have reached the end</Trans>}
</Text>
renderEndMessage ? (
renderEndMessage()
) : (
<Text style={[a.text_sm, t.atoms.text_contrast_low]}>
{endMessageText ?? <Trans>You have reached the end</Trans>}
</Text>
)
) : null}
</View>
)
+9 -1
View File
@@ -19,7 +19,7 @@ import {Text, TextProps} from '#/components/Typography'
const WORD_WRAP = {wordWrap: 1}
export type RichTextProps = TextStyleProp &
Pick<TextProps, 'selectable'> & {
Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
value: RichTextAPI | string
testID?: string
numberOfLines?: number
@@ -43,6 +43,8 @@ export function RichText({
onLinkPress,
interactiveStyle,
emojiMultiplier = 1.85,
onLayout,
onTextLayout,
}: RichTextProps) {
const richText = React.useMemo(
() =>
@@ -70,6 +72,8 @@ export function RichText({
selectable={selectable}
testID={testID}
style={[plainStyles, {fontSize}]}
onLayout={onLayout}
onTextLayout={onTextLayout}
// @ts-ignore web only -prf
dataSet={WORD_WRAP}>
{text}
@@ -83,6 +87,8 @@ export function RichText({
testID={testID}
style={plainStyles}
numberOfLines={numberOfLines}
onLayout={onLayout}
onTextLayout={onTextLayout}
// @ts-ignore web only -prf
dataSet={WORD_WRAP}>
{text}
@@ -163,6 +169,8 @@ export function RichText({
testID={testID}
style={plainStyles}
numberOfLines={numberOfLines}
onLayout={onLayout}
onTextLayout={onTextLayout}
// @ts-ignore web only -prf
dataSet={WORD_WRAP}>
{els}
+540
View File
@@ -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>
</>
)
}