151 feat youtube embed iframe (#176)
* youtube embed iframe temp commit * Fixes styling and code cleanup * lint * Now clicking between the pause and settings button doesn't trigger the parent * use modest branding (less yt logos) * Stop playing the video once there's a navigation event * Make sure the iframe is unmounted on any navigation event * fixes tests * lint
This commit is contained in:
@@ -268,3 +268,32 @@ export function convertBskyAppUrlIfNeeded(url: string): string {
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
export const 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
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {TABS_ENABLED} from '../../build-flags'
|
||||
import {segmentClient} from '../../lib/segmentClient'
|
||||
@@ -228,8 +229,9 @@ export class NavigationModel {
|
||||
]
|
||||
tabIndex = 0
|
||||
|
||||
constructor() {
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(this, {
|
||||
rootStore: false,
|
||||
serialize: false,
|
||||
hydrate: false,
|
||||
})
|
||||
@@ -263,6 +265,7 @@ export class NavigationModel {
|
||||
// =
|
||||
|
||||
navigate(url: string, title?: string) {
|
||||
this.rootStore.emitNavigation()
|
||||
this.tab.navigate(url, title)
|
||||
}
|
||||
|
||||
@@ -300,6 +303,7 @@ export class NavigationModel {
|
||||
// fixed tab helper function
|
||||
// -prf
|
||||
switchTo(purpose: TabPurpose, reset: boolean) {
|
||||
this.rootStore.emitNavigation()
|
||||
switch (purpose) {
|
||||
case TabPurpose.Notifs:
|
||||
this.tabIndex = 2
|
||||
|
||||
@@ -21,8 +21,8 @@ export class RootStoreModel {
|
||||
agent: AtpAgent
|
||||
log = new LogModel()
|
||||
session = new SessionModel(this)
|
||||
nav = new NavigationModel()
|
||||
shell = new ShellUiModel()
|
||||
nav = new NavigationModel(this)
|
||||
shell = new ShellUiModel(this)
|
||||
me = new MeModel(this)
|
||||
onboard = new OnboardModel()
|
||||
profiles = new ProfilesViewModel(this)
|
||||
@@ -158,6 +158,14 @@ export class RootStoreModel {
|
||||
DeviceEventEmitter.emit('session-dropped')
|
||||
}
|
||||
|
||||
onNavigation(handler: () => void): EmitterSubscription {
|
||||
return DeviceEventEmitter.addListener('navigation', handler)
|
||||
}
|
||||
|
||||
emitNavigation() {
|
||||
DeviceEventEmitter.emit('navigation')
|
||||
}
|
||||
|
||||
// background fetch
|
||||
// =
|
||||
// - we use this to poll for unread notifications, which is not "ideal" behavior but
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {ProfileViewModel} from './profile-view'
|
||||
import {isObj, hasProp} from '../lib/type-guards'
|
||||
@@ -113,8 +114,12 @@ export class ShellUiModel {
|
||||
isComposerActive = false
|
||||
composerOpts: ComposerOpts | undefined
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {serialize: false, hydrate: false})
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(this, {
|
||||
serialize: false,
|
||||
rootStore: false,
|
||||
hydrate: false,
|
||||
})
|
||||
}
|
||||
|
||||
serialize(): unknown {
|
||||
@@ -152,6 +157,7 @@ export class ShellUiModel {
|
||||
| ReportAccountModal
|
||||
| DeleteAccountModal,
|
||||
) {
|
||||
this.rootStore.emitNavigation()
|
||||
this.isModalActive = true
|
||||
this.activeModal = modal
|
||||
}
|
||||
@@ -162,6 +168,7 @@ export class ShellUiModel {
|
||||
}
|
||||
|
||||
openLightbox(lightbox: ProfileImageLightbox | ImagesLightbox) {
|
||||
this.rootStore.emitNavigation()
|
||||
this.isLightboxActive = true
|
||||
this.activeLightbox = lightbox
|
||||
}
|
||||
@@ -172,6 +179,7 @@ export class ShellUiModel {
|
||||
}
|
||||
|
||||
openComposer(opts: ComposerOpts) {
|
||||
this.rootStore.emitNavigation()
|
||||
this.isComposerActive = true
|
||||
this.composerOpts = opts
|
||||
}
|
||||
|
||||
@@ -22,17 +22,21 @@ export const Link = observer(function Link({
|
||||
noFeedback,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
href: string
|
||||
href?: string
|
||||
title?: string
|
||||
children?: React.ReactNode
|
||||
noFeedback?: boolean
|
||||
}) {
|
||||
const store = useStores()
|
||||
const onPress = () => {
|
||||
handleLink(store, href, false)
|
||||
if (href) {
|
||||
handleLink(store, href, false)
|
||||
}
|
||||
}
|
||||
const onLongPress = () => {
|
||||
handleLink(store, href, true)
|
||||
if (href) {
|
||||
handleLink(store, href, true)
|
||||
}
|
||||
}
|
||||
if (noFeedback) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react'
|
||||
import {Text} from '../text/Text'
|
||||
import {Image} from '../images/Image'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {usePalette} from '../../../lib/hooks/usePalette'
|
||||
import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external'
|
||||
|
||||
const ExternalLinkEmbed = ({
|
||||
link,
|
||||
onImagePress,
|
||||
}: {
|
||||
link: PresentedExternal
|
||||
onImagePress: () => void
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<>
|
||||
{link.thumb ? (
|
||||
<Image
|
||||
uri={link.thumb}
|
||||
style={styles.extImage}
|
||||
onPress={onImagePress}
|
||||
/>
|
||||
) : undefined}
|
||||
<View style={styles.extInner}>
|
||||
<Text type="md-bold" numberOfLines={2} style={[pal.text]}>
|
||||
{link.title || link.uri}
|
||||
</Text>
|
||||
<Text
|
||||
type="sm"
|
||||
numberOfLines={1}
|
||||
style={[pal.textLight, styles.extUri]}>
|
||||
{link.uri}
|
||||
</Text>
|
||||
{link.description ? (
|
||||
<Text
|
||||
type="sm"
|
||||
numberOfLines={2}
|
||||
style={[pal.text, styles.extDescription]}>
|
||||
{link.description}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
extInner: {
|
||||
padding: 10,
|
||||
},
|
||||
extImage: {
|
||||
borderTopLeftRadius: 6,
|
||||
borderTopRightRadius: 6,
|
||||
width: '100%',
|
||||
maxHeight: 200,
|
||||
},
|
||||
extUri: {
|
||||
marginTop: 2,
|
||||
},
|
||||
extDescription: {
|
||||
marginTop: 4,
|
||||
},
|
||||
})
|
||||
|
||||
export default ExternalLinkEmbed
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, {useEffect} from 'react'
|
||||
import {useState} from 'react'
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
TouchableWithoutFeedback,
|
||||
EmitterSubscription,
|
||||
} from 'react-native'
|
||||
import YoutubePlayer from 'react-native-youtube-iframe'
|
||||
import {usePalette} from '../../../lib/hooks/usePalette'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import ExternalLinkEmbed from './ExternalLinkEmbed'
|
||||
import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external'
|
||||
import {useStores} from '../../../../state'
|
||||
|
||||
const YoutubeEmbed = ({
|
||||
link,
|
||||
videoId,
|
||||
}: {
|
||||
videoId: string
|
||||
link: PresentedExternal
|
||||
}) => {
|
||||
const store = useStores()
|
||||
const [displayVideoPlayer, setDisplayVideoPlayer] = useState(false)
|
||||
const [playerDimensions, setPlayerDimensions] = useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
})
|
||||
const pal = usePalette('default')
|
||||
const handlePlayButtonPressed = () => {
|
||||
setDisplayVideoPlayer(true)
|
||||
}
|
||||
const handleOnLayout = (event: {
|
||||
nativeEvent: {layout: {width: any; height: any}}
|
||||
}) => {
|
||||
setPlayerDimensions({
|
||||
width: event.nativeEvent.layout.width,
|
||||
height: event.nativeEvent.layout.height,
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
let sub: EmitterSubscription
|
||||
if (displayVideoPlayer) {
|
||||
sub = store.onNavigation(() => {
|
||||
setDisplayVideoPlayer(false)
|
||||
})
|
||||
}
|
||||
return () => sub && sub.remove()
|
||||
}, [displayVideoPlayer, store])
|
||||
|
||||
if (!displayVideoPlayer) {
|
||||
return (
|
||||
<View
|
||||
style={[styles.extOuter, pal.view, pal.border]}
|
||||
onLayout={handleOnLayout}>
|
||||
<ExternalLinkEmbed link={link} onImagePress={handlePlayButtonPressed} />
|
||||
<Pressable onPress={handlePlayButtonPressed} style={styles.playButton}>
|
||||
<FontAwesomeIcon icon="play" size={24} color="white" />
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const height = (playerDimensions.width / 16) * 9
|
||||
const noop = () => {}
|
||||
|
||||
return (
|
||||
<TouchableWithoutFeedback onPress={noop}>
|
||||
<View>
|
||||
{/* Removing the outter View will make tap events propagate to parents */}
|
||||
<YoutubePlayer
|
||||
initialPlayerParams={{
|
||||
modestbranding: true,
|
||||
}}
|
||||
webViewProps={{
|
||||
startInLoadingState: true,
|
||||
}}
|
||||
height={height}
|
||||
videoId={videoId}
|
||||
webViewStyle={styles.webView}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
extOuter: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
playButton: {
|
||||
position: 'absolute',
|
||||
top: '20%',
|
||||
alignSelf: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'black',
|
||||
padding: 10,
|
||||
borderRadius: 50,
|
||||
opacity: 0.8,
|
||||
},
|
||||
webView: {
|
||||
alignItems: 'center',
|
||||
alignContent: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
export default YoutubeEmbed
|
||||
@@ -7,14 +7,16 @@ import {
|
||||
Image as RNImage,
|
||||
} from 'react-native'
|
||||
import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from './text/Text'
|
||||
import {Image} from './images/Image'
|
||||
import {ImageLayoutGrid} from './images/ImageLayoutGrid'
|
||||
import {ImagesLightbox} from '../../../state/models/shell-ui'
|
||||
import {useStores} from '../../../state'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {saveImageModal} from '../../../lib/images'
|
||||
import {Link} from '../Link'
|
||||
import {Image} from '../images/Image'
|
||||
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
||||
import {ImagesLightbox} from '../../../../state/models/shell-ui'
|
||||
import {useStores} from '../../../../state'
|
||||
import {usePalette} from '../../../lib/hooks/usePalette'
|
||||
import {saveImageModal} from '../../../../lib/images'
|
||||
import YoutubeEmbed from './YoutubeEmbed'
|
||||
import ExternalLinkEmbed from './ExternalLinkEmbed'
|
||||
import {getYoutubeVideoId} from '../../../../lib/strings'
|
||||
|
||||
type Embed =
|
||||
| AppBskyEmbedImages.Presented
|
||||
@@ -103,33 +105,18 @@ export function PostEmbeds({
|
||||
}
|
||||
if (AppBskyEmbedExternal.isPresented(embed)) {
|
||||
const link = embed.external
|
||||
const youtubeVideoId = getYoutubeVideoId(link.uri)
|
||||
|
||||
if (youtubeVideoId) {
|
||||
return <YoutubeEmbed videoId={youtubeVideoId} link={link} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
style={[styles.extOuter, pal.view, pal.border, style]}
|
||||
href={link.uri}
|
||||
noFeedback>
|
||||
{link.thumb ? (
|
||||
<Image uri={link.thumb} style={styles.extImage} />
|
||||
) : undefined}
|
||||
<View style={styles.extInner}>
|
||||
<Text type="md-bold" numberOfLines={2} style={[pal.text]}>
|
||||
{link.title || link.uri}
|
||||
</Text>
|
||||
<Text
|
||||
type="sm"
|
||||
numberOfLines={1}
|
||||
style={[pal.textLight, styles.extUri]}>
|
||||
{link.uri}
|
||||
</Text>
|
||||
{link.description ? (
|
||||
<Text
|
||||
type="sm"
|
||||
numberOfLines={2}
|
||||
style={[pal.text, styles.extDescription]}>
|
||||
{link.description}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
<ExternalLinkEmbed link={link} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -149,19 +136,4 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
extInner: {
|
||||
padding: 10,
|
||||
},
|
||||
extImage: {
|
||||
borderTopLeftRadius: 6,
|
||||
borderTopRightRadius: 6,
|
||||
width: '100%',
|
||||
maxHeight: 200,
|
||||
},
|
||||
extUri: {
|
||||
marginTop: 2,
|
||||
},
|
||||
extDescription: {
|
||||
marginTop: 4,
|
||||
},
|
||||
})
|
||||
@@ -65,6 +65,8 @@ import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
|
||||
import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
|
||||
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
|
||||
import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
|
||||
import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
|
||||
import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
|
||||
|
||||
export function setup() {
|
||||
library.add(
|
||||
@@ -133,5 +135,7 @@ export function setup() {
|
||||
faTrashCan,
|
||||
faX,
|
||||
faXmark,
|
||||
faPlay,
|
||||
faPause,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user