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:
@@ -27,6 +27,7 @@ import {useA11y} from '#/state/a11y'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {List, ListMethods, ListProps} from '#/view/com/util/List'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useThemeName} from '#/alf/util/useColorModeTheme'
|
||||
import {Context, useDialogContext} from '#/components/Dialog/context'
|
||||
import {
|
||||
DialogControlProps,
|
||||
@@ -55,7 +56,8 @@ export function Outer({
|
||||
nativeOptions,
|
||||
testID,
|
||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||
const t = useTheme()
|
||||
const themeName = useThemeName()
|
||||
const t = useTheme(themeName)
|
||||
const ref = React.useRef<BottomSheetNativeComponent>(null)
|
||||
const closeCallbacks = React.useRef<(() => void)[]>([])
|
||||
const {setDialogIsOpen, setFullyExpandedCount} =
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a, ViewStyleProp} from '#/alf'
|
||||
|
||||
const Context = createContext({
|
||||
gap: 0,
|
||||
})
|
||||
|
||||
export function Row({
|
||||
children,
|
||||
gap = 0,
|
||||
style,
|
||||
}: ViewStyleProp & {
|
||||
children: React.ReactNode
|
||||
gap?: number
|
||||
}) {
|
||||
return (
|
||||
<Context.Provider value={useMemo(() => ({gap}), [gap])}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.flex_1,
|
||||
{
|
||||
marginLeft: -gap / 2,
|
||||
marginRight: -gap / 2,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Col({
|
||||
children,
|
||||
width = 1,
|
||||
style,
|
||||
}: ViewStyleProp & {
|
||||
children: React.ReactNode
|
||||
width?: number
|
||||
}) {
|
||||
const {gap} = useContext(Context)
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_col,
|
||||
{
|
||||
paddingLeft: gap / 2,
|
||||
paddingRight: gap / 2,
|
||||
width: `${width * 100}%`,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -122,7 +122,11 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
|
||||
shape="square"
|
||||
onPress={onPressBack}
|
||||
hitSlop={HITSLOP_30}
|
||||
style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, style]}
|
||||
style={[
|
||||
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
|
||||
a.bg_transparent,
|
||||
style,
|
||||
]}
|
||||
{...props}>
|
||||
<ButtonIcon icon={ArrowLeft} size="lg" />
|
||||
</Button>
|
||||
|
||||
@@ -6,12 +6,18 @@ import {gradients} from '#/alf/tokens'
|
||||
|
||||
export function LinearGradientBackground({
|
||||
style,
|
||||
gradient = 'sky',
|
||||
children,
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
style: StyleProp<ViewStyle>
|
||||
children: React.ReactNode
|
||||
style?: StyleProp<ViewStyle>
|
||||
gradient?: keyof typeof gradients
|
||||
children?: React.ReactNode
|
||||
start?: [number, number]
|
||||
end?: [number, number]
|
||||
}) {
|
||||
const gradient = gradients.sky.values.map(([_, color]) => {
|
||||
const colors = gradients[gradient].values.map(([_, color]) => {
|
||||
return color
|
||||
}) as [string, string, ...string[]]
|
||||
|
||||
@@ -20,7 +26,7 @@ export function LinearGradientBackground({
|
||||
}
|
||||
|
||||
return (
|
||||
<LinearGradient colors={gradient} style={style}>
|
||||
<LinearGradient colors={colors} style={style} start={start} end={end}>
|
||||
{children}
|
||||
</LinearGradient>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ import {Text, TextProps} from '#/components/Typography'
|
||||
const WORD_WRAP = {wordWrap: 1}
|
||||
|
||||
export type RichTextProps = TextStyleProp &
|
||||
Pick<TextProps, 'selectable'> & {
|
||||
Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
|
||||
value: RichTextAPI | string
|
||||
testID?: string
|
||||
numberOfLines?: number
|
||||
@@ -43,6 +43,8 @@ export function RichText({
|
||||
onLinkPress,
|
||||
interactiveStyle,
|
||||
emojiMultiplier = 1.85,
|
||||
onLayout,
|
||||
onTextLayout,
|
||||
}: RichTextProps) {
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
@@ -70,6 +72,8 @@ export function RichText({
|
||||
selectable={selectable}
|
||||
testID={testID}
|
||||
style={[plainStyles, {fontSize}]}
|
||||
onLayout={onLayout}
|
||||
onTextLayout={onTextLayout}
|
||||
// @ts-ignore web only -prf
|
||||
dataSet={WORD_WRAP}>
|
||||
{text}
|
||||
@@ -83,6 +87,8 @@ export function RichText({
|
||||
testID={testID}
|
||||
style={plainStyles}
|
||||
numberOfLines={numberOfLines}
|
||||
onLayout={onLayout}
|
||||
onTextLayout={onTextLayout}
|
||||
// @ts-ignore web only -prf
|
||||
dataSet={WORD_WRAP}>
|
||||
{text}
|
||||
@@ -163,6 +169,8 @@ export function RichText({
|
||||
testID={testID}
|
||||
style={plainStyles}
|
||||
numberOfLines={numberOfLines}
|
||||
onLayout={onLayout}
|
||||
onTextLayout={onTextLayout}
|
||||
// @ts-ignore web only -prf
|
||||
dataSet={WORD_WRAP}>
|
||||
{els}
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user