Additional embed sources and external-media consent controls (#2424)

* add apple music embed

* add vimeo embed

* add logic for tenor and giphy embeds

* keep it simple, use playerUri for images too

* add gif embed player

* lint, fix tests

* remove links that can't produce a thumb

* Revert "remove links that can't produce a thumb"

This reverts commit 985b92b4e622db936bb0c79fdf324099b9c8fcd8.

* Revert "Revert "remove links that can't produce a thumb""

This reverts commit 4895ded8b5120c4fc52b43ae85c9a01ea0b1a733.

* Revert "Revert "Revert "remove links that can't produce a thumb"""

This reverts commit 36d04b517ba5139e1639f2eda28d7f9aaa2dbfb6.

* properly obtain giphy metadata regardless of used url

* test fixes

* adjust gif player

* add all twitch embed types

* support m.youtube links

* few logic adjustments

* adjust spotify player height

* prefetch gif before showing

* use memory-disk cache policy on gifs

* use `disk` cachePolicy on ios - can't start/stop animation

* support pause/play on web

* onLoad fix

* remove extra pressable, add accessibility, fix scale issues

* improve size of embed

* add settings

* fix(?) settings

* add source to embed player params

* update tests

* better naming and settings options

* consent modal

* fix test id

* why is webstorm adding .tsx

* web modal

* simplify types

* adjust snap points

* remove unnecessary yt embed library. just use the webview always

* remove now useless WebGifStill 😭

* more type cleanup

* more type cleanup

* combine parse and prefs check in one memo

* improve dimensions of youtube shorts

* oops didn't commit the test 🫥

* add shorts as separate embed type

* fix up schema

* shorts modal

* hide gif details

* support localized spotify embeds

* more cleanup

* improve look and accessibility of gif embeds

* Update routing for the external embeds settings page

* Update and simplify the external embed preferences screen

* Update copy in embedconsent modal and add 'allow all' button

---------

Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
Paul Frazee 2024-01-04 17:37:36 -08:00 committed by GitHub
parent db62f27241
commit 0dae24e78f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1240 additions and 131 deletions

View file

@ -0,0 +1,153 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {s, colors, gradients} from 'lib/styles'
import {Text} from '../util/text/Text'
import {ScrollView} from './util'
import {usePalette} from 'lib/hooks/usePalette'
import {
EmbedPlayerSource,
embedPlayerSources,
externalEmbedLabels,
} from '#/lib/strings/embed-player'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {useSetExternalEmbedPref} from '#/state/preferences/external-embeds-prefs'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
export const snapPoints = [450]
export function Component({
onAccept,
source,
}: {
onAccept: () => void
source: EmbedPlayerSource
}) {
const pal = usePalette('default')
const {closeModal} = useModalControls()
const {_} = useLingui()
const setExternalEmbedPref = useSetExternalEmbedPref()
const {isMobile} = useWebMediaQueries()
const onShowAllPress = React.useCallback(() => {
for (const key of embedPlayerSources) {
setExternalEmbedPref(key, 'show')
}
onAccept()
closeModal()
}, [closeModal, onAccept, setExternalEmbedPref])
const onShowPress = React.useCallback(() => {
setExternalEmbedPref(source, 'show')
onAccept()
closeModal()
}, [closeModal, onAccept, setExternalEmbedPref, source])
const onHidePress = React.useCallback(() => {
setExternalEmbedPref(source, 'hide')
closeModal()
}, [closeModal, setExternalEmbedPref, source])
return (
<ScrollView
testID="embedConsentModal"
style={[
s.flex1,
pal.view,
isMobile
? {paddingHorizontal: 20, paddingTop: 10}
: {paddingHorizontal: 30},
]}>
<Text style={[pal.text, styles.title]}>
<Trans>External Media</Trans>
</Text>
<Text style={pal.text}>
<Trans>
This content is hosted by {externalEmbedLabels[source]}. Do you want
to enable external media?
</Trans>
</Text>
<View style={[s.mt10]} />
<Text style={pal.textLight}>
<Trans>
External media may allow websites to collect information about you and
your device. No information is sent or requested until you press the
"play" button.
</Trans>
</Text>
<View style={[s.mt20]} />
<TouchableOpacity
testID="enableAllBtn"
onPress={onShowAllPress}
accessibilityRole="button"
accessibilityLabel={_(
msg`Show embeds from ${externalEmbedLabels[source]}`,
)}
accessibilityHint=""
onAccessibilityEscape={closeModal}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text style={[s.white, s.bold, s.f18]}>
<Trans>Enable External Media</Trans>
</Text>
</LinearGradient>
</TouchableOpacity>
<View style={[s.mt10]} />
<TouchableOpacity
testID="enableSourceBtn"
onPress={onShowPress}
accessibilityRole="button"
accessibilityLabel={_(
msg`Never load embeds from ${externalEmbedLabels[source]}`,
)}
accessibilityHint=""
onAccessibilityEscape={closeModal}>
<View style={[styles.btn, pal.btn]}>
<Text style={[pal.text, s.bold, s.f18]}>
<Trans>Enable {externalEmbedLabels[source]} only</Trans>
</Text>
</View>
</TouchableOpacity>
<View style={[s.mt10]} />
<TouchableOpacity
testID="disableSourceBtn"
onPress={onHidePress}
accessibilityRole="button"
accessibilityLabel={_(
msg`Never load embeds from ${externalEmbedLabels[source]}`,
)}
accessibilityHint=""
onAccessibilityEscape={closeModal}>
<View style={[styles.btn, pal.btn]}>
<Text style={[pal.text, s.bold, s.f18]}>
<Trans>No thanks</Trans>
</Text>
</View>
</TouchableOpacity>
</ScrollView>
)
}
const styles = StyleSheet.create({
title: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 24,
marginBottom: 12,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
borderRadius: 32,
padding: 14,
backgroundColor: colors.gray1,
},
})

View file

@ -38,6 +38,7 @@ import * as VerifyEmailModal from './VerifyEmail'
import * as ChangeEmailModal from './ChangeEmail'
import * as SwitchAccountModal from './SwitchAccount'
import * as LinkWarningModal from './LinkWarning'
import * as EmbedConsentModal from './EmbedConsent'
const DEFAULT_SNAPPOINTS = ['90%']
const HANDLE_HEIGHT = 24
@ -176,6 +177,9 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'link-warning') {
snapPoints = LinkWarningModal.snapPoints
element = <LinkWarningModal.Component {...activeModal} />
} else if (activeModal?.name === 'embed-consent') {
snapPoints = EmbedConsentModal.snapPoints
element = <EmbedConsentModal.Component {...activeModal} />
} else {
return null
}

View file

@ -34,6 +34,7 @@ import * as BirthDateSettingsModal from './BirthDateSettings'
import * as VerifyEmailModal from './VerifyEmail'
import * as ChangeEmailModal from './ChangeEmail'
import * as LinkWarningModal from './LinkWarning'
import * as EmbedConsentModal from './EmbedConsent'
export function ModalsContainer() {
const {isModalActive, activeModals} = useModals()
@ -129,6 +130,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ChangeEmailModal.Component />
} else if (modal.name === 'link-warning') {
element = <LinkWarningModal.Component {...modal} />
} else if (modal.name === 'embed-consent') {
element = <EmbedConsentModal.Component {...modal} />
} else {
return null
}

View file

@ -0,0 +1,170 @@
import {EmbedPlayerParams, getGifDims} from 'lib/strings/embed-player'
import React from 'react'
import {Image, ImageLoadEventData} from 'expo-image'
import {
ActivityIndicator,
GestureResponderEvent,
LayoutChangeEvent,
Pressable,
StyleSheet,
View,
} from 'react-native'
import {isIOS, isNative, isWeb} from '#/platform/detection'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useExternalEmbedsPrefs} from 'state/preferences'
import {useModalControls} from 'state/modals'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {AppBskyEmbedExternal} from '@atproto/api'
export function ExternalGifEmbed({
link,
params,
}: {
link: AppBskyEmbedExternal.ViewExternal
params: EmbedPlayerParams
}) {
const externalEmbedsPrefs = useExternalEmbedsPrefs()
const {openModal} = useModalControls()
const {_} = useLingui()
const thumbHasLoaded = React.useRef(false)
const viewWidth = React.useRef(0)
// Tracking if the placer has been activated
const [isPlayerActive, setIsPlayerActive] = React.useState(false)
// Tracking whether the gif has been loaded yet
const [isPrefetched, setIsPrefetched] = React.useState(false)
// Tracking whether the image is animating
const [isAnimating, setIsAnimating] = React.useState(true)
const [imageDims, setImageDims] = React.useState({height: 100, width: 1})
// Used for controlling animation
const imageRef = React.useRef<Image>(null)
const load = React.useCallback(() => {
setIsPlayerActive(true)
Image.prefetch(params.playerUri).then(() => {
// Replace the image once it's fetched
setIsPrefetched(true)
})
}, [params.playerUri])
const onPlayPress = React.useCallback(
(event: GestureResponderEvent) => {
// Don't propagate on web
event.preventDefault()
// Show consent if this is the first load
if (externalEmbedsPrefs?.[params.source] === undefined) {
openModal({
name: 'embed-consent',
source: params.source,
onAccept: load,
})
return
}
// If the player isn't active, we want to activate it and prefetch the gif
if (!isPlayerActive) {
load()
return
}
// Control animation on native
setIsAnimating(prev => {
if (prev) {
if (isNative) {
imageRef.current?.stopAnimating()
}
return false
} else {
if (isNative) {
imageRef.current?.startAnimating()
}
return true
}
})
},
[externalEmbedsPrefs, isPlayerActive, load, openModal, params.source],
)
const onLoad = React.useCallback((e: ImageLoadEventData) => {
if (thumbHasLoaded.current) return
setImageDims(getGifDims(e.source.height, e.source.width, viewWidth.current))
thumbHasLoaded.current = true
}, [])
const onLayout = React.useCallback((e: LayoutChangeEvent) => {
viewWidth.current = e.nativeEvent.layout.width
}, [])
return (
<Pressable
style={[
{height: imageDims.height},
styles.topRadius,
styles.gifContainer,
]}
onPress={onPlayPress}
onLayout={onLayout}
accessibilityRole="button"
accessibilityHint={_(msg`Plays the GIF`)}
accessibilityLabel={_(msg`Play ${link.title}`)}>
{(!isPrefetched || !isAnimating) && ( // If we have not loaded or are not animating, show the overlay
<View style={[styles.layer, styles.overlayLayer]}>
<View style={[styles.overlayContainer, styles.topRadius]}>
{!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active
<FontAwesomeIcon icon="play" size={42} color="white" />
) : (
// Activity indicator while gif loads
<ActivityIndicator size="large" color="white" />
)}
</View>
</View>
)}
<Image
source={{
uri:
!isPrefetched || (isWeb && !isAnimating)
? link.thumb
: params.playerUri,
}} // Web uses the thumb to control playback
style={{flex: 1}}
ref={imageRef}
onLoad={onLoad}
autoplay={isAnimating}
contentFit="contain"
accessibilityIgnoresInvertColors
accessibilityLabel={link.title}
accessibilityHint={link.title}
cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios
/>
</Pressable>
)
}
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,
},
gifContainer: {
width: '100%',
overflow: 'hidden',
},
})

View file

@ -8,6 +8,8 @@ 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'
import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed'
import {useExternalEmbedsPrefs} from 'state/preferences'
export const ExternalLinkEmbed = ({
link,
@ -16,11 +18,15 @@ export const ExternalLinkEmbed = ({
}) => {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const externalEmbedPrefs = useExternalEmbedsPrefs()
const embedPlayerParams = React.useMemo(
() => parseEmbedPlayerFromUrl(link.uri),
[link.uri],
)
const embedPlayerParams = React.useMemo(() => {
const params = parseEmbedPlayerFromUrl(link.uri)
if (params && externalEmbedPrefs?.[params.source] !== 'hide') {
return params
}
}, [link.uri, externalEmbedPrefs])
return (
<View style={{flexDirection: 'column'}}>
@ -40,9 +46,12 @@ export const ExternalLinkEmbed = ({
/>
</View>
) : undefined}
{embedPlayerParams && (
<ExternalPlayer link={link} params={embedPlayerParams} />
)}
{(embedPlayerParams?.isGif && (
<ExternalGifEmbed link={link} params={embedPlayerParams} />
)) ||
(embedPlayerParams && (
<ExternalPlayer link={link} params={embedPlayerParams} />
))}
<View
style={{
paddingHorizontal: isMobile ? 10 : 14,
@ -55,10 +64,12 @@ export const ExternalLinkEmbed = ({
style={[pal.textLight, styles.extUri]}>
{toNiceDomain(link.uri)}
</Text>
<Text type="lg-bold" numberOfLines={4} style={[pal.text]}>
{link.title || link.uri}
</Text>
{link.description ? (
{!embedPlayerParams?.isGif && (
<Text type="lg-bold" numberOfLines={4} style={[pal.text]}>
{link.title || link.uri}
</Text>
)}
{link.description && !embedPlayerParams?.hideDetails ? (
<Text
type="md"
numberOfLines={4}

View file

@ -16,14 +16,17 @@ import Animated, {
import {Image} from 'expo-image'
import {WebView} from 'react-native-webview'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import YoutubePlayer from 'react-native-youtube-iframe'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {AppBskyEmbedExternal} from '@atproto/api'
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'
import {useExternalEmbedsPrefs} from 'state/preferences'
import {useModalControls} from 'state/modals'
interface ShouldStartLoadRequest {
url: string
@ -39,6 +42,8 @@ function PlaceholderOverlay({
isPlayerActive: boolean
onPress: (event: GestureResponderEvent) => void
}) {
const {_} = useLingui()
// If the player is active and not loading, we don't want to show the overlay.
if (isPlayerActive && !isLoading) return null
@ -46,8 +51,8 @@ function PlaceholderOverlay({
<View style={[styles.layer, styles.overlayLayer]}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Play Video"
accessibilityHint=""
accessibilityLabel={_(msg`Play Video`)}
accessibilityHint={_(msg`Play Video`)}
onPress={onPress}
style={[styles.overlayContainer, styles.topRadius]}>
{!isPlayerActive ? (
@ -84,31 +89,21 @@ function Player({
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 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>
)}
</View>
</EventStopper>
</View>
)
@ -125,6 +120,8 @@ export function ExternalPlayer({
const navigation = useNavigation<NavigationProp>()
const insets = useSafeAreaInsets()
const windowDims = useWindowDimensions()
const externalEmbedsPrefs = useExternalEmbedsPrefs()
const {openModal} = useModalControls()
const [isPlayerActive, setPlayerActive] = React.useState(false)
const [isLoading, setIsLoading] = React.useState(true)
@ -194,12 +191,26 @@ export function ExternalPlayer({
setIsLoading(false)
}, [])
const onPlayPress = React.useCallback((event: GestureResponderEvent) => {
// Prevent this from propagating upward on web
event.preventDefault()
const onPlayPress = React.useCallback(
(event: GestureResponderEvent) => {
// Prevent this from propagating upward on web
event.preventDefault()
setPlayerActive(true)
}, [])
if (externalEmbedsPrefs?.[params.source] === undefined) {
openModal({
name: 'embed-consent',
source: params.source,
onAccept: () => {
setPlayerActive(true)
},
})
return
}
setPlayerActive(true)
},
[externalEmbedsPrefs, openModal, params.source],
)
// measure the layout to set sizing
const onLayout = React.useCallback(
@ -231,7 +242,6 @@ export function ExternalPlayer({
accessibilityIgnoresInvertColors
/>
)}
<PlaceholderOverlay
isLoading={isLoading}
isPlayerActive={isPlayerActive}
@ -274,4 +284,8 @@ const styles = StyleSheet.create({
webview: {
backgroundColor: 'transparent',
},
gifContainer: {
width: '100%',
overflow: 'hidden',
},
})