Skip to content
Merged
81 changes: 77 additions & 4 deletions src/app/Scenes/Article/Components/ArticleHero.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,104 @@
import { Flex, Image, Spacer, Text, useScreenDimensions } from "@artsy/palette-mobile"
import { ArticleHero_article$key } from "__generated__/ArticleHero_article.graphql"
import { ArticleHeroVideo } from "app/Scenes/Article/Components/ArticleHeroVideo"
import { useFeatureFlag } from "app/utils/hooks/useFeatureFlag"
import { DateTime } from "luxon"
import LinearGradient from "react-native-linear-gradient"
import { useFragment, graphql } from "react-relay"

interface ArticleHeroProps {
article: ArticleHero_article$key
}

export const ArticleHero: React.FC<ArticleHeroProps> = ({ article }) => {
const { width } = useScreenDimensions()
const { width, height: screenHeight, safeAreaInsets } = useScreenDimensions()
const data = useFragment(ArticleHeroFragment, article)
const showBlurhash = useFeatureFlag("ARShowBlurhashImagePlaceholder")

const hasVideo = !!data.hero?.media
const hasImage = !!data.hero?.image?.url

if (hasVideo) {
// Calculate height similar to web: max(50vh - navHeight, 360px)
const navHeight = 50 + safeAreaInsets.top
const videoHeight = Math.max(screenHeight * 0.5 - navHeight, 360)

return (
<Flex style={{ marginTop: safeAreaInsets.top }}>
<Flex width={width} height={videoHeight} position="relative">
<ArticleHeroVideo videoUrl={data.hero.media} width={width} height={videoHeight} />
<LinearGradient
colors={["rgba(0,0,0,0)", "rgba(0,0,0,0.6)"]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
}}
>
<Flex px={2} pb={2} pt={4}>
<Text variant="sm" color="white" fontWeight="bold">
{data.vertical}
</Text>

<Text
variant="xl"
color="white"
style={{
textShadowColor: "rgba(0,0,0,0.25)",
textShadowRadius: 15,
}}
>
{data.title}
</Text>

<Text
variant="md"
color="white"
style={{
textShadowColor: "rgba(0,0,0,0.25)",
textShadowRadius: 15,
}}
>
{data.byline}
</Text>

{!!data.publishedAt && (
<Text
color="white"
variant="xs"
mt={0.5}
style={{
textShadowColor: "rgba(0,0,0,0.25)",
textShadowRadius: 15,
}}
>
{DateTime.fromISO(data.publishedAt).toFormat("MMM d, yyyy")}
</Text>
)}
</Flex>
</LinearGradient>
</Flex>
</Flex>
)
}

// Render image with text below
return (
<>
{!!data.hero?.image?.url && (
{hasImage ? (
<>
<Image
width={width}
src={data.hero.image.url}
aspectRatio={data.hero.image.aspectRatio}
blurhash={showBlurhash ? data.hero.image.blurhash : undefined}
/>

<Spacer y={2} />
</>
)}
) : null}

<Flex mx={2}>
<Text variant="xs" color="mono100">
Expand Down Expand Up @@ -60,6 +132,7 @@ const ArticleHeroFragment = graphql`
publishedAt
hero {
... on ArticleFeatureSection {
media
image {
aspectRatio
blurhash
Expand Down
89 changes: 89 additions & 0 deletions src/app/Scenes/Article/Components/ArticleHeroVideo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Flex, useColor } from "@artsy/palette-mobile"
import { useMemo } from "react"
import { Platform } from "react-native"
import { WebView } from "react-native-webview"

interface ArticleHeroVideoProps {
videoUrl: string
width: number
height: number
}

export const ArticleHeroVideo: React.FC<ArticleHeroVideoProps> = ({ videoUrl, width, height }) => {
const color = useColor()
const backgroundColor = color("mono30")

// Memoize HTML to prevent re-renders
const html = useMemo(
() => `
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: ${backgroundColor};
overflow: hidden;
position: fixed;
width: 100%;
height: 100%;
}
video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
position: absolute;
top: 0;
left: 0;
}
</style>
</head>
<body>
<video
autoplay
loop
muted
playsinline
preload="auto"
webkit-playsinline
>
<source src="${videoUrl}" type="video/mp4">
</video>
</body>
</html>
`,
[videoUrl, backgroundColor]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using embedded html and webviews built in video playback capabilities, customization is limited and performance specifically on Android is not great (at least on my old test device), but is what we are currently using for embedded videos, if we find it is not up to the task we should explore video libraries like react-native-video

)

return (
<Flex width={width} height={height} backgroundColor="mono30" testID="ArticleHeroVideo">
<WebView
source={{ html }}
style={{ flex: 1, backgroundColor }}
allowsInlineMediaPlayback
mediaPlaybackRequiresUserAction={false}
scrollEnabled={false}
bounces={false}
// Prevent zooming
scalesPageToFit={Platform.OS === "android"}
// Performance optimizations
androidLayerType="hardware"
androidHardwareAccelerationDisabled={false}
cacheEnabled={true}
cacheMode="LOAD_DEFAULT"
// Faster initial load
startInLoadingState={false}
// Prevent unnecessary re-renders
setSupportMultipleWindows={false}
// Allow mixed content for better Android network performance
mixedContentMode="always"
/>
</Flex>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ describe("ArticleBody", () => {
})

it("renders", () => {
renderWithRelay()
renderWithRelay({
Article: () => ({
hero: {
media: null,
},
}),
})

expect(screen.UNSAFE_getByType(ArticleHero)).toBeTruthy()
expect(screen.UNSAFE_getByType(ArticleSection)).toBeTruthy()
Expand Down
23 changes: 23 additions & 0 deletions src/app/Scenes/Article/Components/__tests__/ArticleHero.tests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe("ArticleHero", () => {
renderWithRelay({
Article: () => ({
hero: {
media: null,
image: {
url: "https://example.com/image.jpg",
aspectRatio: 1.5,
Expand All @@ -50,6 +51,7 @@ describe("ArticleHero", () => {
renderWithRelay({
Article: () => ({
hero: {
media: null,
image: {
url: "https://example.com/image.jpg",
aspectRatio: 1.5,
Expand All @@ -62,6 +64,27 @@ describe("ArticleHero", () => {
expect(screen.UNSAFE_getByType(Image)).toBeTruthy()
})

it("renders hero video if available", () => {
renderWithRelay({
Article: () => ({
hero: {
media: "https://example.com/video.mp4",
image: {
url: "https://example.com/image.jpg",
aspectRatio: 1.5,
},
},
vertical: "Vertical",
title: "Title",
byline: "Byline",
publishedAt: "2023-06-15T00:00:00.000Z",
}),
})

expect(screen.UNSAFE_queryByType(Image)).toBeNull()
expect(screen.getByTestId("ArticleHeroVideo")).toBeTruthy()
})

it("does not render hero image if not available", () => {
renderWithRelay({
Article: () => ({
Expand Down
6 changes: 6 additions & 0 deletions src/app/Scenes/Article/__tests__/ArticleScreen.tests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ describe("ArticleScreen", () => {
Article: () => ({
title: "Article Title",
layout: "STANDARD",
hero: {
media: null,
},
}),
})

Expand All @@ -32,6 +35,9 @@ describe("ArticleScreen", () => {
Article: () => ({
title: "Article Title",
layout: "FEATURE",
hero: {
media: null,
},
}),
})

Expand Down
15 changes: 12 additions & 3 deletions src/setupJest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,14 +167,23 @@ jest.mock("react-native-webview", () => {
const React = require("react")
const { View } = require("react-native")

const MockWebView = React.forwardRef((props: any, ref: any) => {
return <View ref={ref} {...props} />
})

return {
__esModule: true,
default: React.forwardRef((props: any, ref: any) => {
return <View ref={ref} {...props} />
}),
default: MockWebView,
WebView: MockWebView,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing named export mock was causing test failures

}
})

jest.mock("react-native-linear-gradient", () => {
const React = require("react")
const { View } = require("react-native")
return React.forwardRef((props: any, ref: any) => <View {...props} ref={ref} />)
})

jest.mock("react-native-share", () => ({
open: jest.fn(),
}))
Expand Down