about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2024-01-04 17:37:36 -0800
committerGitHub <noreply@github.com>2024-01-04 17:37:36 -0800
commit0dae24e78ffe0b2d69349a03f669949e4d5afc21 (patch)
treef2895aebae675d0c5d6c9213626a37a98a53f724 /src/view
parentdb62f272412df2c34e1a57200291b53fa1cd07aa (diff)
downloadvoidsky-0dae24e78ffe0b2d69349a03f669949e4d5afc21.tar.zst
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>
Diffstat (limited to 'src/view')
-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
-rw-r--r--src/view/icons/index.tsx6
-rw-r--r--src/view/screens/PreferencesExternalEmbeds.tsx138
-rw-r--r--src/view/screens/Settings.tsx33
9 files changed, 576 insertions, 48 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',
+  },
 })
diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx
index 089d3f0a8..221b9702c 100644
--- a/src/view/icons/index.tsx
+++ b/src/view/icons/index.tsx
@@ -29,9 +29,10 @@ import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight'
 import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle'
 import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck'
 import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck'
+import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot'
 import {faCircleExclamation} from '@fortawesome/free-solid-svg-icons/faCircleExclamation'
+import {faCirclePlay} from '@fortawesome/free-regular-svg-icons/faCirclePlay'
 import {faCircleUser} from '@fortawesome/free-regular-svg-icons/faCircleUser'
-import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot'
 import {faClone} from '@fortawesome/free-solid-svg-icons/faClone'
 import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone'
 import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
@@ -129,9 +130,10 @@ library.add(
   faCircle,
   faCircleCheck,
   farCircleCheck,
+  faCircleDot,
   faCircleExclamation,
+  faCirclePlay,
   faCircleUser,
-  faCircleDot,
   faClone,
   farClone,
   faComment,
diff --git a/src/view/screens/PreferencesExternalEmbeds.tsx b/src/view/screens/PreferencesExternalEmbeds.tsx
new file mode 100644
index 000000000..24e7d56df
--- /dev/null
+++ b/src/view/screens/PreferencesExternalEmbeds.tsx
@@ -0,0 +1,138 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
+import {s} from 'lib/styles'
+import {Text} from '../com/util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {
+  EmbedPlayerSource,
+  externalEmbedLabels,
+} from '#/lib/strings/embed-player'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {Trans} from '@lingui/macro'
+import {ScrollView} from '../com/util/Views'
+import {
+  useExternalEmbedsPrefs,
+  useSetExternalEmbedPref,
+} from 'state/preferences'
+import {ToggleButton} from 'view/com/util/forms/ToggleButton'
+import {SimpleViewHeader} from '../com/util/SimpleViewHeader'
+
+type Props = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'PreferencesExternalEmbeds'
+>
+export function PreferencesExternalEmbeds({}: Props) {
+  const pal = usePalette('default')
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {screen} = useAnalytics()
+  const {isMobile} = useWebMediaQueries()
+
+  useFocusEffect(
+    React.useCallback(() => {
+      screen('PreferencesExternalEmbeds')
+      setMinimalShellMode(false)
+    }, [screen, setMinimalShellMode]),
+  )
+
+  return (
+    <View style={s.hContentRegion} testID="preferencesExternalEmbedsScreen">
+      <SimpleViewHeader
+        showBackButton={isMobile}
+        style={[
+          pal.border,
+          {borderBottomWidth: 1},
+          !isMobile && {borderLeftWidth: 1, borderRightWidth: 1},
+        ]}>
+        <View style={{flex: 1}}>
+          <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
+            <Trans>External Media Preferences</Trans>
+          </Text>
+          <Text style={pal.textLight}>
+            <Trans>Customize media from external sites.</Trans>
+          </Text>
+        </View>
+      </SimpleViewHeader>
+      <ScrollView
+        // @ts-ignore web only -prf
+        dataSet={{'stable-gutters': 1}}
+        contentContainerStyle={[pal.viewLight, {paddingBottom: 200}]}>
+        <View style={[pal.view]}>
+          <View style={styles.infoCard}>
+            <Text style={pal.text}>
+              <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>
+        </View>
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          Enable media players for
+        </Text>
+        {Object.entries(externalEmbedLabels).map(([key, label]) => (
+          <PrefSelector
+            source={key as EmbedPlayerSource}
+            label={label}
+            key={key}
+          />
+        ))}
+      </ScrollView>
+    </View>
+  )
+}
+
+function PrefSelector({
+  source,
+  label,
+}: {
+  source: EmbedPlayerSource
+  label: string
+}) {
+  const pal = usePalette('default')
+  const setExternalEmbedPref = useSetExternalEmbedPref()
+  const sources = useExternalEmbedsPrefs()
+
+  return (
+    <View>
+      <View style={[pal.view, styles.toggleCard]}>
+        <ToggleButton
+          type="default-light"
+          label={label}
+          labelType="lg"
+          isSelected={sources?.[source] === 'show'}
+          onPress={() =>
+            setExternalEmbedPref(
+              source,
+              sources?.[source] === 'show' ? 'hide' : 'show',
+            )
+          }
+        />
+      </View>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  heading: {
+    paddingHorizontal: 18,
+    paddingTop: 14,
+    paddingBottom: 14,
+  },
+  spacer: {
+    height: 8,
+  },
+  infoCard: {
+    paddingHorizontal: 20,
+    paddingVertical: 14,
+  },
+  toggleCard: {
+    paddingVertical: 8,
+    paddingHorizontal: 6,
+    marginBottom: 1,
+  },
+})
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 5b381a138..fedd348e2 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -563,6 +563,39 @@ export function SettingsScreen({}: Props) {
             <Trans>Moderation</Trans>
           </Text>
         </TouchableOpacity>
+
+        <View style={styles.spacer20} />
+
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Privacy</Trans>
+        </Text>
+
+        <TouchableOpacity
+          testID="externalEmbedsBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={
+            isSwitchingAccounts
+              ? undefined
+              : () => navigation.navigate('PreferencesExternalEmbeds')
+          }
+          accessibilityRole="button"
+          accessibilityHint=""
+          accessibilityLabel={_(msg`Opens external embeds settings`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon={['far', 'circle-play']}
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>External Media Preferences</Trans>
+          </Text>
+        </TouchableOpacity>
+
         <View style={styles.spacer20} />
 
         <Text type="xl-bold" style={[pal.text, styles.heading]}>