about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--__tests__/lib/string.test.ts42
-rw-r--r--modules/expo-bluesky-gif-view/android/build.gradle98
-rw-r--r--modules/expo-bluesky-gif-view/android/src/main/AndroidManifest.xml2
-rw-r--r--modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/AppCompatImageViewExtended.kt37
-rw-r--r--modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/ExpoBlueskyGifViewModule.kt54
-rw-r--r--modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/GifView.kt180
-rw-r--r--modules/expo-bluesky-gif-view/expo-module.config.json9
-rw-r--r--modules/expo-bluesky-gif-view/index.ts1
-rw-r--r--modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifView.podspec23
-rw-r--r--modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifViewModule.swift47
-rw-r--r--modules/expo-bluesky-gif-view/ios/GifView.swift185
-rw-r--r--modules/expo-bluesky-gif-view/ios/Util.swift17
-rw-r--r--modules/expo-bluesky-gif-view/src/GifView.tsx39
-rw-r--r--modules/expo-bluesky-gif-view/src/GifView.types.ts15
-rw-r--r--modules/expo-bluesky-gif-view/src/GifView.web.tsx82
-rw-r--r--src/lib/statsig/gates.ts1
-rw-r--r--src/lib/strings/embed-player.ts46
-rw-r--r--src/view/com/composer/Composer.tsx10
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx70
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx150
-rw-r--r--src/view/com/util/post-embeds/GifEmbed.tsx140
-rw-r--r--src/view/com/util/post-embeds/index.tsx52
22 files changed, 1129 insertions, 171 deletions
diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts
index eeb5ae157..03d685249 100644
--- a/__tests__/lib/string.test.ts
+++ b/__tests__/lib/string.test.ts
@@ -459,6 +459,12 @@ describe('parseEmbedPlayerFromUrl', () => {
     'https://tenor.com/view',
     'https://tenor.com/view/gifId.gif',
     'https://tenor.com/intl/view/gifId.gif',
+
+    'https://media.tenor.com/someID_AAAAC/someName.gif?hh=100&ww=100',
+    'https://media.tenor.com/someID_AAAAC/someName.gif',
+    'https://media.tenor.com/someID/someName.gif',
+    'https://media.tenor.com/someID',
+    'https://media.tenor.com',
   ]
 
   const outputs = [
@@ -628,20 +634,14 @@ describe('parseEmbedPlayerFromUrl', () => {
     },
     undefined,
     undefined,
-
     {
       type: 'giphy_gif',
       source: 'giphy',
       isGif: true,
       hideDetails: true,
       metaUri: 'https://giphy.com/gifs/39248209509382934029',
-      playerUri: 'https://i.giphy.com/media/39248209509382934029/200.mp4',
-      dimensions: {
-        width: 100,
-        height: 100,
-      },
+      playerUri: 'https://i.giphy.com/media/39248209509382934029/200.webp',
     },
-
     {
       type: 'giphy_gif',
       source: 'giphy',
@@ -736,29 +736,27 @@ describe('parseEmbedPlayerFromUrl', () => {
       playerUri: 'https://i.giphy.com/media/gifId/200.webp',
     },
 
-    {
-      type: 'tenor_gif',
-      source: 'tenor',
-      isGif: true,
-      hideDetails: true,
-      playerUri: 'https://tenor.com/view/gifId.gif',
-    },
     undefined,
     undefined,
+    undefined,
+    undefined,
+    undefined,
+
     {
       type: 'tenor_gif',
       source: 'tenor',
       isGif: true,
       hideDetails: true,
-      playerUri: 'https://tenor.com/view/gifId.gif',
-    },
-    {
-      type: 'tenor_gif',
-      source: 'tenor',
-      isGif: true,
-      hideDetails: true,
-      playerUri: 'https://tenor.com/intl/view/gifId.gif',
+      playerUri: 'https://t.gifs.bsky.app/someID_AAAAM/someName.gif',
+      dimensions: {
+        width: 100,
+        height: 100,
+      },
     },
+    undefined,
+    undefined,
+    undefined,
+    undefined,
   ]
 
   it('correctly grabs the correct id from uri', () => {
diff --git a/modules/expo-bluesky-gif-view/android/build.gradle b/modules/expo-bluesky-gif-view/android/build.gradle
new file mode 100644
index 000000000..c209a35ae
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/android/build.gradle
@@ -0,0 +1,98 @@
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'maven-publish'
+
+group = 'expo.modules.blueskygifview'
+version = '0.5.0'
+
+buildscript {
+  def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+  if (expoModulesCorePlugin.exists()) {
+    apply from: expoModulesCorePlugin
+    applyKotlinExpoModulesCorePlugin()
+  }
+
+  // Simple helper that allows the root project to override versions declared by this library.
+  ext.safeExtGet = { prop, fallback ->
+    rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
+  }
+
+  // Ensures backward compatibility
+  ext.getKotlinVersion = {
+    if (ext.has("kotlinVersion")) {
+      ext.kotlinVersion()
+    } else {
+      ext.safeExtGet("kotlinVersion", "1.8.10")
+    }
+  }
+
+  repositories {
+    mavenCentral()
+  }
+
+  dependencies {
+    classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}")
+  }
+}
+
+afterEvaluate {
+  publishing {
+    publications {
+      release(MavenPublication) {
+        from components.release
+      }
+    }
+    repositories {
+      maven {
+        url = mavenLocal().url
+      }
+    }
+  }
+}
+
+android {
+  compileSdkVersion safeExtGet("compileSdkVersion", 33)
+
+  def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
+  if (agpVersion.tokenize('.')[0].toInteger() < 8) {
+    compileOptions {
+      sourceCompatibility JavaVersion.VERSION_11
+      targetCompatibility JavaVersion.VERSION_11
+    }
+
+    kotlinOptions {
+      jvmTarget = JavaVersion.VERSION_11.majorVersion
+    }
+  }
+
+  namespace "expo.modules.blueskygifview"
+  defaultConfig {
+    minSdkVersion safeExtGet("minSdkVersion", 21)
+    targetSdkVersion safeExtGet("targetSdkVersion", 34)
+    versionCode 1
+    versionName "0.5.0"
+  }
+  lintOptions {
+    abortOnError false
+  }
+  publishing {
+    singleVariant("release") {
+      withSourcesJar()
+    }
+  }
+}
+
+repositories {
+  mavenCentral()
+}
+
+dependencies {
+  implementation 'androidx.appcompat:appcompat:1.6.1'
+    def GLIDE_VERSION = "4.13.2"
+
+  implementation project(':expo-modules-core')
+  implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
+
+  // Keep glide version up to date with expo-image so that we don't have duplicate deps
+  implementation 'com.github.bumptech.glide:glide:4.13.2'
+}
diff --git a/modules/expo-bluesky-gif-view/android/src/main/AndroidManifest.xml b/modules/expo-bluesky-gif-view/android/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..bdae66c8f
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+<manifest>
+</manifest>
diff --git a/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/AppCompatImageViewExtended.kt b/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/AppCompatImageViewExtended.kt
new file mode 100644
index 000000000..5d2084845
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/AppCompatImageViewExtended.kt
@@ -0,0 +1,37 @@
+package expo.modules.blueskygifview
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.drawable.Animatable
+import androidx.appcompat.widget.AppCompatImageView
+
+class AppCompatImageViewExtended(context: Context, private val parent: GifView): AppCompatImageView(context) {
+  override fun onDraw(canvas: Canvas) {
+    super.onDraw(canvas)
+
+    if (this.drawable is Animatable) {
+      if (!parent.isLoaded) {
+        parent.isLoaded = true
+        parent.firePlayerStateChange()
+      }
+
+      if (!parent.isPlaying) {
+        this.pause()
+      }
+    }
+  }
+
+  fun pause() {
+    val drawable = this.drawable
+    if (drawable is Animatable) {
+      drawable.stop()
+    }
+  }
+
+  fun play() {
+    val drawable = this.drawable
+    if (drawable is Animatable) {
+      drawable.start()
+    }
+  }
+}
\ No newline at end of file
diff --git a/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/ExpoBlueskyGifViewModule.kt b/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/ExpoBlueskyGifViewModule.kt
new file mode 100644
index 000000000..625e1d45f
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/ExpoBlueskyGifViewModule.kt
@@ -0,0 +1,54 @@
+package expo.modules.blueskygifview
+
+import com.bumptech.glide.Glide
+import com.bumptech.glide.load.engine.DiskCacheStrategy
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class ExpoBlueskyGifViewModule : Module() {
+  override fun definition() = ModuleDefinition {
+    Name("ExpoBlueskyGifView")
+
+    AsyncFunction("prefetchAsync") { sources: List<String> ->
+      val activity = appContext.currentActivity ?: return@AsyncFunction
+      val glide = Glide.with(activity)
+
+      sources.forEach { source ->
+        glide
+          .download(source)
+          .diskCacheStrategy(DiskCacheStrategy.DATA)
+          .submit()
+      }
+    }
+
+    View(GifView::class) {
+      Events(
+        "onPlayerStateChange"
+      )
+
+      Prop("source") { view: GifView, source: String ->
+        view.source = source
+      }
+
+      Prop("placeholderSource") { view: GifView, source: String ->
+        view.placeholderSource = source
+      }
+
+      Prop("autoplay") { view: GifView, autoplay: Boolean ->
+        view.autoplay = autoplay
+      }
+
+      AsyncFunction("playAsync") { view: GifView ->
+        view.play()
+      }
+
+      AsyncFunction("pauseAsync") { view: GifView ->
+        view.pause()
+      }
+
+      AsyncFunction("toggleAsync") { view: GifView ->
+        view.toggle()
+      }
+    }
+  }
+}
diff --git a/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/GifView.kt b/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/GifView.kt
new file mode 100644
index 000000000..be5830df7
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/GifView.kt
@@ -0,0 +1,180 @@
+package expo.modules.blueskygifview
+
+
+import android.content.Context
+import android.graphics.Color
+import android.graphics.drawable.Animatable
+import android.graphics.drawable.Drawable
+import com.bumptech.glide.Glide
+import com.bumptech.glide.load.engine.DiskCacheStrategy
+import com.bumptech.glide.load.engine.GlideException
+import com.bumptech.glide.request.RequestListener
+import com.bumptech.glide.request.target.Target
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.exception.Exceptions
+import expo.modules.kotlin.viewevent.EventDispatcher
+import expo.modules.kotlin.views.ExpoView
+
+class GifView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
+  // Events
+  private val onPlayerStateChange by EventDispatcher()
+
+  // Glide
+  private val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity()
+  private val glide = Glide.with(activity)
+  val imageView = AppCompatImageViewExtended(context, this)
+  var isPlaying = true
+  var isLoaded = false
+
+  // Requests
+  private var placeholderRequest: Target<Drawable>? = null
+  private var webpRequest: Target<Drawable>? = null
+
+  // Props
+  var placeholderSource: String? = null
+  var source: String? = null
+  var autoplay: Boolean = true
+    set(value) {
+      field = value
+
+      if (value) {
+        this.play()
+      } else {
+        this.pause()
+      }
+    }
+
+
+  //<editor-fold desc="Lifecycle">
+
+  init {
+    this.setBackgroundColor(Color.TRANSPARENT)
+
+    this.imageView.setBackgroundColor(Color.TRANSPARENT)
+    this.imageView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
+
+    this.addView(this.imageView)
+  }
+
+  override fun onAttachedToWindow() {
+    if (this.imageView.drawable == null || this.imageView.drawable !is Animatable) {
+      this.load()
+    } else if (this.isPlaying) {
+      this.imageView.play()
+    }
+    super.onAttachedToWindow()
+  }
+
+  override fun onDetachedFromWindow() {
+    this.imageView.pause()
+    super.onDetachedFromWindow()
+  }
+
+  //</editor-fold>
+
+  //<editor-fold desc="Loading">
+
+  private fun load() {
+    if (placeholderSource == null || source == null) {
+      return
+    }
+
+    this.webpRequest = glide.load(source)
+      .diskCacheStrategy(DiskCacheStrategy.DATA)
+      .skipMemoryCache(false)
+      .listener(object: RequestListener<Drawable> {
+        override fun onResourceReady(
+          resource: Drawable?,
+          model: Any?,
+          target: Target<Drawable>?,
+          dataSource: com.bumptech.glide.load.DataSource?,
+          isFirstResource: Boolean
+        ): Boolean {
+          if (placeholderRequest != null) {
+            glide.clear(placeholderRequest)
+          }
+          return false
+        }
+
+        override fun onLoadFailed(
+          e: GlideException?,
+          model: Any?,
+          target: Target<Drawable>?,
+          isFirstResource: Boolean
+        ): Boolean {
+          return true
+        }
+      })
+      .into(this.imageView)
+
+    if (this.imageView.drawable == null || this.imageView.drawable !is Animatable) {
+      this.placeholderRequest = glide.load(placeholderSource)
+        .diskCacheStrategy(DiskCacheStrategy.DATA)
+        // Let's not bloat the memory cache with placeholders
+        .skipMemoryCache(true)
+        .listener(object: RequestListener<Drawable> {
+          override fun onResourceReady(
+            resource: Drawable?,
+            model: Any?,
+            target: Target<Drawable>?,
+            dataSource: com.bumptech.glide.load.DataSource?,
+            isFirstResource: Boolean
+          ): Boolean {
+            // Incase this request finishes after the webp, let's just not set
+            // the drawable. This shouldn't happen because the request should get cancelled
+            if (imageView.drawable == null) {
+              imageView.setImageDrawable(resource)
+            }
+            return true
+          }
+
+          override fun onLoadFailed(
+            e: GlideException?,
+            model: Any?,
+            target: Target<Drawable>?,
+            isFirstResource: Boolean
+          ): Boolean {
+            return true
+          }
+        })
+        .submit()
+    }
+  }
+
+  //</editor-fold>
+
+  //<editor-fold desc="Controls">
+
+  fun play() {
+    this.imageView.play()
+    this.isPlaying = true
+    this.firePlayerStateChange()
+  }
+
+  fun pause() {
+    this.imageView.pause()
+    this.isPlaying = false
+    this.firePlayerStateChange()
+  }
+
+  fun toggle() {
+    if (this.isPlaying) {
+      this.pause()
+    } else {
+      this.play()
+    }
+  }
+
+  //</editor-fold>
+
+  //<editor-fold desc="Util">
+
+  fun firePlayerStateChange() {
+    onPlayerStateChange(mapOf(
+      "isPlaying" to this.isPlaying,
+      "isLoaded" to this.isLoaded,
+    ))
+  }
+
+  //</editor-fold>
+}
diff --git a/modules/expo-bluesky-gif-view/expo-module.config.json b/modules/expo-bluesky-gif-view/expo-module.config.json
new file mode 100644
index 000000000..0756c8e24
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/expo-module.config.json
@@ -0,0 +1,9 @@
+{
+  "platforms": ["ios", "android", "web"],
+  "ios": {
+    "modules": ["ExpoBlueskyGifViewModule"]
+  },
+  "android": {
+    "modules": ["expo.modules.blueskygifview.ExpoBlueskyGifViewModule"]
+  }
+}
diff --git a/modules/expo-bluesky-gif-view/index.ts b/modules/expo-bluesky-gif-view/index.ts
new file mode 100644
index 000000000..0244a5491
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/index.ts
@@ -0,0 +1 @@
+export {GifView} from './src/GifView'
diff --git a/modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifView.podspec b/modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifView.podspec
new file mode 100644
index 000000000..ddd0877b2
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifView.podspec
@@ -0,0 +1,23 @@
+Pod::Spec.new do |s|
+  s.name           = 'ExpoBlueskyGifView'
+  s.version        = '1.0.0'
+  s.summary        = 'A simple GIF player for Bluesky'
+  s.description    = 'A simple GIF player for Bluesky'
+  s.author         = ''
+  s.homepage       = 'https://github.com/bluesky-social/social-app'
+  s.platforms      = { :ios => '13.4', :tvos => '13.4' }
+  s.source         = { git: '' }
+  s.static_framework = true
+
+  s.dependency 'ExpoModulesCore'
+  s.dependency 'SDWebImage', '~> 5.17.0'
+  s.dependency 'SDWebImageWebPCoder', '~> 0.13.0'
+
+  # Swift/Objective-C compatibility
+  s.pod_target_xcconfig = {
+    'DEFINES_MODULE' => 'YES',
+    'SWIFT_COMPILATION_MODE' => 'wholemodule'
+  }
+
+  s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
+end
diff --git a/modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifViewModule.swift b/modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifViewModule.swift
new file mode 100644
index 000000000..7c7132290
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifViewModule.swift
@@ -0,0 +1,47 @@
+import ExpoModulesCore
+import SDWebImage
+import SDWebImageWebPCoder
+
+public class ExpoBlueskyGifViewModule: Module {
+  public func definition() -> ModuleDefinition {
+    Name("ExpoBlueskyGifView")
+    
+    OnCreate {
+      SDImageCodersManager.shared.addCoder(SDImageGIFCoder.shared)
+    }
+    
+    AsyncFunction("prefetchAsync") { (sources: [URL]) in
+      SDWebImagePrefetcher.shared.prefetchURLs(sources, context: Util.createContext(), progress: nil)
+    }
+
+    View(GifView.self) {
+      Events(
+        "onPlayerStateChange"
+      )
+      
+      Prop("source") { (view: GifView, prop: String) in
+        view.source = prop
+      }
+      
+      Prop("placeholderSource") { (view: GifView, prop: String) in
+        view.placeholderSource = prop
+      }
+      
+      Prop("autoplay") { (view: GifView, prop: Bool) in
+        view.autoplay = prop
+      }
+      
+      AsyncFunction("toggleAsync") { (view: GifView) in
+        view.toggle()
+      }
+      
+      AsyncFunction("playAsync") { (view: GifView) in
+        view.play()
+      }
+      
+      AsyncFunction("pauseAsync") { (view: GifView) in
+        view.pause()
+      }
+    }
+  }
+}
diff --git a/modules/expo-bluesky-gif-view/ios/GifView.swift b/modules/expo-bluesky-gif-view/ios/GifView.swift
new file mode 100644
index 000000000..de722d7a6
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/ios/GifView.swift
@@ -0,0 +1,185 @@
+import ExpoModulesCore
+import SDWebImage
+import SDWebImageWebPCoder
+
+typealias SDWebImageContext = [SDWebImageContextOption: Any]
+
+public class GifView: ExpoView, AVPlayerViewControllerDelegate {
+  // Events
+  private let onPlayerStateChange = EventDispatcher()
+
+  // SDWebImage
+  private let imageView = SDAnimatedImageView(frame: .zero)
+  private let imageManager = SDWebImageManager(
+    cache: SDImageCache.shared,
+    loader: SDImageLoadersManager.shared
+  )
+  private var isPlaying = true
+  private var isLoaded = false
+  
+  // Requests
+  private var webpOperation: SDWebImageCombinedOperation?
+  private var placeholderOperation: SDWebImageCombinedOperation?
+
+  // Props
+  var source: String? = nil
+  var placeholderSource: String? = nil
+  var autoplay = true {
+    didSet {
+      if !autoplay {
+        self.pause()
+      } else {
+        self.play()
+      }
+    }
+  }
+
+  // MARK: - Lifecycle
+
+  public required init(appContext: AppContext? = nil) {
+    super.init(appContext: appContext)
+    self.clipsToBounds = true
+
+    self.imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
+    self.imageView.layer.masksToBounds = false
+    self.imageView.backgroundColor = .clear
+    self.imageView.contentMode = .scaleToFill
+
+    // We have to explicitly set this to false. If we don't, every time
+    // the view comes into the viewport, it will start animating again
+    self.imageView.autoPlayAnimatedImage = false
+
+    self.addSubview(self.imageView)
+  }
+
+  public override func willMove(toWindow newWindow: UIWindow?) {
+    if newWindow == nil {
+      // Don't cancel the placeholder operation, because we really want that to complete for
+      // when we scroll back up
+      self.webpOperation?.cancel()
+      self.placeholderOperation?.cancel()
+    } else if self.imageView.image == nil {
+      self.load()
+    }
+  }
+
+  // MARK: - Loading
+
+  private func load() {
+    guard let source = self.source, let placeholderSource = self.placeholderSource else {
+      return
+    }
+
+    self.webpOperation?.cancel()
+    self.placeholderOperation?.cancel()
+
+    // We only need to start an operation for the placeholder if it doesn't exist
+    // in the cache already. Cache key is by default the absolute URL of the image.
+    // See:
+    // https://github.com/SDWebImage/SDWebImage/blob/master/Docs/HowToUse.md#using-asynchronous-image-caching-independently
+    if !SDImageCache.shared.diskImageDataExists(withKey: source),
+       let url = URL(string: placeholderSource)
+    {
+      self.placeholderOperation = imageManager.loadImage(
+        with: url,
+        options: [.retryFailed],
+        context: Util.createContext(),
+        progress: onProgress(_:_:_:),
+        completed: onLoaded(_:_:_:_:_:_:)
+      )
+    }
+
+    if let url = URL(string: source) {
+      self.webpOperation = imageManager.loadImage(
+        with: url,
+        options: [.retryFailed],
+        context: Util.createContext(),
+        progress: onProgress(_:_:_:),
+        completed: onLoaded(_:_:_:_:_:_:)
+      )
+    }
+  }
+
+  private func setImage(_ image: UIImage) {
+    if self.imageView.image == nil || image.sd_isAnimated {
+      self.imageView.image = image
+    }
+
+    if image.sd_isAnimated {
+      self.firePlayerStateChange()
+      if isPlaying {
+        self.imageView.startAnimating()
+      }
+    }
+  }
+
+  // MARK: - Loading blocks
+
+  private func onProgress(_ receivedSize: Int, _ expectedSize: Int, _ imageUrl: URL?) {}
+
+  private func onLoaded(
+    _ image: UIImage?,
+    _ data: Data?,
+    _ error: Error?,
+    _ cacheType: SDImageCacheType,
+    _ finished: Bool,
+    _ imageUrl: URL?
+  ) {
+    guard finished else {
+      return
+    }
+
+    if let placeholderSource = self.placeholderSource,
+       imageUrl?.absoluteString == placeholderSource,
+       self.imageView.image == nil,
+       let image = image
+    {
+      self.setImage(image)
+      return
+    }
+
+    if let source = self.source,
+       imageUrl?.absoluteString == source,
+       // UIImage perf suckssss if the image is animated
+       let data = data,
+       let animatedImage = SDAnimatedImage(data: data)
+    {
+      self.placeholderOperation?.cancel()
+      self.isPlaying = self.autoplay
+      self.isLoaded = true
+      self.setImage(animatedImage)
+      self.firePlayerStateChange()
+    }
+  }
+
+  // MARK: - Playback Controls
+
+  func play() {
+    self.imageView.startAnimating()
+    self.isPlaying = true
+    self.firePlayerStateChange()
+  }
+
+  func pause() {
+    self.imageView.stopAnimating()
+    self.isPlaying = false
+    self.firePlayerStateChange()
+  }
+
+  func toggle() {
+    if self.isPlaying {
+      self.pause()
+    } else {
+      self.play()
+    }
+  }
+
+  // MARK: - Util
+
+  private func firePlayerStateChange() {
+    onPlayerStateChange([
+      "isPlaying": self.isPlaying,
+      "isLoaded": self.isLoaded
+    ])
+  }
+}
diff --git a/modules/expo-bluesky-gif-view/ios/Util.swift b/modules/expo-bluesky-gif-view/ios/Util.swift
new file mode 100644
index 000000000..55ed4152a
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/ios/Util.swift
@@ -0,0 +1,17 @@
+import SDWebImage
+
+class Util {
+  static func createContext() -> SDWebImageContext {
+    var context = SDWebImageContext()
+
+    // SDAnimatedImage for some reason has issues whenever loaded from memory. Instead, we
+    // will just use the disk. SDWebImage will manage this cache for us, so we don't need
+    // to worry about clearing it.
+    context[.originalQueryCacheType] = SDImageCacheType.disk.rawValue
+    context[.originalStoreCacheType] = SDImageCacheType.disk.rawValue
+    context[.queryCacheType] = SDImageCacheType.disk.rawValue
+    context[.storeCacheType] = SDImageCacheType.disk.rawValue
+
+    return context
+  }
+}
diff --git a/modules/expo-bluesky-gif-view/src/GifView.tsx b/modules/expo-bluesky-gif-view/src/GifView.tsx
new file mode 100644
index 000000000..87258de17
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/src/GifView.tsx
@@ -0,0 +1,39 @@
+import React from 'react'
+import {requireNativeModule} from 'expo'
+import {requireNativeViewManager} from 'expo-modules-core'
+
+import {GifViewProps} from './GifView.types'
+
+const NativeModule = requireNativeModule('ExpoBlueskyGifView')
+const NativeView: React.ComponentType<
+  GifViewProps & {ref: React.RefObject<any>}
+> = requireNativeViewManager('ExpoBlueskyGifView')
+
+export class GifView extends React.PureComponent<GifViewProps> {
+  // TODO native types, should all be the same as those in this class
+  private nativeRef: React.RefObject<any> = React.createRef()
+
+  constructor(props: GifViewProps | Readonly<GifViewProps>) {
+    super(props)
+  }
+
+  static async prefetchAsync(sources: string[]): Promise<void> {
+    return await NativeModule.prefetchAsync(sources)
+  }
+
+  async playAsync(): Promise<void> {
+    await this.nativeRef.current.playAsync()
+  }
+
+  async pauseAsync(): Promise<void> {
+    await this.nativeRef.current.pauseAsync()
+  }
+
+  async toggleAsync(): Promise<void> {
+    await this.nativeRef.current.toggleAsync()
+  }
+
+  render() {
+    return <NativeView {...this.props} ref={this.nativeRef} />
+  }
+}
diff --git a/modules/expo-bluesky-gif-view/src/GifView.types.ts b/modules/expo-bluesky-gif-view/src/GifView.types.ts
new file mode 100644
index 000000000..29ec277f2
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/src/GifView.types.ts
@@ -0,0 +1,15 @@
+import {ViewProps} from 'react-native'
+
+export interface GifViewStateChangeEvent {
+  nativeEvent: {
+    isPlaying: boolean
+    isLoaded: boolean
+  }
+}
+
+export interface GifViewProps extends ViewProps {
+  autoplay?: boolean
+  source?: string
+  placeholderSource?: string
+  onPlayerStateChange?: (event: GifViewStateChangeEvent) => void
+}
diff --git a/modules/expo-bluesky-gif-view/src/GifView.web.tsx b/modules/expo-bluesky-gif-view/src/GifView.web.tsx
new file mode 100644
index 000000000..c197e01a1
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/src/GifView.web.tsx
@@ -0,0 +1,82 @@
+import * as React from 'react'
+import {StyleSheet} from 'react-native'
+
+import {GifViewProps} from './GifView.types'
+
+export class GifView extends React.PureComponent<GifViewProps> {
+  private readonly videoPlayerRef: React.RefObject<HTMLMediaElement> =
+    React.createRef()
+  private isLoaded = false
+
+  constructor(props: GifViewProps | Readonly<GifViewProps>) {
+    super(props)
+  }
+
+  componentDidUpdate(prevProps: Readonly<GifViewProps>) {
+    if (prevProps.autoplay !== this.props.autoplay) {
+      if (this.props.autoplay) {
+        this.playAsync()
+      } else {
+        this.pauseAsync()
+      }
+    }
+  }
+
+  static async prefetchAsync(_: string[]): Promise<void> {
+    console.warn('prefetchAsync is not supported on web')
+  }
+
+  private firePlayerStateChangeEvent = () => {
+    this.props.onPlayerStateChange?.({
+      nativeEvent: {
+        isPlaying: !this.videoPlayerRef.current?.paused,
+        isLoaded: this.isLoaded,
+      },
+    })
+  }
+
+  private onLoad = () => {
+    // Prevent multiple calls to onLoad because onCanPlay will fire after each loop
+    if (this.isLoaded) {
+      return
+    }
+
+    this.isLoaded = true
+    this.firePlayerStateChangeEvent()
+  }
+
+  async playAsync(): Promise<void> {
+    this.videoPlayerRef.current?.play()
+  }
+
+  async pauseAsync(): Promise<void> {
+    this.videoPlayerRef.current?.pause()
+  }
+
+  async toggleAsync(): Promise<void> {
+    if (this.videoPlayerRef.current?.paused) {
+      await this.playAsync()
+    } else {
+      await this.pauseAsync()
+    }
+  }
+
+  render() {
+    return (
+      <video
+        src={this.props.source}
+        autoPlay={this.props.autoplay ? 'autoplay' : undefined}
+        preload={this.props.autoplay ? 'auto' : undefined}
+        playsInline={true}
+        loop="loop"
+        muted="muted"
+        style={StyleSheet.flatten(this.props.style)}
+        onCanPlay={this.onLoad}
+        onPlay={this.firePlayerStateChangeEvent}
+        onPause={this.firePlayerStateChangeEvent}
+        aria-label={this.props.accessibilityLabel}
+        ref={this.videoPlayerRef}
+      />
+    )
+  }
+}
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index c41083afb..84183c1d9 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -4,7 +4,6 @@ export type Gate =
   | 'disable_min_shell_on_foregrounding_v2'
   | 'disable_poll_on_discover_v2'
   | 'hide_vertical_scroll_indicators'
-  | 'new_gif_player'
   | 'show_follow_back_label_v2'
   | 'start_session_with_following_v2'
   | 'use_new_suggestions_endpoint'
diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts
index bbc58a206..b1fc75b8b 100644
--- a/src/lib/strings/embed-player.ts
+++ b/src/lib/strings/embed-player.ts
@@ -1,4 +1,4 @@
-import {Dimensions} from 'react-native'
+import {Dimensions, Platform} from 'react-native'
 
 import {isWeb} from 'platform/detection'
 const {height: SCREEN_HEIGHT} = Dimensions.get('window')
@@ -255,16 +255,6 @@ export function parseEmbedPlayerFromUrl(
   if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') {
     const [_, gifs, nameAndId] = urlp.pathname.split('/')
 
-    const h = urlp.searchParams.get('hh')
-    const w = urlp.searchParams.get('ww')
-    let dimensions
-    if (h && w) {
-      dimensions = {
-        height: Number(h),
-        width: Number(w),
-      }
-    }
-
     /*
      * nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name)
      * We want to get the id of the gif, then direct to media.giphy.com/media/{id}/giphy.webp so we can
@@ -281,10 +271,7 @@ export function parseEmbedPlayerFromUrl(
           isGif: true,
           hideDetails: true,
           metaUri: `https://giphy.com/gifs/${gifId}`,
-          playerUri: `https://i.giphy.com/media/${gifId}/${
-            dimensions ? '200.mp4' : '200.webp'
-          }`,
-          dimensions,
+          playerUri: `https://i.giphy.com/media/${gifId}/200.webp`,
         }
       }
     }
@@ -350,21 +337,34 @@ export function parseEmbedPlayerFromUrl(
     }
   }
 
-  if (urlp.hostname === 'tenor.com' || urlp.hostname === 'www.tenor.com') {
-    const [_, pathOrIntl, pathOrFilename, intlFilename] =
-      urlp.pathname.split('/')
-    const isIntl = pathOrFilename === 'view'
-    const filename = isIntl ? intlFilename : pathOrFilename
+  if (urlp.hostname === 'media.tenor.com') {
+    let [_, id, filename] = urlp.pathname.split('/')
 
-    if ((pathOrIntl === 'view' || pathOrFilename === 'view') && filename) {
-      const includesExt = filename.split('.').pop() === 'gif'
+    const h = urlp.searchParams.get('hh')
+    const w = urlp.searchParams.get('ww')
+    let dimensions
+    if (h && w) {
+      dimensions = {
+        height: Number(h),
+        width: Number(w),
+      }
+    }
+
+    if (id && filename && dimensions && id.includes('AAAAC')) {
+      if (Platform.OS === 'web') {
+        id = id.replace('AAAAC', 'AAAP3')
+        filename = filename.replace('.gif', '.webm')
+      } else {
+        id = id.replace('AAAAC', 'AAAAM')
+      }
 
       return {
         type: 'tenor_gif',
         source: 'tenor',
         isGif: true,
         hideDetails: true,
-        playerUri: `${url}${!includesExt ? '.gif' : ''}`,
+        playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
+        dimensions,
       }
     }
   }
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 93e2dc6b5..8d14c16e2 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -121,6 +121,7 @@ export const ComposePost = observer(function ComposePost({
     initQuote,
   )
   const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
+  const [extGif, setExtGif] = useState<Gif>()
   const [labels, setLabels] = useState<string[]>([])
   const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
   const gallery = useMemo(
@@ -318,7 +319,7 @@ export const ComposePost = observer(function ComposePost({
   const onSelectGif = useCallback(
     (gif: Gif) => {
       setExtLink({
-        uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[0]}&ww=${gif.media_formats.gif.dims[1]}`,
+        uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`,
         isLoading: true,
         meta: {
           url: gif.media_formats.gif.url,
@@ -328,6 +329,7 @@ export const ComposePost = observer(function ComposePost({
           description: `ALT: ${gif.content_description}`,
         },
       })
+      setExtGif(gif)
     },
     [setExtLink],
   )
@@ -473,7 +475,11 @@ export const ComposePost = observer(function ComposePost({
           {gallery.isEmpty && extLink && (
             <ExternalEmbed
               link={extLink}
-              onRemove={() => setExtLink(undefined)}
+              gif={extGif}
+              onRemove={() => {
+                setExtLink(undefined)
+                setExtGif(undefined)
+              }}
             />
           )}
           {quote ? (
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index 3c2bf762d..321e29b30 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -1,11 +1,12 @@
 import React from 'react'
-import {TouchableOpacity, View} from 'react-native'
+import {StyleProp, TouchableOpacity, View, ViewStyle} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {ExternalEmbedDraft} from 'lib/api/index'
 import {s} from 'lib/styles'
+import {Gif} from 'state/queries/tenor'
 import {ExternalLinkEmbed} from 'view/com/util/post-embeds/ExternalLinkEmbed'
 import {atoms as a, useTheme} from '#/alf'
 import {Loader} from '#/components/Loader'
@@ -14,9 +15,11 @@ import {Text} from '#/components/Typography'
 export const ExternalEmbed = ({
   link,
   onRemove,
+  gif,
 }: {
   link?: ExternalEmbedDraft
   onRemove: () => void
+  gif?: Gif
 }) => {
   const t = useTheme()
   const {_} = useLingui()
@@ -34,45 +37,38 @@ export const ExternalEmbed = ({
 
   if (!link) return null
 
+  const loadingStyle: ViewStyle | undefined = gif
+    ? {
+        aspectRatio:
+          gif.media_formats.gif.dims[0] / gif.media_formats.gif.dims[1],
+        width: '100%',
+      }
+    : undefined
+
   return (
-    <View
-      style={[
-        a.border,
-        a.rounded_sm,
-        a.mt_2xl,
-        a.mb_xl,
-        a.overflow_hidden,
-        t.atoms.border_contrast_medium,
-      ]}>
+    <View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}>
       {link.isLoading ? (
-        <View
-          style={[
-            a.align_center,
-            a.justify_center,
-            a.py_5xl,
-            t.atoms.bg_contrast_25,
-          ]}>
+        <Container style={loadingStyle}>
           <Loader size="xl" />
-        </View>
+        </Container>
       ) : link.meta?.error ? (
-        <View
-          style={[a.justify_center, a.p_md, a.gap_xs, t.atoms.bg_contrast_25]}>
+        <Container style={[a.align_start, a.p_md, a.gap_xs]}>
           <Text numberOfLines={1} style={t.atoms.text_contrast_high}>
             {link.uri}
           </Text>
           <Text numberOfLines={2} style={[{color: t.palette.negative_400}]}>
-            {link.meta.error}
+            {link.meta?.error}
           </Text>
-        </View>
+        </Container>
       ) : linkInfo ? (
-        <View style={{pointerEvents: 'none'}}>
+        <View style={{pointerEvents: !gif ? 'none' : 'auto'}}>
           <ExternalLinkEmbed link={linkInfo} />
         </View>
       ) : null}
       <TouchableOpacity
         style={{
           position: 'absolute',
-          top: 10,
+          top: 16,
           right: 10,
           height: 36,
           width: 36,
@@ -91,3 +87,29 @@ export const ExternalEmbed = ({
     </View>
   )
 }
+
+function Container({
+  style,
+  children,
+}: {
+  style?: StyleProp<ViewStyle>
+  children: React.ReactNode
+}) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.mt_sm,
+        a.rounded_sm,
+        a.border,
+        a.align_center,
+        a.justify_center,
+        a.py_5xl,
+        t.atoms.bg_contrast_25,
+        t.atoms.border_contrast_medium,
+        style,
+      ]}>
+      {children}
+    </View>
+  )
+}
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index ff7c643f6..1fe75c44e 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -1,27 +1,32 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
+import React, {useCallback} from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
 import {Image} from 'expo-image'
 import {AppBskyEmbedExternal} from '@atproto/api'
 
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {useGate} from 'lib/statsig/statsig'
+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 {Link} from 'view/com/util/Link'
 import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed'
 import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
+import {GifEmbed} from 'view/com/util/post-embeds/GifEmbed'
+import {atoms as a, useTheme} from '#/alf'
 import {Text} from '../text/Text'
 
 export const ExternalLinkEmbed = ({
   link,
+  style,
 }: {
   link: AppBskyEmbedExternal.ViewExternal
+  style?: StyleProp<ViewStyle>
 }) => {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
   const externalEmbedPrefs = useExternalEmbedsPrefs()
-  const gate = useGate()
 
   const embedPlayerParams = React.useMemo(() => {
     const params = parseEmbedPlayerFromUrl(link.uri)
@@ -30,71 +35,96 @@ export const ExternalLinkEmbed = ({
       return params
     }
   }, [link.uri, externalEmbedPrefs])
-  const isCompatibleGiphy =
-    embedPlayerParams?.source === 'giphy' &&
-    embedPlayerParams.dimensions &&
-    gate('new_gif_player')
+
+  if (embedPlayerParams?.source === 'tenor') {
+    return <GifEmbed params={embedPlayerParams} link={link} />
+  }
 
   return (
-    <View style={styles.container}>
-      {link.thumb && !embedPlayerParams ? (
-        <Image
-          style={{aspectRatio: 1.91}}
-          source={{uri: link.thumb}}
-          accessibilityIgnoresInvertColors
-        />
-      ) : undefined}
-      {isCompatibleGiphy ? (
-        <View />
-      ) : embedPlayerParams?.isGif ? (
-        <ExternalGifEmbed link={link} params={embedPlayerParams} />
-      ) : embedPlayerParams ? (
-        <ExternalPlayer link={link} params={embedPlayerParams} />
-      ) : undefined}
-      <View style={[styles.info, {paddingHorizontal: isMobile ? 10 : 14}]}>
-        {!isCompatibleGiphy && (
+    <View style={[a.flex_col, a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
+      <LinkWrapper link={link} style={style}>
+        {link.thumb && !embedPlayerParams ? (
+          <Image
+            style={{
+              aspectRatio: 1.91,
+              borderTopRightRadius: 6,
+              borderTopLeftRadius: 6,
+            }}
+            source={{uri: link.thumb}}
+            accessibilityIgnoresInvertColors
+          />
+        ) : undefined}
+        {embedPlayerParams?.isGif ? (
+          <ExternalGifEmbed link={link} params={embedPlayerParams} />
+        ) : embedPlayerParams ? (
+          <ExternalPlayer link={link} params={embedPlayerParams} />
+        ) : undefined}
+        <View
+          style={[
+            a.flex_1,
+            a.py_sm,
+            {
+              paddingHorizontal: isMobile ? 10 : 14,
+            },
+          ]}>
           <Text
             type="sm"
             numberOfLines={1}
-            style={[pal.textLight, styles.extUri]}>
+            style={[pal.textLight, {marginVertical: 2}]}>
             {toNiceDomain(link.uri)}
           </Text>
-        )}
 
-        {!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && (
-          <Text type="lg-bold" numberOfLines={3} style={[pal.text]}>
-            {link.title || link.uri}
-          </Text>
-        )}
-        {link.description && !embedPlayerParams?.hideDetails ? (
-          <Text
-            type="md"
-            numberOfLines={link.thumb ? 2 : 4}
-            style={[pal.text, styles.extDescription]}>
-            {link.description}
-          </Text>
-        ) : undefined}
-      </View>
+          {!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && (
+            <Text type="lg-bold" numberOfLines={3} style={[pal.text]}>
+              {link.title || link.uri}
+            </Text>
+          )}
+          {link.description ? (
+            <Text
+              type="md"
+              numberOfLines={link.thumb ? 2 : 4}
+              style={[pal.text, a.mt_xs]}>
+              {link.description}
+            </Text>
+          ) : undefined}
+        </View>
+      </LinkWrapper>
     </View>
   )
 }
 
-const styles = StyleSheet.create({
-  container: {
-    flexDirection: 'column',
-    borderRadius: 6,
-    overflow: 'hidden',
-  },
-  info: {
-    width: '100%',
-    bottom: 0,
-    paddingTop: 8,
-    paddingBottom: 10,
-  },
-  extUri: {
-    marginTop: 2,
-  },
-  extDescription: {
-    marginTop: 4,
-  },
-})
+function LinkWrapper({
+  link,
+  style,
+  children,
+}: {
+  link: AppBskyEmbedExternal.ViewExternal
+  style?: StyleProp<ViewStyle>
+  children: React.ReactNode
+}) {
+  const t = useTheme()
+
+  const onShareExternal = useCallback(() => {
+    if (link.uri && isNative) {
+      shareUrl(link.uri)
+    }
+  }, [link.uri])
+
+  return (
+    <Link
+      asAnchor
+      anchorNoUnderline
+      href={link.uri}
+      style={[
+        a.flex_1,
+        a.border,
+        a.rounded_sm,
+        t.atoms.border_contrast_medium,
+        style,
+      ]}
+      hoverStyle={t.atoms.border_contrast_high}
+      onLongPress={onShareExternal}>
+      {children}
+    </Link>
+  )
+}
diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx
new file mode 100644
index 000000000..32bd75df0
--- /dev/null
+++ b/src/view/com/util/post-embeds/GifEmbed.tsx
@@ -0,0 +1,140 @@
+import React from 'react'
+import {Pressable, View} from 'react-native'
+import {AppBskyEmbedExternal} from '@atproto/api'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {EmbedPlayerParams} from 'lib/strings/embed-player'
+import {useAutoplayDisabled} from 'state/preferences'
+import {atoms as a, useTheme} from '#/alf'
+import {Loader} from '#/components/Loader'
+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`Play or pause 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
+            : !isPlaying
+            ? 'rgba(0, 0, 0, 0.3)'
+            : undefined,
+        },
+      ]}
+      onPress={onPress}>
+      {!isLoaded ? (
+        <View>
+          <View style={[a.align_center, a.justify_center]}>
+            <Loader size="xl" />
+          </View>
+        </View>
+      ) : !isPlaying ? (
+        <View
+          style={[
+            a.rounded_full,
+            a.align_center,
+            a.justify_center,
+            {
+              backgroundColor: t.palette.primary_500,
+              width: 60,
+              height: 60,
+            },
+          ]}>
+          <FontAwesomeIcon
+            icon="play"
+            size={42}
+            color="white"
+            style={{marginLeft: 8}}
+          />
+        </View>
+      ) : undefined}
+    </Pressable>
+  )
+}
+
+export function GifEmbed({
+  params,
+  link,
+}: {
+  params: EmbedPlayerParams
+  link: AppBskyEmbedExternal.ViewExternal
+}) {
+  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_sm, a.overflow_hidden, a.mt_sm]}>
+      <View
+        style={[
+          a.rounded_sm,
+          a.overflow_hidden,
+          {
+            aspectRatio: params.dimensions!.width / params.dimensions!.height,
+          },
+        ]}>
+        <PlaybackControls
+          onPress={onPress}
+          isPlaying={playerState.isPlaying}
+          isLoaded={playerState.isLoaded}
+        />
+        <GifView
+          source={params.playerUri}
+          placeholderSource={link.thumb}
+          style={[a.flex_1, a.rounded_sm]}
+          autoplay={!autoplayDisabled}
+          onPlayerStateChange={onPlayerStateChange}
+          ref={playerRef}
+          accessibilityHint={_(msg`Animated GIF`)}
+          accessibilityLabel={link.description.replace('ALT: ', '')}
+        />
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 47091fbb0..7ea5b55cf 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -1,34 +1,32 @@
-import React, {useCallback} from 'react'
+import React from 'react'
 import {
-  StyleSheet,
+  InteractionManager,
   StyleProp,
+  StyleSheet,
+  Text,
   View,
   ViewStyle,
-  Text,
-  InteractionManager,
 } from 'react-native'
 import {Image} from 'expo-image'
 import {
-  AppBskyEmbedImages,
   AppBskyEmbedExternal,
+  AppBskyEmbedImages,
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
   AppBskyFeedDefs,
   AppBskyGraphDefs,
   ModerationDecision,
 } from '@atproto/api'
-import {Link} from '../Link'
-import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
-import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
+
+import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
 import {usePalette} from 'lib/hooks/usePalette'
-import {ExternalLinkEmbed} from './ExternalLinkEmbed'
-import {MaybeQuoteEmbed} from './QuoteEmbed'
-import {AutoSizedImage} from '../images/AutoSizedImage'
-import {ListEmbed} from './ListEmbed'
 import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
 import {ContentHider} from '../../../../components/moderation/ContentHider'
-import {isNative} from '#/platform/detection'
-import {shareUrl} from '#/lib/sharing'
+import {AutoSizedImage} from '../images/AutoSizedImage'
+import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
+import {ExternalLinkEmbed} from './ExternalLinkEmbed'
+import {ListEmbed} from './ListEmbed'
+import {MaybeQuoteEmbed} from './QuoteEmbed'
 
 type Embed =
   | AppBskyEmbedRecord.View
@@ -49,16 +47,6 @@ export function PostEmbeds({
   const pal = usePalette('default')
   const {openLightbox} = useLightboxControls()
 
-  const externalUri = AppBskyEmbedExternal.isView(embed)
-    ? embed.external.uri
-    : null
-
-  const onShareExternal = useCallback(() => {
-    if (externalUri && isNative) {
-      shareUrl(externalUri)
-    }
-  }, [externalUri])
-
   // quote post with media
   // =
   if (AppBskyEmbedRecordWithMedia.isView(embed)) {
@@ -161,18 +149,9 @@ export function PostEmbeds({
   // =
   if (AppBskyEmbedExternal.isView(embed)) {
     const link = embed.external
-
     return (
       <ContentHider modui={moderation?.ui('contentMedia')}>
-        <Link
-          asAnchor
-          anchorNoUnderline
-          href={link.uri}
-          style={[styles.extOuter, pal.view, pal.borderDark, style]}
-          hoverStyle={{borderColor: pal.colors.borderLinkHover}}
-          onLongPress={onShareExternal}>
-          <ExternalLinkEmbed link={link} />
-        </Link>
+        <ExternalLinkEmbed link={link} style={style} />
       </ContentHider>
     )
   }
@@ -187,11 +166,6 @@ const styles = StyleSheet.create({
   singleImage: {
     borderRadius: 8,
   },
-  extOuter: {
-    borderWidth: 1,
-    borderRadius: 8,
-    marginTop: 4,
-  },
   altContainer: {
     backgroundColor: 'rgba(0, 0, 0, 0.75)',
     borderRadius: 6,