about summary refs log tree commit diff
path: root/src/view/com
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com')
-rw-r--r--src/view/com/modals/EmbedConsent.tsx153
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx3
-rw-r--r--src/view/com/util/post-embeds/ExternalGifEmbed.tsx170
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx33
-rw-r--r--src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx84
6 files changed, 401 insertions, 46 deletions
diff --git a/src/view/com/modals/EmbedConsent.tsx b/src/view/com/modals/EmbedConsent.tsx
new file mode 100644
index 000000000..04104c52e
--- /dev/null
+++ b/src/view/com/modals/EmbedConsent.tsx
@@ -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,
+  },
+})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 2aac20dac..f9d211d07 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -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
   }
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 12138f54d..c43a8a6ce 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -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
   }
diff --git a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx
new file mode 100644
index 000000000..f06c8b794
--- /dev/null
+++ b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx
@@ -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',
+  },
+})
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index 1523dcf53..af62aa2b3 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -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}
diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
index ff3dc1ca4..8b0858b69 100644
--- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
@@ -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',
+  },
 })