diff options
author | Hailey <me@haileyok.com> | 2024-04-22 18:54:15 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-04-23 02:54:15 +0100 |
commit | cbb817b5b707042afefbf8ca46a7104d62349492 (patch) | |
tree | 4707b4cb8825f23dfdcb1dce61acfcccca26edee | |
parent | fe9b3f0432d36fd60e5da4ed16be87cd0470e64b (diff) | |
download | voidsky-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>
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, |