Skip to content

Commit db76588

Browse files
brainbicycleclaude
andauthored
feat: support video on feature pages (#12881)
* first pass at rendering video in feature page * cleanup * use screen header to fix header issues * use webview to fix sizing issues * use object cover to get more reasonable resize behavior * refactor: simplify separator logic in Feature component Removed the addSeparatorBetweenAllSections helper function and switched to adding separators inline for better control and clarity. This fixes spacing issues with the video section and makes the code more maintainable. Changes: - Removed addSeparatorBetweenAllSections helper function - Changed from nested arrays to flat array structure - Added separators inline during section construction - Removed flattenDeep dependency - Removed unused WebView import 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * check for allowed domains * feat: add URL validation to FeatureVideo component Added allowlist validation for video URLs to prevent potential XSS attacks. Only URLs from trusted video domains (Vimeo and YouTube) are allowed. Also moved dimension calculations inside useMemo to ensure proper dependency tracking. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * remove unused field * refactor: extract VideoWebView component for reusability Created a new VideoWebView component that encapsulates all the performance optimizations and mobile-specific configurations for video playback. Updated both FeatureVideo and ArticleHeroVideo to use this shared component, reducing code duplication. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * update test to check for video presence * pr review: use url polyfill for parsing * pr review: use url polyfill for search params * pr review: extract to constants --------- Co-authored-by: Claude <[email protected]>
1 parent 1a79713 commit db76588

File tree

7 files changed

+236
-104
lines changed

7 files changed

+236
-104
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Flex } from "@artsy/palette-mobile"
2+
import { Platform } from "react-native"
3+
import { WebView } from "react-native-webview"
4+
5+
interface VideoWebViewProps {
6+
html: string
7+
width: number
8+
height: number
9+
testID?: string
10+
}
11+
12+
/**
13+
* Optimized WebView component for video playback.
14+
* Includes performance optimizations and mobile-specific configurations.
15+
*/
16+
export const VideoWebView: React.FC<VideoWebViewProps> = ({ html, width, height, testID }) => {
17+
return (
18+
<Flex width={width} height={height} testID={testID}>
19+
<WebView
20+
source={{ html }}
21+
style={{ width, height }}
22+
allowsInlineMediaPlayback
23+
mediaPlaybackRequiresUserAction={false}
24+
scrollEnabled={false}
25+
bounces={false}
26+
// Prevent zooming
27+
scalesPageToFit={Platform.OS === "android"}
28+
// Performance optimizations
29+
androidLayerType="hardware"
30+
androidHardwareAccelerationDisabled={false}
31+
cacheEnabled={true}
32+
cacheMode="LOAD_DEFAULT"
33+
// Faster initial load
34+
startInLoadingState={false}
35+
// Prevent unnecessary re-renders
36+
setSupportMultipleWindows={false}
37+
// Allow mixed content for better Android network performance
38+
mixedContentMode="always"
39+
/>
40+
</Flex>
41+
)
42+
}

src/app/Navigation/routes.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,11 @@ export const artsyDotNetRoutes = defineRoutes([
901901
path: "/feature/:slug",
902902
name: "Feature",
903903
Component: FeatureQueryRenderer,
904+
options: {
905+
screenOptions: {
906+
headerShown: false,
907+
},
908+
},
904909
queries: [FeatureScreenQuery],
905910
},
906911
{

src/app/Scenes/Article/Components/ArticleHeroVideo.tsx

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { Flex, useColor } from "@artsy/palette-mobile"
1+
import { useColor } from "@artsy/palette-mobile"
2+
import { VideoWebView } from "app/Components/VideoWebView"
23
import { useMemo } from "react"
3-
import { Platform } from "react-native"
4-
import { WebView } from "react-native-webview"
54

65
interface ArticleHeroVideoProps {
76
videoUrl: string
@@ -61,29 +60,5 @@ export const ArticleHeroVideo: React.FC<ArticleHeroVideoProps> = ({ videoUrl, wi
6160
[videoUrl, backgroundColor]
6261
)
6362

64-
return (
65-
<Flex width={width} height={height} backgroundColor="mono30" testID="ArticleHeroVideo">
66-
<WebView
67-
source={{ html }}
68-
style={{ flex: 1, backgroundColor }}
69-
allowsInlineMediaPlayback
70-
mediaPlaybackRequiresUserAction={false}
71-
scrollEnabled={false}
72-
bounces={false}
73-
// Prevent zooming
74-
scalesPageToFit={Platform.OS === "android"}
75-
// Performance optimizations
76-
androidLayerType="hardware"
77-
androidHardwareAccelerationDisabled={false}
78-
cacheEnabled={true}
79-
cacheMode="LOAD_DEFAULT"
80-
// Faster initial load
81-
startInLoadingState={false}
82-
// Prevent unnecessary re-renders
83-
setSupportMultipleWindows={false}
84-
// Allow mixed content for better Android network performance
85-
mixedContentMode="always"
86-
/>
87-
</Flex>
88-
)
63+
return <VideoWebView html={html} width={width} height={height} testID="ArticleHeroVideo" />
8964
}

src/app/Scenes/Feature/Feature.tsx

Lines changed: 65 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1-
import { Spacer, Flex, Text, Separator, Box, useColor } from "@artsy/palette-mobile"
1+
import { Spacer, Flex, Text, Separator, Box, useColor, Screen } from "@artsy/palette-mobile"
22
import { FeatureQuery } from "__generated__/FeatureQuery.graphql"
33
import { Feature_feature$data } from "__generated__/Feature_feature.graphql"
44
import { AboveTheFoldFlatList } from "app/Components/AboveTheFoldFlatList"
55
import GenericGrid from "app/Components/ArtworkGrids/GenericGrid"
66
import { ReadMore } from "app/Components/ReadMore"
77
import { Stack } from "app/Components/Stack"
8+
import { FeatureVideo } from "app/Scenes/Feature/FeatureVideo"
9+
import { goBack } from "app/system/navigation/navigate"
810
import { getRelayEnvironment } from "app/system/relay/defaultEnvironment"
911
import { extractNodes } from "app/utils/extractNodes"
1012
import { useScreenDimensions } from "app/utils/hooks"
1113
import { PlaceholderRaggedText } from "app/utils/placeholders"
1214
import { renderWithPlaceholder } from "app/utils/renderWithPlaceholder"
13-
import { chunk, flattenDeep } from "lodash"
15+
import { chunk } from "lodash"
1416
import { isTablet } from "react-native-device-info"
1517
import { createFragmentContainer, graphql, QueryRenderer } from "react-relay"
1618
import { FeatureFeaturedLinkFragmentContainer } from "./components/FeatureFeaturedLink"
@@ -27,77 +29,78 @@ interface FlatListSection {
2729
content: React.JSX.Element
2830
}
2931

30-
type FlatListSections = Array<FlatListSection | FlatListSections>
31-
32-
function addSeparatorBetweenAllSections(
33-
sections: FlatListSections,
34-
key: string,
35-
element: React.JSX.Element
36-
) {
37-
const result: FlatListSections = []
38-
for (let i = 0; i < sections.length; i++) {
39-
result.push(sections[i])
40-
if (i !== sections.length - 1) {
41-
result.push({
42-
key: `${key}:separator:${i}`,
43-
content: element,
44-
})
45-
}
46-
}
47-
return result
48-
}
49-
5032
interface FeatureAppProps {
5133
feature: Feature_feature$data
5234
}
5335

36+
const BASE_NAV_HEIGHT = 50
37+
const MAX_VIDEO_HEIGHT = 360
38+
5439
const FeatureApp: React.FC<FeatureAppProps> = ({ feature }) => {
5540
const color = useColor()
5641
const sets = extractNodes(feature.sets)
57-
const { width, orientation } = useScreenDimensions()
42+
const { width, height: screenHeight, orientation, safeAreaInsets } = useScreenDimensions()
43+
// Calculate height similar to web: max(50vh - navHeight, 360px)
44+
const navHeight = BASE_NAV_HEIGHT + safeAreaInsets.top
45+
const videoHeight = Math.max(screenHeight * 0.5 - navHeight, MAX_VIDEO_HEIGHT)
5846

5947
const header: FlatListSection = {
6048
key: "header",
6149
content: <FeatureHeaderFragmentContainer feature={feature} />,
6250
}
6351

6452
// these are the major sections of the page which get separated by a black line
65-
const contentSections: FlatListSections = []
53+
const allSections: FlatListSection[] = []
6654

67-
if (feature.description || feature.callout) {
68-
contentSections.push({
69-
key: "description+callout",
55+
if (feature.description || feature.callout || feature.video?.url) {
56+
allSections.push({
57+
key: "description+callout+video",
7058
content: (
71-
<Flex alignItems="center">
72-
<Stack spacing={4} pt={4} px={2} maxWidth={600}>
73-
{!!feature.description && (
74-
<FeatureMarkdown content={feature.description} textProps={{ variant: "md" }} />
75-
)}
76-
{!!feature.callout && (
77-
<FeatureMarkdown content={feature.callout} textProps={{ variant: "lg" }} />
78-
)}
79-
</Stack>
59+
<Flex>
60+
{!!(feature.description || feature.callout) && (
61+
<Flex alignItems="center">
62+
<Flex gap={4} pt={4} px={2} maxWidth={600}>
63+
{!!feature.description && (
64+
<FeatureMarkdown content={feature.description} textProps={{ variant: "md" }} />
65+
)}
66+
{!!feature.callout && (
67+
<FeatureMarkdown content={feature.callout} textProps={{ variant: "lg" }} />
68+
)}
69+
</Flex>
70+
</Flex>
71+
)}
72+
{!!feature.video?.url && (
73+
<FeatureVideo videoUrl={feature.video.url} width={width} height={videoHeight} />
74+
)}
8075
</Flex>
8176
),
8277
})
8378
}
8479

8580
for (const set of sets) {
86-
const renderedSet: FlatListSections = []
8781
const items = extractNodes(set.orderedItems)
8882
const count = items.length
8983

9084
if (
9185
// Nothing to render: it's possible to have a completely empty yet valid set
9286
(!set.name && !set.description && count === 0) ||
9387
// Or the set isn't a supported type (Sale, etc.)
94-
!SUPPORTED_ITEM_TYPES.includes(set.itemType!)
88+
!set.itemType ||
89+
!SUPPORTED_ITEM_TYPES.includes(set.itemType)
9590
) {
9691
continue
9792
}
9893

94+
// Add separator before this set (except for the first section)
95+
if (allSections.length > 0) {
96+
allSections.push({
97+
key: `separator:${set.id}`,
98+
content: <Separator mb={4} style={{ borderColor: color("mono100") }} />,
99+
})
100+
}
101+
99102
if (set.name || set.description) {
100-
renderedSet.push({
103+
allSections.push({
101104
key: "setTitle:" + set.id,
102105
content: (
103106
<Flex pb={2} mx={2}>
@@ -124,10 +127,10 @@ const FeatureApp: React.FC<FeatureAppProps> = ({ feature }) => {
124127
const columnWidth = (width - 20) / numColumns - 20
125128

126129
const rows = chunk(items, numColumns)
127-
const renderedRows: FlatListSections = []
128130

129-
for (const row of rows) {
130-
renderedRows.push({
131+
for (let i = 0; i < rows.length; i++) {
132+
const row = rows[i]
133+
allSections.push({
131134
key: "featuredLinkRow:" + row[0].id,
132135
content: (
133136
<Stack horizontal px={2}>
@@ -143,16 +146,19 @@ const FeatureApp: React.FC<FeatureAppProps> = ({ feature }) => {
143146
</Stack>
144147
),
145148
})
149+
// Add spacer between rows (except after the last row)
150+
if (i < rows.length - 1) {
151+
allSections.push({
152+
key: `featuredLinkSpacer:${set.id}:${i}`,
153+
content: <Spacer y={4} />,
154+
})
155+
}
146156
}
147157

148-
renderedSet.push(
149-
addSeparatorBetweenAllSections(renderedRows, set.id + ":featuredLink", <Spacer y={4} />)
150-
)
151-
152158
break
153159
}
154160
case "Artwork":
155-
renderedSet.push({
161+
allSections.push({
156162
key: "artworks:" + set.id,
157163
content: (
158164
<Flex mx={2}>
@@ -165,26 +171,18 @@ const FeatureApp: React.FC<FeatureAppProps> = ({ feature }) => {
165171
console.warn("Feature pages only support FeaturedLinks and Artworks")
166172
}
167173
}
168-
169-
contentSections.push(renderedSet)
170174
}
171175

172176
return (
173-
<AboveTheFoldFlatList<FlatListSection>
174-
initialNumToRender={__TEST__ ? 100 : 6}
175-
data={[
176-
header,
177-
...flattenDeep(
178-
addSeparatorBetweenAllSections(
179-
contentSections,
180-
"content",
181-
<Separator mt={4} mb={4} style={{ borderColor: color("mono100") }} />
182-
)
183-
),
184-
]}
185-
renderItem={(item) => item.item.content}
186-
contentContainerStyle={{ paddingBottom: 40 }}
187-
/>
177+
<Screen>
178+
<Screen.AnimatedHeader onBack={goBack} />
179+
<AboveTheFoldFlatList<FlatListSection>
180+
initialNumToRender={__TEST__ ? 100 : 6}
181+
data={[header, ...allSections]}
182+
renderItem={(item) => item.item.content}
183+
contentContainerStyle={{ paddingBottom: 40 }}
184+
/>
185+
</Screen>
188186
)
189187
}
190188

@@ -195,6 +193,9 @@ const FeatureFragmentContainer = createFragmentContainer(FeatureApp, {
195193
...FeatureHeader_feature
196194
description
197195
callout
196+
video {
197+
url
198+
}
198199
sets: setsConnection(first: 20) {
199200
edges {
200201
node {

0 commit comments

Comments
 (0)