3rd party embed player (#2217)

* Implement embed player for YT, spotify, and twitch

* fix: handle blur event

* fix: use video dimensions for twitch

* fix: remove hack (?)

* fix: remove origin whitelist (?)

* fix: prevent ads from opening in browser

* fix: handle embeds that don't have a thumb

* feat: handle dark/light mode

* fix: ts warning

* fix: adjust height of no-thumb label

* fix: adjust height of no-thumb label

* fix: remove debug log, set collapsable to false for player view

* fix: fix dimensions "flash"

* chore: remove old youtube link test

* tests: add tests

* fix: thumbless embed position when loading

* fix: remove background from webview

* cleanup embeds (almost)

* more refactoring

- Use separate layers for player and overlay to prevent weird sizing issues
- Be sure the image is not visible under the player
- Clean up some

* cleanup styles

* parse youtube shorts urls

* remove debug

* add soundcloud tracks and sets (playlists)

* move logic into `ExternalLinkEmbed`

* border radius for yt player on native

* fix styling on web

* allow scrolling in webview on android

* remove unnecessary check

* autoplay yt on web

* fix tests after adding autoplay

* move `useNavigation` to top of component

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
zio/stable
Hailey 2023-12-21 14:33:46 -08:00 committed by GitHub
parent 7ab188dc1f
commit fedb94dd70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 597 additions and 135 deletions

View File

@ -1,6 +1,5 @@
import {RichText} from '@atproto/api'
import {
getYoutubeVideoId,
makeRecordUri,
toNiceDomain,
toShortUrl,
@ -12,6 +11,7 @@ import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
import {makeValidHandle, createFullHandle} from '../../src/lib/strings/handles'
import {cleanError} from '../../src/lib/strings/errors'
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
describe('detectLinkables', () => {
const inputs = [
@ -335,32 +335,6 @@ describe('toShareUrl', () => {
})
})
describe('getYoutubeVideoId', () => {
it(' should return undefined for invalid youtube links', () => {
expect(getYoutubeVideoId('')).toBeUndefined()
expect(getYoutubeVideoId('https://www.google.com')).toBeUndefined()
expect(getYoutubeVideoId('https://www.youtube.com')).toBeUndefined()
expect(
getYoutubeVideoId('https://www.youtube.com/channelName'),
).toBeUndefined()
expect(
getYoutubeVideoId('https://www.youtube.com/channel/channelName'),
).toBeUndefined()
})
it('getYoutubeVideoId should return video id for valid youtube links', () => {
expect(getYoutubeVideoId('https://www.youtube.com/watch?v=videoId')).toBe(
'videoId',
)
expect(
getYoutubeVideoId(
'https://www.youtube.com/watch?v=videoId&feature=share',
),
).toBe('videoId')
expect(getYoutubeVideoId('https://youtu.be/videoId')).toBe('videoId')
})
})
describe('shortenLinks', () => {
const inputs = [
'start https://middle.com/foo/bar?baz=bux#hash end',
@ -396,6 +370,7 @@ describe('shortenLinks', () => {
],
],
]
it('correctly shortens rich text while preserving facet URIs', () => {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]
@ -410,3 +385,141 @@ describe('shortenLinks', () => {
}
})
})
describe('parseEmbedPlayerFromUrl', () => {
const inputs = [
'https://youtu.be/videoId',
'https://www.youtube.com/watch?v=videoId',
'https://www.youtube.com/watch?v=videoId&feature=share',
'https://youtube.com/watch?v=videoId',
'https://youtube.com/watch?v=videoId&feature=share',
'https://youtube.com/shorts/videoId',
'https://youtube.com/shorts/',
'https://youtube.com/',
'https://youtube.com/random',
'https://twitch.tv/channelName',
'https://www.twitch.tv/channelName',
'https://open.spotify.com/playlist/playlistId',
'https://open.spotify.com/playlist/playlistId?param=value',
'https://open.spotify.com/track/songId',
'https://open.spotify.com/track/songId?param=value',
'https://open.spotify.com/album/albumId',
'https://open.spotify.com/album/albumId?param=value',
'https://soundcloud.com/user/track',
'https://soundcloud.com/user/sets/set',
'https://soundcloud.com/user/',
]
const outputs = [
{
type: 'youtube_video',
videoId: 'videoId',
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
},
{
type: 'youtube_video',
videoId: 'videoId',
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
},
{
type: 'youtube_video',
videoId: 'videoId',
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
},
{
type: 'youtube_video',
videoId: 'videoId',
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
},
{
type: 'youtube_video',
videoId: 'videoId',
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
},
{
type: 'youtube_video',
videoId: 'videoId',
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
},
undefined,
undefined,
undefined,
{
type: 'twitch_live',
channelId: 'channelName',
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`,
},
{
type: 'twitch_live',
channelId: 'channelName',
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`,
},
{
type: 'spotify_playlist',
playlistId: 'playlistId',
playerUri: `https://open.spotify.com/embed/playlist/playlistId`,
},
{
type: 'spotify_playlist',
playlistId: 'playlistId',
playerUri: `https://open.spotify.com/embed/playlist/playlistId`,
},
{
type: 'spotify_song',
songId: 'songId',
playerUri: `https://open.spotify.com/embed/track/songId`,
},
{
type: 'spotify_song',
songId: 'songId',
playerUri: `https://open.spotify.com/embed/track/songId`,
},
{
type: 'spotify_album',
albumId: 'albumId',
playerUri: `https://open.spotify.com/embed/album/albumId`,
},
{
type: 'spotify_album',
albumId: 'albumId',
playerUri: `https://open.spotify.com/embed/album/albumId`,
},
{
type: 'soundcloud_track',
user: 'user',
track: 'track',
playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/track&auto_play=true&visual=false&hide_related=true`,
},
{
type: 'soundcloud_set',
user: 'user',
set: 'set',
playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/sets/set&auto_play=true&visual=false&hide_related=true`,
},
undefined,
]
it('correctly grabs the correct id from uri', () => {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]
const output = outputs[i]
const res = parseEmbedPlayerFromUrl(input)
console.log(input)
expect(res).toEqual(output)
}
})
})

View File

@ -160,6 +160,9 @@
"react-native-version-number": "^0.3.6",
"react-native-web": "~0.19.6",
"react-native-web-linear-gradient": "^1.1.2",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "^13.6.2",
"react-native-youtube-iframe": "^2.3.0",
"react-responsive": "^9.0.2",
"rn-fetch-blob": "^0.12.0",
"sentry-expo": "~7.0.1",

View File

@ -0,0 +1,147 @@
export type EmbedPlayerParams =
| {type: 'youtube_video'; videoId: string; playerUri: string}
| {type: 'twitch_live'; channelId: string; playerUri: string}
| {type: 'spotify_album'; albumId: string; playerUri: string}
| {
type: 'spotify_playlist'
playlistId: string
playerUri: string
}
| {type: 'spotify_song'; songId: string; playerUri: string}
| {type: 'soundcloud_track'; user: string; track: string; playerUri: string}
| {type: 'soundcloud_set'; user: string; set: string; playerUri: string}
export function parseEmbedPlayerFromUrl(
url: string,
): EmbedPlayerParams | undefined {
let urlp
try {
urlp = new URL(url)
} catch (e) {
return undefined
}
// youtube
if (urlp.hostname === 'youtu.be') {
const videoId = urlp.pathname.split('/')[1]
if (videoId) {
return {
type: 'youtube_video',
videoId,
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`,
}
}
}
if (urlp.hostname === 'www.youtube.com' || urlp.hostname === 'youtube.com') {
const [_, page, shortVideoId] = urlp.pathname.split('/')
const videoId =
page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
if (videoId) {
return {
type: 'youtube_video',
videoId,
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`,
}
}
}
// twitch
if (urlp.hostname === 'twitch.tv' || urlp.hostname === 'www.twitch.tv') {
const parts = urlp.pathname.split('/')
if (parts.length === 2 && parts[1]) {
return {
type: 'twitch_live',
channelId: parts[1],
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${parts[1]}&parent=localhost`,
}
}
}
// spotify
if (urlp.hostname === 'open.spotify.com') {
const [_, type, id] = urlp.pathname.split('/')
if (type && id) {
if (type === 'playlist') {
return {
type: 'spotify_playlist',
playlistId: id,
playerUri: `https://open.spotify.com/embed/playlist/${id}`,
}
}
if (type === 'album') {
return {
type: 'spotify_album',
albumId: id,
playerUri: `https://open.spotify.com/embed/album/${id}`,
}
}
if (type === 'track') {
return {
type: 'spotify_song',
songId: id,
playerUri: `https://open.spotify.com/embed/track/${id}`,
}
}
}
}
// soundcloud
if (
urlp.hostname === 'soundcloud.com' ||
urlp.hostname === 'www.soundcloud.com'
) {
const [_, user, trackOrSets, set] = urlp.pathname.split('/')
if (user && trackOrSets) {
if (trackOrSets === 'sets' && set) {
return {
type: 'soundcloud_set',
user,
set: set,
playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
}
}
return {
type: 'soundcloud_track',
user,
track: trackOrSets,
playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
}
}
}
}
export function getPlayerHeight({
type,
width,
hasThumb,
}: {
type: EmbedPlayerParams['type']
width: number
hasThumb: boolean
}) {
if (!hasThumb) return (width / 16) * 9
switch (type) {
case 'youtube_video':
case 'twitch_live':
return (width / 16) * 9
case 'spotify_album':
return 380
case 'spotify_playlist':
return 360
case 'spotify_song':
if (width <= 300) {
return 180
}
return 232
case 'soundcloud_track':
return 165
case 'soundcloud_set':
return 360
default:
return width
}
}

View File

@ -139,35 +139,6 @@ export function feedUriToHref(url: string): string {
}
}
export function getYoutubeVideoId(link: string): string | undefined {
let url
try {
url = new URL(link)
} catch (e) {
return undefined
}
if (
url.hostname !== 'www.youtube.com' &&
url.hostname !== 'youtube.com' &&
url.hostname !== 'youtu.be'
) {
return undefined
}
if (url.hostname === 'youtu.be') {
const videoId = url.pathname.split('/')[1]
if (!videoId) {
return undefined
}
return videoId
}
const videoId = url.searchParams.get('v') as string
if (!videoId) {
return undefined
}
return videoId
}
/**
* Checks if the label in the post text matches the host of the link facet.
*

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,22 +6,28 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {AppBskyEmbedExternal} from '@atproto/api'
import {toNiceDomain} from 'lib/strings/url-helpers'
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
export const ExternalLinkEmbed = ({
link,
imageChild,
}: {
link: AppBskyEmbedExternal.ViewExternal
imageChild?: React.ReactNode
}) => {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const embedPlayerParams = React.useMemo(
() => parseEmbedPlayerFromUrl(link.uri),
[link.uri],
)
return (
<View
style={{
flexDirection: isMobile ? 'column' : 'row',
flexDirection: !isMobile && !embedPlayerParams ? 'row' : 'column',
}}>
{link.thumb ? (
{link.thumb && !embedPlayerParams ? (
<View
style={
!isMobile
@ -45,9 +51,11 @@ export const ExternalLinkEmbed = ({
source={{uri: link.thumb}}
accessibilityIgnoresInvertColors
/>
{imageChild}
</View>
) : undefined}
{embedPlayerParams && (
<ExternalPlayer link={link} params={embedPlayerParams} />
)}
<View
style={{
paddingHorizontal: isMobile ? 10 : 14,

View File

@ -0,0 +1,251 @@
import React from 'react'
import {
ActivityIndicator,
Dimensions,
GestureResponderEvent,
Pressable,
StyleSheet,
View,
} from 'react-native'
import {Image} from 'expo-image'
import {WebView} from 'react-native-webview'
import YoutubePlayer from 'react-native-youtube-iframe'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player'
import {EventStopper} from '../EventStopper'
import {AppBskyEmbedExternal} from '@atproto/api'
import {isNative} from 'platform/detection'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
interface ShouldStartLoadRequest {
url: string
}
// This renders the overlay when the player is either inactive or loading as a separate layer
function PlaceholderOverlay({
isLoading,
isPlayerActive,
onPress,
}: {
isLoading: boolean
isPlayerActive: boolean
onPress: (event: GestureResponderEvent) => void
}) {
// If the player is active and not loading, we don't want to show the overlay.
if (isPlayerActive && !isLoading) return null
return (
<View style={[styles.layer, styles.overlayLayer]}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Play Video"
accessibilityHint=""
onPress={onPress}
style={[styles.overlayContainer, styles.topRadius]}>
{!isPlayerActive ? (
<FontAwesomeIcon icon="play" size={42} color="white" />
) : (
<ActivityIndicator size="large" color="white" />
)}
</Pressable>
</View>
)
}
// This renders the webview/youtube player as a separate layer
function Player({
height,
params,
onLoad,
isPlayerActive,
}: {
isPlayerActive: boolean
params: EmbedPlayerParams
height: number
onLoad: () => void
}) {
// ensures we only load what's requested
const onShouldStartLoadWithRequest = React.useCallback(
(event: ShouldStartLoadRequest) => event.url === params.playerUri,
[params.playerUri],
)
// Don't show the player until it is active
if (!isPlayerActive) return null
return (
<View style={[styles.layer, styles.playerLayer]}>
<EventStopper>
{isNative && params.type === 'youtube_video' ? (
<YoutubePlayer
videoId={params.videoId}
play
height={height}
onReady={onLoad}
webViewStyle={[styles.webview, styles.topRadius]}
/>
) : (
<View style={{height, width: '100%'}}>
<WebView
javaScriptEnabled={true}
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
mediaPlaybackRequiresUserAction={false}
allowsInlineMediaPlayback
bounces={false}
allowsFullscreenVideo
nestedScrollEnabled
source={{uri: params.playerUri}}
onLoad={onLoad}
setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
style={[styles.webview, styles.topRadius]}
/>
</View>
)}
</EventStopper>
</View>
)
}
// This renders the player area and handles the logic for when to show the player and when to show the overlay
export function ExternalPlayer({
link,
params,
}: {
link: AppBskyEmbedExternal.ViewExternal
params: EmbedPlayerParams
}) {
const navigation = useNavigation<NavigationProp>()
const [isPlayerActive, setPlayerActive] = React.useState(false)
const [isLoading, setIsLoading] = React.useState(true)
const [dim, setDim] = React.useState({
width: 0,
height: 0,
})
const viewRef = React.useRef<View>(null)
// watch for leaving the viewport due to scrolling
React.useEffect(() => {
// Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will
// continue playing. We need to watch for the blur event
const unsubscribe = navigation.addListener('blur', () => {
setPlayerActive(false)
})
const interval = setInterval(() => {
viewRef.current?.measure((x, y, w, h, pageX, pageY) => {
const window = Dimensions.get('window')
const top = pageY
const bot = pageY + h
const isVisible = isNative
? top >= 0 && bot <= window.height
: !(top >= window.height || bot <= 0)
if (!isVisible) {
setPlayerActive(false)
}
})
}, 1e3)
return () => {
unsubscribe()
clearInterval(interval)
}
}, [viewRef, navigation])
// calculate height for the player and the screen size
const height = React.useMemo(
() =>
getPlayerHeight({
type: params.type,
width: dim.width,
hasThumb: !!link.thumb,
}),
[params.type, dim.width, link.thumb],
)
const onLoad = React.useCallback(() => {
setIsLoading(false)
}, [])
const onPlayPress = React.useCallback((event: GestureResponderEvent) => {
// Prevent this from propagating upward on web
event.preventDefault()
setPlayerActive(true)
}, [])
// measure the layout to set sizing
const onLayout = React.useCallback(
(event: {nativeEvent: {layout: {width: any; height: any}}}) => {
setDim({
width: event.nativeEvent.layout.width,
height: event.nativeEvent.layout.height,
})
},
[],
)
return (
<View
ref={viewRef}
style={{height}}
collapsable={false}
onLayout={onLayout}>
{link.thumb && (!isPlayerActive || isLoading) && (
<Image
style={[
{
width: dim.width,
height,
},
styles.topRadius,
]}
source={{uri: link.thumb}}
accessibilityIgnoresInvertColors
/>
)}
<PlaceholderOverlay
isLoading={isLoading}
isPlayerActive={isPlayerActive}
onPress={onPlayPress}
/>
<Player
isPlayerActive={isPlayerActive}
params={params}
height={height}
onLoad={onLoad}
/>
</View>
)
}
const styles = StyleSheet.create({
topRadius: {
borderTopLeftRadius: 6,
borderTopRightRadius: 6,
},
layer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
overlayContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
},
overlayLayer: {
zIndex: 2,
},
playerLayer: {
zIndex: 3,
},
webview: {
backgroundColor: 'transparent',
},
})

View File

@ -1,55 +0,0 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {usePalette} from 'lib/hooks/usePalette'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {AppBskyEmbedExternal} from '@atproto/api'
import {Link} from '../Link'
export const YoutubeEmbed = ({
link,
style,
}: {
link: AppBskyEmbedExternal.ViewExternal
style?: StyleProp<ViewStyle>
}) => {
const pal = usePalette('default')
const imageChild = (
<View style={styles.playButton}>
<FontAwesomeIcon icon="play" size={24} color="white" />
</View>
)
return (
<Link
asAnchor
style={[styles.extOuter, pal.view, pal.border, style]}
href={link.uri}>
<ExternalLinkEmbed link={link} imageChild={imageChild} />
</Link>
)
}
const styles = StyleSheet.create({
extOuter: {
borderWidth: 1,
borderRadius: 8,
},
playButton: {
position: 'absolute',
alignSelf: 'center',
alignItems: 'center',
top: '44%',
justifyContent: 'center',
backgroundColor: 'black',
padding: 10,
borderRadius: 50,
opacity: 0.8,
},
webView: {
alignItems: 'center',
alignContent: 'center',
justifyContent: 'center',
},
})

View File

@ -23,9 +23,7 @@ import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {YoutubeEmbed} from './YoutubeEmbed'
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {getYoutubeVideoId} from 'lib/strings/url-helpers'
import {MaybeQuoteEmbed} from './QuoteEmbed'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {ListEmbed} from './ListEmbed'
@ -168,19 +166,13 @@ export function PostEmbeds({
// =
if (AppBskyEmbedExternal.isView(embed)) {
const link = embed.external
const youtubeVideoId = getYoutubeVideoId(link.uri)
if (youtubeVideoId) {
return <YoutubeEmbed link={link} style={style} />
}
return (
<Link
asAnchor
style={[styles.extOuter, pal.view, pal.border, style]}
href={link.uri}>
<ExternalLinkEmbed link={link} />
</Link>
<View style={[styles.extOuter, pal.view, pal.border, style]}>
<Link asAnchor href={link.uri}>
<ExternalLinkEmbed link={link} />
</Link>
</View>
)
}

View File

@ -10433,16 +10433,16 @@ escape-html@~1.0.3:
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
escape-string-regexp@2.0.0, escape-string-regexp@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344"
integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==
escape-string-regexp@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
escape-string-regexp@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344"
integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==
escape-string-regexp@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
@ -12501,7 +12501,7 @@ interpret@^3.1.1:
resolved "https://registry.yarnpkg.com/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4"
integrity sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==
invariant@*, invariant@^2.2.4:
invariant@*, invariant@2.2.4, invariant@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
@ -17330,6 +17330,13 @@ qs@6.11.0:
dependencies:
side-channel "^1.0.4"
qs@^6.5.1:
version "6.11.2"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9"
integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==
dependencies:
side-channel "^1.0.4"
query-string@^7.1.3:
version "7.1.3"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.3.tgz#a1cf90e994abb113a325804a972d98276fe02328"
@ -17680,6 +17687,13 @@ react-native-web-linear-gradient@^1.1.2:
resolved "https://registry.yarnpkg.com/react-native-web-linear-gradient/-/react-native-web-linear-gradient-1.1.2.tgz#33f85f7085a0bb5ffa5106faf02ed105b92a9ed7"
integrity sha512-SmUnpwT49CEe78pXvIvYf72Es8Pv+ZYKCnEOgb2zAKpEUDMo0+xElfRJhwt5nfI8krJ5WbFPKnoDgD0uUjAN1A==
react-native-web-webview@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/react-native-web-webview/-/react-native-web-webview-1.0.2.tgz#c215efa70c17589f2c8d640b1f1dc669b18c6e02"
integrity sha512-oNAYNuqUqeqTuAAdIejzDqvUtYA+k5lrvhUYmASdUznZNmyIaoQFA6OKoA4K9F3wdMvark42vUXkUWIp875ewg==
dependencies:
qs "^6.5.1"
react-native-web@~0.19.6:
version "0.19.8"
resolved "https://registry.yarnpkg.com/react-native-web/-/react-native-web-0.19.8.tgz#46127f8b310148fde11e4fef67fe625603599d47"
@ -17694,6 +17708,21 @@ react-native-web@~0.19.6:
postcss-value-parser "^4.2.0"
styleq "^0.1.3"
react-native-webview@^13.6.2:
version "13.6.2"
resolved "https://registry.yarnpkg.com/react-native-webview/-/react-native-webview-13.6.2.tgz#0a9b18793e915add5b5dbdbf32509d7751b49167"
integrity sha512-QzhQ5JCU+Nf2W285DtvCZOVQy/MkJXMwNDYPZvOWQbAOgxJMSSO+BtqXTMA1UPugDsko6PxJ0TxSlUwIwJijDg==
dependencies:
escape-string-regexp "2.0.0"
invariant "2.2.4"
react-native-youtube-iframe@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/react-native-youtube-iframe/-/react-native-youtube-iframe-2.3.0.tgz#40ca8e55db929b91bfa8e8d30e411658cbc304c5"
integrity sha512-M+z63xwXVtS4dX3k8PbtHUUcWN+gRZt6J1EtPE7Y60BMOB979KjpkdrHqeR96or9pNR2W8K5tQhIkMXW2jwo7Q==
dependencies:
events "^3.2.0"
react-native@0.72.5:
version "0.72.5"
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.72.5.tgz#2c343fa6f3ead362cf07376634a33a4078864357"