diff options
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, |