about summary refs log tree commit diff
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-04-22 18:54:15 -0700
committerGitHub <noreply@github.com>2024-04-23 02:54:15 +0100
commitcbb817b5b707042afefbf8ca46a7104d62349492 (patch)
tree4707b4cb8825f23dfdcb1dce61acfcccca26edee
parentfe9b3f0432d36fd60e5da4ed16be87cd0470e64b (diff)
downloadvoidsky-cbb817b5b707042afefbf8ca46a7104d62349492.tar.zst
GIF Viewer (#3605)
* ios player

autoplay after recycle

remove all items from AVPlayer queue

recurururururursion

use managers in the view

add prefetch

make sure player items stay in order

add controller and item managers

start of the view

create module, ios

* android player

smoother

basic caching

prep cache

somewhat works

backup

other files

android impl

blegh

lets go

touchup

add prefetch to js

use caching

* bogus testing commit

* add dims to type

* save

* add the dimensions to the embed info

* add a new case

* add a new case

* limit this case to giphy

* use gate

* Revert "bogus testing commit"

This reverts commit b3c8751b71f7108de9aa843b22ded4e0249fa854.

* add web player base

* flip mp4/webp

* basic mp4 player for web

* move some stuff into `ExternalLinkEmbed` instead

* use a class component for web

* remove extra component

* add `onPlayerStateChange` event type on web

* layer properly

* fix tests

* add new test

* about ready. native portions done, a few touch ups on web needed

show placeholder on ios

fix type

rm log

display thumbnail until video is ready to play

add oncanplay, playsinline

remove unused method

add `isLoaded` change event

release player when finished

apply gc to the view

cleanup logs

android gc

rm log

automatic gc for assets

make `nativeRef` private

remove unnecessary `await`

cleanup

rev log

only play on prepare whenever needed

rm unused

perfperfperf

rm var

comment + android width

native height calculations

rm pressable

add event dispatcher on android

add event dispatcher on ios

* ready to test ios

fix autoplay ios

clean

oops

* autoplay on web

* normalize across all platforms

add check for `ALT:`

separate gif embed logic to another file

handle permissions requests

flatten web styles

normalize styles

normalize styles

prefetch functions

pause animatable on foreground android

nits

one more oops

idk where that code went

lint

rethink the usage

wrap up

android

clear bg

update gradle

more android

rename dir

update android namespace

web

ios

add deps

use webp

rm unused

update types

use webp on mobile

* rm gate from types

* remove unused event param

* only start placeholder op if doesn't exist in disk cache

* fix gifs animating on app resume android

* remove comment

* add `isLoaded` for ios

* add `isLoaded` to Android

* onload for web

* add visual loading state

* rm a log

* implement isloaded for android

* dialogs

* replace `webpSource` with `source`

* update prop name

* Move to Tenor for GIFs (#3654)

* update some urls

* right order for dimensions

* add GIF coder for ios

* remove giphy check

* rewrite tenor urls

* remove all the unnecessary stuff for consent

* rm print

* rm log

* check if id and filename are strings

* full size playback controls

* pass tests

* add accessibility to gifs

* use `onPlay` and `onPause`

* rm unused logic for description

* add accessibility label to the controls

* add gif into to external embed in composer

* make it optional

* gif dimensions

* make the jsx look nicer

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
-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,