about summary refs log tree commit diff
path: root/src/components/Post/Embed/ExternalEmbed
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-06-13 12:05:41 -0500
committerGitHub <noreply@github.com>2025-06-13 12:05:41 -0500
commit45f0f7eefecae1922c2f30d4e7760d2b93b1ae56 (patch)
treea2fd6917867f18fe334b54dd3289775c2930bc85 /src/components/Post/Embed/ExternalEmbed
parentba0f5a9bdef5bd0447ded23cab1af222b65511cc (diff)
downloadvoidsky-45f0f7eefecae1922c2f30d4e7760d2b93b1ae56.tar.zst
Port post embeds to new arch (#7408)
* Direct port of embeds to new arch

(cherry picked from commit cc3fa1f6cea396dd9222486c633a508bfee1ecd6)

* Re-org

* Split out ListEmbed and FeedEmbed

* Split out ImageEmbed

* DRY up a bit

* Port over ExternalLinkEmbed

* Port over Player and Gif embeds

* Migrate ComposerReplyTo

* Replace other usages of old post-embeds

* Migrate view contexts

* Copy pasta VideoEmbed

* Copy pasta GifEmbed

* Swap in new file location

* Clean up

* Fix up native

* Add back in correct moderation on List and Feed embeds

* Format

* Prettier

* delete old video utils

* move bandwidth-estimate.ts

* Remove log

* Add LazyQuoteEmbed for composer use

* Clean up unused things

* Remove remaining items

* Prettier

* Fix imports

* Handle nested quotes same as prod

* Add back silenced error handling

* Fix lint

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src/components/Post/Embed/ExternalEmbed')
-rw-r--r--src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx147
-rw-r--r--src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx281
-rw-r--r--src/components/Post/Embed/ExternalEmbed/Gif.tsx224
-rw-r--r--src/components/Post/Embed/ExternalEmbed/index.tsx182
4 files changed, 834 insertions, 0 deletions
diff --git a/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx
new file mode 100644
index 000000000..8a12f0374
--- /dev/null
+++ b/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx
@@ -0,0 +1,147 @@
+import React from 'react'
+import {ActivityIndicator, GestureResponderEvent, Pressable} from 'react-native'
+import {Image} from 'expo-image'
+import {AppBskyEmbedExternal} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {EmbedPlayerParams} from '#/lib/strings/embed-player'
+import {isIOS, isNative, isWeb} from '#/platform/detection'
+import {useExternalEmbedsPrefs} from '#/state/preferences'
+import {atoms as a, useTheme} from '#/alf'
+import {useDialogControl} from '#/components/Dialog'
+import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
+import {Fill} from '#/components/Fill'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
+
+export function ExternalGif({
+  link,
+  params,
+}: {
+  link: AppBskyEmbedExternal.ViewExternal
+  params: EmbedPlayerParams
+}) {
+  const t = useTheme()
+  const externalEmbedsPrefs = useExternalEmbedsPrefs()
+  const {_} = useLingui()
+  const consentDialogControl = useDialogControl()
+
+  // 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)
+
+  // 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) {
+        consentDialogControl.open()
+        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
+        }
+      })
+    },
+    [
+      consentDialogControl,
+      externalEmbedsPrefs,
+      isPlayerActive,
+      load,
+      params.source,
+    ],
+  )
+
+  return (
+    <>
+      <EmbedConsentDialog
+        control={consentDialogControl}
+        source={params.source}
+        onAccept={load}
+      />
+
+      <Pressable
+        style={[
+          {height: 300},
+          a.w_full,
+          a.overflow_hidden,
+          {
+            borderBottomLeftRadius: 0,
+            borderBottomRightRadius: 0,
+          },
+        ]}
+        onPress={onPlayPress}
+        accessibilityRole="button"
+        accessibilityHint={_(msg`Plays the GIF`)}
+        accessibilityLabel={_(msg`Play ${link.title}`)}>
+        <Image
+          source={{
+            uri:
+              !isPrefetched || (isWeb && !isAnimating)
+                ? link.thumb
+                : params.playerUri,
+          }} // Web uses the thumb to control playback
+          style={{flex: 1}}
+          ref={imageRef}
+          autoplay={isAnimating}
+          contentFit="contain"
+          accessibilityIgnoresInvertColors
+          accessibilityLabel={link.title}
+          accessibilityHint={link.title}
+          cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios
+        />
+
+        {(!isPrefetched || !isAnimating) && (
+          <Fill style={[a.align_center, a.justify_center]}>
+            <Fill
+              style={[
+                t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
+                {
+                  opacity: 0.3,
+                },
+              ]}
+            />
+
+            {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active
+              <PlayButtonIcon />
+            ) : (
+              // Activity indicator while gif loads
+              <ActivityIndicator size="large" color="white" />
+            )}
+          </Fill>
+        )}
+      </Pressable>
+    </>
+  )
+}
diff --git a/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx
new file mode 100644
index 000000000..7f6d53340
--- /dev/null
+++ b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx
@@ -0,0 +1,281 @@
+import React from 'react'
+import {
+  ActivityIndicator,
+  GestureResponderEvent,
+  Pressable,
+  StyleSheet,
+  useWindowDimensions,
+  View,
+} from 'react-native'
+import Animated, {
+  measure,
+  runOnJS,
+  useAnimatedRef,
+  useFrameCallback,
+} from 'react-native-reanimated'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {WebView} from 'react-native-webview'
+import {Image} from 'expo-image'
+import {AppBskyEmbedExternal} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {NavigationProp} from '#/lib/routes/types'
+import {EmbedPlayerParams, getPlayerAspect} from '#/lib/strings/embed-player'
+import {isNative} from '#/platform/detection'
+import {useExternalEmbedsPrefs} from '#/state/preferences'
+import {EventStopper} from '#/view/com/util/EventStopper'
+import {atoms as a, useTheme} from '#/alf'
+import {useDialogControl} from '#/components/Dialog'
+import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
+import {Fill} from '#/components/Fill'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
+
+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
+}) {
+  const {_} = useLingui()
+
+  // If the player is active and not loading, we don't want to show the overlay.
+  if (isPlayerActive && !isLoading) return null
+
+  return (
+    <View style={[a.absolute, a.inset_0, styles.overlayLayer]}>
+      <Pressable
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Play Video`)}
+        accessibilityHint={_(msg`Plays the video`)}
+        onPress={onPress}
+        style={[styles.overlayContainer]}>
+        {!isPlayerActive ? (
+          <PlayButtonIcon />
+        ) : (
+          <ActivityIndicator size="large" color="white" />
+        )}
+      </Pressable>
+    </View>
+  )
+}
+
+// This renders the webview/youtube player as a separate layer
+function Player({
+  params,
+  onLoad,
+  isPlayerActive,
+}: {
+  isPlayerActive: boolean
+  params: EmbedPlayerParams
+  onLoad: () => void
+}) {
+  // ensures we only load what's requested
+  // when it's a youtube video, we need to allow both bsky.app and youtube.com
+  const onShouldStartLoadWithRequest = React.useCallback(
+    (event: ShouldStartLoadRequest) =>
+      event.url === params.playerUri ||
+      (params.source.startsWith('youtube') &&
+        event.url.includes('www.youtube.com')),
+    [params.playerUri, params.source],
+  )
+
+  // Don't show the player until it is active
+  if (!isPlayerActive) return null
+
+  return (
+    <EventStopper style={[a.absolute, a.inset_0, styles.playerLayer]}>
+      <WebView
+        javaScriptEnabled={true}
+        onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
+        mediaPlaybackRequiresUserAction={false}
+        allowsInlineMediaPlayback
+        bounces={false}
+        allowsFullscreenVideo
+        nestedScrollEnabled
+        source={{uri: params.playerUri}}
+        onLoad={onLoad}
+        style={styles.webview}
+        setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
+      />
+    </EventStopper>
+  )
+}
+
+// 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 t = useTheme()
+  const navigation = useNavigation<NavigationProp>()
+  const insets = useSafeAreaInsets()
+  const windowDims = useWindowDimensions()
+  const externalEmbedsPrefs = useExternalEmbedsPrefs()
+  const consentDialogControl = useDialogControl()
+
+  const [isPlayerActive, setPlayerActive] = React.useState(false)
+  const [isLoading, setIsLoading] = React.useState(true)
+
+  const aspect = React.useMemo(() => {
+    return getPlayerAspect({
+      type: params.type,
+      width: windowDims.width,
+      hasThumb: !!link.thumb,
+    })
+  }, [params.type, windowDims.width, link.thumb])
+
+  const viewRef = useAnimatedRef()
+  const frameCallback = useFrameCallback(() => {
+    const measurement = measure(viewRef)
+    if (!measurement) return
+
+    const {height: winHeight, width: winWidth} = windowDims
+
+    // Get the proper screen height depending on what is going on
+    const realWinHeight = isNative // If it is native, we always want the larger number
+      ? winHeight > winWidth
+        ? winHeight
+        : winWidth
+      : winHeight // On web, we always want the actual screen height
+
+    const top = measurement.pageY
+    const bot = measurement.pageY + measurement.height
+
+    // We can use the same logic on all platforms against the screenHeight that we get above
+    const isVisible = top <= realWinHeight - insets.bottom && bot >= insets.top
+
+    if (!isVisible) {
+      runOnJS(setPlayerActive)(false)
+    }
+  }, false) // False here disables autostarting the callback
+
+  // watch for leaving the viewport due to scrolling
+  React.useEffect(() => {
+    // We don't want to do anything if the player isn't active
+    if (!isPlayerActive) return
+
+    // 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)
+    })
+
+    // Start watching for changes
+    frameCallback.setActive(true)
+
+    return () => {
+      unsubscribe()
+      frameCallback.setActive(false)
+    }
+  }, [navigation, isPlayerActive, frameCallback])
+
+  const onLoad = React.useCallback(() => {
+    setIsLoading(false)
+  }, [])
+
+  const onPlayPress = React.useCallback(
+    (event: GestureResponderEvent) => {
+      // Prevent this from propagating upward on web
+      event.preventDefault()
+
+      if (externalEmbedsPrefs?.[params.source] === undefined) {
+        consentDialogControl.open()
+        return
+      }
+
+      setPlayerActive(true)
+    },
+    [externalEmbedsPrefs, consentDialogControl, params.source],
+  )
+
+  const onAcceptConsent = React.useCallback(() => {
+    setPlayerActive(true)
+  }, [])
+
+  return (
+    <>
+      <EmbedConsentDialog
+        control={consentDialogControl}
+        source={params.source}
+        onAccept={onAcceptConsent}
+      />
+
+      <Animated.View
+        ref={viewRef}
+        collapsable={false}
+        style={[aspect, a.overflow_hidden]}>
+        {link.thumb && (!isPlayerActive || isLoading) ? (
+          <>
+            <Image
+              style={[a.flex_1]}
+              source={{uri: link.thumb}}
+              accessibilityIgnoresInvertColors
+            />
+            <Fill
+              style={[
+                t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
+                {
+                  opacity: 0.3,
+                },
+              ]}
+            />
+          </>
+        ) : (
+          <Fill
+            style={[
+              {
+                backgroundColor:
+                  t.name === 'light' ? t.palette.contrast_975 : 'black',
+                opacity: 0.3,
+              },
+            ]}
+          />
+        )}
+        <PlaceholderOverlay
+          isLoading={isLoading}
+          isPlayerActive={isPlayerActive}
+          onPress={onPlayPress}
+        />
+        <Player
+          isPlayerActive={isPlayerActive}
+          params={params}
+          onLoad={onLoad}
+        />
+      </Animated.View>
+    </>
+  )
+}
+
+const styles = StyleSheet.create({
+  overlayContainer: {
+    flex: 1,
+    justifyContent: 'center',
+    alignItems: 'center',
+  },
+  overlayLayer: {
+    zIndex: 2,
+  },
+  playerLayer: {
+    zIndex: 3,
+  },
+  webview: {
+    backgroundColor: 'transparent',
+  },
+  gifContainer: {
+    width: '100%',
+    overflow: 'hidden',
+  },
+})
diff --git a/src/components/Post/Embed/ExternalEmbed/Gif.tsx b/src/components/Post/Embed/ExternalEmbed/Gif.tsx
new file mode 100644
index 000000000..a839294f1
--- /dev/null
+++ b/src/components/Post/Embed/ExternalEmbed/Gif.tsx
@@ -0,0 +1,224 @@
+import React from 'react'
+import {
+  Pressable,
+  StyleProp,
+  StyleSheet,
+  TouchableOpacity,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {HITSLOP_20} from '#/lib/constants'
+import {EmbedPlayerParams} from '#/lib/strings/embed-player'
+import {isWeb} from '#/platform/detection'
+import {useAutoplayDisabled} from '#/state/preferences'
+import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
+import {atoms as a, useTheme} from '#/alf'
+import {Fill} from '#/components/Fill'
+import {Loader} from '#/components/Loader'
+import * as Prompt from '#/components/Prompt'
+import {Text} from '#/components/Typography'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
+import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
+import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
+
+function PlaybackControls({
+  onPress,
+  isPlaying,
+  isLoaded,
+}: {
+  onPress: () => void
+  isPlaying: boolean
+  isLoaded: boolean
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+
+  return (
+    <Pressable
+      accessibilityRole="button"
+      accessibilityHint={_(msg`Plays or pauses the GIF`)}
+      accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
+      style={[
+        a.absolute,
+        a.align_center,
+        a.justify_center,
+        !isLoaded && a.border,
+        t.atoms.border_contrast_medium,
+        a.inset_0,
+        a.w_full,
+        a.h_full,
+        {
+          zIndex: 2,
+          backgroundColor: !isLoaded
+            ? t.atoms.bg_contrast_25.backgroundColor
+            : undefined,
+        },
+      ]}
+      onPress={onPress}>
+      {!isLoaded ? (
+        <View>
+          <View style={[a.align_center, a.justify_center]}>
+            <Loader size="xl" />
+          </View>
+        </View>
+      ) : !isPlaying ? (
+        <PlayButtonIcon />
+      ) : undefined}
+    </Pressable>
+  )
+}
+
+export function GifEmbed({
+  params,
+  thumb,
+  altText,
+  isPreferredAltText,
+  hideAlt,
+  style = {width: '100%'},
+}: {
+  params: EmbedPlayerParams
+  thumb: string | undefined
+  altText: string
+  isPreferredAltText: boolean
+  hideAlt?: boolean
+  style?: StyleProp<ViewStyle>
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const autoplayDisabled = useAutoplayDisabled()
+
+  const playerRef = React.useRef<GifView>(null)
+
+  const [playerState, setPlayerState] = React.useState<{
+    isPlaying: boolean
+    isLoaded: boolean
+  }>({
+    isPlaying: !autoplayDisabled,
+    isLoaded: false,
+  })
+
+  const onPlayerStateChange = React.useCallback(
+    (e: GifViewStateChangeEvent) => {
+      setPlayerState(e.nativeEvent)
+    },
+    [],
+  )
+
+  const onPress = React.useCallback(() => {
+    playerRef.current?.toggleAsync()
+  }, [])
+
+  return (
+    <View
+      style={[
+        a.rounded_md,
+        a.overflow_hidden,
+        a.border,
+        t.atoms.border_contrast_low,
+        {aspectRatio: params.dimensions!.width / params.dimensions!.height},
+        style,
+      ]}>
+      <View
+        style={[
+          a.absolute,
+          /*
+           * Aspect ratio was being clipped weirdly on web -esb
+           */
+          {
+            top: -2,
+            bottom: -2,
+            left: -2,
+            right: -2,
+          },
+        ]}>
+        <PlaybackControls
+          onPress={onPress}
+          isPlaying={playerState.isPlaying}
+          isLoaded={playerState.isLoaded}
+        />
+        <GifView
+          source={params.playerUri}
+          placeholderSource={thumb}
+          style={[a.flex_1]}
+          autoplay={!autoplayDisabled}
+          onPlayerStateChange={onPlayerStateChange}
+          ref={playerRef}
+          accessibilityHint={_(msg`Animated GIF`)}
+          accessibilityLabel={altText}
+        />
+        {!playerState.isPlaying && (
+          <Fill
+            style={[
+              t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
+              {
+                opacity: 0.3,
+              },
+            ]}
+          />
+        )}
+        {!hideAlt && isPreferredAltText && <AltText text={altText} />}
+      </View>
+    </View>
+  )
+}
+
+function AltText({text}: {text: string}) {
+  const control = Prompt.usePromptControl()
+  const largeAltBadge = useLargeAltBadgeEnabled()
+
+  const {_} = useLingui()
+  return (
+    <>
+      <TouchableOpacity
+        testID="altTextButton"
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Show alt text`)}
+        accessibilityHint=""
+        hitSlop={HITSLOP_20}
+        onPress={control.open}
+        style={styles.altContainer}>
+        <Text
+          style={[styles.alt, largeAltBadge && a.text_xs]}
+          accessible={false}>
+          <Trans>ALT</Trans>
+        </Text>
+      </TouchableOpacity>
+      <Prompt.Outer control={control}>
+        <Prompt.TitleText>
+          <Trans>Alt Text</Trans>
+        </Prompt.TitleText>
+        <Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
+        <Prompt.Actions>
+          <Prompt.Action
+            onPress={() => control.close()}
+            cta={_(msg`Close`)}
+            color="secondary"
+          />
+        </Prompt.Actions>
+      </Prompt.Outer>
+    </>
+  )
+}
+
+const styles = StyleSheet.create({
+  altContainer: {
+    backgroundColor: 'rgba(0, 0, 0, 0.75)',
+    borderRadius: 6,
+    paddingHorizontal: isWeb ? 8 : 6,
+    paddingVertical: isWeb ? 6 : 3,
+    position: 'absolute',
+    // Related to margin/gap hack. This keeps the alt label in the same position
+    // on all platforms
+    right: isWeb ? 8 : 5,
+    bottom: isWeb ? 8 : 5,
+    zIndex: 2,
+  },
+  alt: {
+    color: 'white',
+    fontSize: isWeb ? 10 : 7,
+    fontWeight: '600',
+  },
+})
diff --git a/src/components/Post/Embed/ExternalEmbed/index.tsx b/src/components/Post/Embed/ExternalEmbed/index.tsx
new file mode 100644
index 000000000..714eaecd6
--- /dev/null
+++ b/src/components/Post/Embed/ExternalEmbed/index.tsx
@@ -0,0 +1,182 @@
+import React, {useCallback} from 'react'
+import {type StyleProp, View, type ViewStyle} from 'react-native'
+import {Image} from 'expo-image'
+import {type AppBskyEmbedExternal} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {parseAltFromGIFDescription} from '#/lib/gif-alt-text'
+import {useHaptics} from '#/lib/haptics'
+import {shareUrl} from '#/lib/sharing'
+import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
+import {toNiceDomain} from '#/lib/strings/url-helpers'
+import {isNative} from '#/platform/detection'
+import {useExternalEmbedsPrefs} from '#/state/preferences'
+import {atoms as a, useTheme} from '#/alf'
+import {Divider} from '#/components/Divider'
+import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+import {ExternalGif} from './ExternalGif'
+import {ExternalPlayer} from './ExternalPlayer'
+import {GifEmbed} from './Gif'
+
+export const ExternalEmbed = ({
+  link,
+  onOpen,
+  style,
+  hideAlt,
+}: {
+  link: AppBskyEmbedExternal.ViewExternal
+  onOpen?: () => void
+  style?: StyleProp<ViewStyle>
+  hideAlt?: boolean
+}) => {
+  const {_} = useLingui()
+  const t = useTheme()
+  const playHaptic = useHaptics()
+  const externalEmbedPrefs = useExternalEmbedsPrefs()
+  const niceUrl = toNiceDomain(link.uri)
+  const imageUri = link.thumb
+  const embedPlayerParams = React.useMemo(() => {
+    const params = parseEmbedPlayerFromUrl(link.uri)
+
+    if (params && externalEmbedPrefs?.[params.source] !== 'hide') {
+      return params
+    }
+  }, [link.uri, externalEmbedPrefs])
+  const hasMedia = Boolean(imageUri || embedPlayerParams)
+
+  const onPress = useCallback(() => {
+    playHaptic('Light')
+    onOpen?.()
+  }, [playHaptic, onOpen])
+
+  const onShareExternal = useCallback(() => {
+    if (link.uri && isNative) {
+      playHaptic('Heavy')
+      shareUrl(link.uri)
+    }
+  }, [link.uri, playHaptic])
+
+  if (embedPlayerParams?.source === 'tenor') {
+    const parsedAlt = parseAltFromGIFDescription(link.description)
+    return (
+      <View style={style}>
+        <GifEmbed
+          params={embedPlayerParams}
+          thumb={link.thumb}
+          altText={parsedAlt.alt}
+          isPreferredAltText={parsedAlt.isPreferred}
+          hideAlt={hideAlt}
+        />
+      </View>
+    )
+  }
+
+  return (
+    <Link
+      label={link.title || _(msg`Open link to ${niceUrl}`)}
+      to={link.uri}
+      shouldProxy={true}
+      onPress={onPress}
+      onLongPress={onShareExternal}>
+      {({hovered}) => (
+        <View
+          style={[
+            a.transition_color,
+            a.flex_col,
+            a.rounded_md,
+            a.overflow_hidden,
+            a.w_full,
+            a.border,
+            style,
+            hovered
+              ? t.atoms.border_contrast_high
+              : t.atoms.border_contrast_low,
+          ]}>
+          {imageUri && !embedPlayerParams ? (
+            <Image
+              style={{
+                aspectRatio: 1.91,
+              }}
+              source={{uri: imageUri}}
+              accessibilityIgnoresInvertColors
+            />
+          ) : undefined}
+
+          {embedPlayerParams?.isGif ? (
+            <ExternalGif link={link} params={embedPlayerParams} />
+          ) : embedPlayerParams ? (
+            <ExternalPlayer link={link} params={embedPlayerParams} />
+          ) : undefined}
+
+          <View
+            style={[
+              a.flex_1,
+              a.pt_sm,
+              {gap: 3},
+              hasMedia && a.border_t,
+              hovered
+                ? t.atoms.border_contrast_high
+                : t.atoms.border_contrast_low,
+            ]}>
+            <View style={[{gap: 3}, a.pb_xs, a.px_md]}>
+              {!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && (
+                <Text
+                  emoji
+                  numberOfLines={3}
+                  style={[a.text_md, a.font_bold, a.leading_snug]}>
+                  {link.title || link.uri}
+                </Text>
+              )}
+              {link.description ? (
+                <Text
+                  emoji
+                  numberOfLines={link.thumb ? 2 : 4}
+                  style={[a.text_sm, a.leading_snug]}>
+                  {link.description}
+                </Text>
+              ) : undefined}
+            </View>
+            <View style={[a.px_md]}>
+              <Divider />
+              <View
+                style={[
+                  a.flex_row,
+                  a.align_center,
+                  a.gap_2xs,
+                  a.pb_sm,
+                  {
+                    paddingTop: 6, // off menu
+                  },
+                ]}>
+                <Globe
+                  size="xs"
+                  style={[
+                    a.transition_color,
+                    hovered
+                      ? t.atoms.text_contrast_medium
+                      : t.atoms.text_contrast_low,
+                  ]}
+                />
+                <Text
+                  numberOfLines={1}
+                  style={[
+                    a.transition_color,
+                    a.text_xs,
+                    a.leading_snug,
+                    hovered
+                      ? t.atoms.text_contrast_high
+                      : t.atoms.text_contrast_medium,
+                  ]}>
+                  {toNiceDomain(link.uri)}
+                </Text>
+              </View>
+            </View>
+          </View>
+        </View>
+      )}
+    </Link>
+  )
+}