Bluesky app fork with some witchin' additions 💫

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>

authored by hailey.at

Dan Abramov
Samuel Newman
and committed by
GitHub
cbb817b5 fe9b3f04

+1129 -171
+20 -22
__tests__/lib/string.test.ts
··· 459 459 'https://tenor.com/view', 460 460 'https://tenor.com/view/gifId.gif', 461 461 'https://tenor.com/intl/view/gifId.gif', 462 + 463 + 'https://media.tenor.com/someID_AAAAC/someName.gif?hh=100&ww=100', 464 + 'https://media.tenor.com/someID_AAAAC/someName.gif', 465 + 'https://media.tenor.com/someID/someName.gif', 466 + 'https://media.tenor.com/someID', 467 + 'https://media.tenor.com', 462 468 ] 463 469 464 470 const outputs = [ ··· 628 634 }, 629 635 undefined, 630 636 undefined, 631 - 632 637 { 633 638 type: 'giphy_gif', 634 639 source: 'giphy', 635 640 isGif: true, 636 641 hideDetails: true, 637 642 metaUri: 'https://giphy.com/gifs/39248209509382934029', 638 - playerUri: 'https://i.giphy.com/media/39248209509382934029/200.mp4', 639 - dimensions: { 640 - width: 100, 641 - height: 100, 642 - }, 643 + playerUri: 'https://i.giphy.com/media/39248209509382934029/200.webp', 643 644 }, 644 - 645 645 { 646 646 type: 'giphy_gif', 647 647 source: 'giphy', ··· 736 736 playerUri: 'https://i.giphy.com/media/gifId/200.webp', 737 737 }, 738 738 739 - { 740 - type: 'tenor_gif', 741 - source: 'tenor', 742 - isGif: true, 743 - hideDetails: true, 744 - playerUri: 'https://tenor.com/view/gifId.gif', 745 - }, 746 739 undefined, 747 740 undefined, 748 - { 749 - type: 'tenor_gif', 750 - source: 'tenor', 751 - isGif: true, 752 - hideDetails: true, 753 - playerUri: 'https://tenor.com/view/gifId.gif', 754 - }, 741 + undefined, 742 + undefined, 743 + undefined, 744 + 755 745 { 756 746 type: 'tenor_gif', 757 747 source: 'tenor', 758 748 isGif: true, 759 749 hideDetails: true, 760 - playerUri: 'https://tenor.com/intl/view/gifId.gif', 750 + playerUri: 'https://t.gifs.bsky.app/someID_AAAAM/someName.gif', 751 + dimensions: { 752 + width: 100, 753 + height: 100, 754 + }, 761 755 }, 756 + undefined, 757 + undefined, 758 + undefined, 759 + undefined, 762 760 ] 763 761 764 762 it('correctly grabs the correct id from uri', () => {
+98
modules/expo-bluesky-gif-view/android/build.gradle
··· 1 + apply plugin: 'com.android.library' 2 + apply plugin: 'kotlin-android' 3 + apply plugin: 'maven-publish' 4 + 5 + group = 'expo.modules.blueskygifview' 6 + version = '0.5.0' 7 + 8 + buildscript { 9 + def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") 10 + if (expoModulesCorePlugin.exists()) { 11 + apply from: expoModulesCorePlugin 12 + applyKotlinExpoModulesCorePlugin() 13 + } 14 + 15 + // Simple helper that allows the root project to override versions declared by this library. 16 + ext.safeExtGet = { prop, fallback -> 17 + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback 18 + } 19 + 20 + // Ensures backward compatibility 21 + ext.getKotlinVersion = { 22 + if (ext.has("kotlinVersion")) { 23 + ext.kotlinVersion() 24 + } else { 25 + ext.safeExtGet("kotlinVersion", "1.8.10") 26 + } 27 + } 28 + 29 + repositories { 30 + mavenCentral() 31 + } 32 + 33 + dependencies { 34 + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}") 35 + } 36 + } 37 + 38 + afterEvaluate { 39 + publishing { 40 + publications { 41 + release(MavenPublication) { 42 + from components.release 43 + } 44 + } 45 + repositories { 46 + maven { 47 + url = mavenLocal().url 48 + } 49 + } 50 + } 51 + } 52 + 53 + android { 54 + compileSdkVersion safeExtGet("compileSdkVersion", 33) 55 + 56 + def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION 57 + if (agpVersion.tokenize('.')[0].toInteger() < 8) { 58 + compileOptions { 59 + sourceCompatibility JavaVersion.VERSION_11 60 + targetCompatibility JavaVersion.VERSION_11 61 + } 62 + 63 + kotlinOptions { 64 + jvmTarget = JavaVersion.VERSION_11.majorVersion 65 + } 66 + } 67 + 68 + namespace "expo.modules.blueskygifview" 69 + defaultConfig { 70 + minSdkVersion safeExtGet("minSdkVersion", 21) 71 + targetSdkVersion safeExtGet("targetSdkVersion", 34) 72 + versionCode 1 73 + versionName "0.5.0" 74 + } 75 + lintOptions { 76 + abortOnError false 77 + } 78 + publishing { 79 + singleVariant("release") { 80 + withSourcesJar() 81 + } 82 + } 83 + } 84 + 85 + repositories { 86 + mavenCentral() 87 + } 88 + 89 + dependencies { 90 + implementation 'androidx.appcompat:appcompat:1.6.1' 91 + def GLIDE_VERSION = "4.13.2" 92 + 93 + implementation project(':expo-modules-core') 94 + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}" 95 + 96 + // Keep glide version up to date with expo-image so that we don't have duplicate deps 97 + implementation 'com.github.bumptech.glide:glide:4.13.2' 98 + }
+2
modules/expo-bluesky-gif-view/android/src/main/AndroidManifest.xml
··· 1 + <manifest> 2 + </manifest>
+37
modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/AppCompatImageViewExtended.kt
··· 1 + package expo.modules.blueskygifview 2 + 3 + import android.content.Context 4 + import android.graphics.Canvas 5 + import android.graphics.drawable.Animatable 6 + import androidx.appcompat.widget.AppCompatImageView 7 + 8 + class AppCompatImageViewExtended(context: Context, private val parent: GifView): AppCompatImageView(context) { 9 + override fun onDraw(canvas: Canvas) { 10 + super.onDraw(canvas) 11 + 12 + if (this.drawable is Animatable) { 13 + if (!parent.isLoaded) { 14 + parent.isLoaded = true 15 + parent.firePlayerStateChange() 16 + } 17 + 18 + if (!parent.isPlaying) { 19 + this.pause() 20 + } 21 + } 22 + } 23 + 24 + fun pause() { 25 + val drawable = this.drawable 26 + if (drawable is Animatable) { 27 + drawable.stop() 28 + } 29 + } 30 + 31 + fun play() { 32 + val drawable = this.drawable 33 + if (drawable is Animatable) { 34 + drawable.start() 35 + } 36 + } 37 + }
+54
modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/ExpoBlueskyGifViewModule.kt
··· 1 + package expo.modules.blueskygifview 2 + 3 + import com.bumptech.glide.Glide 4 + import com.bumptech.glide.load.engine.DiskCacheStrategy 5 + import expo.modules.kotlin.modules.Module 6 + import expo.modules.kotlin.modules.ModuleDefinition 7 + 8 + class ExpoBlueskyGifViewModule : Module() { 9 + override fun definition() = ModuleDefinition { 10 + Name("ExpoBlueskyGifView") 11 + 12 + AsyncFunction("prefetchAsync") { sources: List<String> -> 13 + val activity = appContext.currentActivity ?: return@AsyncFunction 14 + val glide = Glide.with(activity) 15 + 16 + sources.forEach { source -> 17 + glide 18 + .download(source) 19 + .diskCacheStrategy(DiskCacheStrategy.DATA) 20 + .submit() 21 + } 22 + } 23 + 24 + View(GifView::class) { 25 + Events( 26 + "onPlayerStateChange" 27 + ) 28 + 29 + Prop("source") { view: GifView, source: String -> 30 + view.source = source 31 + } 32 + 33 + Prop("placeholderSource") { view: GifView, source: String -> 34 + view.placeholderSource = source 35 + } 36 + 37 + Prop("autoplay") { view: GifView, autoplay: Boolean -> 38 + view.autoplay = autoplay 39 + } 40 + 41 + AsyncFunction("playAsync") { view: GifView -> 42 + view.play() 43 + } 44 + 45 + AsyncFunction("pauseAsync") { view: GifView -> 46 + view.pause() 47 + } 48 + 49 + AsyncFunction("toggleAsync") { view: GifView -> 50 + view.toggle() 51 + } 52 + } 53 + } 54 + }
+180
modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/GifView.kt
··· 1 + package expo.modules.blueskygifview 2 + 3 + 4 + import android.content.Context 5 + import android.graphics.Color 6 + import android.graphics.drawable.Animatable 7 + import android.graphics.drawable.Drawable 8 + import com.bumptech.glide.Glide 9 + import com.bumptech.glide.load.engine.DiskCacheStrategy 10 + import com.bumptech.glide.load.engine.GlideException 11 + import com.bumptech.glide.request.RequestListener 12 + import com.bumptech.glide.request.target.Target 13 + import expo.modules.kotlin.AppContext 14 + import expo.modules.kotlin.exception.Exceptions 15 + import expo.modules.kotlin.viewevent.EventDispatcher 16 + import expo.modules.kotlin.views.ExpoView 17 + 18 + class GifView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { 19 + // Events 20 + private val onPlayerStateChange by EventDispatcher() 21 + 22 + // Glide 23 + private val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity() 24 + private val glide = Glide.with(activity) 25 + val imageView = AppCompatImageViewExtended(context, this) 26 + var isPlaying = true 27 + var isLoaded = false 28 + 29 + // Requests 30 + private var placeholderRequest: Target<Drawable>? = null 31 + private var webpRequest: Target<Drawable>? = null 32 + 33 + // Props 34 + var placeholderSource: String? = null 35 + var source: String? = null 36 + var autoplay: Boolean = true 37 + set(value) { 38 + field = value 39 + 40 + if (value) { 41 + this.play() 42 + } else { 43 + this.pause() 44 + } 45 + } 46 + 47 + 48 + //<editor-fold desc="Lifecycle"> 49 + 50 + init { 51 + this.setBackgroundColor(Color.TRANSPARENT) 52 + 53 + this.imageView.setBackgroundColor(Color.TRANSPARENT) 54 + this.imageView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) 55 + 56 + this.addView(this.imageView) 57 + } 58 + 59 + override fun onAttachedToWindow() { 60 + if (this.imageView.drawable == null || this.imageView.drawable !is Animatable) { 61 + this.load() 62 + } else if (this.isPlaying) { 63 + this.imageView.play() 64 + } 65 + super.onAttachedToWindow() 66 + } 67 + 68 + override fun onDetachedFromWindow() { 69 + this.imageView.pause() 70 + super.onDetachedFromWindow() 71 + } 72 + 73 + //</editor-fold> 74 + 75 + //<editor-fold desc="Loading"> 76 + 77 + private fun load() { 78 + if (placeholderSource == null || source == null) { 79 + return 80 + } 81 + 82 + this.webpRequest = glide.load(source) 83 + .diskCacheStrategy(DiskCacheStrategy.DATA) 84 + .skipMemoryCache(false) 85 + .listener(object: RequestListener<Drawable> { 86 + override fun onResourceReady( 87 + resource: Drawable?, 88 + model: Any?, 89 + target: Target<Drawable>?, 90 + dataSource: com.bumptech.glide.load.DataSource?, 91 + isFirstResource: Boolean 92 + ): Boolean { 93 + if (placeholderRequest != null) { 94 + glide.clear(placeholderRequest) 95 + } 96 + return false 97 + } 98 + 99 + override fun onLoadFailed( 100 + e: GlideException?, 101 + model: Any?, 102 + target: Target<Drawable>?, 103 + isFirstResource: Boolean 104 + ): Boolean { 105 + return true 106 + } 107 + }) 108 + .into(this.imageView) 109 + 110 + if (this.imageView.drawable == null || this.imageView.drawable !is Animatable) { 111 + this.placeholderRequest = glide.load(placeholderSource) 112 + .diskCacheStrategy(DiskCacheStrategy.DATA) 113 + // Let's not bloat the memory cache with placeholders 114 + .skipMemoryCache(true) 115 + .listener(object: RequestListener<Drawable> { 116 + override fun onResourceReady( 117 + resource: Drawable?, 118 + model: Any?, 119 + target: Target<Drawable>?, 120 + dataSource: com.bumptech.glide.load.DataSource?, 121 + isFirstResource: Boolean 122 + ): Boolean { 123 + // Incase this request finishes after the webp, let's just not set 124 + // the drawable. This shouldn't happen because the request should get cancelled 125 + if (imageView.drawable == null) { 126 + imageView.setImageDrawable(resource) 127 + } 128 + return true 129 + } 130 + 131 + override fun onLoadFailed( 132 + e: GlideException?, 133 + model: Any?, 134 + target: Target<Drawable>?, 135 + isFirstResource: Boolean 136 + ): Boolean { 137 + return true 138 + } 139 + }) 140 + .submit() 141 + } 142 + } 143 + 144 + //</editor-fold> 145 + 146 + //<editor-fold desc="Controls"> 147 + 148 + fun play() { 149 + this.imageView.play() 150 + this.isPlaying = true 151 + this.firePlayerStateChange() 152 + } 153 + 154 + fun pause() { 155 + this.imageView.pause() 156 + this.isPlaying = false 157 + this.firePlayerStateChange() 158 + } 159 + 160 + fun toggle() { 161 + if (this.isPlaying) { 162 + this.pause() 163 + } else { 164 + this.play() 165 + } 166 + } 167 + 168 + //</editor-fold> 169 + 170 + //<editor-fold desc="Util"> 171 + 172 + fun firePlayerStateChange() { 173 + onPlayerStateChange(mapOf( 174 + "isPlaying" to this.isPlaying, 175 + "isLoaded" to this.isLoaded, 176 + )) 177 + } 178 + 179 + //</editor-fold> 180 + }
+9
modules/expo-bluesky-gif-view/expo-module.config.json
··· 1 + { 2 + "platforms": ["ios", "android", "web"], 3 + "ios": { 4 + "modules": ["ExpoBlueskyGifViewModule"] 5 + }, 6 + "android": { 7 + "modules": ["expo.modules.blueskygifview.ExpoBlueskyGifViewModule"] 8 + } 9 + }
+1
modules/expo-bluesky-gif-view/index.ts
··· 1 + export {GifView} from './src/GifView'
+23
modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifView.podspec
··· 1 + Pod::Spec.new do |s| 2 + s.name = 'ExpoBlueskyGifView' 3 + s.version = '1.0.0' 4 + s.summary = 'A simple GIF player for Bluesky' 5 + s.description = 'A simple GIF player for Bluesky' 6 + s.author = '' 7 + s.homepage = 'https://github.com/bluesky-social/social-app' 8 + s.platforms = { :ios => '13.4', :tvos => '13.4' } 9 + s.source = { git: '' } 10 + s.static_framework = true 11 + 12 + s.dependency 'ExpoModulesCore' 13 + s.dependency 'SDWebImage', '~> 5.17.0' 14 + s.dependency 'SDWebImageWebPCoder', '~> 0.13.0' 15 + 16 + # Swift/Objective-C compatibility 17 + s.pod_target_xcconfig = { 18 + 'DEFINES_MODULE' => 'YES', 19 + 'SWIFT_COMPILATION_MODE' => 'wholemodule' 20 + } 21 + 22 + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" 23 + end
+47
modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifViewModule.swift
··· 1 + import ExpoModulesCore 2 + import SDWebImage 3 + import SDWebImageWebPCoder 4 + 5 + public class ExpoBlueskyGifViewModule: Module { 6 + public func definition() -> ModuleDefinition { 7 + Name("ExpoBlueskyGifView") 8 + 9 + OnCreate { 10 + SDImageCodersManager.shared.addCoder(SDImageGIFCoder.shared) 11 + } 12 + 13 + AsyncFunction("prefetchAsync") { (sources: [URL]) in 14 + SDWebImagePrefetcher.shared.prefetchURLs(sources, context: Util.createContext(), progress: nil) 15 + } 16 + 17 + View(GifView.self) { 18 + Events( 19 + "onPlayerStateChange" 20 + ) 21 + 22 + Prop("source") { (view: GifView, prop: String) in 23 + view.source = prop 24 + } 25 + 26 + Prop("placeholderSource") { (view: GifView, prop: String) in 27 + view.placeholderSource = prop 28 + } 29 + 30 + Prop("autoplay") { (view: GifView, prop: Bool) in 31 + view.autoplay = prop 32 + } 33 + 34 + AsyncFunction("toggleAsync") { (view: GifView) in 35 + view.toggle() 36 + } 37 + 38 + AsyncFunction("playAsync") { (view: GifView) in 39 + view.play() 40 + } 41 + 42 + AsyncFunction("pauseAsync") { (view: GifView) in 43 + view.pause() 44 + } 45 + } 46 + } 47 + }
+185
modules/expo-bluesky-gif-view/ios/GifView.swift
··· 1 + import ExpoModulesCore 2 + import SDWebImage 3 + import SDWebImageWebPCoder 4 + 5 + typealias SDWebImageContext = [SDWebImageContextOption: Any] 6 + 7 + public class GifView: ExpoView, AVPlayerViewControllerDelegate { 8 + // Events 9 + private let onPlayerStateChange = EventDispatcher() 10 + 11 + // SDWebImage 12 + private let imageView = SDAnimatedImageView(frame: .zero) 13 + private let imageManager = SDWebImageManager( 14 + cache: SDImageCache.shared, 15 + loader: SDImageLoadersManager.shared 16 + ) 17 + private var isPlaying = true 18 + private var isLoaded = false 19 + 20 + // Requests 21 + private var webpOperation: SDWebImageCombinedOperation? 22 + private var placeholderOperation: SDWebImageCombinedOperation? 23 + 24 + // Props 25 + var source: String? = nil 26 + var placeholderSource: String? = nil 27 + var autoplay = true { 28 + didSet { 29 + if !autoplay { 30 + self.pause() 31 + } else { 32 + self.play() 33 + } 34 + } 35 + } 36 + 37 + // MARK: - Lifecycle 38 + 39 + public required init(appContext: AppContext? = nil) { 40 + super.init(appContext: appContext) 41 + self.clipsToBounds = true 42 + 43 + self.imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] 44 + self.imageView.layer.masksToBounds = false 45 + self.imageView.backgroundColor = .clear 46 + self.imageView.contentMode = .scaleToFill 47 + 48 + // We have to explicitly set this to false. If we don't, every time 49 + // the view comes into the viewport, it will start animating again 50 + self.imageView.autoPlayAnimatedImage = false 51 + 52 + self.addSubview(self.imageView) 53 + } 54 + 55 + public override func willMove(toWindow newWindow: UIWindow?) { 56 + if newWindow == nil { 57 + // Don't cancel the placeholder operation, because we really want that to complete for 58 + // when we scroll back up 59 + self.webpOperation?.cancel() 60 + self.placeholderOperation?.cancel() 61 + } else if self.imageView.image == nil { 62 + self.load() 63 + } 64 + } 65 + 66 + // MARK: - Loading 67 + 68 + private func load() { 69 + guard let source = self.source, let placeholderSource = self.placeholderSource else { 70 + return 71 + } 72 + 73 + self.webpOperation?.cancel() 74 + self.placeholderOperation?.cancel() 75 + 76 + // We only need to start an operation for the placeholder if it doesn't exist 77 + // in the cache already. Cache key is by default the absolute URL of the image. 78 + // See: 79 + // https://github.com/SDWebImage/SDWebImage/blob/master/Docs/HowToUse.md#using-asynchronous-image-caching-independently 80 + if !SDImageCache.shared.diskImageDataExists(withKey: source), 81 + let url = URL(string: placeholderSource) 82 + { 83 + self.placeholderOperation = imageManager.loadImage( 84 + with: url, 85 + options: [.retryFailed], 86 + context: Util.createContext(), 87 + progress: onProgress(_:_:_:), 88 + completed: onLoaded(_:_:_:_:_:_:) 89 + ) 90 + } 91 + 92 + if let url = URL(string: source) { 93 + self.webpOperation = imageManager.loadImage( 94 + with: url, 95 + options: [.retryFailed], 96 + context: Util.createContext(), 97 + progress: onProgress(_:_:_:), 98 + completed: onLoaded(_:_:_:_:_:_:) 99 + ) 100 + } 101 + } 102 + 103 + private func setImage(_ image: UIImage) { 104 + if self.imageView.image == nil || image.sd_isAnimated { 105 + self.imageView.image = image 106 + } 107 + 108 + if image.sd_isAnimated { 109 + self.firePlayerStateChange() 110 + if isPlaying { 111 + self.imageView.startAnimating() 112 + } 113 + } 114 + } 115 + 116 + // MARK: - Loading blocks 117 + 118 + private func onProgress(_ receivedSize: Int, _ expectedSize: Int, _ imageUrl: URL?) {} 119 + 120 + private func onLoaded( 121 + _ image: UIImage?, 122 + _ data: Data?, 123 + _ error: Error?, 124 + _ cacheType: SDImageCacheType, 125 + _ finished: Bool, 126 + _ imageUrl: URL? 127 + ) { 128 + guard finished else { 129 + return 130 + } 131 + 132 + if let placeholderSource = self.placeholderSource, 133 + imageUrl?.absoluteString == placeholderSource, 134 + self.imageView.image == nil, 135 + let image = image 136 + { 137 + self.setImage(image) 138 + return 139 + } 140 + 141 + if let source = self.source, 142 + imageUrl?.absoluteString == source, 143 + // UIImage perf suckssss if the image is animated 144 + let data = data, 145 + let animatedImage = SDAnimatedImage(data: data) 146 + { 147 + self.placeholderOperation?.cancel() 148 + self.isPlaying = self.autoplay 149 + self.isLoaded = true 150 + self.setImage(animatedImage) 151 + self.firePlayerStateChange() 152 + } 153 + } 154 + 155 + // MARK: - Playback Controls 156 + 157 + func play() { 158 + self.imageView.startAnimating() 159 + self.isPlaying = true 160 + self.firePlayerStateChange() 161 + } 162 + 163 + func pause() { 164 + self.imageView.stopAnimating() 165 + self.isPlaying = false 166 + self.firePlayerStateChange() 167 + } 168 + 169 + func toggle() { 170 + if self.isPlaying { 171 + self.pause() 172 + } else { 173 + self.play() 174 + } 175 + } 176 + 177 + // MARK: - Util 178 + 179 + private func firePlayerStateChange() { 180 + onPlayerStateChange([ 181 + "isPlaying": self.isPlaying, 182 + "isLoaded": self.isLoaded 183 + ]) 184 + } 185 + }
+17
modules/expo-bluesky-gif-view/ios/Util.swift
··· 1 + import SDWebImage 2 + 3 + class Util { 4 + static func createContext() -> SDWebImageContext { 5 + var context = SDWebImageContext() 6 + 7 + // SDAnimatedImage for some reason has issues whenever loaded from memory. Instead, we 8 + // will just use the disk. SDWebImage will manage this cache for us, so we don't need 9 + // to worry about clearing it. 10 + context[.originalQueryCacheType] = SDImageCacheType.disk.rawValue 11 + context[.originalStoreCacheType] = SDImageCacheType.disk.rawValue 12 + context[.queryCacheType] = SDImageCacheType.disk.rawValue 13 + context[.storeCacheType] = SDImageCacheType.disk.rawValue 14 + 15 + return context 16 + } 17 + }
+39
modules/expo-bluesky-gif-view/src/GifView.tsx
··· 1 + import React from 'react' 2 + import {requireNativeModule} from 'expo' 3 + import {requireNativeViewManager} from 'expo-modules-core' 4 + 5 + import {GifViewProps} from './GifView.types' 6 + 7 + const NativeModule = requireNativeModule('ExpoBlueskyGifView') 8 + const NativeView: React.ComponentType< 9 + GifViewProps & {ref: React.RefObject<any>} 10 + > = requireNativeViewManager('ExpoBlueskyGifView') 11 + 12 + export class GifView extends React.PureComponent<GifViewProps> { 13 + // TODO native types, should all be the same as those in this class 14 + private nativeRef: React.RefObject<any> = React.createRef() 15 + 16 + constructor(props: GifViewProps | Readonly<GifViewProps>) { 17 + super(props) 18 + } 19 + 20 + static async prefetchAsync(sources: string[]): Promise<void> { 21 + return await NativeModule.prefetchAsync(sources) 22 + } 23 + 24 + async playAsync(): Promise<void> { 25 + await this.nativeRef.current.playAsync() 26 + } 27 + 28 + async pauseAsync(): Promise<void> { 29 + await this.nativeRef.current.pauseAsync() 30 + } 31 + 32 + async toggleAsync(): Promise<void> { 33 + await this.nativeRef.current.toggleAsync() 34 + } 35 + 36 + render() { 37 + return <NativeView {...this.props} ref={this.nativeRef} /> 38 + } 39 + }
+15
modules/expo-bluesky-gif-view/src/GifView.types.ts
··· 1 + import {ViewProps} from 'react-native' 2 + 3 + export interface GifViewStateChangeEvent { 4 + nativeEvent: { 5 + isPlaying: boolean 6 + isLoaded: boolean 7 + } 8 + } 9 + 10 + export interface GifViewProps extends ViewProps { 11 + autoplay?: boolean 12 + source?: string 13 + placeholderSource?: string 14 + onPlayerStateChange?: (event: GifViewStateChangeEvent) => void 15 + }
+82
modules/expo-bluesky-gif-view/src/GifView.web.tsx
··· 1 + import * as React from 'react' 2 + import {StyleSheet} from 'react-native' 3 + 4 + import {GifViewProps} from './GifView.types' 5 + 6 + export class GifView extends React.PureComponent<GifViewProps> { 7 + private readonly videoPlayerRef: React.RefObject<HTMLMediaElement> = 8 + React.createRef() 9 + private isLoaded = false 10 + 11 + constructor(props: GifViewProps | Readonly<GifViewProps>) { 12 + super(props) 13 + } 14 + 15 + componentDidUpdate(prevProps: Readonly<GifViewProps>) { 16 + if (prevProps.autoplay !== this.props.autoplay) { 17 + if (this.props.autoplay) { 18 + this.playAsync() 19 + } else { 20 + this.pauseAsync() 21 + } 22 + } 23 + } 24 + 25 + static async prefetchAsync(_: string[]): Promise<void> { 26 + console.warn('prefetchAsync is not supported on web') 27 + } 28 + 29 + private firePlayerStateChangeEvent = () => { 30 + this.props.onPlayerStateChange?.({ 31 + nativeEvent: { 32 + isPlaying: !this.videoPlayerRef.current?.paused, 33 + isLoaded: this.isLoaded, 34 + }, 35 + }) 36 + } 37 + 38 + private onLoad = () => { 39 + // Prevent multiple calls to onLoad because onCanPlay will fire after each loop 40 + if (this.isLoaded) { 41 + return 42 + } 43 + 44 + this.isLoaded = true 45 + this.firePlayerStateChangeEvent() 46 + } 47 + 48 + async playAsync(): Promise<void> { 49 + this.videoPlayerRef.current?.play() 50 + } 51 + 52 + async pauseAsync(): Promise<void> { 53 + this.videoPlayerRef.current?.pause() 54 + } 55 + 56 + async toggleAsync(): Promise<void> { 57 + if (this.videoPlayerRef.current?.paused) { 58 + await this.playAsync() 59 + } else { 60 + await this.pauseAsync() 61 + } 62 + } 63 + 64 + render() { 65 + return ( 66 + <video 67 + src={this.props.source} 68 + autoPlay={this.props.autoplay ? 'autoplay' : undefined} 69 + preload={this.props.autoplay ? 'auto' : undefined} 70 + playsInline={true} 71 + loop="loop" 72 + muted="muted" 73 + style={StyleSheet.flatten(this.props.style)} 74 + onCanPlay={this.onLoad} 75 + onPlay={this.firePlayerStateChangeEvent} 76 + onPause={this.firePlayerStateChangeEvent} 77 + aria-label={this.props.accessibilityLabel} 78 + ref={this.videoPlayerRef} 79 + /> 80 + ) 81 + } 82 + }
-1
src/lib/statsig/gates.ts
··· 4 4 | 'disable_min_shell_on_foregrounding_v2' 5 5 | 'disable_poll_on_discover_v2' 6 6 | 'hide_vertical_scroll_indicators' 7 - | 'new_gif_player' 8 7 | 'show_follow_back_label_v2' 9 8 | 'start_session_with_following_v2' 10 9 | 'use_new_suggestions_endpoint'
+23 -23
src/lib/strings/embed-player.ts
··· 1 - import {Dimensions} from 'react-native' 1 + import {Dimensions, Platform} from 'react-native' 2 2 3 3 import {isWeb} from 'platform/detection' 4 4 const {height: SCREEN_HEIGHT} = Dimensions.get('window') ··· 255 255 if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') { 256 256 const [_, gifs, nameAndId] = urlp.pathname.split('/') 257 257 258 - const h = urlp.searchParams.get('hh') 259 - const w = urlp.searchParams.get('ww') 260 - let dimensions 261 - if (h && w) { 262 - dimensions = { 263 - height: Number(h), 264 - width: Number(w), 265 - } 266 - } 267 - 268 258 /* 269 259 * nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name) 270 260 * We want to get the id of the gif, then direct to media.giphy.com/media/{id}/giphy.webp so we can ··· 281 271 isGif: true, 282 272 hideDetails: true, 283 273 metaUri: `https://giphy.com/gifs/${gifId}`, 284 - playerUri: `https://i.giphy.com/media/${gifId}/${ 285 - dimensions ? '200.mp4' : '200.webp' 286 - }`, 287 - dimensions, 274 + playerUri: `https://i.giphy.com/media/${gifId}/200.webp`, 288 275 } 289 276 } 290 277 } ··· 350 337 } 351 338 } 352 339 353 - if (urlp.hostname === 'tenor.com' || urlp.hostname === 'www.tenor.com') { 354 - const [_, pathOrIntl, pathOrFilename, intlFilename] = 355 - urlp.pathname.split('/') 356 - const isIntl = pathOrFilename === 'view' 357 - const filename = isIntl ? intlFilename : pathOrFilename 340 + if (urlp.hostname === 'media.tenor.com') { 341 + let [_, id, filename] = urlp.pathname.split('/') 342 + 343 + const h = urlp.searchParams.get('hh') 344 + const w = urlp.searchParams.get('ww') 345 + let dimensions 346 + if (h && w) { 347 + dimensions = { 348 + height: Number(h), 349 + width: Number(w), 350 + } 351 + } 358 352 359 - if ((pathOrIntl === 'view' || pathOrFilename === 'view') && filename) { 360 - const includesExt = filename.split('.').pop() === 'gif' 353 + if (id && filename && dimensions && id.includes('AAAAC')) { 354 + if (Platform.OS === 'web') { 355 + id = id.replace('AAAAC', 'AAAP3') 356 + filename = filename.replace('.gif', '.webm') 357 + } else { 358 + id = id.replace('AAAAC', 'AAAAM') 359 + } 361 360 362 361 return { 363 362 type: 'tenor_gif', 364 363 source: 'tenor', 365 364 isGif: true, 366 365 hideDetails: true, 367 - playerUri: `${url}${!includesExt ? '.gif' : ''}`, 366 + playerUri: `https://t.gifs.bsky.app/${id}/${filename}`, 367 + dimensions, 368 368 } 369 369 } 370 370 }
+8 -2
src/view/com/composer/Composer.tsx
··· 121 121 initQuote, 122 122 ) 123 123 const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) 124 + const [extGif, setExtGif] = useState<Gif>() 124 125 const [labels, setLabels] = useState<string[]>([]) 125 126 const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([]) 126 127 const gallery = useMemo( ··· 318 319 const onSelectGif = useCallback( 319 320 (gif: Gif) => { 320 321 setExtLink({ 321 - uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[0]}&ww=${gif.media_formats.gif.dims[1]}`, 322 + uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`, 322 323 isLoading: true, 323 324 meta: { 324 325 url: gif.media_formats.gif.url, ··· 328 329 description: `ALT: ${gif.content_description}`, 329 330 }, 330 331 }) 332 + setExtGif(gif) 331 333 }, 332 334 [setExtLink], 333 335 ) ··· 473 475 {gallery.isEmpty && extLink && ( 474 476 <ExternalEmbed 475 477 link={extLink} 476 - onRemove={() => setExtLink(undefined)} 478 + gif={extGif} 479 + onRemove={() => { 480 + setExtLink(undefined) 481 + setExtGif(undefined) 482 + }} 477 483 /> 478 484 )} 479 485 {quote ? (
+46 -24
src/view/com/composer/ExternalEmbed.tsx
··· 1 1 import React from 'react' 2 - import {TouchableOpacity, View} from 'react-native' 2 + import {StyleProp, TouchableOpacity, View, ViewStyle} from 'react-native' 3 3 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 4 import {msg} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 7 7 import {ExternalEmbedDraft} from 'lib/api/index' 8 8 import {s} from 'lib/styles' 9 + import {Gif} from 'state/queries/tenor' 9 10 import {ExternalLinkEmbed} from 'view/com/util/post-embeds/ExternalLinkEmbed' 10 11 import {atoms as a, useTheme} from '#/alf' 11 12 import {Loader} from '#/components/Loader' ··· 14 15 export const ExternalEmbed = ({ 15 16 link, 16 17 onRemove, 18 + gif, 17 19 }: { 18 20 link?: ExternalEmbedDraft 19 21 onRemove: () => void 22 + gif?: Gif 20 23 }) => { 21 24 const t = useTheme() 22 25 const {_} = useLingui() ··· 34 37 35 38 if (!link) return null 36 39 40 + const loadingStyle: ViewStyle | undefined = gif 41 + ? { 42 + aspectRatio: 43 + gif.media_formats.gif.dims[0] / gif.media_formats.gif.dims[1], 44 + width: '100%', 45 + } 46 + : undefined 47 + 37 48 return ( 38 - <View 39 - style={[ 40 - a.border, 41 - a.rounded_sm, 42 - a.mt_2xl, 43 - a.mb_xl, 44 - a.overflow_hidden, 45 - t.atoms.border_contrast_medium, 46 - ]}> 49 + <View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}> 47 50 {link.isLoading ? ( 48 - <View 49 - style={[ 50 - a.align_center, 51 - a.justify_center, 52 - a.py_5xl, 53 - t.atoms.bg_contrast_25, 54 - ]}> 51 + <Container style={loadingStyle}> 55 52 <Loader size="xl" /> 56 - </View> 53 + </Container> 57 54 ) : link.meta?.error ? ( 58 - <View 59 - style={[a.justify_center, a.p_md, a.gap_xs, t.atoms.bg_contrast_25]}> 55 + <Container style={[a.align_start, a.p_md, a.gap_xs]}> 60 56 <Text numberOfLines={1} style={t.atoms.text_contrast_high}> 61 57 {link.uri} 62 58 </Text> 63 59 <Text numberOfLines={2} style={[{color: t.palette.negative_400}]}> 64 - {link.meta.error} 60 + {link.meta?.error} 65 61 </Text> 66 - </View> 62 + </Container> 67 63 ) : linkInfo ? ( 68 - <View style={{pointerEvents: 'none'}}> 64 + <View style={{pointerEvents: !gif ? 'none' : 'auto'}}> 69 65 <ExternalLinkEmbed link={linkInfo} /> 70 66 </View> 71 67 ) : null} 72 68 <TouchableOpacity 73 69 style={{ 74 70 position: 'absolute', 75 - top: 10, 71 + top: 16, 76 72 right: 10, 77 73 height: 36, 78 74 width: 36, ··· 91 87 </View> 92 88 ) 93 89 } 90 + 91 + function Container({ 92 + style, 93 + children, 94 + }: { 95 + style?: StyleProp<ViewStyle> 96 + children: React.ReactNode 97 + }) { 98 + const t = useTheme() 99 + return ( 100 + <View 101 + style={[ 102 + a.mt_sm, 103 + a.rounded_sm, 104 + a.border, 105 + a.align_center, 106 + a.justify_center, 107 + a.py_5xl, 108 + t.atoms.bg_contrast_25, 109 + t.atoms.border_contrast_medium, 110 + style, 111 + ]}> 112 + {children} 113 + </View> 114 + ) 115 + }
+90 -60
src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet, View} from 'react-native' 1 + import React, {useCallback} from 'react' 2 + import {StyleProp, View, ViewStyle} from 'react-native' 3 3 import {Image} from 'expo-image' 4 4 import {AppBskyEmbedExternal} from '@atproto/api' 5 5 6 6 import {usePalette} from 'lib/hooks/usePalette' 7 7 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 8 - import {useGate} from 'lib/statsig/statsig' 8 + import {shareUrl} from 'lib/sharing' 9 9 import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player' 10 10 import {toNiceDomain} from 'lib/strings/url-helpers' 11 + import {isNative} from 'platform/detection' 11 12 import {useExternalEmbedsPrefs} from 'state/preferences' 13 + import {Link} from 'view/com/util/Link' 12 14 import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed' 13 15 import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed' 16 + import {GifEmbed} from 'view/com/util/post-embeds/GifEmbed' 17 + import {atoms as a, useTheme} from '#/alf' 14 18 import {Text} from '../text/Text' 15 19 16 20 export const ExternalLinkEmbed = ({ 17 21 link, 22 + style, 18 23 }: { 19 24 link: AppBskyEmbedExternal.ViewExternal 25 + style?: StyleProp<ViewStyle> 20 26 }) => { 21 27 const pal = usePalette('default') 22 28 const {isMobile} = useWebMediaQueries() 23 29 const externalEmbedPrefs = useExternalEmbedsPrefs() 24 - const gate = useGate() 25 30 26 31 const embedPlayerParams = React.useMemo(() => { 27 32 const params = parseEmbedPlayerFromUrl(link.uri) ··· 30 35 return params 31 36 } 32 37 }, [link.uri, externalEmbedPrefs]) 33 - const isCompatibleGiphy = 34 - embedPlayerParams?.source === 'giphy' && 35 - embedPlayerParams.dimensions && 36 - gate('new_gif_player') 38 + 39 + if (embedPlayerParams?.source === 'tenor') { 40 + return <GifEmbed params={embedPlayerParams} link={link} /> 41 + } 37 42 38 43 return ( 39 - <View style={styles.container}> 40 - {link.thumb && !embedPlayerParams ? ( 41 - <Image 42 - style={{aspectRatio: 1.91}} 43 - source={{uri: link.thumb}} 44 - accessibilityIgnoresInvertColors 45 - /> 46 - ) : undefined} 47 - {isCompatibleGiphy ? ( 48 - <View /> 49 - ) : embedPlayerParams?.isGif ? ( 50 - <ExternalGifEmbed link={link} params={embedPlayerParams} /> 51 - ) : embedPlayerParams ? ( 52 - <ExternalPlayer link={link} params={embedPlayerParams} /> 53 - ) : undefined} 54 - <View style={[styles.info, {paddingHorizontal: isMobile ? 10 : 14}]}> 55 - {!isCompatibleGiphy && ( 44 + <View style={[a.flex_col, a.rounded_sm, a.overflow_hidden, a.mt_sm]}> 45 + <LinkWrapper link={link} style={style}> 46 + {link.thumb && !embedPlayerParams ? ( 47 + <Image 48 + style={{ 49 + aspectRatio: 1.91, 50 + borderTopRightRadius: 6, 51 + borderTopLeftRadius: 6, 52 + }} 53 + source={{uri: link.thumb}} 54 + accessibilityIgnoresInvertColors 55 + /> 56 + ) : undefined} 57 + {embedPlayerParams?.isGif ? ( 58 + <ExternalGifEmbed link={link} params={embedPlayerParams} /> 59 + ) : embedPlayerParams ? ( 60 + <ExternalPlayer link={link} params={embedPlayerParams} /> 61 + ) : undefined} 62 + <View 63 + style={[ 64 + a.flex_1, 65 + a.py_sm, 66 + { 67 + paddingHorizontal: isMobile ? 10 : 14, 68 + }, 69 + ]}> 56 70 <Text 57 71 type="sm" 58 72 numberOfLines={1} 59 - style={[pal.textLight, styles.extUri]}> 73 + style={[pal.textLight, {marginVertical: 2}]}> 60 74 {toNiceDomain(link.uri)} 61 75 </Text> 62 - )} 63 76 64 - {!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && ( 65 - <Text type="lg-bold" numberOfLines={3} style={[pal.text]}> 66 - {link.title || link.uri} 67 - </Text> 68 - )} 69 - {link.description && !embedPlayerParams?.hideDetails ? ( 70 - <Text 71 - type="md" 72 - numberOfLines={link.thumb ? 2 : 4} 73 - style={[pal.text, styles.extDescription]}> 74 - {link.description} 75 - </Text> 76 - ) : undefined} 77 - </View> 77 + {!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && ( 78 + <Text type="lg-bold" numberOfLines={3} style={[pal.text]}> 79 + {link.title || link.uri} 80 + </Text> 81 + )} 82 + {link.description ? ( 83 + <Text 84 + type="md" 85 + numberOfLines={link.thumb ? 2 : 4} 86 + style={[pal.text, a.mt_xs]}> 87 + {link.description} 88 + </Text> 89 + ) : undefined} 90 + </View> 91 + </LinkWrapper> 78 92 </View> 79 93 ) 80 94 } 81 95 82 - const styles = StyleSheet.create({ 83 - container: { 84 - flexDirection: 'column', 85 - borderRadius: 6, 86 - overflow: 'hidden', 87 - }, 88 - info: { 89 - width: '100%', 90 - bottom: 0, 91 - paddingTop: 8, 92 - paddingBottom: 10, 93 - }, 94 - extUri: { 95 - marginTop: 2, 96 - }, 97 - extDescription: { 98 - marginTop: 4, 99 - }, 100 - }) 96 + function LinkWrapper({ 97 + link, 98 + style, 99 + children, 100 + }: { 101 + link: AppBskyEmbedExternal.ViewExternal 102 + style?: StyleProp<ViewStyle> 103 + children: React.ReactNode 104 + }) { 105 + const t = useTheme() 106 + 107 + const onShareExternal = useCallback(() => { 108 + if (link.uri && isNative) { 109 + shareUrl(link.uri) 110 + } 111 + }, [link.uri]) 112 + 113 + return ( 114 + <Link 115 + asAnchor 116 + anchorNoUnderline 117 + href={link.uri} 118 + style={[ 119 + a.flex_1, 120 + a.border, 121 + a.rounded_sm, 122 + t.atoms.border_contrast_medium, 123 + style, 124 + ]} 125 + hoverStyle={t.atoms.border_contrast_high} 126 + onLongPress={onShareExternal}> 127 + {children} 128 + </Link> 129 + ) 130 + }
+140
src/view/com/util/post-embeds/GifEmbed.tsx
··· 1 + import React from 'react' 2 + import {Pressable, View} from 'react-native' 3 + import {AppBskyEmbedExternal} from '@atproto/api' 4 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 + import {msg} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + 8 + import {EmbedPlayerParams} from 'lib/strings/embed-player' 9 + import {useAutoplayDisabled} from 'state/preferences' 10 + import {atoms as a, useTheme} from '#/alf' 11 + import {Loader} from '#/components/Loader' 12 + import {GifView} from '../../../../../modules/expo-bluesky-gif-view' 13 + import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types' 14 + 15 + function PlaybackControls({ 16 + onPress, 17 + isPlaying, 18 + isLoaded, 19 + }: { 20 + onPress: () => void 21 + isPlaying: boolean 22 + isLoaded: boolean 23 + }) { 24 + const {_} = useLingui() 25 + const t = useTheme() 26 + 27 + return ( 28 + <Pressable 29 + accessibilityRole="button" 30 + accessibilityHint={_(msg`Play or pause the GIF`)} 31 + accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)} 32 + style={[ 33 + a.absolute, 34 + a.align_center, 35 + a.justify_center, 36 + !isLoaded && a.border, 37 + t.atoms.border_contrast_medium, 38 + a.inset_0, 39 + a.w_full, 40 + a.h_full, 41 + { 42 + zIndex: 2, 43 + backgroundColor: !isLoaded 44 + ? t.atoms.bg_contrast_25.backgroundColor 45 + : !isPlaying 46 + ? 'rgba(0, 0, 0, 0.3)' 47 + : undefined, 48 + }, 49 + ]} 50 + onPress={onPress}> 51 + {!isLoaded ? ( 52 + <View> 53 + <View style={[a.align_center, a.justify_center]}> 54 + <Loader size="xl" /> 55 + </View> 56 + </View> 57 + ) : !isPlaying ? ( 58 + <View 59 + style={[ 60 + a.rounded_full, 61 + a.align_center, 62 + a.justify_center, 63 + { 64 + backgroundColor: t.palette.primary_500, 65 + width: 60, 66 + height: 60, 67 + }, 68 + ]}> 69 + <FontAwesomeIcon 70 + icon="play" 71 + size={42} 72 + color="white" 73 + style={{marginLeft: 8}} 74 + /> 75 + </View> 76 + ) : undefined} 77 + </Pressable> 78 + ) 79 + } 80 + 81 + export function GifEmbed({ 82 + params, 83 + link, 84 + }: { 85 + params: EmbedPlayerParams 86 + link: AppBskyEmbedExternal.ViewExternal 87 + }) { 88 + const {_} = useLingui() 89 + const autoplayDisabled = useAutoplayDisabled() 90 + 91 + const playerRef = React.useRef<GifView>(null) 92 + 93 + const [playerState, setPlayerState] = React.useState<{ 94 + isPlaying: boolean 95 + isLoaded: boolean 96 + }>({ 97 + isPlaying: !autoplayDisabled, 98 + isLoaded: false, 99 + }) 100 + 101 + const onPlayerStateChange = React.useCallback( 102 + (e: GifViewStateChangeEvent) => { 103 + setPlayerState(e.nativeEvent) 104 + }, 105 + [], 106 + ) 107 + 108 + const onPress = React.useCallback(() => { 109 + playerRef.current?.toggleAsync() 110 + }, []) 111 + 112 + return ( 113 + <View style={[a.rounded_sm, a.overflow_hidden, a.mt_sm]}> 114 + <View 115 + style={[ 116 + a.rounded_sm, 117 + a.overflow_hidden, 118 + { 119 + aspectRatio: params.dimensions!.width / params.dimensions!.height, 120 + }, 121 + ]}> 122 + <PlaybackControls 123 + onPress={onPress} 124 + isPlaying={playerState.isPlaying} 125 + isLoaded={playerState.isLoaded} 126 + /> 127 + <GifView 128 + source={params.playerUri} 129 + placeholderSource={link.thumb} 130 + style={[a.flex_1, a.rounded_sm]} 131 + autoplay={!autoplayDisabled} 132 + onPlayerStateChange={onPlayerStateChange} 133 + ref={playerRef} 134 + accessibilityHint={_(msg`Animated GIF`)} 135 + accessibilityLabel={link.description.replace('ALT: ', '')} 136 + /> 137 + </View> 138 + </View> 139 + ) 140 + }
+13 -39
src/view/com/util/post-embeds/index.tsx
··· 1 - import React, {useCallback} from 'react' 1 + import React from 'react' 2 2 import { 3 + InteractionManager, 4 + StyleProp, 3 5 StyleSheet, 4 - StyleProp, 6 + Text, 5 7 View, 6 8 ViewStyle, 7 - Text, 8 - InteractionManager, 9 9 } from 'react-native' 10 10 import {Image} from 'expo-image' 11 11 import { 12 - AppBskyEmbedImages, 13 12 AppBskyEmbedExternal, 13 + AppBskyEmbedImages, 14 14 AppBskyEmbedRecord, 15 15 AppBskyEmbedRecordWithMedia, 16 16 AppBskyFeedDefs, 17 17 AppBskyGraphDefs, 18 18 ModerationDecision, 19 19 } from '@atproto/api' 20 - import {Link} from '../Link' 21 - import {ImageLayoutGrid} from '../images/ImageLayoutGrid' 22 - import {useLightboxControls, ImagesLightbox} from '#/state/lightbox' 20 + 21 + import {ImagesLightbox, useLightboxControls} from '#/state/lightbox' 23 22 import {usePalette} from 'lib/hooks/usePalette' 24 - import {ExternalLinkEmbed} from './ExternalLinkEmbed' 25 - import {MaybeQuoteEmbed} from './QuoteEmbed' 26 - import {AutoSizedImage} from '../images/AutoSizedImage' 27 - import {ListEmbed} from './ListEmbed' 28 23 import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' 29 24 import {ContentHider} from '../../../../components/moderation/ContentHider' 30 - import {isNative} from '#/platform/detection' 31 - import {shareUrl} from '#/lib/sharing' 25 + import {AutoSizedImage} from '../images/AutoSizedImage' 26 + import {ImageLayoutGrid} from '../images/ImageLayoutGrid' 27 + import {ExternalLinkEmbed} from './ExternalLinkEmbed' 28 + import {ListEmbed} from './ListEmbed' 29 + import {MaybeQuoteEmbed} from './QuoteEmbed' 32 30 33 31 type Embed = 34 32 | AppBskyEmbedRecord.View ··· 48 46 }) { 49 47 const pal = usePalette('default') 50 48 const {openLightbox} = useLightboxControls() 51 - 52 - const externalUri = AppBskyEmbedExternal.isView(embed) 53 - ? embed.external.uri 54 - : null 55 - 56 - const onShareExternal = useCallback(() => { 57 - if (externalUri && isNative) { 58 - shareUrl(externalUri) 59 - } 60 - }, [externalUri]) 61 49 62 50 // quote post with media 63 51 // = ··· 161 149 // = 162 150 if (AppBskyEmbedExternal.isView(embed)) { 163 151 const link = embed.external 164 - 165 152 return ( 166 153 <ContentHider modui={moderation?.ui('contentMedia')}> 167 - <Link 168 - asAnchor 169 - anchorNoUnderline 170 - href={link.uri} 171 - style={[styles.extOuter, pal.view, pal.borderDark, style]} 172 - hoverStyle={{borderColor: pal.colors.borderLinkHover}} 173 - onLongPress={onShareExternal}> 174 - <ExternalLinkEmbed link={link} /> 175 - </Link> 154 + <ExternalLinkEmbed link={link} style={style} /> 176 155 </ContentHider> 177 156 ) 178 157 } ··· 186 165 }, 187 166 singleImage: { 188 167 borderRadius: 8, 189 - }, 190 - extOuter: { 191 - borderWidth: 1, 192 - borderRadius: 8, 193 - marginTop: 4, 194 168 }, 195 169 altContainer: { 196 170 backgroundColor: 'rgba(0, 0, 0, 0.75)',