Merge remote-tracking branch 'upstream/main' into Improve-notification-localization
This commit is contained in:
@@ -253,6 +253,11 @@
|
|||||||
from { opacity: 1; }
|
from { opacity: 1; }
|
||||||
to { opacity: 0; }
|
to { opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.force-no-clicks > *,
|
||||||
|
.force-no-clicks * {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</style>
|
</style>
|
||||||
{% include "scripts.html" %}
|
{% include "scripts.html" %}
|
||||||
|
|||||||
+2
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "1.90.0",
|
"version": "1.91.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.13.3",
|
"@atproto/api": "0.13.5",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
@@ -199,7 +199,6 @@
|
|||||||
"react-responsive": "^9.0.2",
|
"react-responsive": "^9.0.2",
|
||||||
"react-textarea-autosize": "^8.5.3",
|
"react-textarea-autosize": "^8.5.3",
|
||||||
"rn-fetch-blob": "^0.12.0",
|
"rn-fetch-blob": "^0.12.0",
|
||||||
"rn-tourguide": "bluesky-social/rn-tourguide",
|
|
||||||
"sentry-expo": "~7.0.1",
|
"sentry-expo": "~7.0.1",
|
||||||
"statsig-react-native-expo": "^4.6.1",
|
"statsig-react-native-expo": "^4.6.1",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
|
|||||||
@@ -1,3 +1,27 @@
|
|||||||
|
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
|
||||||
|
index 473f964..f37aff9 100644
|
||||||
|
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
|
||||||
|
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
|
||||||
|
@@ -41,6 +41,11 @@ sealed class PlayerEvent {
|
||||||
|
override val name = "playToEnd"
|
||||||
|
}
|
||||||
|
|
||||||
|
+ data class PlayerTimeRemainingChanged(val timeRemaining: Double): PlayerEvent() {
|
||||||
|
+ override val name = "timeRemainingChange"
|
||||||
|
+ override val arguments = arrayOf(timeRemaining)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
fun emit(player: VideoPlayer, listeners: List<VideoPlayerListener>) {
|
||||||
|
when (this) {
|
||||||
|
is StatusChanged -> listeners.forEach { it.onStatusChanged(player, status, oldStatus, error) }
|
||||||
|
@@ -49,6 +54,7 @@ sealed class PlayerEvent {
|
||||||
|
is SourceChanged -> listeners.forEach { it.onSourceChanged(player, source, oldSource) }
|
||||||
|
is PlaybackRateChanged -> listeners.forEach { it.onPlaybackRateChanged(player, rate, oldRate) }
|
||||||
|
is PlayedToEnd -> listeners.forEach { it.onPlayedToEnd(player) }
|
||||||
|
+ is PlayerTimeRemainingChanged -> listeners.forEach { it.onPlayerTimeRemainingChanged(player, timeRemaining) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
||||||
index 9905e13..47342ff 100644
|
index 9905e13..47342ff 100644
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
||||||
@@ -20,6 +44,42 @@ index 9905e13..47342ff 100644
|
|||||||
fullscreenButton?.visibility = if (visible) {
|
fullscreenButton?.visibility = if (visible) {
|
||||||
android.view.View.VISIBLE
|
android.view.View.VISIBLE
|
||||||
} else {
|
} else {
|
||||||
|
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..0249e23
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt
|
||||||
|
@@ -0,0 +1,29 @@
|
||||||
|
+import android.os.Handler
|
||||||
|
+import android.os.Looper
|
||||||
|
+import androidx.annotation.OptIn
|
||||||
|
+import androidx.media3.common.util.UnstableApi
|
||||||
|
+import expo.modules.video.PlayerEvent
|
||||||
|
+import expo.modules.video.VideoPlayer
|
||||||
|
+import kotlin.math.floor
|
||||||
|
+
|
||||||
|
+@OptIn(UnstableApi::class)
|
||||||
|
+class ProgressTracker(private val videoPlayer: VideoPlayer) : Runnable {
|
||||||
|
+ private val handler: Handler = Handler(Looper.getMainLooper())
|
||||||
|
+ private val player = videoPlayer.player
|
||||||
|
+
|
||||||
|
+ init {
|
||||||
|
+ handler.post(this)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ override fun run() {
|
||||||
|
+ val currentPosition = player.currentPosition
|
||||||
|
+ val duration = player.duration
|
||||||
|
+ val timeRemaining = floor(((duration - currentPosition) / 1000).toDouble())
|
||||||
|
+ videoPlayer.sendEvent(PlayerEvent.PlayerTimeRemainingChanged(timeRemaining))
|
||||||
|
+ handler.postDelayed(this, 1000 /* ms */)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ fun remove() {
|
||||||
|
+ handler.removeCallbacks(this)
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
\ No newline at end of file
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
||||||
index ec3da2a..5a1397a 100644
|
index ec3da2a..5a1397a 100644
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
||||||
@@ -35,6 +95,74 @@ index ec3da2a..5a1397a 100644
|
|||||||
)
|
)
|
||||||
|
|
||||||
Prop("player") { view: VideoView, player: VideoPlayer ->
|
Prop("player") { view: VideoView, player: VideoPlayer ->
|
||||||
|
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
|
||||||
|
index 58f00af..5ad8237 100644
|
||||||
|
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
|
||||||
|
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
|
||||||
|
@@ -1,5 +1,6 @@
|
||||||
|
package expo.modules.video
|
||||||
|
|
||||||
|
+import ProgressTracker
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.SurfaceView
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
@@ -35,11 +36,13 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
||||||
|
.Builder(context, renderersFactory)
|
||||||
|
.setLooper(context.mainLooper)
|
||||||
|
.build()
|
||||||
|
+ var progressTracker: ProgressTracker? = null
|
||||||
|
|
||||||
|
val serviceConnection = PlaybackServiceConnection(WeakReference(player))
|
||||||
|
|
||||||
|
var playing by IgnoreSameSet(false) { new, old ->
|
||||||
|
sendEvent(PlayerEvent.IsPlayingChanged(new, old))
|
||||||
|
+ addOrRemoveProgressTracker()
|
||||||
|
}
|
||||||
|
|
||||||
|
var uncommittedSource: VideoSource? = source
|
||||||
|
@@ -141,6 +144,9 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
+ this.progressTracker?.remove()
|
||||||
|
+ this.progressTracker = null
|
||||||
|
+
|
||||||
|
appContext?.reactContext?.unbindService(serviceConnection)
|
||||||
|
serviceConnection.playbackServiceBinder?.service?.unregisterPlayer(player)
|
||||||
|
VideoManager.unregisterVideoPlayer(this@VideoPlayer)
|
||||||
|
@@ -228,7 +234,7 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
||||||
|
listeners.removeAll { it.get() == videoPlayerListener }
|
||||||
|
}
|
||||||
|
|
||||||
|
- private fun sendEvent(event: PlayerEvent) {
|
||||||
|
+ fun sendEvent(event: PlayerEvent) {
|
||||||
|
// Emits to the native listeners
|
||||||
|
event.emit(this, listeners.mapNotNull { it.get() })
|
||||||
|
// Emits to the JS side
|
||||||
|
@@ -240,4 +246,13 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
||||||
|
sendEvent(eventName, *args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
+
|
||||||
|
+ private fun addOrRemoveProgressTracker() {
|
||||||
|
+ this.progressTracker?.remove()
|
||||||
|
+ if (this.playing) {
|
||||||
|
+ this.progressTracker = ProgressTracker(this)
|
||||||
|
+ } else {
|
||||||
|
+ this.progressTracker = null
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt
|
||||||
|
index f654254..dcfe3f0 100644
|
||||||
|
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt
|
||||||
|
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt
|
||||||
|
@@ -15,4 +15,5 @@ interface VideoPlayerListener {
|
||||||
|
fun onSourceChanged(player: VideoPlayer, source: VideoSource?, oldSource: VideoSource?) {}
|
||||||
|
fun onPlaybackRateChanged(player: VideoPlayer, rate: Float, oldRate: Float?) {}
|
||||||
|
fun onPlayedToEnd(player: VideoPlayer) {}
|
||||||
|
+ fun onPlayerTimeRemainingChanged(player: VideoPlayer, timeRemaining: Double) {}
|
||||||
|
}
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
||||||
index a951d80..3932535 100644
|
index a951d80..3932535 100644
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
||||||
@@ -64,8 +192,21 @@ index a951d80..3932535 100644
|
|||||||
isInFullscreen = false
|
isInFullscreen = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diff --git a/node_modules/expo-video/build/VideoPlayer.types.d.ts b/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
||||||
|
index a09fcfe..65fe29a 100644
|
||||||
|
--- a/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
||||||
|
+++ b/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
||||||
|
@@ -128,6 +128,8 @@ export type VideoPlayerEvents = {
|
||||||
|
* Handler for an event emitted when the current media source of the player changes.
|
||||||
|
*/
|
||||||
|
sourceChange(newSource: VideoSource, previousSource: VideoSource): void;
|
||||||
|
+
|
||||||
|
+ timeRemainingChange(timeRemaining: number): void;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Describes the current status of the player.
|
||||||
diff --git a/node_modules/expo-video/build/VideoView.types.d.ts b/node_modules/expo-video/build/VideoView.types.d.ts
|
diff --git a/node_modules/expo-video/build/VideoView.types.d.ts b/node_modules/expo-video/build/VideoView.types.d.ts
|
||||||
index cb9ca6d..60e9f4e 100644
|
index cb9ca6d..ed8bb7e 100644
|
||||||
--- a/node_modules/expo-video/build/VideoView.types.d.ts
|
--- a/node_modules/expo-video/build/VideoView.types.d.ts
|
||||||
+++ b/node_modules/expo-video/build/VideoView.types.d.ts
|
+++ b/node_modules/expo-video/build/VideoView.types.d.ts
|
||||||
@@ -89,5 +89,8 @@ export interface VideoViewProps extends ViewProps {
|
@@ -89,5 +89,8 @@ export interface VideoViewProps extends ViewProps {
|
||||||
@@ -77,6 +218,7 @@ index cb9ca6d..60e9f4e 100644
|
|||||||
+ onExitFullscreen?: () => void;
|
+ onExitFullscreen?: () => void;
|
||||||
}
|
}
|
||||||
//# sourceMappingURL=VideoView.types.d.ts.map
|
//# sourceMappingURL=VideoView.types.d.ts.map
|
||||||
|
\ No newline at end of file
|
||||||
diff --git a/node_modules/expo-video/ios/VideoModule.swift b/node_modules/expo-video/ios/VideoModule.swift
|
diff --git a/node_modules/expo-video/ios/VideoModule.swift b/node_modules/expo-video/ios/VideoModule.swift
|
||||||
index c537a12..e4a918f 100644
|
index c537a12..e4a918f 100644
|
||||||
--- a/node_modules/expo-video/ios/VideoModule.swift
|
--- a/node_modules/expo-video/ios/VideoModule.swift
|
||||||
@@ -92,6 +234,96 @@ index c537a12..e4a918f 100644
|
|||||||
)
|
)
|
||||||
|
|
||||||
Prop("player") { (view, player: VideoPlayer?) in
|
Prop("player") { (view, player: VideoPlayer?) in
|
||||||
|
diff --git a/node_modules/expo-video/ios/VideoPlayer.swift b/node_modules/expo-video/ios/VideoPlayer.swift
|
||||||
|
index 3315b88..f482390 100644
|
||||||
|
--- a/node_modules/expo-video/ios/VideoPlayer.swift
|
||||||
|
+++ b/node_modules/expo-video/ios/VideoPlayer.swift
|
||||||
|
@@ -185,6 +185,10 @@ internal final class VideoPlayer: SharedRef<AVPlayer>, Hashable, VideoPlayerObse
|
||||||
|
safeEmit(event: "sourceChange", arguments: newVideoPlayerItem?.videoSource, oldVideoPlayerItem?.videoSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {
|
||||||
|
+ safeEmit(event: "timeRemainingChange", arguments: timeRemaining)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
func safeEmit<each A: AnyArgument>(event: String, arguments: repeat each A) {
|
||||||
|
if self.appContext != nil {
|
||||||
|
self.emit(event: event, arguments: repeat each arguments)
|
||||||
|
diff --git a/node_modules/expo-video/ios/VideoPlayerObserver.swift b/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
||||||
|
index d289e26..de9a26f 100644
|
||||||
|
--- a/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
||||||
|
+++ b/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
||||||
|
@@ -21,6 +21,7 @@ protocol VideoPlayerObserverDelegate: AnyObject {
|
||||||
|
func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?)
|
||||||
|
func onIsMutedChanged(player: AVPlayer, oldIsMuted: Bool?, newIsMuted: Bool)
|
||||||
|
func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status)
|
||||||
|
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default implementations for the delegate
|
||||||
|
@@ -33,6 +34,7 @@ extension VideoPlayerObserverDelegate {
|
||||||
|
func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?) {}
|
||||||
|
func onIsMutedChanged(player: AVPlayer, oldIsMuted: Bool?, newIsMuted: Bool) {}
|
||||||
|
func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status) {}
|
||||||
|
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper used to store WeakReferences to the observer delegate
|
||||||
|
@@ -91,6 +93,7 @@ class VideoPlayerObserver {
|
||||||
|
private var playerVolumeObserver: NSKeyValueObservation?
|
||||||
|
private var playerCurrentItemObserver: NSKeyValueObservation?
|
||||||
|
private var playerIsMutedObserver: NSKeyValueObservation?
|
||||||
|
+ private var playerPeriodicTimeObserver: Any?
|
||||||
|
|
||||||
|
// Current player item observers
|
||||||
|
private var playbackBufferEmptyObserver: NSKeyValueObservation?
|
||||||
|
@@ -152,6 +155,9 @@ class VideoPlayerObserver {
|
||||||
|
playerVolumeObserver?.invalidate()
|
||||||
|
playerIsMutedObserver?.invalidate()
|
||||||
|
playerCurrentItemObserver?.invalidate()
|
||||||
|
+ if let playerPeriodicTimeObserver = self.playerPeriodicTimeObserver {
|
||||||
|
+ player?.removeTimeObserver(playerPeriodicTimeObserver)
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initializeCurrentPlayerItemObservers(player: AVPlayer, playerItem: AVPlayerItem) {
|
||||||
|
@@ -270,6 +276,7 @@ class VideoPlayerObserver {
|
||||||
|
|
||||||
|
if isPlaying != (player.timeControlStatus == .playing) {
|
||||||
|
isPlaying = player.timeControlStatus == .playing
|
||||||
|
+ addPeriodicTimeObserverIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -310,4 +317,28 @@ class VideoPlayerObserver {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
+
|
||||||
|
+ private func onPlayerTimeRemainingChanged(_ player: AVPlayer, _ timeRemaining: Double) {
|
||||||
|
+ delegates.forEach { delegate in
|
||||||
|
+ delegate.value?.onPlayerTimeRemainingChanged(player: player, timeRemaining: timeRemaining)
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ private func addPeriodicTimeObserverIfNeeded() {
|
||||||
|
+ guard self.playerPeriodicTimeObserver == nil, let player = self.player else {
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if isPlaying {
|
||||||
|
+ // Add the time update listener
|
||||||
|
+ playerPeriodicTimeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(1.0, preferredTimescale: Int32(NSEC_PER_SEC)), queue: nil) { event in
|
||||||
|
+ guard let duration = player.currentItem?.duration else {
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ let timeRemaining = (duration.seconds - event.seconds).rounded()
|
||||||
|
+ self.onPlayerTimeRemainingChanged(player, timeRemaining)
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
}
|
||||||
diff --git a/node_modules/expo-video/ios/VideoView.swift b/node_modules/expo-video/ios/VideoView.swift
|
diff --git a/node_modules/expo-video/ios/VideoView.swift b/node_modules/expo-video/ios/VideoView.swift
|
||||||
index f4579e4..10c5908 100644
|
index f4579e4..10c5908 100644
|
||||||
--- a/node_modules/expo-video/ios/VideoView.swift
|
--- a/node_modules/expo-video/ios/VideoView.swift
|
||||||
@@ -121,6 +353,19 @@ index f4579e4..10c5908 100644
|
|||||||
self.isFullscreen = false
|
self.isFullscreen = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
diff --git a/node_modules/expo-video/src/VideoPlayer.types.ts b/node_modules/expo-video/src/VideoPlayer.types.ts
|
||||||
|
index aaf4b63..f438196 100644
|
||||||
|
--- a/node_modules/expo-video/src/VideoPlayer.types.ts
|
||||||
|
+++ b/node_modules/expo-video/src/VideoPlayer.types.ts
|
||||||
|
@@ -151,6 +151,8 @@ export type VideoPlayerEvents = {
|
||||||
|
* Handler for an event emitted when the current media source of the player changes.
|
||||||
|
*/
|
||||||
|
sourceChange(newSource: VideoSource, previousSource: VideoSource): void;
|
||||||
|
+
|
||||||
|
+ timeRemainingChange(timeRemaining: number): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts
|
diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts
|
||||||
index 29fe5db..e1fbf59 100644
|
index 29fe5db..e1fbf59 100644
|
||||||
--- a/node_modules/expo-video/src/VideoView.types.ts
|
--- a/node_modules/expo-video/src/VideoView.types.ts
|
||||||
|
|||||||
+8
-11
@@ -52,7 +52,7 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
|||||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||||
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
||||||
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
||||||
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
|
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoNativeContext'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {Shell} from '#/view/shell'
|
import {Shell} from '#/view/shell'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
@@ -60,7 +60,6 @@ import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
|||||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
import {Splash} from '#/Splash'
|
import {Splash} from '#/Splash'
|
||||||
import {Provider as TourProvider} from '#/tours'
|
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
|
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
|
||||||
|
|
||||||
@@ -127,15 +126,13 @@ function InnerApp() {
|
|||||||
<UnreadNotifsProvider>
|
<UnreadNotifsProvider>
|
||||||
<BackgroundNotificationPreferencesProvider>
|
<BackgroundNotificationPreferencesProvider>
|
||||||
<MutedThreadsProvider>
|
<MutedThreadsProvider>
|
||||||
<TourProvider>
|
<ProgressGuideProvider>
|
||||||
<ProgressGuideProvider>
|
<GestureHandlerRootView
|
||||||
<GestureHandlerRootView
|
style={s.h100pct}>
|
||||||
style={s.h100pct}>
|
<TestCtrls />
|
||||||
<TestCtrls />
|
<Shell />
|
||||||
<Shell />
|
</GestureHandlerRootView>
|
||||||
</GestureHandlerRootView>
|
</ProgressGuideProvider>
|
||||||
</ProgressGuideProvider>
|
|
||||||
</TourProvider>
|
|
||||||
</MutedThreadsProvider>
|
</MutedThreadsProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
</UnreadNotifsProvider>
|
</UnreadNotifsProvider>
|
||||||
|
|||||||
+4
-7
@@ -40,7 +40,7 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
|||||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||||
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
||||||
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
|
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {ToastContainer} from '#/view/com/util/Toast.web'
|
import {ToastContainer} from '#/view/com/util/Toast.web'
|
||||||
import {Shell} from '#/view/shell/index'
|
import {Shell} from '#/view/shell/index'
|
||||||
@@ -48,7 +48,6 @@ import {ThemeProvider as Alf} from '#/alf'
|
|||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
import {Provider as TourProvider} from '#/tours'
|
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
|
|
||||||
function InnerApp() {
|
function InnerApp() {
|
||||||
@@ -111,11 +110,9 @@ function InnerApp() {
|
|||||||
<BackgroundNotificationPreferencesProvider>
|
<BackgroundNotificationPreferencesProvider>
|
||||||
<MutedThreadsProvider>
|
<MutedThreadsProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<TourProvider>
|
<ProgressGuideProvider>
|
||||||
<ProgressGuideProvider>
|
<Shell />
|
||||||
<Shell />
|
</ProgressGuideProvider>
|
||||||
</ProgressGuideProvider>
|
|
||||||
</TourProvider>
|
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</MutedThreadsProvider>
|
</MutedThreadsProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ let ListMaybePlaceholder = ({
|
|||||||
return (
|
return (
|
||||||
<CenteredView
|
<CenteredView
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.h_full_vh,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
!gtMobile ? a.justify_between : a.gap_5xl,
|
!gtMobile ? a.justify_between : a.gap_5xl,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ export function useInteractionState() {
|
|||||||
|
|
||||||
const onIn = React.useCallback(() => {
|
const onIn = React.useCallback(() => {
|
||||||
setState(true)
|
setState(true)
|
||||||
}, [setState])
|
}, [])
|
||||||
const onOut = React.useCallback(() => {
|
const onOut = React.useCallback(() => {
|
||||||
setState(false)
|
setState(false)
|
||||||
}, [setState])
|
}, [])
|
||||||
|
|
||||||
return React.useMemo(
|
return React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -392,27 +392,20 @@ export class FeedTuner {
|
|||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
_dryRun: boolean,
|
_dryRun: boolean,
|
||||||
): FeedViewPostsSlice[] => {
|
): FeedViewPostsSlice[] => {
|
||||||
const candidateSlices = slices.slice()
|
|
||||||
|
|
||||||
// early return if no languages have been specified
|
// early return if no languages have been specified
|
||||||
if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) {
|
if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) {
|
||||||
return slices
|
return slices
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < slices.length; i++) {
|
const candidateSlices = slices.filter(slice => {
|
||||||
let hasPreferredLang = false
|
for (const item of slice.items) {
|
||||||
for (const item of slices[i].items) {
|
|
||||||
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
||||||
hasPreferredLang = true
|
return true
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if item does not fit preferred language, remove it
|
// if item does not fit preferred language, remove it
|
||||||
if (!hasPreferredLang) {
|
return false
|
||||||
candidateSlices.splice(i, 1)
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the language filter cleared out the entire page, return the original set
|
// if the language filter cleared out the entire page, return the original set
|
||||||
// so that something always shows
|
// so that something always shows
|
||||||
|
|||||||
+27
-11
@@ -3,12 +3,14 @@ import {
|
|||||||
AppBskyEmbedImages,
|
AppBskyEmbedImages,
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
|
AppBskyEmbedVideo,
|
||||||
AppBskyFeedPostgate,
|
AppBskyFeedPostgate,
|
||||||
|
AtUri,
|
||||||
|
BlobRef,
|
||||||
BskyAgent,
|
BskyAgent,
|
||||||
ComAtprotoLabelDefs,
|
ComAtprotoLabelDefs,
|
||||||
RichText,
|
RichText,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {AtUri} from '@atproto/api'
|
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {writePostgateRecord} from '#/state/queries/postgate'
|
import {writePostgateRecord} from '#/state/queries/postgate'
|
||||||
@@ -43,10 +45,7 @@ interface PostOpts {
|
|||||||
uri: string
|
uri: string
|
||||||
cid: string
|
cid: string
|
||||||
}
|
}
|
||||||
video?: {
|
video?: BlobRef
|
||||||
uri: string
|
|
||||||
cid: string
|
|
||||||
}
|
|
||||||
extLink?: ExternalEmbedDraft
|
extLink?: ExternalEmbedDraft
|
||||||
images?: ImageModel[]
|
images?: ImageModel[]
|
||||||
labels?: string[]
|
labels?: string[]
|
||||||
@@ -61,18 +60,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
|||||||
| AppBskyEmbedImages.Main
|
| AppBskyEmbedImages.Main
|
||||||
| AppBskyEmbedExternal.Main
|
| AppBskyEmbedExternal.Main
|
||||||
| AppBskyEmbedRecord.Main
|
| AppBskyEmbedRecord.Main
|
||||||
|
| AppBskyEmbedVideo.Main
|
||||||
| AppBskyEmbedRecordWithMedia.Main
|
| AppBskyEmbedRecordWithMedia.Main
|
||||||
| undefined
|
| undefined
|
||||||
let reply
|
let reply
|
||||||
let rt = new RichText(
|
let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true})
|
||||||
{text: opts.rawText.trimEnd()},
|
|
||||||
{
|
|
||||||
cleanNewlines: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
opts.onStateChange?.('Processing...')
|
opts.onStateChange?.('Processing...')
|
||||||
|
|
||||||
await rt.detectFacets(agent)
|
await rt.detectFacets(agent)
|
||||||
|
|
||||||
rt = shortenLinks(rt)
|
rt = shortenLinks(rt)
|
||||||
rt = stripInvalidMentions(rt)
|
rt = stripInvalidMentions(rt)
|
||||||
|
|
||||||
@@ -129,6 +126,25 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// add video embed if present
|
||||||
|
if (opts.video) {
|
||||||
|
if (opts.quote) {
|
||||||
|
embed = {
|
||||||
|
$type: 'app.bsky.embed.recordWithMedia',
|
||||||
|
record: embed,
|
||||||
|
media: {
|
||||||
|
$type: 'app.bsky.embed.video',
|
||||||
|
video: opts.video,
|
||||||
|
} as AppBskyEmbedVideo.Main,
|
||||||
|
} as AppBskyEmbedRecordWithMedia.Main
|
||||||
|
} else {
|
||||||
|
embed = {
|
||||||
|
$type: 'app.bsky.embed.video',
|
||||||
|
video: opts.video,
|
||||||
|
} as AppBskyEmbedVideo.Main
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// add external embed if present
|
// add external embed if present
|
||||||
if (opts.extLink && !opts.images?.length) {
|
if (opts.extLink && !opts.images?.length) {
|
||||||
if (opts.extLink.embed) {
|
if (opts.extLink.embed) {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function cancelable<A, T>(
|
||||||
|
f: (args: A) => Promise<T>,
|
||||||
|
signal: AbortSignal,
|
||||||
|
) {
|
||||||
|
return (args: A) => {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
reject(new AbortError())
|
||||||
|
})
|
||||||
|
f(args).then(resolve, reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AbortError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Aborted')
|
||||||
|
this.name = 'AbortError'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
import React from 'react'
|
import {useWindowDimensions} from 'react-native'
|
||||||
import {Dimensions} from 'react-native'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
|
|
||||||
|
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||||
|
|
||||||
const MIN_POST_HEIGHT = 100
|
const MIN_POST_HEIGHT = 100
|
||||||
|
|
||||||
export function useInitialNumToRender(minItemHeight: number = MIN_POST_HEIGHT) {
|
export function useInitialNumToRender({
|
||||||
return React.useMemo(() => {
|
minItemHeight = MIN_POST_HEIGHT,
|
||||||
const screenHeight = Dimensions.get('window').height
|
screenHeightOffset = 0,
|
||||||
return Math.ceil(screenHeight / minItemHeight) + 1
|
}: {minItemHeight?: number; screenHeightOffset?: number} = {}) {
|
||||||
}, [minItemHeight])
|
const {height: screenHeight} = useWindowDimensions()
|
||||||
|
const {top: topInset} = useSafeAreaInsets()
|
||||||
|
const bottomBarHeight = useBottomBarOffset()
|
||||||
|
|
||||||
|
const finalHeight =
|
||||||
|
screenHeight - screenHeightOffset - topInset - bottomBarHeight
|
||||||
|
return Math.floor(finalHeight / minItemHeight) + 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,19 +8,25 @@ export type CompressedVideo = {
|
|||||||
export async function compressVideo(
|
export async function compressVideo(
|
||||||
file: string,
|
file: string,
|
||||||
opts?: {
|
opts?: {
|
||||||
getCancellationId?: (id: string) => void
|
signal?: AbortSignal
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
},
|
},
|
||||||
): Promise<CompressedVideo> {
|
): Promise<CompressedVideo> {
|
||||||
const {onProgress, getCancellationId} = opts || {}
|
const {onProgress, signal} = opts || {}
|
||||||
|
|
||||||
const compressed = await Video.compress(
|
const compressed = await Video.compress(
|
||||||
file,
|
file,
|
||||||
{
|
{
|
||||||
getCancellationId,
|
|
||||||
compressionMethod: 'manual',
|
compressionMethod: 'manual',
|
||||||
bitrate: 3_000_000, // 3mbps
|
bitrate: 3_000_000, // 3mbps
|
||||||
maxSize: 1920,
|
maxSize: 1920,
|
||||||
|
getCancellationId: id => {
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
Video.cancelCompression(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
onProgress,
|
onProgress,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ export type CompressedVideo = {
|
|||||||
// doesn't actually compress, but throws if >100MB
|
// doesn't actually compress, but throws if >100MB
|
||||||
export async function compressVideo(
|
export async function compressVideo(
|
||||||
file: string,
|
file: string,
|
||||||
_callbacks?: {
|
_opts?: {
|
||||||
onProgress: (progress: number) => void
|
signal?: AbortSignal
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
},
|
},
|
||||||
): Promise<CompressedVideo> {
|
): Promise<CompressedVideo> {
|
||||||
const blob = await fetch(file).then(res => res.blob())
|
const blob = await fetch(file).then(res => res.blob())
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* TEMPORARY: THIS IS A TEMPORARY PLACEHOLDER. THAT MEANS IT IS TEMPORARY. I.E. WILL BE REMOVED. NOT TO USE IN PRODUCTION.
|
|
||||||
* @temporary
|
|
||||||
* PS: This is a temporary placeholder for the video types. It will be removed once the actual types are implemented.
|
|
||||||
* Not joking, this is temporary.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface JobStatus {
|
|
||||||
jobId: string
|
|
||||||
did: string
|
|
||||||
cid: string
|
|
||||||
state: JobState
|
|
||||||
progress?: number
|
|
||||||
errorHuman?: string
|
|
||||||
errorMachine?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum JobState {
|
|
||||||
JOB_STATE_UNSPECIFIED = 'JOB_STATE_UNSPECIFIED',
|
|
||||||
JOB_STATE_CREATED = 'JOB_STATE_CREATED',
|
|
||||||
JOB_STATE_ENCODING = 'JOB_STATE_ENCODING',
|
|
||||||
JOB_STATE_ENCODED = 'JOB_STATE_ENCODED',
|
|
||||||
JOB_STATE_UPLOADING = 'JOB_STATE_UPLOADING',
|
|
||||||
JOB_STATE_UPLOADED = 'JOB_STATE_UPLOADED',
|
|
||||||
JOB_STATE_CDN_PROCESSING = 'JOB_STATE_CDN_PROCESSING',
|
|
||||||
JOB_STATE_CDN_PROCESSED = 'JOB_STATE_CDN_PROCESSED',
|
|
||||||
JOB_STATE_FAILED = 'JOB_STATE_FAILED',
|
|
||||||
JOB_STATE_COMPLETED = 'JOB_STATE_COMPLETED',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UploadVideoResponse {
|
|
||||||
job_id: string
|
|
||||||
did: string
|
|
||||||
cid: string
|
|
||||||
state: JobState
|
|
||||||
}
|
|
||||||
@@ -216,12 +216,6 @@ export type LogEvents = {
|
|||||||
|
|
||||||
'profile:header:suggestedFollowsCard:press': {}
|
'profile:header:suggestedFollowsCard:press': {}
|
||||||
|
|
||||||
'debug:followingPrefs': {
|
|
||||||
followingShowRepliesFromPref: 'all' | 'following' | 'off'
|
|
||||||
followingRepliesMinLikePref: number
|
|
||||||
}
|
|
||||||
'debug:followingDisplayed': {}
|
|
||||||
|
|
||||||
'test:all:always': {}
|
'test:all:always': {}
|
||||||
'test:all:sometimes': {}
|
'test:all:sometimes': {}
|
||||||
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
|
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ export type Gate =
|
|||||||
// Keep this alphabetic please.
|
// Keep this alphabetic please.
|
||||||
| 'debug_show_feedcontext'
|
| 'debug_show_feedcontext'
|
||||||
| 'fixed_bottom_bar'
|
| 'fixed_bottom_bar'
|
||||||
| 'new_user_guided_tour'
|
|
||||||
| 'onboarding_minimum_interests'
|
| 'onboarding_minimum_interests'
|
||||||
| 'show_follow_back_label_v2'
|
|
||||||
| 'suggested_feeds_interstitial'
|
| 'suggested_feeds_interstitial'
|
||||||
| 'video_debug'
|
| 'video_debug'
|
||||||
| 'videos'
|
| 'videos'
|
||||||
|
|||||||
@@ -339,3 +339,21 @@ export function shortLinkToHref(url: string): string {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getHostnameFromUrl(url: string): string | null {
|
||||||
|
let urlp
|
||||||
|
try {
|
||||||
|
urlp = new URL(url)
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return urlp.hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServiceAuthAudFromUrl(url: string): string | null {
|
||||||
|
const hostname = getHostnameFromUrl(url)
|
||||||
|
if (!hostname) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return `did:web:${hostname}`
|
||||||
|
}
|
||||||
|
|||||||
+94
-94
@@ -68,7 +68,7 @@ export const LANGUAGES: Language[] = [
|
|||||||
{code3: 'alt', code2: '', name: 'Southern Altai'},
|
{code3: 'alt', code2: '', name: 'Southern Altai'},
|
||||||
{code3: 'amh', code2: 'am', name: 'Amharic'},
|
{code3: 'amh', code2: 'am', name: 'Amharic'},
|
||||||
{code3: 'ang', code2: '', name: 'English, Old (ca.450-1100)'},
|
{code3: 'ang', code2: '', name: 'English, Old (ca.450-1100)'},
|
||||||
{code3: 'anp ', code2: 'Angika', name: 'Angika'},
|
{code3: 'anp', code2: '', name: 'Angika'},
|
||||||
{code3: 'apa', code2: '', name: 'Apache languages'},
|
{code3: 'apa', code2: '', name: 'Apache languages'},
|
||||||
{code3: 'ara', code2: 'ar', name: 'Arabic'},
|
{code3: 'ara', code2: 'ar', name: 'Arabic'},
|
||||||
{
|
{
|
||||||
@@ -233,7 +233,7 @@ export const LANGUAGES: Language[] = [
|
|||||||
{code3: 'gre', code2: 'el', name: 'Greek, Modern (1453-)'},
|
{code3: 'gre', code2: 'el', name: 'Greek, Modern (1453-)'},
|
||||||
{code3: 'grn', code2: 'gn', name: 'Guarani'},
|
{code3: 'grn', code2: 'gn', name: 'Guarani'},
|
||||||
{code3: 'gsw', code2: '', name: 'Swiss German; Alemannic; Alsatian'},
|
{code3: 'gsw', code2: '', name: 'Swiss German; Alemannic; Alsatian'},
|
||||||
{code3: 'gujgu', code2: 'Gujarati', name: 'goudjrati'},
|
{code3: 'guj', code2: 'gu', name: 'Gujarati'},
|
||||||
{code3: 'gwi', code2: '', name: "Gwich'in"},
|
{code3: 'gwi', code2: '', name: "Gwich'in"},
|
||||||
{code3: 'hai', code2: '', name: 'Haida'},
|
{code3: 'hai', code2: '', name: 'Haida'},
|
||||||
{code3: 'hat', code2: 'ht', name: 'Haitian; Haitian Creole'},
|
{code3: 'hat', code2: 'ht', name: 'Haitian; Haitian Creole'},
|
||||||
@@ -339,8 +339,8 @@ export const LANGUAGES: Language[] = [
|
|||||||
{code3: 'lun', code2: '', name: 'Lunda'},
|
{code3: 'lun', code2: '', name: 'Lunda'},
|
||||||
{
|
{
|
||||||
code3: 'luo',
|
code3: 'luo',
|
||||||
code2: ' Luo (Kenya and Tanzania)',
|
code2: '',
|
||||||
name: 'luo (Kenya et Tanzanie)',
|
name: 'Luo (Kenya and Tanzania)',
|
||||||
},
|
},
|
||||||
{code3: 'lus', code2: '', name: 'Lushai'},
|
{code3: 'lus', code2: '', name: 'Lushai'},
|
||||||
{code3: 'mac', code2: 'mk', name: 'Macedonian'},
|
{code3: 'mac', code2: 'mk', name: 'Macedonian'},
|
||||||
@@ -430,162 +430,162 @@ export const LANGUAGES: Language[] = [
|
|||||||
{code3: 'oto', code2: '', name: 'Otomian languages'},
|
{code3: 'oto', code2: '', name: 'Otomian languages'},
|
||||||
{code3: 'paa', code2: '', name: 'Papuan languages'},
|
{code3: 'paa', code2: '', name: 'Papuan languages'},
|
||||||
{code3: 'pag', code2: '', name: 'Pangasinan'},
|
{code3: 'pag', code2: '', name: 'Pangasinan'},
|
||||||
{code3: 'pal', code2: ' ', name: 'Pahlavi'},
|
{code3: 'pal', code2: '', name: 'Pahlavi'},
|
||||||
{code3: 'pam', code2: ' ', name: 'Pampanga; Kapampangan'},
|
{code3: 'pam', code2: '', name: 'Pampanga; Kapampangan'},
|
||||||
{code3: 'pan', code2: 'paPanjabi; Punjabi', name: 'pendjabi'},
|
{code3: 'pan', code2: 'pa', name: 'Panjabi; Punjabi'},
|
||||||
{code3: 'pap', code2: ' ', name: 'Papiamento'},
|
{code3: 'pap', code2: '', name: 'Papiamento'},
|
||||||
{code3: 'pau', code2: ' ', name: 'Palauan'},
|
{code3: 'pau', code2: '', name: 'Palauan'},
|
||||||
{code3: 'peo', code2: ' ', name: 'Persian, Old (ca.600-400 B.C.)'},
|
{code3: 'peo', code2: '', name: 'Persian, Old (ca.600-400 B.C.)'},
|
||||||
{code3: 'per', code2: 'fa', name: 'Persian'},
|
{code3: 'per', code2: 'fa', name: 'Persian'},
|
||||||
{code3: 'phi', code2: ' ', name: 'Philippine languages'},
|
{code3: 'phi', code2: '', name: 'Philippine languages'},
|
||||||
{code3: 'phn', code2: ' ', name: 'Phoenician'},
|
{code3: 'phn', code2: '', name: 'Phoenician'},
|
||||||
{code3: 'pli', code2: 'pi', name: 'Pali'},
|
{code3: 'pli', code2: 'pi', name: 'Pali'},
|
||||||
{code3: 'pol', code2: 'pl', name: 'Polish'},
|
{code3: 'pol', code2: 'pl', name: 'Polish'},
|
||||||
{code3: 'pon', code2: ' ', name: 'Pohnpeian'},
|
{code3: 'pon', code2: '', name: 'Pohnpeian'},
|
||||||
{code3: 'por', code2: 'pt', name: 'Portuguese'},
|
{code3: 'por', code2: 'pt', name: 'Portuguese'},
|
||||||
{code3: 'pra', code2: ' ', name: 'Prakrit languages'},
|
{code3: 'pra', code2: '', name: 'Prakrit languages'},
|
||||||
{
|
{
|
||||||
code3: 'pro',
|
code3: 'pro',
|
||||||
code2: ' ',
|
code2: '',
|
||||||
name: 'Provençal, Old (to 1500);Occitan, Old (to 1500)',
|
name: 'Provençal, Old (to 1500);Occitan, Old (to 1500)',
|
||||||
},
|
},
|
||||||
{code3: 'pus', code2: 'ps', name: 'Pushto; Pashto'},
|
{code3: 'pus', code2: 'ps', name: 'Pushto; Pashto'},
|
||||||
{code3: 'que', code2: 'qu', name: 'Quechua'},
|
{code3: 'que', code2: 'qu', name: 'Quechua'},
|
||||||
{code3: 'raj', code2: ' ', name: 'Rajasthani'},
|
{code3: 'raj', code2: '', name: 'Rajasthani'},
|
||||||
{code3: 'rap', code2: ' ', name: 'Rapanui'},
|
{code3: 'rap', code2: '', name: 'Rapanui'},
|
||||||
{code3: 'rar', code2: ' ', name: 'Rarotongan; Cook Islands Maori'},
|
{code3: 'rar', code2: '', name: 'Rarotongan; Cook Islands Maori'},
|
||||||
{code3: 'roa', code2: ' ', name: 'Romance languages'},
|
{code3: 'roa', code2: '', name: 'Romance languages'},
|
||||||
{code3: 'roh', code2: 'rm', name: 'Romansh'},
|
{code3: 'roh', code2: 'rm', name: 'Romansh'},
|
||||||
{code3: 'rom', code2: ' ', name: 'Romany'},
|
{code3: 'rom', code2: '', name: 'Romany'},
|
||||||
{code3: 'rum', code2: 'ro', name: 'Romanian; Moldavian; Moldovan'},
|
{code3: 'rum', code2: 'ro', name: 'Romanian; Moldavian; Moldovan'},
|
||||||
{code3: 'ron', code2: 'ro', name: 'Romanian; Moldavian; Moldovan'},
|
{code3: 'ron', code2: 'ro', name: 'Romanian; Moldavian; Moldovan'},
|
||||||
{code3: 'run', code2: 'rn', name: 'Rundi'},
|
{code3: 'run', code2: 'rn', name: 'Rundi'},
|
||||||
{code3: 'rup', code2: ' ', name: 'Aromanian; Arumanian; Macedo-Romanian'},
|
{code3: 'rup', code2: '', name: 'Aromanian; Arumanian; Macedo-Romanian'},
|
||||||
{code3: 'rus', code2: 'ru', name: 'Russian'},
|
{code3: 'rus', code2: 'ru', name: 'Russian'},
|
||||||
{code3: 'sad', code2: ' ', name: 'Sandawe'},
|
{code3: 'sad', code2: '', name: 'Sandawe'},
|
||||||
{code3: 'sag', code2: 'sg', name: 'Sango'},
|
{code3: 'sag', code2: 'sg', name: 'Sango'},
|
||||||
{code3: 'sah', code2: ' ', name: 'Yakut'},
|
{code3: 'sah', code2: '', name: 'Yakut'},
|
||||||
{code3: 'sai', code2: ' ', name: 'South American Indian languages'},
|
{code3: 'sai', code2: '', name: 'South American Indian languages'},
|
||||||
{code3: 'sal', code2: ' ', name: 'Salishan languages'},
|
{code3: 'sal', code2: '', name: 'Salishan languages'},
|
||||||
{code3: 'sam', code2: ' ', name: 'Samaritan Aramaic'},
|
{code3: 'sam', code2: '', name: 'Samaritan Aramaic'},
|
||||||
{code3: 'san', code2: 'sa', name: 'Sanskrit'},
|
{code3: 'san', code2: 'sa', name: 'Sanskrit'},
|
||||||
{code3: 'sas', code2: ' ', name: 'Sasak'},
|
{code3: 'sas', code2: '', name: 'Sasak'},
|
||||||
{code3: 'sat', code2: ' ', name: 'Santali'},
|
{code3: 'sat', code2: '', name: 'Santali'},
|
||||||
{code3: 'scn', code2: ' ', name: 'Sicilian'},
|
{code3: 'scn', code2: '', name: 'Sicilian'},
|
||||||
{code3: 'sco', code2: ' ', name: 'Scots'},
|
{code3: 'sco', code2: '', name: 'Scots'},
|
||||||
{code3: 'sel', code2: ' ', name: 'Selkup'},
|
{code3: 'sel', code2: '', name: 'Selkup'},
|
||||||
{code3: 'sem', code2: ' ', name: 'Semitic languages'},
|
{code3: 'sem', code2: '', name: 'Semitic languages'},
|
||||||
{code3: 'sga', code2: ' ', name: 'Irish, Old (to 900)'},
|
{code3: 'sga', code2: '', name: 'Irish, Old (to 900)'},
|
||||||
{code3: 'sgn', code2: ' ', name: 'Sign Languages'},
|
{code3: 'sgn', code2: '', name: 'Sign Languages'},
|
||||||
{code3: 'shn', code2: ' ', name: 'Shan'},
|
{code3: 'shn', code2: '', name: 'Shan'},
|
||||||
{code3: 'sid', code2: ' ', name: 'Sidamo'},
|
{code3: 'sid', code2: '', name: 'Sidamo'},
|
||||||
{code3: 'sin', code2: 'si', name: 'Sinhala; Sinhalese'},
|
{code3: 'sin', code2: 'si', name: 'Sinhala; Sinhalese'},
|
||||||
{code3: 'sio', code2: ' ', name: 'Siouan languages'},
|
{code3: 'sio', code2: '', name: 'Siouan languages'},
|
||||||
{code3: 'sit', code2: ' ', name: 'Sino-Tibetan languages'},
|
{code3: 'sit', code2: '', name: 'Sino-Tibetan languages'},
|
||||||
{code3: 'sla', code2: ' ', name: 'Slavic languages'},
|
{code3: 'sla', code2: '', name: 'Slavic languages'},
|
||||||
{code3: 'slo', code2: 'sk', name: 'Slovak'},
|
{code3: 'slo', code2: 'sk', name: 'Slovak'},
|
||||||
{code3: 'slk', code2: 'sk', name: 'Slovak'},
|
{code3: 'slk', code2: 'sk', name: 'Slovak'},
|
||||||
{code3: 'slv', code2: 'sl', name: 'Slovenian'},
|
{code3: 'slv', code2: 'sl', name: 'Slovenian'},
|
||||||
{code3: 'sma', code2: ' ', name: 'Southern Sami'},
|
{code3: 'sma', code2: '', name: 'Southern Sami'},
|
||||||
{code3: 'sme', code2: 'se', name: 'Northern Sami'},
|
{code3: 'sme', code2: 'se', name: 'Northern Sami'},
|
||||||
{code3: 'smi', code2: ' ', name: 'Sami languages'},
|
{code3: 'smi', code2: '', name: 'Sami languages'},
|
||||||
{code3: 'smj', code2: ' ', name: 'Lule Sami'},
|
{code3: 'smj', code2: '', name: 'Lule Sami'},
|
||||||
{code3: 'smn', code2: ' ', name: 'Inari Sami'},
|
{code3: 'smn', code2: '', name: 'Inari Sami'},
|
||||||
{code3: 'smo', code2: 'sm', name: 'Samoan'},
|
{code3: 'smo', code2: 'sm', name: 'Samoan'},
|
||||||
{code3: 'sms', code2: ' ', name: 'Skolt Sami'},
|
{code3: 'sms', code2: '', name: 'Skolt Sami'},
|
||||||
{code3: 'sna', code2: 'sn', name: 'Shona'},
|
{code3: 'sna', code2: 'sn', name: 'Shona'},
|
||||||
{code3: 'snd', code2: 'sd', name: 'Sindhi'},
|
{code3: 'snd', code2: 'sd', name: 'Sindhi'},
|
||||||
{code3: 'snk', code2: ' ', name: 'Soninke'},
|
{code3: 'snk', code2: '', name: 'Soninke'},
|
||||||
{code3: 'sog', code2: ' ', name: 'Sogdian'},
|
{code3: 'sog', code2: '', name: 'Sogdian'},
|
||||||
{code3: 'som', code2: 'so', name: 'Somali'},
|
{code3: 'som', code2: 'so', name: 'Somali'},
|
||||||
{code3: 'son', code2: ' ', name: 'Songhai languages'},
|
{code3: 'son', code2: '', name: 'Songhai languages'},
|
||||||
{code3: 'sot', code2: 'st', name: 'Sotho, Southern'},
|
{code3: 'sot', code2: 'st', name: 'Sotho, Southern'},
|
||||||
{code3: 'spa', code2: 'es', name: 'Spanish'},
|
{code3: 'spa', code2: 'es', name: 'Spanish'},
|
||||||
{code3: 'sqi', code2: 'sq', name: 'Albanian'},
|
{code3: 'sqi', code2: 'sq', name: 'Albanian'},
|
||||||
{code3: 'srd', code2: 'sc', name: 'Sardinian'},
|
{code3: 'srd', code2: 'sc', name: 'Sardinian'},
|
||||||
{code3: 'srn', code2: ' ', name: 'Sranan Tongo'},
|
{code3: 'srn', code2: '', name: 'Sranan Tongo'},
|
||||||
{code3: 'srp', code2: 'sr', name: 'Serbian'},
|
{code3: 'srp', code2: 'sr', name: 'Serbian'},
|
||||||
{code3: 'srr', code2: ' ', name: 'Serer'},
|
{code3: 'srr', code2: '', name: 'Serer'},
|
||||||
{code3: 'ssa', code2: ' ', name: 'Nilo-Saharan languages'},
|
{code3: 'ssa', code2: '', name: 'Nilo-Saharan languages'},
|
||||||
{code3: 'ssw', code2: 'ss', name: 'Swati'},
|
{code3: 'ssw', code2: 'ss', name: 'Swati'},
|
||||||
{code3: 'suk', code2: ' ', name: 'Sukuma'},
|
{code3: 'suk', code2: '', name: 'Sukuma'},
|
||||||
{code3: 'sun', code2: 'su', name: 'Sundanese'},
|
{code3: 'sun', code2: 'su', name: 'Sundanese'},
|
||||||
{code3: 'sus', code2: ' ', name: 'Susu'},
|
{code3: 'sus', code2: '', name: 'Susu'},
|
||||||
{code3: 'sux', code2: ' ', name: 'Sumerian'},
|
{code3: 'sux', code2: '', name: 'Sumerian'},
|
||||||
{code3: 'swa', code2: 'sw', name: 'Swahili'},
|
{code3: 'swa', code2: 'sw', name: 'Swahili'},
|
||||||
{code3: 'swe', code2: 'sv', name: 'Swedish'},
|
{code3: 'swe', code2: 'sv', name: 'Swedish'},
|
||||||
{code3: 'syc', code2: ' ', name: 'Classical Syriac'},
|
{code3: 'syc', code2: '', name: 'Classical Syriac'},
|
||||||
{code3: 'syr', code2: ' ', name: 'Syriac'},
|
{code3: 'syr', code2: '', name: 'Syriac'},
|
||||||
{code3: 'tah', code2: 'ty', name: 'Tahitian'},
|
{code3: 'tah', code2: 'ty', name: 'Tahitian'},
|
||||||
{code3: 'tai', code2: ' ', name: 'Tai languages'},
|
{code3: 'tai', code2: '', name: 'Tai languages'},
|
||||||
{code3: 'tam', code2: 'ta', name: 'Tamil'},
|
{code3: 'tam', code2: 'ta', name: 'Tamil'},
|
||||||
{code3: 'tat', code2: 'tt', name: 'Tatar'},
|
{code3: 'tat', code2: 'tt', name: 'Tatar'},
|
||||||
{code3: 'tel', code2: 'te', name: 'Telugu'},
|
{code3: 'tel', code2: 'te', name: 'Telugu'},
|
||||||
{code3: 'tem', code2: ' ', name: 'Timne'},
|
{code3: 'tem', code2: '', name: 'Timne'},
|
||||||
{code3: 'ter', code2: ' ', name: 'Tereno'},
|
{code3: 'ter', code2: '', name: 'Tereno'},
|
||||||
{code3: 'tet', code2: ' ', name: 'Tetum'},
|
{code3: 'tet', code2: '', name: 'Tetum'},
|
||||||
{code3: 'tgk', code2: 'tg', name: 'Tajik'},
|
{code3: 'tgk', code2: 'tg', name: 'Tajik'},
|
||||||
{code3: 'tgl', code2: 'tl', name: 'Tagalog'},
|
{code3: 'tgl', code2: 'tl', name: 'Tagalog'},
|
||||||
{code3: 'tha', code2: 'th', name: 'Thai'},
|
{code3: 'tha', code2: 'th', name: 'Thai'},
|
||||||
{code3: 'tib', code2: 'bo', name: 'Tibetan'},
|
{code3: 'tib', code2: 'bo', name: 'Tibetan'},
|
||||||
{code3: 'tig', code2: ' ', name: 'Tigre'},
|
{code3: 'tig', code2: '', name: 'Tigre'},
|
||||||
{code3: 'tir', code2: 'ti', name: 'Tigrinya'},
|
{code3: 'tir', code2: 'ti', name: 'Tigrinya'},
|
||||||
{code3: 'tiv', code2: ' ', name: 'Tiv'},
|
{code3: 'tiv', code2: '', name: 'Tiv'},
|
||||||
{code3: 'tkl', code2: ' ', name: 'Tokelau'},
|
{code3: 'tkl', code2: '', name: 'Tokelau'},
|
||||||
{code3: 'tlh', code2: ' ', name: 'Klingon; tlhIngan-Hol'},
|
{code3: 'tlh', code2: '', name: 'Klingon; tlhIngan-Hol'},
|
||||||
{code3: 'tli', code2: ' ', name: 'Tlingit'},
|
{code3: 'tli', code2: '', name: 'Tlingit'},
|
||||||
{code3: 'tmh', code2: ' ', name: 'Tamashek'},
|
{code3: 'tmh', code2: '', name: 'Tamashek'},
|
||||||
{code3: 'tog', code2: ' ', name: 'Tonga (Nyasa)'},
|
{code3: 'tog', code2: '', name: 'Tonga (Nyasa)'},
|
||||||
{code3: 'ton', code2: 'to', name: 'Tonga (Tonga Islands)'},
|
{code3: 'ton', code2: 'to', name: 'Tonga (Tonga Islands)'},
|
||||||
{code3: 'tpi', code2: ' ', name: 'Tok Pisin'},
|
{code3: 'tpi', code2: '', name: 'Tok Pisin'},
|
||||||
{code3: 'tsi', code2: ' ', name: 'Tsimshian'},
|
{code3: 'tsi', code2: '', name: 'Tsimshian'},
|
||||||
{code3: 'tsn', code2: 'tn', name: 'Tswana'},
|
{code3: 'tsn', code2: 'tn', name: 'Tswana'},
|
||||||
{code3: 'tso', code2: 'ts', name: 'Tsonga'},
|
{code3: 'tso', code2: 'ts', name: 'Tsonga'},
|
||||||
{code3: 'tuk', code2: 'tk', name: 'Turkmen'},
|
{code3: 'tuk', code2: 'tk', name: 'Turkmen'},
|
||||||
{code3: 'tum', code2: ' ', name: 'Tumbuka'},
|
{code3: 'tum', code2: '', name: 'Tumbuka'},
|
||||||
{code3: 'tup', code2: ' ', name: 'Tupi languages'},
|
{code3: 'tup', code2: '', name: 'Tupi languages'},
|
||||||
{code3: 'tur', code2: 'tr', name: 'Turkish'},
|
{code3: 'tur', code2: 'tr', name: 'Turkish'},
|
||||||
{code3: 'tut', code2: ' ', name: 'Altaic languages'},
|
{code3: 'tut', code2: '', name: 'Altaic languages'},
|
||||||
{code3: 'tvl', code2: ' ', name: 'Tuvalu'},
|
{code3: 'tvl', code2: '', name: 'Tuvalu'},
|
||||||
{code3: 'twi', code2: 'tw', name: 'Twi'},
|
{code3: 'twi', code2: 'tw', name: 'Twi'},
|
||||||
{code3: 'tyv', code2: ' ', name: 'Tuvinian'},
|
{code3: 'tyv', code2: '', name: 'Tuvinian'},
|
||||||
{code3: 'udm', code2: ' ', name: 'Udmurt'},
|
{code3: 'udm', code2: '', name: 'Udmurt'},
|
||||||
{code3: 'uga', code2: ' ', name: 'Ugaritic'},
|
{code3: 'uga', code2: '', name: 'Ugaritic'},
|
||||||
{code3: 'uig', code2: 'ug', name: 'Uighur; Uyghur'},
|
{code3: 'uig', code2: 'ug', name: 'Uighur; Uyghur'},
|
||||||
{code3: 'ukr', code2: 'uk', name: 'Ukrainian'},
|
{code3: 'ukr', code2: 'uk', name: 'Ukrainian'},
|
||||||
{code3: 'umb', code2: ' ', name: 'Umbundu'},
|
{code3: 'umb', code2: '', name: 'Umbundu'},
|
||||||
{code3: 'und', code2: ' ', name: 'Undetermined'},
|
{code3: 'und', code2: '', name: 'Undetermined'},
|
||||||
{code3: 'urd', code2: 'ur', name: 'Urdu'},
|
{code3: 'urd', code2: 'ur', name: 'Urdu'},
|
||||||
{code3: 'uzb', code2: 'uz', name: 'Uzbek'},
|
{code3: 'uzb', code2: 'uz', name: 'Uzbek'},
|
||||||
{code3: 'vai', code2: ' ', name: 'Vai'},
|
{code3: 'vai', code2: '', name: 'Vai'},
|
||||||
{code3: 'ven', code2: 've', name: 'Venda'},
|
{code3: 'ven', code2: 've', name: 'Venda'},
|
||||||
{code3: 'vie', code2: 'vi', name: 'Vietnamese'},
|
{code3: 'vie', code2: 'vi', name: 'Vietnamese'},
|
||||||
{code3: 'vol', code2: 'vo', name: 'Volapük'},
|
{code3: 'vol', code2: 'vo', name: 'Volapük'},
|
||||||
{code3: 'vot', code2: ' ', name: 'Votic'},
|
{code3: 'vot', code2: '', name: 'Votic'},
|
||||||
{code3: 'wak', code2: ' ', name: 'Wakashan languages'},
|
{code3: 'wak', code2: '', name: 'Wakashan languages'},
|
||||||
{code3: 'wal', code2: ' ', name: 'Wolaitta; Wolaytta'},
|
{code3: 'wal', code2: '', name: 'Wolaitta; Wolaytta'},
|
||||||
{code3: 'war', code2: ' ', name: 'Waray'},
|
{code3: 'war', code2: '', name: 'Waray'},
|
||||||
{code3: 'was', code2: ' ', name: 'Washo'},
|
{code3: 'was', code2: '', name: 'Washo'},
|
||||||
{code3: 'wel', code2: 'cy', name: 'Welsh'},
|
{code3: 'wel', code2: 'cy', name: 'Welsh'},
|
||||||
{code3: 'wen', code2: ' ', name: 'Sorbian languages'},
|
{code3: 'wen', code2: '', name: 'Sorbian languages'},
|
||||||
{code3: 'wln', code2: 'wa', name: 'Walloon'},
|
{code3: 'wln', code2: 'wa', name: 'Walloon'},
|
||||||
{code3: 'wol', code2: 'wo', name: 'Wolof'},
|
{code3: 'wol', code2: 'wo', name: 'Wolof'},
|
||||||
{code3: 'xal', code2: ' ', name: 'Kalmyk; Oirat'},
|
{code3: 'xal', code2: '', name: 'Kalmyk; Oirat'},
|
||||||
{code3: 'xho', code2: 'xh', name: 'Xhosa'},
|
{code3: 'xho', code2: 'xh', name: 'Xhosa'},
|
||||||
{code3: 'yao', code2: ' ', name: 'Yao'},
|
{code3: 'yao', code2: '', name: 'Yao'},
|
||||||
{code3: 'yap', code2: ' ', name: 'Yapese'},
|
{code3: 'yap', code2: '', name: 'Yapese'},
|
||||||
{code3: 'yid', code2: 'yi', name: 'Yiddish'},
|
{code3: 'yid', code2: 'yi', name: 'Yiddish'},
|
||||||
{code3: 'yor', code2: 'yo', name: 'Yoruba'},
|
{code3: 'yor', code2: 'yo', name: 'Yoruba'},
|
||||||
{code3: 'ypk', code2: ' ', name: 'Yupik languages'},
|
{code3: 'ypk', code2: '', name: 'Yupik languages'},
|
||||||
{code3: 'zap', code2: ' ', name: 'Zapotec'},
|
{code3: 'zap', code2: '', name: 'Zapotec'},
|
||||||
{code3: 'zbl', code2: ' ', name: 'Blissymbols; Blissymbolics; Bliss'},
|
{code3: 'zbl', code2: '', name: 'Blissymbols; Blissymbolics; Bliss'},
|
||||||
{code3: 'zen', code2: ' ', name: 'Zenaga'},
|
{code3: 'zen', code2: '', name: 'Zenaga'},
|
||||||
{code3: 'zgh', code2: ' ', name: 'Standard Moroccan Tamazight'},
|
{code3: 'zgh', code2: '', name: 'Standard Moroccan Tamazight'},
|
||||||
{code3: 'zha', code2: 'za', name: 'Zhuang; Chuang'},
|
{code3: 'zha', code2: 'za', name: 'Zhuang; Chuang'},
|
||||||
{code3: 'zho', code2: 'zh', name: 'Chinese'},
|
{code3: 'zho', code2: 'zh', name: 'Chinese'},
|
||||||
{code3: 'znd', code2: ' ', name: 'Zande languages'},
|
{code3: 'znd', code2: '', name: 'Zande languages'},
|
||||||
{code3: 'zul', code2: 'zu', name: 'Zulu'},
|
{code3: 'zul', code2: 'zu', name: 'Zulu'},
|
||||||
{code3: 'zun', code2: ' ', name: 'Zuni'},
|
{code3: 'zun', code2: '', name: 'Zuni'},
|
||||||
{
|
{
|
||||||
code3: 'zza',
|
code3: 'zza',
|
||||||
code2: '',
|
code2: '',
|
||||||
|
|||||||
+13
-15
@@ -1,12 +1,11 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {ListRenderItemInfo, Pressable, StyleSheet, View} from 'react-native'
|
import {ListRenderItemInfo, Pressable, View} from 'react-native'
|
||||||
import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
|
import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||||
|
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
|
||||||
import {HITSLOP_10} from 'lib/constants'
|
import {HITSLOP_10} from 'lib/constants'
|
||||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||||
import {CommonNavigatorParams} from 'lib/routes/types'
|
import {CommonNavigatorParams} from 'lib/routes/types'
|
||||||
@@ -39,7 +38,6 @@ export default function HashtagScreen({
|
|||||||
}: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) {
|
}: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) {
|
||||||
const {tag, author} = route.params
|
const {tag, author} = route.params
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const pal = usePalette('default')
|
|
||||||
|
|
||||||
const fullTag = React.useMemo(() => {
|
const fullTag = React.useMemo(() => {
|
||||||
return `#${decodeURIComponent(tag)}`
|
return `#${decodeURIComponent(tag)}`
|
||||||
@@ -111,7 +109,7 @@ export default function HashtagScreen({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CenteredView sideBorders style={[pal.border, pal.view]}>
|
<CenteredView sideBorders={true}>
|
||||||
<ViewHeader
|
<ViewHeader
|
||||||
showOnDesktop
|
showOnDesktop
|
||||||
title={headerTitle}
|
title={headerTitle}
|
||||||
@@ -138,8 +136,17 @@ export default function HashtagScreen({
|
|||||||
onPageSelected={onPageSelected}
|
onPageSelected={onPageSelected}
|
||||||
renderTabBar={props => (
|
renderTabBar={props => (
|
||||||
<CenteredView
|
<CenteredView
|
||||||
sideBorders
|
sideBorders={true}
|
||||||
style={[pal.border, pal.view, styles.tabBarContainer]}>
|
// @ts-ignore web only
|
||||||
|
style={
|
||||||
|
isWeb
|
||||||
|
? {
|
||||||
|
position: isWeb ? 'sticky' : '',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}>
|
||||||
<TabBar items={sections.map(section => section.title)} {...props} />
|
<TabBar items={sections.map(section => section.title)} {...props} />
|
||||||
</CenteredView>
|
</CenteredView>
|
||||||
)}
|
)}
|
||||||
@@ -234,12 +241,3 @@ function HashtagScreenTab({
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
tabBarContainer: {
|
|
||||||
// @ts-ignore web only
|
|
||||||
position: isWeb ? 'sticky' : '',
|
|
||||||
top: 0,
|
|
||||||
zIndex: 1,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function MessagesScreen({navigation, route}: Props) {
|
|||||||
)
|
)
|
||||||
}, [_, t])
|
}, [_, t])
|
||||||
|
|
||||||
const initialNumToRender = useInitialNumToRender(80)
|
const initialNumToRender = useInitialNumToRender({minItemHeight: 80})
|
||||||
const [isPTRing, setIsPTRing] = useState(false)
|
const [isPTRing, setIsPTRing] = useState(false)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
|
|||||||
import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
|
import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {TOURS, useSetQueuedTour} from '#/tours'
|
|
||||||
|
|
||||||
export function StepFinished() {
|
export function StepFinished() {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
@@ -59,7 +58,6 @@ export function StepFinished() {
|
|||||||
const activeStarterPack = useActiveStarterPack()
|
const activeStarterPack = useActiveStarterPack()
|
||||||
const setActiveStarterPack = useSetActiveStarterPack()
|
const setActiveStarterPack = useSetActiveStarterPack()
|
||||||
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
|
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
|
||||||
const setQueuedTour = useSetQueuedTour()
|
|
||||||
const {startProgressGuide} = useProgressGuideControls()
|
const {startProgressGuide} = useProgressGuideControls()
|
||||||
|
|
||||||
const finishOnboarding = React.useCallback(async () => {
|
const finishOnboarding = React.useCallback(async () => {
|
||||||
@@ -189,7 +187,6 @@ export function StepFinished() {
|
|||||||
setSaving(false)
|
setSaving(false)
|
||||||
setActiveStarterPack(undefined)
|
setActiveStarterPack(undefined)
|
||||||
setHasCheckedForStarterPack(true)
|
setHasCheckedForStarterPack(true)
|
||||||
setQueuedTour(TOURS.HOME)
|
|
||||||
startProgressGuide('like-10-and-follow-7')
|
startProgressGuide('like-10-and-follow-7')
|
||||||
dispatch({type: 'finish'})
|
dispatch({type: 'finish'})
|
||||||
onboardDispatch({type: 'finish'})
|
onboardDispatch({type: 'finish'})
|
||||||
@@ -223,7 +220,6 @@ export function StepFinished() {
|
|||||||
requestNotificationsPermission,
|
requestNotificationsPermission,
|
||||||
setActiveStarterPack,
|
setActiveStarterPack,
|
||||||
setHasCheckedForStarterPack,
|
setHasCheckedForStarterPack,
|
||||||
setQueuedTour,
|
|
||||||
startProgressGuide,
|
startProgressGuide,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {isNative} from '#/platform/detection'
|
|||||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||||
|
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {Text} from '#/view/com/util/text/Text'
|
import {Text} from '#/view/com/util/text/Text'
|
||||||
import {Feed} from 'view/com/posts/Feed'
|
import {Feed} from 'view/com/posts/Feed'
|
||||||
@@ -42,6 +43,10 @@ export const ProfileFeedSection = React.forwardRef<
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [hasNew, setHasNew] = React.useState(false)
|
const [hasNew, setHasNew] = React.useState(false)
|
||||||
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
|
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
|
||||||
|
const shouldUseAdjustedNumToRender = feed.endsWith('posts_and_author_threads')
|
||||||
|
const adjustedInitialNumToRender = useInitialNumToRender({
|
||||||
|
screenHeightOffset: headerHeight,
|
||||||
|
})
|
||||||
|
|
||||||
const onScrollToTop = React.useCallback(() => {
|
const onScrollToTop = React.useCallback(() => {
|
||||||
scrollElRef.current?.scrollToOffset({
|
scrollElRef.current?.scrollToOffset({
|
||||||
@@ -79,7 +84,9 @@ export const ProfileFeedSection = React.forwardRef<
|
|||||||
headerOffset={headerHeight}
|
headerOffset={headerHeight}
|
||||||
renderEndOfFeed={ProfileEndOfFeed}
|
renderEndOfFeed={ProfileEndOfFeed}
|
||||||
ignoreFilterFor={ignoreFilterFor}
|
ignoreFilterFor={ignoreFilterFor}
|
||||||
outsideHeaderOffset={headerHeight}
|
initialNumToRender={
|
||||||
|
shouldUseAdjustedNumToRender ? adjustedInitialNumToRender : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{(isScrolledDown || hasNew) && (
|
{(isScrolledDown || hasNew) && (
|
||||||
<LoadLatestBtn
|
<LoadLatestBtn
|
||||||
|
|||||||
@@ -41,10 +41,9 @@ export function StepCaptcha() {
|
|||||||
(code: string) => {
|
(code: string) => {
|
||||||
setCompleted(true)
|
setCompleted(true)
|
||||||
logEvent('signup:captchaSuccess', {})
|
logEvent('signup:captchaSuccess', {})
|
||||||
const submitTask = {code, mutableProcessed: false}
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'submit',
|
type: 'submit',
|
||||||
task: submitTask,
|
task: {verificationCode: code, mutableProcessed: false},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[dispatch],
|
[dispatch],
|
||||||
|
|||||||
@@ -65,8 +65,10 @@ export function StepHandle() {
|
|||||||
})
|
})
|
||||||
// phoneVerificationRequired is actually whether a captcha is required
|
// phoneVerificationRequired is actually whether a captcha is required
|
||||||
if (!state.serviceDescription?.phoneVerificationRequired) {
|
if (!state.serviceDescription?.phoneVerificationRequired) {
|
||||||
const submitTask = {code: undefined, mutableProcessed: false}
|
dispatch({
|
||||||
dispatch({type: 'submit', task: submitTask})
|
type: 'submit',
|
||||||
|
task: {verificationCode: undefined, mutableProcessed: false},
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dispatch({type: 'next'})
|
dispatch({type: 'next'})
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export enum SignupStep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SubmitTask = {
|
type SubmitTask = {
|
||||||
code: string | undefined
|
verificationCode: string | undefined
|
||||||
mutableProcessed: boolean // OK to mutate assuming it's never read in render.
|
mutableProcessed: boolean // OK to mutate assuming it's never read in render.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +62,6 @@ export type SignupAction =
|
|||||||
| {type: 'setDateOfBirth'; value: Date}
|
| {type: 'setDateOfBirth'; value: Date}
|
||||||
| {type: 'setInviteCode'; value: string}
|
| {type: 'setInviteCode'; value: string}
|
||||||
| {type: 'setHandle'; value: string}
|
| {type: 'setHandle'; value: string}
|
||||||
| {type: 'setVerificationCode'; value: string}
|
|
||||||
| {type: 'setError'; value: string}
|
| {type: 'setError'; value: string}
|
||||||
| {type: 'setIsLoading'; value: boolean}
|
| {type: 'setIsLoading'; value: boolean}
|
||||||
| {type: 'submit'; task: SubmitTask}
|
| {type: 'submit'; task: SubmitTask}
|
||||||
@@ -189,11 +188,7 @@ export function useSubmitSignup() {
|
|||||||
const onboardingDispatch = useOnboardingDispatch()
|
const onboardingDispatch = useOnboardingDispatch()
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
async (
|
async (state: SignupState, dispatch: (action: SignupAction) => void) => {
|
||||||
state: SignupState,
|
|
||||||
dispatch: (action: SignupAction) => void,
|
|
||||||
verificationCode?: string,
|
|
||||||
) => {
|
|
||||||
if (!state.email) {
|
if (!state.email) {
|
||||||
dispatch({type: 'setStep', value: SignupStep.INFO})
|
dispatch({type: 'setStep', value: SignupStep.INFO})
|
||||||
return dispatch({
|
return dispatch({
|
||||||
@@ -224,7 +219,7 @@ export function useSubmitSignup() {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
state.serviceDescription?.phoneVerificationRequired &&
|
state.serviceDescription?.phoneVerificationRequired &&
|
||||||
!verificationCode
|
!state.pendingSubmit?.verificationCode
|
||||||
) {
|
) {
|
||||||
dispatch({type: 'setStep', value: SignupStep.CAPTCHA})
|
dispatch({type: 'setStep', value: SignupStep.CAPTCHA})
|
||||||
logger.error('Signup Flow Error', {
|
logger.error('Signup Flow Error', {
|
||||||
@@ -247,7 +242,7 @@ export function useSubmitSignup() {
|
|||||||
password: state.password,
|
password: state.password,
|
||||||
birthDate: state.dateOfBirth,
|
birthDate: state.dateOfBirth,
|
||||||
inviteCode: state.inviteCode.trim(),
|
inviteCode: state.inviteCode.trim(),
|
||||||
verificationCode: verificationCode,
|
verificationCode: state.pendingSubmit?.verificationCode,
|
||||||
})
|
})
|
||||||
/*
|
/*
|
||||||
* Must happen last so that if the user has multiple tabs open and
|
* Must happen last so that if the user has multiple tabs open and
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
import {ImagePickerAsset} from 'expo-image-picker'
|
import {ImagePickerAsset} from 'expo-image-picker'
|
||||||
import {useMutation} from '@tanstack/react-query'
|
import {useMutation} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {cancelable} from '#/lib/async/cancelable'
|
||||||
import {CompressedVideo, compressVideo} from 'lib/media/video/compress'
|
import {CompressedVideo, compressVideo} from 'lib/media/video/compress'
|
||||||
|
|
||||||
export function useCompressVideoMutation({
|
export function useCompressVideoMutation({
|
||||||
onProgress,
|
onProgress,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
|
signal,
|
||||||
}: {
|
}: {
|
||||||
onProgress: (progress: number) => void
|
onProgress: (progress: number) => void
|
||||||
onError: (e: any) => void
|
onError: (e: any) => void
|
||||||
onSuccess: (video: CompressedVideo) => void
|
onSuccess: (video: CompressedVideo) => void
|
||||||
|
signal: AbortSignal
|
||||||
}) {
|
}) {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (asset: ImagePickerAsset) => {
|
mutationKey: ['video', 'compress'],
|
||||||
return await compressVideo(asset.uri, {
|
mutationFn: cancelable(
|
||||||
onProgress: num => onProgress(trunc2dp(num)),
|
(asset: ImagePickerAsset) =>
|
||||||
})
|
compressVideo(asset.uri, {
|
||||||
},
|
onProgress: num => onProgress(trunc2dp(num)),
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
),
|
||||||
onError,
|
onError,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? ''
|
import {useMemo} from 'react'
|
||||||
|
import {AtpAgent} from '@atproto/api'
|
||||||
|
|
||||||
|
const UPLOAD_ENDPOINT = 'https://video.bsky.app/'
|
||||||
|
|
||||||
export const createVideoEndpointUrl = (
|
export const createVideoEndpointUrl = (
|
||||||
route: string,
|
route: string,
|
||||||
@@ -13,3 +16,11 @@ export const createVideoEndpointUrl = (
|
|||||||
}
|
}
|
||||||
return url.href
|
return url.href
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useVideoAgent() {
|
||||||
|
return useMemo(() => {
|
||||||
|
return new AtpAgent({
|
||||||
|
service: UPLOAD_ENDPOINT,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,51 +1,58 @@
|
|||||||
import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
|
import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
|
||||||
|
import {AppBskyVideoDefs} from '@atproto/api'
|
||||||
import {useMutation} from '@tanstack/react-query'
|
import {useMutation} from '@tanstack/react-query'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
|
import {cancelable} from '#/lib/async/cancelable'
|
||||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||||
import {UploadVideoResponse} from '#/lib/media/video/types'
|
|
||||||
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
||||||
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
|
|
||||||
|
|
||||||
export const useUploadVideoMutation = ({
|
export const useUploadVideoMutation = ({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
setProgress,
|
setProgress,
|
||||||
|
signal,
|
||||||
}: {
|
}: {
|
||||||
onSuccess: (response: UploadVideoResponse) => void
|
onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
|
||||||
onError: (e: any) => void
|
onError: (e: any) => void
|
||||||
setProgress: (progress: number) => void
|
setProgress: (progress: number) => void
|
||||||
|
signal: AbortSignal
|
||||||
}) => {
|
}) => {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (video: CompressedVideo) => {
|
mutationKey: ['video', 'upload'],
|
||||||
const uri = createVideoEndpointUrl('/upload', {
|
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||||
|
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||||
did: currentAccount!.did,
|
did: currentAccount!.did,
|
||||||
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
|
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
|
||||||
})
|
})
|
||||||
|
|
||||||
// a logged-in agent should have this set, but we'll check just in case
|
if (!currentAccount?.service) {
|
||||||
if (!agent.pdsUrl) {
|
throw new Error('User is not logged in')
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceAuthAud = getServiceAuthAudFromUrl(currentAccount.service)
|
||||||
|
if (!serviceAuthAud) {
|
||||||
throw new Error('Agent does not have a PDS URL')
|
throw new Error('Agent does not have a PDS URL')
|
||||||
}
|
}
|
||||||
|
|
||||||
const {data: serviceAuth} =
|
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
|
||||||
await agent.api.com.atproto.server.getServiceAuth({
|
{
|
||||||
aud: `did:web:${agent.pdsUrl.hostname}`,
|
aud: serviceAuthAud,
|
||||||
lxm: 'com.atproto.repo.uploadBlob',
|
lxm: 'com.atproto.repo.uploadBlob',
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const uploadTask = createUploadTask(
|
const uploadTask = createUploadTask(
|
||||||
uri,
|
uri,
|
||||||
video.uri,
|
video.uri,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'dev-key': UPLOAD_HEADER,
|
'content-type': 'video/mp4',
|
||||||
'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4?
|
|
||||||
Authorization: `Bearer ${serviceAuth.token}`,
|
Authorization: `Bearer ${serviceAuth.token}`,
|
||||||
},
|
},
|
||||||
httpMethod: 'POST',
|
httpMethod: 'POST',
|
||||||
@@ -59,12 +66,9 @@ export const useUploadVideoMutation = ({
|
|||||||
throw new Error('No response')
|
throw new Error('No response')
|
||||||
}
|
}
|
||||||
|
|
||||||
// @TODO rm, useful for debugging/getting video cid
|
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
|
||||||
console.log('[VIDEO]', res.body)
|
|
||||||
const responseBody = JSON.parse(res.body) as UploadVideoResponse
|
|
||||||
onSuccess(responseBody)
|
|
||||||
return responseBody
|
return responseBody
|
||||||
},
|
}, signal),
|
||||||
onError,
|
onError,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,79 +1,85 @@
|
|||||||
|
import {AppBskyVideoDefs} from '@atproto/api'
|
||||||
import {useMutation} from '@tanstack/react-query'
|
import {useMutation} from '@tanstack/react-query'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
|
import {cancelable} from '#/lib/async/cancelable'
|
||||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||||
import {UploadVideoResponse} from '#/lib/media/video/types'
|
|
||||||
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
||||||
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
|
|
||||||
|
|
||||||
export const useUploadVideoMutation = ({
|
export const useUploadVideoMutation = ({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
setProgress,
|
setProgress,
|
||||||
|
signal,
|
||||||
}: {
|
}: {
|
||||||
onSuccess: (response: UploadVideoResponse) => void
|
onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
|
||||||
onError: (e: any) => void
|
onError: (e: any) => void
|
||||||
setProgress: (progress: number) => void
|
setProgress: (progress: number) => void
|
||||||
|
signal: AbortSignal
|
||||||
}) => {
|
}) => {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (video: CompressedVideo) => {
|
mutationKey: ['video', 'upload'],
|
||||||
const uri = createVideoEndpointUrl('/upload', {
|
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||||
|
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||||
did: currentAccount!.did,
|
did: currentAccount!.did,
|
||||||
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
|
name: `${nanoid(12)}.mp4`, // @TODO: make sure it's always mp4'
|
||||||
})
|
})
|
||||||
|
|
||||||
// a logged-in agent should have this set, but we'll check just in case
|
if (!currentAccount?.service) {
|
||||||
if (!agent.pdsUrl) {
|
throw new Error('User is not logged in')
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceAuthAud = getServiceAuthAudFromUrl(currentAccount.service)
|
||||||
|
if (!serviceAuthAud) {
|
||||||
throw new Error('Agent does not have a PDS URL')
|
throw new Error('Agent does not have a PDS URL')
|
||||||
}
|
}
|
||||||
|
|
||||||
const {data: serviceAuth} =
|
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
|
||||||
await agent.api.com.atproto.server.getServiceAuth({
|
{
|
||||||
aud: `did:web:${agent.pdsUrl.hostname}`,
|
aud: serviceAuthAud,
|
||||||
lxm: 'com.atproto.repo.uploadBlob',
|
lxm: 'com.atproto.repo.uploadBlob',
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
const bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest()
|
const xhr = new XMLHttpRequest()
|
||||||
const res = (await new Promise((resolve, reject) => {
|
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
|
||||||
xhr.upload.addEventListener('progress', e => {
|
(resolve, reject) => {
|
||||||
const progress = e.loaded / e.total
|
xhr.upload.addEventListener('progress', e => {
|
||||||
setProgress(progress)
|
const progress = e.loaded / e.total
|
||||||
})
|
setProgress(progress)
|
||||||
xhr.onloadend = () => {
|
})
|
||||||
if (xhr.readyState === 4) {
|
xhr.onloadend = () => {
|
||||||
const uploadRes = JSON.parse(
|
if (xhr.readyState === 4) {
|
||||||
xhr.responseText,
|
const uploadRes = JSON.parse(
|
||||||
) as UploadVideoResponse
|
xhr.responseText,
|
||||||
resolve(uploadRes)
|
) as AppBskyVideoDefs.JobStatus
|
||||||
onSuccess(uploadRes)
|
resolve(uploadRes)
|
||||||
} else {
|
onSuccess(uploadRes)
|
||||||
|
} else {
|
||||||
|
reject()
|
||||||
|
onError(new Error('Failed to upload video'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xhr.onerror = () => {
|
||||||
reject()
|
reject()
|
||||||
onError(new Error('Failed to upload video'))
|
onError(new Error('Failed to upload video'))
|
||||||
}
|
}
|
||||||
}
|
xhr.open('POST', uri)
|
||||||
xhr.onerror = () => {
|
xhr.setRequestHeader('Content-Type', 'video/mp4')
|
||||||
reject()
|
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
|
||||||
onError(new Error('Failed to upload video'))
|
xhr.send(bytes)
|
||||||
}
|
},
|
||||||
xhr.open('POST', uri)
|
)
|
||||||
xhr.setRequestHeader('Content-Type', 'video/mp4') // @TODO how we we set the proper content type?
|
|
||||||
// @TODO remove this header for prod
|
|
||||||
xhr.setRequestHeader('dev-key', UPLOAD_HEADER)
|
|
||||||
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
|
|
||||||
xhr.send(bytes)
|
|
||||||
})) as UploadVideoResponse
|
|
||||||
|
|
||||||
// @TODO rm for prod
|
|
||||||
console.log('[VIDEO]', res)
|
|
||||||
return res
|
return res
|
||||||
},
|
}, signal),
|
||||||
onError,
|
onError,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,68 +1,72 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {ImagePickerAsset} from 'expo-image-picker'
|
import {ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
import {AppBskyVideoDefs, BlobRef} from '@atproto/api'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQuery} from '@tanstack/react-query'
|
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {CompressedVideo} from 'lib/media/video/compress'
|
import {CompressedVideo} from 'lib/media/video/compress'
|
||||||
import {VideoTooLargeError} from 'lib/media/video/errors'
|
import {VideoTooLargeError} from 'lib/media/video/errors'
|
||||||
import {JobState, JobStatus} from 'lib/media/video/types'
|
|
||||||
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
|
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
|
||||||
import {createVideoEndpointUrl} from 'state/queries/video/util'
|
import {useVideoAgent} from 'state/queries/video/util'
|
||||||
import {useUploadVideoMutation} from 'state/queries/video/video-upload'
|
import {useUploadVideoMutation} from 'state/queries/video/video-upload'
|
||||||
|
|
||||||
type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done'
|
type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done'
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| {
|
| {type: 'SetStatus'; status: Status}
|
||||||
type: 'SetStatus'
|
| {type: 'SetProgress'; progress: number}
|
||||||
status: Status
|
| {type: 'SetError'; error: string | undefined}
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'SetProgress'
|
|
||||||
progress: number
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'SetError'
|
|
||||||
error: string | undefined
|
|
||||||
}
|
|
||||||
| {type: 'Reset'}
|
| {type: 'Reset'}
|
||||||
| {type: 'SetAsset'; asset: ImagePickerAsset}
|
| {type: 'SetAsset'; asset: ImagePickerAsset}
|
||||||
| {type: 'SetVideo'; video: CompressedVideo}
|
| {type: 'SetVideo'; video: CompressedVideo}
|
||||||
| {type: 'SetJobStatus'; jobStatus: JobStatus}
|
| {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus}
|
||||||
|
| {type: 'SetBlobRef'; blobRef: BlobRef}
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
status: Status
|
status: Status
|
||||||
progress: number
|
progress: number
|
||||||
asset?: ImagePickerAsset
|
asset?: ImagePickerAsset
|
||||||
video: CompressedVideo | null
|
video: CompressedVideo | null
|
||||||
jobStatus?: JobStatus
|
jobStatus?: AppBskyVideoDefs.JobStatus
|
||||||
|
blobRef?: BlobRef
|
||||||
error?: string
|
error?: string
|
||||||
|
abortController: AbortController
|
||||||
}
|
}
|
||||||
|
|
||||||
function reducer(state: State, action: Action): State {
|
function reducer(queryClient: QueryClient) {
|
||||||
let updatedState = state
|
return (state: State, action: Action): State => {
|
||||||
if (action.type === 'SetStatus') {
|
let updatedState = state
|
||||||
updatedState = {...state, status: action.status}
|
if (action.type === 'SetStatus') {
|
||||||
} else if (action.type === 'SetProgress') {
|
updatedState = {...state, status: action.status}
|
||||||
updatedState = {...state, progress: action.progress}
|
} else if (action.type === 'SetProgress') {
|
||||||
} else if (action.type === 'SetError') {
|
updatedState = {...state, progress: action.progress}
|
||||||
updatedState = {...state, error: action.error}
|
} else if (action.type === 'SetError') {
|
||||||
} else if (action.type === 'Reset') {
|
updatedState = {...state, error: action.error}
|
||||||
updatedState = {
|
} else if (action.type === 'Reset') {
|
||||||
status: 'idle',
|
state.abortController.abort()
|
||||||
progress: 0,
|
queryClient.cancelQueries({
|
||||||
video: null,
|
queryKey: ['video'],
|
||||||
|
})
|
||||||
|
updatedState = {
|
||||||
|
status: 'idle',
|
||||||
|
progress: 0,
|
||||||
|
video: null,
|
||||||
|
blobRef: undefined,
|
||||||
|
abortController: new AbortController(),
|
||||||
|
}
|
||||||
|
} else if (action.type === 'SetAsset') {
|
||||||
|
updatedState = {...state, asset: action.asset}
|
||||||
|
} else if (action.type === 'SetVideo') {
|
||||||
|
updatedState = {...state, video: action.video}
|
||||||
|
} else if (action.type === 'SetJobStatus') {
|
||||||
|
updatedState = {...state, jobStatus: action.jobStatus}
|
||||||
|
} else if (action.type === 'SetBlobRef') {
|
||||||
|
updatedState = {...state, blobRef: action.blobRef}
|
||||||
}
|
}
|
||||||
} else if (action.type === 'SetAsset') {
|
return updatedState
|
||||||
updatedState = {...state, asset: action.asset}
|
|
||||||
} else if (action.type === 'SetVideo') {
|
|
||||||
updatedState = {...state, video: action.video}
|
|
||||||
} else if (action.type === 'SetJobStatus') {
|
|
||||||
updatedState = {...state, jobStatus: action.jobStatus}
|
|
||||||
}
|
}
|
||||||
return updatedState
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUploadVideo({
|
export function useUploadVideo({
|
||||||
@@ -73,14 +77,16 @@ export function useUploadVideo({
|
|||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const [state, dispatch] = React.useReducer(reducer, {
|
const queryClient = useQueryClient()
|
||||||
|
const [state, dispatch] = React.useReducer(reducer(queryClient), {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
video: null,
|
video: null,
|
||||||
|
abortController: new AbortController(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const {setJobId} = useUploadStatusQuery({
|
const {setJobId} = useUploadStatusQuery({
|
||||||
onStatusChange: (status: JobStatus) => {
|
onStatusChange: (status: AppBskyVideoDefs.JobStatus) => {
|
||||||
// This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user
|
// This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user
|
||||||
// Leaving it for now though
|
// Leaving it for now though
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -89,7 +95,11 @@ export function useUploadVideo({
|
|||||||
})
|
})
|
||||||
setStatus(status.state.toString())
|
setStatus(status.state.toString())
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: blobRef => {
|
||||||
|
dispatch({
|
||||||
|
type: 'SetBlobRef',
|
||||||
|
blobRef,
|
||||||
|
})
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SetStatus',
|
type: 'SetStatus',
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
@@ -104,7 +114,7 @@ export function useUploadVideo({
|
|||||||
type: 'SetStatus',
|
type: 'SetStatus',
|
||||||
status: 'processing',
|
status: 'processing',
|
||||||
})
|
})
|
||||||
setJobId(response.job_id)
|
setJobId(response.jobId)
|
||||||
},
|
},
|
||||||
onError: e => {
|
onError: e => {
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -116,6 +126,7 @@ export function useUploadVideo({
|
|||||||
setProgress: p => {
|
setProgress: p => {
|
||||||
dispatch({type: 'SetProgress', progress: p})
|
dispatch({type: 'SetProgress', progress: p})
|
||||||
},
|
},
|
||||||
|
signal: state.abortController.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
const {mutate: onSelectVideo} = useCompressVideoMutation({
|
const {mutate: onSelectVideo} = useCompressVideoMutation({
|
||||||
@@ -148,6 +159,7 @@ export function useUploadVideo({
|
|||||||
})
|
})
|
||||||
onVideoCompressed(video)
|
onVideoCompressed(video)
|
||||||
},
|
},
|
||||||
|
signal: state.abortController.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectVideo = (asset: ImagePickerAsset) => {
|
const selectVideo = (asset: ImagePickerAsset) => {
|
||||||
@@ -163,7 +175,6 @@ export function useUploadVideo({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clearVideo = () => {
|
const clearVideo = () => {
|
||||||
// @TODO cancel any running jobs
|
|
||||||
dispatch({type: 'Reset'})
|
dispatch({type: 'Reset'})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,21 +190,27 @@ const useUploadStatusQuery = ({
|
|||||||
onStatusChange,
|
onStatusChange,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: {
|
}: {
|
||||||
onStatusChange: (status: JobStatus) => void
|
onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void
|
||||||
onSuccess: () => void
|
onSuccess: (blobRef: BlobRef) => void
|
||||||
}) => {
|
}) => {
|
||||||
|
const videoAgent = useVideoAgent()
|
||||||
const [enabled, setEnabled] = React.useState(true)
|
const [enabled, setEnabled] = React.useState(true)
|
||||||
const [jobId, setJobId] = React.useState<string>()
|
const [jobId, setJobId] = React.useState<string>()
|
||||||
|
|
||||||
const {isLoading, isError} = useQuery({
|
const {isLoading, isError} = useQuery({
|
||||||
queryKey: ['video-upload'],
|
queryKey: ['video', 'upload status', jobId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const url = createVideoEndpointUrl(`/job/${jobId}/status`)
|
if (!jobId) return // this won't happen, can ignore
|
||||||
const res = await fetch(url)
|
|
||||||
const status = (await res.json()) as JobStatus
|
const {data} = await videoAgent.app.bsky.video.getJobStatus({jobId})
|
||||||
if (status.state === JobState.JOB_STATE_COMPLETED) {
|
const status = data.jobStatus
|
||||||
|
if (status.state === 'JOB_STATE_COMPLETED') {
|
||||||
setEnabled(false)
|
setEnabled(false)
|
||||||
onSuccess()
|
if (!status.blob)
|
||||||
|
throw new Error('Job completed, but did not return a blob')
|
||||||
|
onSuccess(status.blob)
|
||||||
|
} else if (status.state === 'JOB_STATE_FAILED') {
|
||||||
|
throw new Error('Job failed to process')
|
||||||
}
|
}
|
||||||
onStatusChange(status)
|
onStatusChange(status)
|
||||||
return status
|
return status
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import {useTourGuideController} from 'rn-tourguide'
|
|
||||||
|
|
||||||
import {Button} from '#/components/Button'
|
|
||||||
import {Text} from '#/components/Typography'
|
|
||||||
|
|
||||||
export function TourDebugButton() {
|
|
||||||
const {start} = useTourGuideController('home')
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
label="Start tour"
|
|
||||||
onPress={() => {
|
|
||||||
start()
|
|
||||||
}}>
|
|
||||||
{() => <Text>t</Text>}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import {msg} from '@lingui/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {
|
|
||||||
IStep,
|
|
||||||
TourGuideZone,
|
|
||||||
TourGuideZoneByPosition,
|
|
||||||
useTourGuideController,
|
|
||||||
} from 'rn-tourguide'
|
|
||||||
|
|
||||||
import {DISCOVER_FEED_URI} from '#/lib/constants'
|
|
||||||
import {isWeb} from '#/platform/detection'
|
|
||||||
import {useSetSelectedFeed} from '#/state/shell/selected-feed'
|
|
||||||
import {TOURS} from '.'
|
|
||||||
import {useHeaderPosition} from './positioning'
|
|
||||||
|
|
||||||
export function HomeTour() {
|
|
||||||
const {_} = useLingui()
|
|
||||||
const {tourKey, eventEmitter} = useTourGuideController(TOURS.HOME)
|
|
||||||
const setSelectedFeed = useSetSelectedFeed()
|
|
||||||
const headerPosition = useHeaderPosition()
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const handleOnStepChange = (step?: IStep) => {
|
|
||||||
if (step?.order === 2) {
|
|
||||||
setSelectedFeed('following')
|
|
||||||
} else if (step?.order === 3) {
|
|
||||||
setSelectedFeed(`feedgen|${DISCOVER_FEED_URI}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eventEmitter?.on('stepChange', handleOnStepChange)
|
|
||||||
return () => {
|
|
||||||
eventEmitter?.off('stepChange', handleOnStepChange)
|
|
||||||
}
|
|
||||||
}, [eventEmitter, setSelectedFeed])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TourGuideZoneByPosition
|
|
||||||
isTourGuide
|
|
||||||
tourKey={tourKey}
|
|
||||||
zone={1}
|
|
||||||
top={headerPosition.top}
|
|
||||||
left={headerPosition.left}
|
|
||||||
width={headerPosition.width}
|
|
||||||
height={headerPosition.height}
|
|
||||||
borderRadiusObject={headerPosition.borderRadiusObject}
|
|
||||||
text={_(msg`Switch between feeds to control your experience.`)}
|
|
||||||
/>
|
|
||||||
<TourGuideZoneByPosition
|
|
||||||
isTourGuide
|
|
||||||
tourKey={tourKey}
|
|
||||||
zone={2}
|
|
||||||
top={headerPosition.top}
|
|
||||||
left={headerPosition.left}
|
|
||||||
width={headerPosition.width}
|
|
||||||
height={headerPosition.height}
|
|
||||||
borderRadiusObject={headerPosition.borderRadiusObject}
|
|
||||||
text={_(msg`Following shows the latest posts from people you follow.`)}
|
|
||||||
/>
|
|
||||||
<TourGuideZoneByPosition
|
|
||||||
isTourGuide
|
|
||||||
tourKey={tourKey}
|
|
||||||
zone={3}
|
|
||||||
top={headerPosition.top}
|
|
||||||
left={headerPosition.left}
|
|
||||||
width={headerPosition.width}
|
|
||||||
height={headerPosition.height}
|
|
||||||
borderRadiusObject={headerPosition.borderRadiusObject}
|
|
||||||
text={_(msg`Discover learns which posts you like as you browse.`)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HomeTourExploreWrapper({
|
|
||||||
children,
|
|
||||||
}: React.PropsWithChildren<{}>) {
|
|
||||||
const {_} = useLingui()
|
|
||||||
const {tourKey} = useTourGuideController(TOURS.HOME)
|
|
||||||
return (
|
|
||||||
<TourGuideZone
|
|
||||||
tourKey={tourKey}
|
|
||||||
zone={4}
|
|
||||||
tooltipBottomOffset={50}
|
|
||||||
shape={isWeb ? 'rectangle' : 'circle'}
|
|
||||||
text={_(
|
|
||||||
msg`Find more feeds and accounts to follow in the Explore page.`,
|
|
||||||
)}>
|
|
||||||
{children}
|
|
||||||
</TourGuideZone>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
import {
|
|
||||||
AccessibilityInfo,
|
|
||||||
findNodeHandle,
|
|
||||||
Pressable,
|
|
||||||
Text as RNText,
|
|
||||||
View,
|
|
||||||
} from 'react-native'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {FocusScope} from '@tamagui/focus-scope'
|
|
||||||
import {IStep, Labels} from 'rn-tourguide'
|
|
||||||
|
|
||||||
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
|
|
||||||
import {useA11y} from '#/state/a11y'
|
|
||||||
import {Logo} from '#/view/icons/Logo'
|
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
|
||||||
import {leading, Text} from '#/components/Typography'
|
|
||||||
|
|
||||||
const stopPropagation = (e: any) => e.stopPropagation()
|
|
||||||
|
|
||||||
export interface TooltipComponentProps {
|
|
||||||
isFirstStep?: boolean
|
|
||||||
isLastStep?: boolean
|
|
||||||
currentStep: IStep
|
|
||||||
labels?: Labels
|
|
||||||
handleNext?: () => void
|
|
||||||
handlePrev?: () => void
|
|
||||||
handleStop?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TooltipComponent({
|
|
||||||
isLastStep,
|
|
||||||
handleNext,
|
|
||||||
handleStop,
|
|
||||||
currentStep,
|
|
||||||
labels,
|
|
||||||
}: TooltipComponentProps) {
|
|
||||||
const t = useTheme()
|
|
||||||
const {_} = useLingui()
|
|
||||||
const btnRef = React.useRef<View>(null)
|
|
||||||
const textRef = React.useRef<RNText>(null)
|
|
||||||
const {screenReaderEnabled} = useA11y()
|
|
||||||
useWebBodyScrollLock(true)
|
|
||||||
|
|
||||||
const focusTextNode = () => {
|
|
||||||
const node = textRef.current ? findNodeHandle(textRef.current) : undefined
|
|
||||||
if (node) {
|
|
||||||
AccessibilityInfo.setAccessibilityFocus(node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle initial focus immediately on mount
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
focusTextNode()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// handle focus between steps
|
|
||||||
const innerHandleNext = () => {
|
|
||||||
handleNext?.()
|
|
||||||
setTimeout(() => focusTextNode(), 200)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FocusScope loop enabled trapped>
|
|
||||||
<View
|
|
||||||
role="alert"
|
|
||||||
aria-role="alert"
|
|
||||||
aria-label={_(msg`A help tooltip`)}
|
|
||||||
accessibilityLiveRegion="polite"
|
|
||||||
// iOS
|
|
||||||
accessibilityViewIsModal
|
|
||||||
// Android
|
|
||||||
importantForAccessibility="yes"
|
|
||||||
// @ts-ignore web only
|
|
||||||
onClick={stopPropagation}
|
|
||||||
onStartShouldSetResponder={_ => true}
|
|
||||||
onTouchEnd={stopPropagation}
|
|
||||||
style={[
|
|
||||||
t.atoms.bg,
|
|
||||||
a.px_lg,
|
|
||||||
a.py_lg,
|
|
||||||
a.flex_col,
|
|
||||||
a.gap_md,
|
|
||||||
a.rounded_sm,
|
|
||||||
a.shadow_md,
|
|
||||||
{maxWidth: 300},
|
|
||||||
]}>
|
|
||||||
{screenReaderEnabled && (
|
|
||||||
<Pressable
|
|
||||||
style={[
|
|
||||||
a.absolute,
|
|
||||||
a.inset_0,
|
|
||||||
a.z_10,
|
|
||||||
{height: 10, bottom: 'auto'},
|
|
||||||
]}
|
|
||||||
accessibilityLabel={_(
|
|
||||||
msg`Start of onboarding tour window. Do not move backward. Instead, go forward for more options, or press to skip.`,
|
|
||||||
)}
|
|
||||||
accessibilityHint={undefined}
|
|
||||||
onPress={handleStop}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
|
||||||
<Logo width={16} style={{position: 'relative', top: 0}} />
|
|
||||||
<Text
|
|
||||||
accessible={false}
|
|
||||||
style={[a.text_sm, a.font_semibold, t.atoms.text_contrast_medium]}>
|
|
||||||
<Trans>Quick tip</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<RNText
|
|
||||||
ref={textRef}
|
|
||||||
testID="stepDescription"
|
|
||||||
accessibilityLabel={_(
|
|
||||||
msg`Onboarding tour step ${currentStep.name}: ${currentStep.text}`,
|
|
||||||
)}
|
|
||||||
accessibilityHint={undefined}
|
|
||||||
style={[
|
|
||||||
a.text_md,
|
|
||||||
t.atoms.text,
|
|
||||||
a.pb_sm,
|
|
||||||
{
|
|
||||||
lineHeight: leading(a.text_md, a.leading_snug),
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
{currentStep.text}
|
|
||||||
</RNText>
|
|
||||||
{!isLastStep ? (
|
|
||||||
<Button
|
|
||||||
ref={btnRef}
|
|
||||||
variant="gradient"
|
|
||||||
color="gradient_sky"
|
|
||||||
size="medium"
|
|
||||||
onPress={innerHandleNext}
|
|
||||||
label={labels?.next || _(msg`Go to the next step of the tour`)}>
|
|
||||||
<ButtonText>{labels?.next || _(msg`Next`)}</ButtonText>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="gradient"
|
|
||||||
color="gradient_sky"
|
|
||||||
size="medium"
|
|
||||||
onPress={handleStop}
|
|
||||||
label={
|
|
||||||
labels?.finish ||
|
|
||||||
_(msg`Finish tour and begin using the application`)
|
|
||||||
}>
|
|
||||||
<ButtonText>{labels?.finish || _(msg`Let's go!`)}</ButtonText>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{screenReaderEnabled && (
|
|
||||||
<Pressable
|
|
||||||
style={[a.absolute, a.inset_0, a.z_10, {height: 10, top: 'auto'}]}
|
|
||||||
accessibilityLabel={_(
|
|
||||||
msg`End of onboarding tour window. Do not move forward. Instead, go backward for more options, or press to skip.`,
|
|
||||||
)}
|
|
||||||
accessibilityHint={undefined}
|
|
||||||
onPress={handleStop}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</FocusScope>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import {InteractionManager} from 'react-native'
|
|
||||||
import {TourGuideProvider, useTourGuideController} from 'rn-tourguide'
|
|
||||||
|
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
|
||||||
import {HomeTour} from './HomeTour'
|
|
||||||
import {TooltipComponent} from './Tooltip'
|
|
||||||
|
|
||||||
export enum TOURS {
|
|
||||||
HOME = 'home',
|
|
||||||
}
|
|
||||||
|
|
||||||
type StateContext = TOURS | null
|
|
||||||
type SetContext = (v: TOURS | null) => void
|
|
||||||
|
|
||||||
const stateContext = React.createContext<StateContext>(null)
|
|
||||||
const setContext = React.createContext<SetContext>((_: TOURS | null) => {})
|
|
||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
|
||||||
const theme = useColorModeTheme()
|
|
||||||
const [state, setState] = React.useState<TOURS | null>(() => null)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TourGuideProvider
|
|
||||||
androidStatusBarVisible
|
|
||||||
tooltipComponent={TooltipComponent}
|
|
||||||
backdropColor={
|
|
||||||
theme === 'light' ? 'rgba(0, 0, 0, 0.15)' : 'rgba(0, 0, 0, 0.8)'
|
|
||||||
}
|
|
||||||
preventOutsideInteraction>
|
|
||||||
<stateContext.Provider value={state}>
|
|
||||||
<setContext.Provider value={setState}>
|
|
||||||
<HomeTour />
|
|
||||||
{children}
|
|
||||||
</setContext.Provider>
|
|
||||||
</stateContext.Provider>
|
|
||||||
</TourGuideProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTriggerTourIfQueued(tour: TOURS) {
|
|
||||||
const {start} = useTourGuideController(tour)
|
|
||||||
const setQueuedTour = React.useContext(setContext)
|
|
||||||
const queuedTour = React.useContext(stateContext)
|
|
||||||
const gate = useGate()
|
|
||||||
|
|
||||||
return React.useCallback(() => {
|
|
||||||
if (queuedTour === tour) {
|
|
||||||
setQueuedTour(null)
|
|
||||||
InteractionManager.runAfterInteractions(() => {
|
|
||||||
if (gate('new_user_guided_tour')) {
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [tour, queuedTour, setQueuedTour, start, gate])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSetQueuedTour() {
|
|
||||||
return React.useContext(setContext)
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import {useWindowDimensions} from 'react-native'
|
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
|
||||||
|
|
||||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
|
||||||
|
|
||||||
export function useHeaderPosition() {
|
|
||||||
const {headerHeight} = useShellLayout()
|
|
||||||
const {width} = useWindowDimensions()
|
|
||||||
const insets = useSafeAreaInsets()
|
|
||||||
|
|
||||||
return {
|
|
||||||
top: insets.top,
|
|
||||||
left: 10,
|
|
||||||
width: width - 20,
|
|
||||||
height: headerHeight.value,
|
|
||||||
borderRadiusObject: {
|
|
||||||
topLeft: 4,
|
|
||||||
topRight: 4,
|
|
||||||
bottomLeft: 4,
|
|
||||||
bottomRight: 4,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import {useWindowDimensions} from 'react-native'
|
|
||||||
|
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
|
||||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
|
||||||
|
|
||||||
export function useHeaderPosition() {
|
|
||||||
const {headerHeight} = useShellLayout()
|
|
||||||
const winDim = useWindowDimensions()
|
|
||||||
const {isMobile} = useWebMediaQueries()
|
|
||||||
|
|
||||||
let left = 0
|
|
||||||
let width = winDim.width
|
|
||||||
if (width > 590 && !isMobile) {
|
|
||||||
left = winDim.width / 2 - 295
|
|
||||||
width = 590
|
|
||||||
}
|
|
||||||
|
|
||||||
let offset = isMobile ? 45 : 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
top: headerHeight.value - offset,
|
|
||||||
left,
|
|
||||||
width,
|
|
||||||
height: 45,
|
|
||||||
borderRadiusObject: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, {
|
import React, {
|
||||||
Suspense,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
@@ -178,7 +177,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
clearVideo,
|
clearVideo,
|
||||||
state: videoUploadState,
|
state: videoUploadState,
|
||||||
} = useUploadVideo({
|
} = useUploadVideo({
|
||||||
setStatus: (status: string) => setProcessingState(status),
|
setStatus: setProcessingState,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
if (publishOnUpload) {
|
if (publishOnUpload) {
|
||||||
onPressPublish(true)
|
onPressPublish(true)
|
||||||
@@ -348,6 +347,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
postgate,
|
postgate,
|
||||||
onStateChange: setProcessingState,
|
onStateChange: setProcessingState,
|
||||||
langs: toPostLanguages(langPrefs.postLanguage),
|
langs: toPostLanguages(langPrefs.postLanguage),
|
||||||
|
video: videoUploadState.blobRef,
|
||||||
})
|
})
|
||||||
).uri
|
).uri
|
||||||
try {
|
try {
|
||||||
@@ -699,15 +699,10 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
<VideoTranscodeProgress
|
<VideoTranscodeProgress
|
||||||
asset={videoUploadState.asset}
|
asset={videoUploadState.asset}
|
||||||
progress={videoUploadState.progress}
|
progress={videoUploadState.progress}
|
||||||
|
clear={clearVideo}
|
||||||
/>
|
/>
|
||||||
) : videoUploadState.video ? (
|
) : videoUploadState.video ? (
|
||||||
// remove suspense when we get rid of lazy
|
<VideoPreview video={videoUploadState.video} clear={clearVideo} />
|
||||||
<Suspense fallback={null}>
|
|
||||||
<VideoPreview
|
|
||||||
video={videoUploadState.video}
|
|
||||||
clear={clearVideo}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</Animated.ScrollView>
|
</Animated.ScrollView>
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export function ExternalEmbedRemoveBtn({onRemove}: {onRemove: () => void}) {
|
|||||||
}}
|
}}
|
||||||
onPress={onRemove}
|
onPress={onRemove}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={_(msg`Remove image preview`)}
|
accessibilityLabel={_(msg`Remove attachment`)}
|
||||||
accessibilityHint={_(msg`Removes the image preview`)}
|
accessibilityHint={_(msg`Removes the attachment`)}
|
||||||
onAccessibilityEscape={onRemove}>
|
onAccessibilityEscape={onRemove}>
|
||||||
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
|
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -3,18 +3,19 @@ import {View} from 'react-native'
|
|||||||
// @ts-expect-error no type definition
|
// @ts-expect-error no type definition
|
||||||
import ProgressPie from 'react-native-progress/Pie'
|
import ProgressPie from 'react-native-progress/Pie'
|
||||||
import {ImagePickerAsset} from 'expo-image-picker'
|
import {ImagePickerAsset} from 'expo-image-picker'
|
||||||
import {Trans} from '@lingui/macro'
|
|
||||||
|
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Text} from '#/components/Typography'
|
import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn'
|
||||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||||
|
|
||||||
export function VideoTranscodeProgress({
|
export function VideoTranscodeProgress({
|
||||||
asset,
|
asset,
|
||||||
progress,
|
progress,
|
||||||
|
clear,
|
||||||
}: {
|
}: {
|
||||||
asset: ImagePickerAsset
|
asset: ImagePickerAsset
|
||||||
progress: number
|
progress: number
|
||||||
|
clear: () => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
@@ -41,16 +42,14 @@ export function VideoTranscodeProgress({
|
|||||||
a.inset_0,
|
a.inset_0,
|
||||||
]}>
|
]}>
|
||||||
<ProgressPie
|
<ProgressPie
|
||||||
size={64}
|
size={48}
|
||||||
borderWidth={4}
|
borderWidth={3}
|
||||||
borderColor={t.atoms.text.color}
|
borderColor={t.atoms.text.color}
|
||||||
color={t.atoms.text.color}
|
color={t.atoms.text.color}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
/>
|
/>
|
||||||
<Text>
|
|
||||||
<Trans>Compressing...</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -428,6 +428,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
|
|||||||
(item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1
|
(item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1
|
||||||
const hasUnrevealedParents =
|
const hasUnrevealedParents =
|
||||||
index === 0 && skeleton?.parents && maxParents < skeleton.parents.length
|
index === 0 && skeleton?.parents && maxParents < skeleton.parents.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
|
ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {msg, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {track} from 'lib/analytics/analytics'
|
import {track} from 'lib/analytics/analytics'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
@@ -48,7 +47,6 @@ function PostThreadFollowBtnLoaded({
|
|||||||
'PostThreadItem',
|
'PostThreadItem',
|
||||||
)
|
)
|
||||||
const requireAuth = useRequireAuth()
|
const requireAuth = useRequireAuth()
|
||||||
const gate = useGate()
|
|
||||||
|
|
||||||
const isFollowing = !!profile.viewer?.following
|
const isFollowing = !!profile.viewer?.following
|
||||||
const isFollowedBy = !!profile.viewer?.followedBy
|
const isFollowedBy = !!profile.viewer?.followedBy
|
||||||
@@ -140,7 +138,7 @@ function PostThreadFollowBtnLoaded({
|
|||||||
style={[!isFollowing ? palInverted.text : pal.text, s.bold]}
|
style={[!isFollowing ? palInverted.text : pal.text, s.bold]}
|
||||||
numberOfLines={1}>
|
numberOfLines={1}>
|
||||||
{!isFollowing ? (
|
{!isFollowing ? (
|
||||||
isFollowedBy && gate('show_follow_back_label_v2') ? (
|
isFollowedBy ? (
|
||||||
<Trans>Follow Back</Trans>
|
<Trans>Follow Back</Trans>
|
||||||
) : (
|
) : (
|
||||||
<Trans>Follow</Trans>
|
<Trans>Follow</Trans>
|
||||||
|
|||||||
@@ -398,7 +398,9 @@ let PostThreadItemLoaded = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
{post.quoteCount != null && post.quoteCount !== 0 ? (
|
{post.quoteCount != null &&
|
||||||
|
post.quoteCount !== 0 &&
|
||||||
|
!post.viewer?.embeddingDisabled ? (
|
||||||
<Link
|
<Link
|
||||||
style={styles.expandedInfoItem}
|
style={styles.expandedInfoItem}
|
||||||
href={quotesHref}
|
href={quotesHref}
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ let Feed = ({
|
|||||||
ListHeaderComponent,
|
ListHeaderComponent,
|
||||||
extraData,
|
extraData,
|
||||||
savedFeedConfig,
|
savedFeedConfig,
|
||||||
|
initialNumToRender: initialNumToRenderOverride,
|
||||||
}: {
|
}: {
|
||||||
feed: FeedDescriptor
|
feed: FeedDescriptor
|
||||||
feedParams?: FeedParams
|
feedParams?: FeedParams
|
||||||
@@ -180,7 +181,7 @@ let Feed = ({
|
|||||||
ListHeaderComponent?: () => JSX.Element
|
ListHeaderComponent?: () => JSX.Element
|
||||||
extraData?: any
|
extraData?: any
|
||||||
savedFeedConfig?: AppBskyActorDefs.SavedFeed
|
savedFeedConfig?: AppBskyActorDefs.SavedFeed
|
||||||
outsideHeaderOffset?: number
|
initialNumToRender?: number
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
@@ -545,7 +546,7 @@ let Feed = ({
|
|||||||
desktopFixedHeight={
|
desktopFixedHeight={
|
||||||
desktopFixedHeightOffset ? desktopFixedHeightOffset : true
|
desktopFixedHeightOffset ? desktopFixedHeightOffset : true
|
||||||
}
|
}
|
||||||
initialNumToRender={initialNumToRender}
|
initialNumToRender={initialNumToRenderOverride ?? initialNumToRender}
|
||||||
windowSize={11}
|
windowSize={11}
|
||||||
onItemSeen={feedFeedback.onItemSeen}
|
onItemSeen={feedFeedback.onItemSeen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,37 +17,37 @@ import {msg, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {isReasonFeedSource, ReasonFeedSource} from '#/lib/api/feed/types'
|
||||||
|
import {MAX_POST_LINES} from '#/lib/constants'
|
||||||
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
import {countLines} from '#/lib/strings/helpers'
|
||||||
|
import {s} from '#/lib/styles'
|
||||||
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
|
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
|
||||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||||
|
import {precacheProfile} from '#/state/queries/profile'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useComposerControls} from '#/state/shell/composer'
|
import {useComposerControls} from '#/state/shell/composer'
|
||||||
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||||
import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
|
import {FeedNameText} from '#/view/com/util/FeedInfoText'
|
||||||
import {MAX_POST_LINES} from 'lib/constants'
|
import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {PostEmbeds} from '#/view/com/util/post-embeds'
|
||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
import {Text} from '#/view/com/util/text/Text'
|
||||||
import {sanitizeHandle} from 'lib/strings/handles'
|
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {countLines} from 'lib/strings/helpers'
|
|
||||||
import {s} from 'lib/styles'
|
|
||||||
import {precacheProfile} from 'state/queries/profile'
|
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
|
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
|
||||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||||
|
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
|
||||||
|
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||||
import {AppModerationCause} from '#/components/Pills'
|
import {AppModerationCause} from '#/components/Pills'
|
||||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
|
|
||||||
import {PostAlerts} from '../../../components/moderation/PostAlerts'
|
|
||||||
import {FeedNameText} from '../util/FeedInfoText'
|
|
||||||
import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link'
|
import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link'
|
||||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
|
||||||
import {PostEmbeds} from '../util/post-embeds'
|
|
||||||
import {VideoEmbed} from '../util/post-embeds/VideoEmbed'
|
import {VideoEmbed} from '../util/post-embeds/VideoEmbed'
|
||||||
import {PostMeta} from '../util/PostMeta'
|
|
||||||
import {Text} from '../util/text/Text'
|
|
||||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
|
||||||
import {AviFollowButton} from './AviFollowButton'
|
import {AviFollowButton} from './AviFollowButton'
|
||||||
|
|
||||||
interface FeedItemProps {
|
interface FeedItemProps {
|
||||||
@@ -571,7 +571,11 @@ function VideoDebug() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VideoEmbed
|
<VideoEmbed
|
||||||
source={`https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8?ignore_me_just_testing_frontend_stuff=${id}`}
|
embed={{
|
||||||
|
playlist: `https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8?ignore_me_just_testing_frontend_stuff=${id}`,
|
||||||
|
cid: 'Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ',
|
||||||
|
aspectRatio: {height: 9, width: 16},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {useVideoPlayer, VideoPlayer} from 'expo-video'
|
||||||
|
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
|
|
||||||
|
const Context = React.createContext<{
|
||||||
|
activeSource: string | null
|
||||||
|
setActiveSource: (src: string) => void
|
||||||
|
player: VideoPlayer
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
export function Provider({children}: {children: React.ReactNode}) {
|
||||||
|
if (!isNative) {
|
||||||
|
throw new Error('ActiveVideoProvider may only be used on native.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [activeSource, setActiveSource] = React.useState('')
|
||||||
|
|
||||||
|
const player = useVideoPlayer(activeSource, p => {
|
||||||
|
p.muted = true
|
||||||
|
p.loop = true
|
||||||
|
p.play()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Context.Provider value={{activeSource, setActiveSource, player}}>
|
||||||
|
{children}
|
||||||
|
</Context.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActiveVideoNative() {
|
||||||
|
const context = React.useContext(Context)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useActiveVideoNative must be used within a ActiveVideoNativeProvider',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
+23
-28
@@ -8,19 +8,21 @@ import React, {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import {useWindowDimensions} from 'react-native'
|
import {useWindowDimensions} from 'react-native'
|
||||||
|
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative, isWeb} from '#/platform/detection'
|
||||||
import {VideoPlayerProvider} from './VideoPlayerContext'
|
|
||||||
|
|
||||||
const ActiveVideoContext = React.createContext<{
|
const Context = React.createContext<{
|
||||||
activeViewId: string | null
|
activeViewId: string | null
|
||||||
setActiveView: (viewId: string, src: string) => void
|
setActiveView: (viewId: string) => void
|
||||||
sendViewPosition: (viewId: string, y: number) => void
|
sendViewPosition: (viewId: string, y: number) => void
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
|
export function Provider({children}: {children: React.ReactNode}) {
|
||||||
|
if (!isWeb) {
|
||||||
|
throw new Error('ActiveVideoWebContext may onl be used on web.')
|
||||||
|
}
|
||||||
|
|
||||||
const [activeViewId, setActiveViewId] = useState<string | null>(null)
|
const [activeViewId, setActiveViewId] = useState<string | null>(null)
|
||||||
const activeViewLocationRef = useRef(Infinity)
|
const activeViewLocationRef = useRef(Infinity)
|
||||||
const [source, setSource] = useState<string | null>(null)
|
|
||||||
const {height: windowHeight} = useWindowDimensions()
|
const {height: windowHeight} = useWindowDimensions()
|
||||||
|
|
||||||
// minimising re-renders by using refs
|
// minimising re-renders by using refs
|
||||||
@@ -31,9 +33,8 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
|
|||||||
}, [activeViewId])
|
}, [activeViewId])
|
||||||
|
|
||||||
const setActiveView = useCallback(
|
const setActiveView = useCallback(
|
||||||
(viewId: string, src: string) => {
|
(viewId: string) => {
|
||||||
setActiveViewId(viewId)
|
setActiveViewId(viewId)
|
||||||
setSource(src)
|
|
||||||
manuallySetRef.current = true
|
manuallySetRef.current = true
|
||||||
// we don't know the exact position, but it's definitely on screen
|
// we don't know the exact position, but it's definitely on screen
|
||||||
// so just guess that it's in the middle. Any value is fine
|
// so just guess that it's in the middle. Any value is fine
|
||||||
@@ -88,32 +89,26 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
|
|||||||
[activeViewId, setActiveView, sendViewPosition],
|
[activeViewId, setActiveView, sendViewPosition],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return <Context.Provider value={value}>{children}</Context.Provider>
|
||||||
<ActiveVideoContext.Provider value={value}>
|
|
||||||
<VideoPlayerProvider source={source ?? ''}>
|
|
||||||
{children}
|
|
||||||
</VideoPlayerProvider>
|
|
||||||
</ActiveVideoContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useActiveVideoView({source}: {source: string}) {
|
export function useActiveVideoWeb() {
|
||||||
const context = React.useContext(ActiveVideoContext)
|
const context = React.useContext(Context)
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useActiveVideo must be used within a ActiveVideoProvider')
|
throw new Error(
|
||||||
|
'useActiveVideoWeb must be used within a ActiveVideoWebProvider',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {activeViewId, setActiveView, sendViewPosition} = context
|
||||||
const id = useId()
|
const id = useId()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
active: context.activeViewId === id,
|
active: activeViewId === id,
|
||||||
setActive: useCallback(
|
setActive: () => {
|
||||||
() => context.setActiveView(id, source),
|
setActiveView(id)
|
||||||
[context, id, source],
|
},
|
||||||
),
|
currentActiveView: activeViewId,
|
||||||
currentActiveView: context.activeViewId,
|
sendPosition: (y: number) => sendViewPosition(id, y),
|
||||||
sendPosition: useCallback(
|
|
||||||
(y: number) => context.sendViewPosition(id, y),
|
|
||||||
[context, id],
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,25 @@
|
|||||||
import React, {useCallback, useState} from 'react'
|
import React, {useCallback, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {VideoEmbedInnerNative} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
|
import {clamp} from '#/lib/numbers'
|
||||||
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
|
import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
|
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||||
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
|
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
|
||||||
import {ErrorBoundary} from '../ErrorBoundary'
|
import {ErrorBoundary} from '../ErrorBoundary'
|
||||||
import {useActiveVideoView} from './ActiveVideoContext'
|
import {useActiveVideoNative} from './ActiveVideoNativeContext'
|
||||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||||
|
|
||||||
export function VideoEmbed({source}: {source: string}) {
|
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {active, setActive} = useActiveVideoView({source})
|
const {activeSource, setActiveSource} = useActiveVideoNative()
|
||||||
|
const isActive = embed.playlist === activeSource
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
const [key, setKey] = useState(0)
|
const [key, setKey] = useState(0)
|
||||||
@@ -24,37 +29,61 @@ export function VideoEmbed({source}: {source: string}) {
|
|||||||
),
|
),
|
||||||
[key],
|
[key],
|
||||||
)
|
)
|
||||||
|
const gate = useGate()
|
||||||
|
|
||||||
|
if (!gate('videos')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let aspectRatio = 16 / 9
|
||||||
|
|
||||||
|
if (embed.aspectRatio) {
|
||||||
|
const {width, height} = embed.aspectRatio
|
||||||
|
aspectRatio = width / height
|
||||||
|
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.rounded_sm,
|
a.rounded_sm,
|
||||||
{aspectRatio: 16 / 9},
|
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
t.atoms.bg_contrast_25,
|
{aspectRatio},
|
||||||
|
{backgroundColor: t.palette.black},
|
||||||
a.my_xs,
|
a.my_xs,
|
||||||
]}>
|
]}>
|
||||||
<ErrorBoundary renderError={renderError} key={key}>
|
<ErrorBoundary renderError={renderError} key={key}>
|
||||||
<VisibilityView
|
<VisibilityView
|
||||||
enabled={true}
|
enabled={true}
|
||||||
onChangeStatus={isActive => {
|
onChangeStatus={isVisible => {
|
||||||
if (isActive) {
|
if (isVisible) {
|
||||||
setActive()
|
setActiveSource(embed.playlist)
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{active ? (
|
{isActive ? (
|
||||||
<VideoEmbedInnerNative />
|
<VideoEmbedInnerNative embed={embed} />
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<>
|
||||||
style={[a.flex_1, t.atoms.bg_contrast_25]}
|
<Image
|
||||||
onPress={setActive}
|
source={{uri: embed.thumbnail}}
|
||||||
label={_(msg`Play video`)}
|
alt={embed.alt}
|
||||||
variant="ghost"
|
style={a.flex_1}
|
||||||
color="secondary"
|
contentFit="contain"
|
||||||
size="large">
|
accessibilityIgnoresInvertColors
|
||||||
<ButtonIcon icon={PlayIcon} />
|
/>
|
||||||
</Button>
|
<Button
|
||||||
|
style={[a.absolute, a.inset_0]}
|
||||||
|
onPress={() => {
|
||||||
|
setActiveSource(embed.playlist)
|
||||||
|
}}
|
||||||
|
label={_(msg`Play video`)}
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
size="large">
|
||||||
|
<PlayIcon width={48} fill={t.palette.white} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</VisibilityView>
|
</VisibilityView>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
|
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||||
import {Trans} from '@lingui/macro'
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {clamp} from '#/lib/numbers'
|
||||||
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
import {
|
import {
|
||||||
HLSUnsupportedError,
|
HLSUnsupportedError,
|
||||||
VideoEmbedInnerWeb,
|
VideoEmbedInnerWeb,
|
||||||
} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
|
} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {ErrorBoundary} from '../ErrorBoundary'
|
import {ErrorBoundary} from '../ErrorBoundary'
|
||||||
import {useActiveVideoView} from './ActiveVideoContext'
|
import {useActiveVideoWeb} from './ActiveVideoWebContext'
|
||||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||||
|
|
||||||
export function VideoEmbed({source}: {source: string}) {
|
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const gate = useGate()
|
||||||
const {active, setActive, sendPosition, currentActiveView} =
|
const {active, setActive, sendPosition, currentActiveView} =
|
||||||
useActiveVideoView({source})
|
useActiveVideoWeb()
|
||||||
const [onScreen, setOnScreen] = useState(false)
|
const [onScreen, setOnScreen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,12 +47,25 @@ export function VideoEmbed({source}: {source: string}) {
|
|||||||
[key],
|
[key],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!gate('videos')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let aspectRatio = 16 / 9
|
||||||
|
|
||||||
|
if (embed.aspectRatio) {
|
||||||
|
const {width, height} = embed.aspectRatio
|
||||||
|
// min: 3/1, max: square
|
||||||
|
aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.w_full,
|
a.w_full,
|
||||||
{aspectRatio: 16 / 9},
|
{aspectRatio},
|
||||||
t.atoms.bg_contrast_25,
|
{backgroundColor: t.palette.black},
|
||||||
|
a.relative,
|
||||||
a.rounded_sm,
|
a.rounded_sm,
|
||||||
a.my_xs,
|
a.my_xs,
|
||||||
]}>
|
]}>
|
||||||
@@ -61,7 +78,7 @@ export function VideoEmbed({source}: {source: string}) {
|
|||||||
sendPosition={sendPosition}
|
sendPosition={sendPosition}
|
||||||
isAnyViewActive={currentActiveView !== null}>
|
isAnyViewActive={currentActiveView !== null}>
|
||||||
<VideoEmbedInnerWeb
|
<VideoEmbedInnerWeb
|
||||||
source={source}
|
embed={embed}
|
||||||
active={active}
|
active={active}
|
||||||
setActive={setActive}
|
setActive={setActive}
|
||||||
onScreen={onScreen}
|
onScreen={onScreen}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated'
|
||||||
|
|
||||||
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolutely positioned time indicator showing how many seconds are remaining
|
||||||
|
* Time is in seconds
|
||||||
|
*/
|
||||||
|
export function TimeIndicator({time}: {time: number}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
if (isNaN(time)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(time / 60)
|
||||||
|
const seconds = String(time % 60).padStart(2, '0')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={native(FadeInDown.duration(300))}
|
||||||
|
exiting={native(FadeOutDown.duration(500))}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 3,
|
||||||
|
position: 'absolute',
|
||||||
|
left: 5,
|
||||||
|
bottom: 5,
|
||||||
|
minHeight: 20,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
{color: t.palette.white, fontSize: 12},
|
||||||
|
a.font_bold,
|
||||||
|
{lineHeight: 1.25},
|
||||||
|
]}>
|
||||||
|
{minutes}:{seconds}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,26 +1,33 @@
|
|||||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||||
import {Pressable, View} from 'react-native'
|
import {Pressable, View} from 'react-native'
|
||||||
import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated'
|
import Animated, {FadeInDown} from 'react-native-reanimated'
|
||||||
import {VideoPlayer, VideoView} from 'expo-video'
|
import {VideoPlayer, VideoView} from 'expo-video'
|
||||||
|
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useIsFocused} from '@react-navigation/native'
|
import {useIsFocused} from '@react-navigation/native'
|
||||||
|
|
||||||
import {HITSLOP_30} from '#/lib/constants'
|
import {HITSLOP_30} from '#/lib/constants'
|
||||||
import {useAppState} from '#/lib/hooks/useAppState'
|
import {useAppState} from '#/lib/hooks/useAppState'
|
||||||
|
import {clamp} from '#/lib/numbers'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useVideoPlayer} from '#/view/com/util/post-embeds/VideoPlayerContext'
|
import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext'
|
||||||
import {android, atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||||
import {Text} from '#/components/Typography'
|
|
||||||
import {
|
import {
|
||||||
AudioCategory,
|
AudioCategory,
|
||||||
PlatformInfo,
|
PlatformInfo,
|
||||||
} from '../../../../../../modules/expo-bluesky-swiss-army'
|
} from '../../../../../../modules/expo-bluesky-swiss-army'
|
||||||
|
import {TimeIndicator} from './TimeIndicator'
|
||||||
|
|
||||||
export function VideoEmbedInnerNative() {
|
export function VideoEmbedInnerNative({
|
||||||
const player = useVideoPlayer()
|
embed,
|
||||||
|
}: {
|
||||||
|
embed: AppBskyEmbedVideo.View
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {player} = useActiveVideoNative()
|
||||||
const ref = useRef<VideoView>(null)
|
const ref = useRef<VideoView>(null)
|
||||||
const isScreenFocused = useIsFocused()
|
const isScreenFocused = useIsFocused()
|
||||||
const isAppFocused = useAppState()
|
const isAppFocused = useAppState()
|
||||||
@@ -47,13 +54,23 @@ export function VideoEmbedInnerNative() {
|
|||||||
ref.current?.enterFullscreen()
|
ref.current?.enterFullscreen()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
let aspectRatio = 16 / 9
|
||||||
|
|
||||||
|
if (embed.aspectRatio) {
|
||||||
|
const {width, height} = embed.aspectRatio
|
||||||
|
aspectRatio = width / height
|
||||||
|
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_1, a.relative]}>
|
<View style={[a.flex_1, a.relative, {aspectRatio}]}>
|
||||||
<VideoView
|
<VideoView
|
||||||
ref={ref}
|
ref={ref}
|
||||||
player={player}
|
player={player}
|
||||||
style={[a.flex_1, a.rounded_sm]}
|
style={[a.flex_1, a.rounded_sm]}
|
||||||
|
contentFit="contain"
|
||||||
nativeControls={true}
|
nativeControls={true}
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
onEnterFullscreen={() => {
|
onEnterFullscreen={() => {
|
||||||
PlatformInfo.setAudioCategory(AudioCategory.Playback)
|
PlatformInfo.setAudioCategory(AudioCategory.Playback)
|
||||||
PlatformInfo.setAudioActive(true)
|
PlatformInfo.setAudioActive(true)
|
||||||
@@ -65,13 +82,17 @@ export function VideoEmbedInnerNative() {
|
|||||||
player.muted = true
|
player.muted = true
|
||||||
if (!player.playing) player.play()
|
if (!player.playing) player.play()
|
||||||
}}
|
}}
|
||||||
|
accessibilityLabel={
|
||||||
|
embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`)
|
||||||
|
}
|
||||||
|
accessibilityHint=""
|
||||||
/>
|
/>
|
||||||
<Controls player={player} enterFullscreen={enterFullscreen} />
|
<VideoControls player={player} enterFullscreen={enterFullscreen} />
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Controls({
|
function VideoControls({
|
||||||
player,
|
player,
|
||||||
enterFullscreen,
|
enterFullscreen,
|
||||||
}: {
|
}: {
|
||||||
@@ -81,33 +102,22 @@ function Controls({
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const [isMuted, setIsMuted] = useState(player.muted)
|
const [isMuted, setIsMuted] = useState(player.muted)
|
||||||
const [duration, setDuration] = useState(() => Math.floor(player.duration))
|
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
||||||
const [currentTime, setCurrentTime] = useState(() =>
|
|
||||||
Math.floor(player.currentTime),
|
|
||||||
)
|
|
||||||
|
|
||||||
const timeRemaining = duration - currentTime
|
|
||||||
const minutes = Math.floor(timeRemaining / 60)
|
|
||||||
const seconds = String(timeRemaining % 60).padStart(2, '0')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
|
||||||
// duration gets reset to 0 on loop
|
|
||||||
if (player.duration) setDuration(Math.floor(player.duration))
|
|
||||||
setCurrentTime(Math.floor(player.currentTime))
|
|
||||||
|
|
||||||
// how often should we update the time?
|
|
||||||
// 1000 gets out of sync with the video time
|
|
||||||
}, 250)
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||||
const sub = player.addListener('volumeChange', ({isMuted}) => {
|
const volumeSub = player.addListener('volumeChange', ({isMuted}) => {
|
||||||
setIsMuted(isMuted)
|
setIsMuted(isMuted)
|
||||||
})
|
})
|
||||||
|
const timeSub = player.addListener(
|
||||||
|
'timeRemainingChange',
|
||||||
|
secondsRemaining => {
|
||||||
|
setTimeRemaining(secondsRemaining)
|
||||||
|
},
|
||||||
|
)
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(interval)
|
volumeSub.remove()
|
||||||
sub.remove()
|
timeSub.remove()
|
||||||
}
|
}
|
||||||
}, [player])
|
}, [player])
|
||||||
|
|
||||||
@@ -143,37 +153,11 @@ function Controls({
|
|||||||
// 1. timeRemaining is a number - was seeing NaNs
|
// 1. timeRemaining is a number - was seeing NaNs
|
||||||
// 2. duration is greater than 0 - means metadata has loaded
|
// 2. duration is greater than 0 - means metadata has loaded
|
||||||
// 3. we're less than 5 second into the video
|
// 3. we're less than 5 second into the video
|
||||||
const showTime = !isNaN(timeRemaining) && duration > 0 && currentTime <= 5
|
const showTime = !isNaN(timeRemaining)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.absolute, a.inset_0]}>
|
<View style={[a.absolute, a.inset_0]}>
|
||||||
{showTime && (
|
{showTime && <TimeIndicator time={timeRemaining} />}
|
||||||
<Animated.View
|
|
||||||
entering={FadeInDown.duration(300)}
|
|
||||||
exiting={FadeOutDown.duration(500)}
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
|
||||||
borderRadius: 6,
|
|
||||||
paddingHorizontal: 6,
|
|
||||||
paddingVertical: 3,
|
|
||||||
position: 'absolute',
|
|
||||||
left: 5,
|
|
||||||
bottom: 5,
|
|
||||||
minHeight: 20,
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
{color: t.palette.white, fontSize: 12},
|
|
||||||
a.font_bold,
|
|
||||||
android({lineHeight: 1.25}),
|
|
||||||
]}>
|
|
||||||
{minutes}:{seconds}
|
|
||||||
</Text>
|
|
||||||
</Animated.View>
|
|
||||||
)}
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPressFullscreen}
|
onPress={onPressFullscreen}
|
||||||
style={a.flex_1}
|
style={a.flex_1}
|
||||||
@@ -181,35 +165,33 @@ function Controls({
|
|||||||
accessibilityHint={_(msg`Tap to enter full screen`)}
|
accessibilityHint={_(msg`Tap to enter full screen`)}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
/>
|
/>
|
||||||
{duration > 0 && (
|
<Animated.View
|
||||||
<Animated.View
|
entering={FadeInDown.duration(300)}
|
||||||
entering={FadeInDown.duration(300)}
|
style={{
|
||||||
style={{
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
borderRadius: 6,
|
||||||
borderRadius: 6,
|
paddingHorizontal: 6,
|
||||||
paddingHorizontal: 6,
|
paddingVertical: 3,
|
||||||
paddingVertical: 3,
|
position: 'absolute',
|
||||||
position: 'absolute',
|
bottom: 5,
|
||||||
bottom: 5,
|
right: 5,
|
||||||
right: 5,
|
minHeight: 20,
|
||||||
minHeight: 20,
|
justifyContent: 'center',
|
||||||
justifyContent: 'center',
|
}}>
|
||||||
}}>
|
<Pressable
|
||||||
<Pressable
|
onPress={toggleMuted}
|
||||||
onPress={toggleMuted}
|
style={a.flex_1}
|
||||||
style={a.flex_1}
|
accessibilityLabel={isMuted ? _(msg`Muted`) : _(msg`Unmuted`)}
|
||||||
accessibilityLabel={isMuted ? _(msg`Muted`) : _(msg`Unmuted`)}
|
accessibilityHint={_(msg`Tap to toggle sound`)}
|
||||||
accessibilityHint={_(msg`Tap to toggle sound`)}
|
accessibilityRole="button"
|
||||||
accessibilityRole="button"
|
hitSlop={HITSLOP_30}>
|
||||||
hitSlop={HITSLOP_30}>
|
{isMuted ? (
|
||||||
{isMuted ? (
|
<MuteIcon width={14} fill={t.palette.white} />
|
||||||
<MuteIcon width={14} fill={t.palette.white} />
|
) : (
|
||||||
) : (
|
<UnmuteIcon width={14} fill={t.palette.white} />
|
||||||
<UnmuteIcon width={14} fill={t.palette.white} />
|
)}
|
||||||
)}
|
</Pressable>
|
||||||
</Pressable>
|
</Animated.View>
|
||||||
</Animated.View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,27 @@
|
|||||||
import React, {useEffect, useRef, useState} from 'react'
|
import React, {useEffect, useId, useRef, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
|
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||||
import Hls from 'hls.js'
|
import Hls from 'hls.js'
|
||||||
|
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Controls} from './VideoWebControls'
|
import {Controls} from './VideoWebControls'
|
||||||
|
|
||||||
export function VideoEmbedInnerWeb({
|
export function VideoEmbedInnerWeb({
|
||||||
source,
|
embed,
|
||||||
active,
|
active,
|
||||||
setActive,
|
setActive,
|
||||||
onScreen,
|
onScreen,
|
||||||
}: {
|
}: {
|
||||||
source: string
|
embed: AppBskyEmbedVideo.View
|
||||||
active?: boolean
|
active: boolean
|
||||||
setActive?: () => void
|
setActive: () => void
|
||||||
onScreen?: boolean
|
onScreen: boolean
|
||||||
}) {
|
}) {
|
||||||
if (active == null || setActive == null || onScreen == null) {
|
|
||||||
throw new Error(
|
|
||||||
'active, setActive, and onScreen are required VideoEmbedInner props on web.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const ref = useRef<HTMLVideoElement>(null)
|
const ref = useRef<HTMLVideoElement>(null)
|
||||||
const [focused, setFocused] = useState(false)
|
const [focused, setFocused] = useState(false)
|
||||||
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
|
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
|
||||||
|
const figId = useId()
|
||||||
|
|
||||||
const hlsRef = useRef<Hls | undefined>(undefined)
|
const hlsRef = useRef<Hls | undefined>(undefined)
|
||||||
|
|
||||||
@@ -37,7 +33,7 @@ export function VideoEmbedInnerWeb({
|
|||||||
hlsRef.current = hls
|
hlsRef.current = hls
|
||||||
|
|
||||||
hls.attachMedia(ref.current)
|
hls.attachMedia(ref.current)
|
||||||
hls.loadSource(source)
|
hls.loadSource(embed.playlist)
|
||||||
|
|
||||||
// initial value, later on it's managed by Controls
|
// initial value, later on it's managed by Controls
|
||||||
hls.autoLevelCapping = 0
|
hls.autoLevelCapping = 0
|
||||||
@@ -53,29 +49,40 @@ export function VideoEmbedInnerWeb({
|
|||||||
hls.detachMedia()
|
hls.detachMedia()
|
||||||
hls.destroy()
|
hls.destroy()
|
||||||
}
|
}
|
||||||
}, [source])
|
}, [embed.playlist])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View style={[a.flex_1, a.rounded_sm, a.overflow_hidden]}>
|
||||||
style={[
|
<div ref={containerRef} style={{height: '100%', width: '100%'}}>
|
||||||
a.w_full,
|
<figure style={{margin: 0, position: 'absolute', inset: 0}}>
|
||||||
a.rounded_sm,
|
<video
|
||||||
// TODO: get from embed metadata
|
ref={ref}
|
||||||
// max should be 1 / 1
|
poster={embed.thumbnail}
|
||||||
{aspectRatio: 16 / 9},
|
style={{width: '100%', height: '100%', objectFit: 'contain'}}
|
||||||
a.overflow_hidden,
|
playsInline
|
||||||
]}>
|
preload="none"
|
||||||
<div
|
loop
|
||||||
ref={containerRef}
|
muted={!focused}
|
||||||
style={{width: '100%', height: '100%', display: 'flex'}}>
|
aria-labelledby={embed.alt ? figId : undefined}
|
||||||
<video
|
/>
|
||||||
ref={ref}
|
{embed.alt && (
|
||||||
style={{width: '100%', height: '100%', objectFit: 'contain'}}
|
<figcaption
|
||||||
playsInline
|
id={figId}
|
||||||
preload="none"
|
style={{
|
||||||
loop
|
position: 'absolute',
|
||||||
muted={!focused}
|
width: 1,
|
||||||
/>
|
height: 1,
|
||||||
|
padding: 0,
|
||||||
|
margin: -1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
clip: 'rect(0, 0, 0, 0)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
borderWidth: 0,
|
||||||
|
}}>
|
||||||
|
{embed.alt}
|
||||||
|
</figcaption>
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
<Controls
|
<Controls
|
||||||
videoRef={ref}
|
videoRef={ref}
|
||||||
hlsRef={hlsRef}
|
hlsRef={hlsRef}
|
||||||
|
|||||||
@@ -6,17 +6,19 @@ import React, {
|
|||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {Pressable, View} from 'react-native'
|
import {Pressable, View} from 'react-native'
|
||||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
import {SvgProps} from 'react-native-svg'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import type Hls from 'hls.js'
|
import type Hls from 'hls.js'
|
||||||
|
|
||||||
import {isIPhoneWeb} from 'platform/detection'
|
import {isFirefox} from '#/lib/browser'
|
||||||
|
import {clamp} from '#/lib/numbers'
|
||||||
|
import {isIPhoneWeb} from '#/platform/detection'
|
||||||
import {
|
import {
|
||||||
useAutoplayDisabled,
|
useAutoplayDisabled,
|
||||||
useSetSubtitlesEnabled,
|
useSetSubtitlesEnabled,
|
||||||
useSubtitlesEnabled,
|
useSubtitlesEnabled,
|
||||||
} from 'state/preferences'
|
} from '#/state/preferences'
|
||||||
import {atoms as a, useTheme, web} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
@@ -34,6 +36,7 @@ import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
|||||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {TimeIndicator} from './TimeIndicator'
|
||||||
|
|
||||||
export function Controls({
|
export function Controls({
|
||||||
videoRef,
|
videoRef,
|
||||||
@@ -173,6 +176,50 @@ export function Controls({
|
|||||||
toggleFullscreen()
|
toggleFullscreen()
|
||||||
}, [drawFocus, toggleFullscreen])
|
}, [drawFocus, toggleFullscreen])
|
||||||
|
|
||||||
|
const onSeek = useCallback(
|
||||||
|
(time: number) => {
|
||||||
|
if (!videoRef.current) return
|
||||||
|
if (videoRef.current.fastSeek) {
|
||||||
|
videoRef.current.fastSeek(time)
|
||||||
|
} else {
|
||||||
|
videoRef.current.currentTime = time
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[videoRef],
|
||||||
|
)
|
||||||
|
|
||||||
|
const playStateBeforeSeekRef = useRef(false)
|
||||||
|
|
||||||
|
const onSeekStart = useCallback(() => {
|
||||||
|
drawFocus()
|
||||||
|
playStateBeforeSeekRef.current = playing
|
||||||
|
pause()
|
||||||
|
}, [playing, pause, drawFocus])
|
||||||
|
|
||||||
|
const onSeekEnd = useCallback(() => {
|
||||||
|
if (playStateBeforeSeekRef.current) {
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
}, [play])
|
||||||
|
|
||||||
|
const seekLeft = useCallback(() => {
|
||||||
|
if (!videoRef.current) return
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||||
|
const currentTime = videoRef.current.currentTime
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||||
|
const duration = videoRef.current.duration || 0
|
||||||
|
onSeek(clamp(currentTime - 5, 0, duration))
|
||||||
|
}, [onSeek, videoRef])
|
||||||
|
|
||||||
|
const seekRight = useCallback(() => {
|
||||||
|
if (!videoRef.current) return
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||||
|
const currentTime = videoRef.current.currentTime
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||||
|
const duration = videoRef.current.duration || 0
|
||||||
|
onSeek(clamp(currentTime + 5, 0, duration))
|
||||||
|
}, [onSeek, videoRef])
|
||||||
|
|
||||||
const showControls =
|
const showControls =
|
||||||
(focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
|
(focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
|
||||||
|
|
||||||
@@ -197,7 +244,7 @@ export function Controls({
|
|||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityHint={_(
|
accessibilityHint={_(
|
||||||
focused
|
!focused
|
||||||
? msg`Unmute video`
|
? msg`Unmute video`
|
||||||
: playing
|
: playing
|
||||||
? msg`Pause video`
|
? msg`Pause video`
|
||||||
@@ -206,107 +253,87 @@ export function Controls({
|
|||||||
style={a.flex_1}
|
style={a.flex_1}
|
||||||
onPress={onPressEmptySpace}
|
onPress={onPressEmptySpace}
|
||||||
/>
|
/>
|
||||||
|
{active && !showControls && !focused && (
|
||||||
|
<TimeIndicator time={Math.floor(duration - currentTime)} />
|
||||||
|
)}
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.flex_shrink_0,
|
a.flex_shrink_0,
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.px_sm,
|
a.px_xs,
|
||||||
a.pt_sm,
|
|
||||||
a.pb_md,
|
|
||||||
a.gap_md,
|
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
web({
|
web({
|
||||||
background:
|
background:
|
||||||
'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))',
|
'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))',
|
||||||
}),
|
}),
|
||||||
showControls ? {opacity: 1} : {opacity: 0},
|
{opacity: showControls ? 1 : 0},
|
||||||
|
{transition: 'opacity 0.2s ease-in-out'},
|
||||||
]}>
|
]}>
|
||||||
<Button
|
<Scrubber
|
||||||
label={_(playing ? msg`Pause` : msg`Play`)}
|
duration={duration}
|
||||||
onPress={onPressPlayPause}
|
currentTime={currentTime}
|
||||||
{...btnProps}>
|
onSeek={onSeek}
|
||||||
{playing ? (
|
onSeekStart={onSeekStart}
|
||||||
<PauseIcon fill={t.palette.white} width={20} />
|
onSeekEnd={onSeekEnd}
|
||||||
) : (
|
seekLeft={seekLeft}
|
||||||
<PlayIcon fill={t.palette.white} width={20} />
|
seekRight={seekRight}
|
||||||
)}
|
togglePlayPause={togglePlayPause}
|
||||||
</Button>
|
drawFocus={drawFocus}
|
||||||
<View style={a.flex_1} />
|
/>
|
||||||
<Text style={{color: t.palette.white}}>
|
<View
|
||||||
{formatTime(currentTime)} / {formatTime(duration)}
|
|
||||||
</Text>
|
|
||||||
{hasSubtitleTrack && (
|
|
||||||
<Button
|
|
||||||
label={_(
|
|
||||||
subtitlesEnabled ? msg`Disable subtitles` : msg`Enable subtitles`,
|
|
||||||
)}
|
|
||||||
onPress={onPressSubtitles}
|
|
||||||
{...btnProps}>
|
|
||||||
{subtitlesEnabled ? (
|
|
||||||
<CCActiveIcon fill={t.palette.white} width={20} />
|
|
||||||
) : (
|
|
||||||
<CCInactiveIcon fill={t.palette.white} width={20} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
label={_(muted ? msg`Unmute` : msg`Mute`)}
|
|
||||||
onPress={onPressMute}
|
|
||||||
{...btnProps}>
|
|
||||||
{muted ? (
|
|
||||||
<MuteIcon fill={t.palette.white} width={20} />
|
|
||||||
) : (
|
|
||||||
<UnmuteIcon fill={t.palette.white} width={20} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{!isIPhoneWeb && (
|
|
||||||
<Button
|
|
||||||
label={_(muted ? msg`Unmute` : msg`Mute`)}
|
|
||||||
onPress={onPressFullscreen}
|
|
||||||
{...btnProps}>
|
|
||||||
{isFullscreen ? (
|
|
||||||
<ArrowsInIcon fill={t.palette.white} width={20} />
|
|
||||||
) : (
|
|
||||||
<ArrowsOutIcon fill={t.palette.white} width={20} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{(showControls || !focused) && (
|
|
||||||
<Animated.View
|
|
||||||
entering={FadeIn.duration(200)}
|
|
||||||
exiting={FadeOut.duration(200)}
|
|
||||||
style={[
|
style={[
|
||||||
a.absolute,
|
a.flex_1,
|
||||||
{
|
a.px_xs,
|
||||||
height: 5,
|
a.pt_sm,
|
||||||
bottom: 0,
|
a.pb_md,
|
||||||
left: 0,
|
a.gap_md,
|
||||||
right: 0,
|
a.flex_row,
|
||||||
backgroundColor: 'rgba(255,255,255,0.4)',
|
a.align_center,
|
||||||
},
|
|
||||||
]}>
|
]}>
|
||||||
{duration > 0 && (
|
<ControlButton
|
||||||
<View
|
active={playing}
|
||||||
style={[
|
activeLabel={_(msg`Pause`)}
|
||||||
a.h_full,
|
inactiveLabel={_(msg`Play`)}
|
||||||
a.mr_auto,
|
activeIcon={PauseIcon}
|
||||||
{
|
inactiveIcon={PlayIcon}
|
||||||
backgroundColor: t.palette.white,
|
onPress={onPressPlayPause}
|
||||||
width: `${(currentTime / duration) * 100}%`,
|
/>
|
||||||
opacity: 0.8,
|
<View style={a.flex_1} />
|
||||||
},
|
<Text style={{color: t.palette.white}}>
|
||||||
]}
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</Text>
|
||||||
|
{hasSubtitleTrack && (
|
||||||
|
<ControlButton
|
||||||
|
active={subtitlesEnabled}
|
||||||
|
activeLabel={_(msg`Disable subtitles`)}
|
||||||
|
inactiveLabel={_(msg`Enable subtitles`)}
|
||||||
|
activeIcon={CCActiveIcon}
|
||||||
|
inactiveIcon={CCInactiveIcon}
|
||||||
|
onPress={onPressSubtitles}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
<ControlButton
|
||||||
)}
|
active={muted}
|
||||||
|
activeLabel={_(msg`Unmute`)}
|
||||||
|
inactiveLabel={_(msg`Mute`)}
|
||||||
|
activeIcon={MuteIcon}
|
||||||
|
inactiveIcon={UnmuteIcon}
|
||||||
|
onPress={onPressMute}
|
||||||
|
/>
|
||||||
|
{!isIPhoneWeb && (
|
||||||
|
<ControlButton
|
||||||
|
active={isFullscreen}
|
||||||
|
activeLabel={_(msg`Exit fullscreen`)}
|
||||||
|
inactiveLabel={_(msg`Fullscreen`)}
|
||||||
|
activeIcon={ArrowsInIcon}
|
||||||
|
inactiveIcon={ArrowsOutIcon}
|
||||||
|
onPress={onPressFullscreen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
{(buffering || error) && (
|
{(buffering || error) && (
|
||||||
<Animated.View
|
<View
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
entering={FadeIn.delay(1000).duration(200)}
|
|
||||||
exiting={FadeOut.duration(200)}
|
|
||||||
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
{buffering && <Loader fill={t.palette.white} size="lg" />}
|
{buffering && <Loader fill={t.palette.white} size="lg" />}
|
||||||
{error && (
|
{error && (
|
||||||
@@ -314,19 +341,278 @@ export function Controls({
|
|||||||
<Trans>An error occurred</Trans>
|
<Trans>An error occurred</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const btnProps = {
|
function ControlButton({
|
||||||
variant: 'ghost',
|
active,
|
||||||
shape: 'round',
|
activeLabel,
|
||||||
size: 'medium',
|
inactiveLabel,
|
||||||
style: a.p_2xs,
|
activeIcon: ActiveIcon,
|
||||||
hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'},
|
inactiveIcon: InactiveIcon,
|
||||||
} as const
|
onPress,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
activeLabel: string
|
||||||
|
inactiveLabel: string
|
||||||
|
activeIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>>
|
||||||
|
inactiveIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>>
|
||||||
|
onPress: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
label={active ? activeLabel : inactiveLabel}
|
||||||
|
onPress={onPress}
|
||||||
|
variant="ghost"
|
||||||
|
shape="round"
|
||||||
|
size="medium"
|
||||||
|
style={a.p_2xs}
|
||||||
|
hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.1)'}}>
|
||||||
|
{active ? (
|
||||||
|
<ActiveIcon fill={t.palette.white} width={20} />
|
||||||
|
) : (
|
||||||
|
<InactiveIcon fill={t.palette.white} width={20} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Scrubber({
|
||||||
|
duration,
|
||||||
|
currentTime,
|
||||||
|
onSeek,
|
||||||
|
onSeekEnd,
|
||||||
|
onSeekStart,
|
||||||
|
seekLeft,
|
||||||
|
seekRight,
|
||||||
|
togglePlayPause,
|
||||||
|
drawFocus,
|
||||||
|
}: {
|
||||||
|
duration: number
|
||||||
|
currentTime: number
|
||||||
|
onSeek: (time: number) => void
|
||||||
|
onSeekEnd: () => void
|
||||||
|
onSeekStart: () => void
|
||||||
|
seekLeft: () => void
|
||||||
|
seekRight: () => void
|
||||||
|
togglePlayPause: () => void
|
||||||
|
drawFocus: () => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const [scrubberActive, setScrubberActive] = useState(false)
|
||||||
|
const {
|
||||||
|
state: hovered,
|
||||||
|
onIn: onMouseEnter,
|
||||||
|
onOut: onMouseLeave,
|
||||||
|
} = useInteractionState()
|
||||||
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||||
|
const [seekPosition, setSeekPosition] = useState(0)
|
||||||
|
const isSeekingRef = useRef(false)
|
||||||
|
const barRef = useRef<HTMLDivElement>(null)
|
||||||
|
const circleRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const seek = useCallback(
|
||||||
|
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!barRef.current) return
|
||||||
|
const {left, width} = barRef.current.getBoundingClientRect()
|
||||||
|
const x = evt.clientX
|
||||||
|
const percent = clamp((x - left) / width, 0, 1) * duration
|
||||||
|
onSeek(percent)
|
||||||
|
setSeekPosition(percent)
|
||||||
|
},
|
||||||
|
[duration, onSeek],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPointerDown = useCallback(
|
||||||
|
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const target = evt.target
|
||||||
|
if (target instanceof Element) {
|
||||||
|
evt.preventDefault()
|
||||||
|
target.setPointerCapture(evt.pointerId)
|
||||||
|
isSeekingRef.current = true
|
||||||
|
seek(evt)
|
||||||
|
setScrubberActive(true)
|
||||||
|
onSeekStart()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[seek, onSeekStart],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPointerMove = useCallback(
|
||||||
|
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (isSeekingRef.current) {
|
||||||
|
evt.preventDefault()
|
||||||
|
seek(evt)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[seek],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPointerUp = useCallback(
|
||||||
|
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const target = evt.target
|
||||||
|
if (isSeekingRef.current && target instanceof Element) {
|
||||||
|
evt.preventDefault()
|
||||||
|
target.releasePointerCapture(evt.pointerId)
|
||||||
|
isSeekingRef.current = false
|
||||||
|
onSeekEnd()
|
||||||
|
setScrubberActive(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSeekEnd],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// HACK: there's divergent browser behaviour about what to do when
|
||||||
|
// a pointerUp event is fired outside the element that captured the
|
||||||
|
// pointer. Firefox clicks on the element the mouse is over, so we have
|
||||||
|
// to make everything unclickable while seeking -sfn
|
||||||
|
if (isFirefox && scrubberActive) {
|
||||||
|
document.body.classList.add('force-no-clicks')
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
const {signal} = abortController
|
||||||
|
document.documentElement.addEventListener(
|
||||||
|
'mouseleave',
|
||||||
|
() => {
|
||||||
|
isSeekingRef.current = false
|
||||||
|
onSeekEnd()
|
||||||
|
setScrubberActive(false)
|
||||||
|
},
|
||||||
|
{signal},
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.classList.remove('force-no-clicks')
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [scrubberActive, onSeekEnd])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!circleRef.current) return
|
||||||
|
if (focused) {
|
||||||
|
const abortController = new AbortController()
|
||||||
|
const {signal} = abortController
|
||||||
|
circleRef.current.addEventListener(
|
||||||
|
'keydown',
|
||||||
|
evt => {
|
||||||
|
// space: play/pause
|
||||||
|
// arrow left: seek backward
|
||||||
|
// arrow right: seek forward
|
||||||
|
|
||||||
|
if (evt.key === ' ') {
|
||||||
|
evt.preventDefault()
|
||||||
|
drawFocus()
|
||||||
|
togglePlayPause()
|
||||||
|
} else if (evt.key === 'ArrowLeft') {
|
||||||
|
evt.preventDefault()
|
||||||
|
drawFocus()
|
||||||
|
seekLeft()
|
||||||
|
} else if (evt.key === 'ArrowRight') {
|
||||||
|
evt.preventDefault()
|
||||||
|
drawFocus()
|
||||||
|
seekRight()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{signal},
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => abortController.abort()
|
||||||
|
}
|
||||||
|
}, [focused, seekLeft, seekRight, togglePlayPause, drawFocus])
|
||||||
|
|
||||||
|
const progress = scrubberActive ? seekPosition : currentTime
|
||||||
|
const progressPercent = (progress / duration) * 100
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
testID="scrubber"
|
||||||
|
style={[{height: 10, width: '100%'}, a.flex_shrink_0, a.px_xs]}
|
||||||
|
// @ts-expect-error web only -sfn
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}>
|
||||||
|
<div
|
||||||
|
ref={barRef}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
cursor: scrubberActive ? 'grabbing' : 'grab',
|
||||||
|
}}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.rounded_full,
|
||||||
|
a.overflow_hidden,
|
||||||
|
{backgroundColor: 'rgba(255, 255, 255, 0.4)'},
|
||||||
|
{height: hovered || scrubberActive ? 6 : 3},
|
||||||
|
]}>
|
||||||
|
{currentTime > 0 && duration > 0 && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.h_full,
|
||||||
|
{backgroundColor: t.palette.white},
|
||||||
|
{width: `${progressPercent}%`},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<div
|
||||||
|
ref={circleRef}
|
||||||
|
aria-label={_(msg`Seek slider`)}
|
||||||
|
role="slider"
|
||||||
|
aria-valuemax={duration}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuenow={currentTime}
|
||||||
|
aria-valuetext={_(
|
||||||
|
msg`${formatTime(currentTime)} of ${formatTime(duration)}`,
|
||||||
|
)}
|
||||||
|
tabIndex={0}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
height: 16,
|
||||||
|
width: 16,
|
||||||
|
left: `calc(${progressPercent}% - 8px)`,
|
||||||
|
borderRadius: 8,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.h_full,
|
||||||
|
a.rounded_full,
|
||||||
|
{backgroundColor: t.palette.white},
|
||||||
|
{
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
scale:
|
||||||
|
hovered || scrubberActive || focused
|
||||||
|
? scrubberActive
|
||||||
|
? 1
|
||||||
|
: 0.6
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(time: number) {
|
function formatTime(time: number) {
|
||||||
if (isNaN(time)) {
|
if (isNaN(time)) {
|
||||||
@@ -421,14 +707,6 @@ function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) {
|
|||||||
setError(false)
|
setError(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSeeking = () => {
|
|
||||||
setBuffering(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSeeked = () => {
|
|
||||||
setBuffering(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStalled = () => {
|
const handleStalled = () => {
|
||||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||||
bufferingTimeout = setTimeout(() => {
|
bufferingTimeout = setTimeout(() => {
|
||||||
@@ -474,12 +752,6 @@ function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) {
|
|||||||
ref.current.addEventListener('playing', handlePlaying, {
|
ref.current.addEventListener('playing', handlePlaying, {
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
})
|
})
|
||||||
ref.current.addEventListener('seeking', handleSeeking, {
|
|
||||||
signal: abortController.signal,
|
|
||||||
})
|
|
||||||
ref.current.addEventListener('seeked', handleSeeked, {
|
|
||||||
signal: abortController.signal,
|
|
||||||
})
|
|
||||||
ref.current.addEventListener('stalled', handleStalled, {
|
ref.current.addEventListener('stalled', handleStalled, {
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import React, {useContext} from 'react'
|
|
||||||
import type {VideoPlayer} from 'expo-video'
|
|
||||||
import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video'
|
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {
|
|
||||||
AudioCategory,
|
|
||||||
PlatformInfo,
|
|
||||||
} from '../../../../../modules/expo-bluesky-swiss-army'
|
|
||||||
|
|
||||||
const VideoPlayerContext = React.createContext<VideoPlayer | null>(null)
|
|
||||||
|
|
||||||
export function VideoPlayerProvider({
|
|
||||||
source,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
source: string
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
|
||||||
const player = useExpoVideoPlayer(source, player => {
|
|
||||||
try {
|
|
||||||
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
|
||||||
PlatformInfo.setAudioActive(false)
|
|
||||||
|
|
||||||
player.loop = true
|
|
||||||
player.muted = true
|
|
||||||
player.play()
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Failed to init video player', {safeMessage: err})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VideoPlayerContext.Provider value={player}>
|
|
||||||
{children}
|
|
||||||
</VideoPlayerContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVideoPlayer() {
|
|
||||||
const context = useContext(VideoPlayerContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useVideoPlayer must be used within a VideoPlayerProvider')
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
export function VideoPlayerProvider({children}: {children: React.ReactNode}) {
|
|
||||||
return children
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVideoPlayer() {
|
|
||||||
throw new Error('useVideoPlayer must not be used on web')
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
AppBskyEmbedImages,
|
AppBskyEmbedImages,
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
|
AppBskyEmbedVideo,
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyGraphDefs,
|
AppBskyGraphDefs,
|
||||||
moderateFeedGenerator,
|
moderateFeedGenerator,
|
||||||
@@ -33,10 +34,12 @@ import {AutoSizedImage} from '../images/AutoSizedImage'
|
|||||||
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
||||||
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
||||||
import {MaybeQuoteEmbed} from './QuoteEmbed'
|
import {MaybeQuoteEmbed} from './QuoteEmbed'
|
||||||
|
import {VideoEmbed} from './VideoEmbed'
|
||||||
|
|
||||||
type Embed =
|
type Embed =
|
||||||
| AppBskyEmbedRecord.View
|
| AppBskyEmbedRecord.View
|
||||||
| AppBskyEmbedImages.View
|
| AppBskyEmbedImages.View
|
||||||
|
| AppBskyEmbedVideo.View
|
||||||
| AppBskyEmbedExternal.View
|
| AppBskyEmbedExternal.View
|
||||||
| AppBskyEmbedRecordWithMedia.View
|
| AppBskyEmbedRecordWithMedia.View
|
||||||
| {$type: string; [k: string]: unknown}
|
| {$type: string; [k: string]: unknown}
|
||||||
@@ -175,6 +178,14 @@ export function PostEmbeds({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (AppBskyEmbedVideo.isView(embed)) {
|
||||||
|
return (
|
||||||
|
<ContentHider modui={moderation?.ui('contentMedia')}>
|
||||||
|
<VideoEmbed embed={embed} />
|
||||||
|
</ContentHider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return <View />
|
return <View />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {logEvent, LogEvents} from '#/lib/statsig/statsig'
|
|||||||
import {useGate} from '#/lib/statsig/statsig'
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
import {emitSoftReset} from '#/state/events'
|
import {emitSoftReset} from '#/state/events'
|
||||||
import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
|
import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
|
||||||
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
|
import {FeedParams} from '#/state/queries/post-feed'
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
@@ -29,7 +29,6 @@ import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
|
|||||||
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
|
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
|
||||||
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
|
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
|
||||||
import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned'
|
import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned'
|
||||||
import {TOURS, useTriggerTourIfQueued} from '#/tours'
|
|
||||||
import {HomeHeader} from '../com/home/HomeHeader'
|
import {HomeHeader} from '../com/home/HomeHeader'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home' | 'Start'>
|
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home' | 'Start'>
|
||||||
@@ -88,7 +87,6 @@ function HomeScreenReady({
|
|||||||
const selectedIndex = Math.max(0, maybeFoundIndex)
|
const selectedIndex = Math.max(0, maybeFoundIndex)
|
||||||
const selectedFeed = allFeeds[selectedIndex]
|
const selectedFeed = allFeeds[selectedIndex]
|
||||||
const requestNotificationsPermission = useRequestNotificationsPermission()
|
const requestNotificationsPermission = useRequestNotificationsPermission()
|
||||||
const triggerTourIfQueued = useTriggerTourIfQueued(TOURS.HOME)
|
|
||||||
const gate = useGate()
|
const gate = useGate()
|
||||||
|
|
||||||
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
|
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
|
||||||
@@ -110,30 +108,6 @@ function HomeScreenReady({
|
|||||||
}
|
}
|
||||||
}, [selectedIndex])
|
}, [selectedIndex])
|
||||||
|
|
||||||
// Temporary, remove when finished debugging
|
|
||||||
const debugHasLoggedFollowingPrefs = React.useRef(false)
|
|
||||||
const debugLogFollowingPrefs = React.useCallback(
|
|
||||||
(feed: FeedDescriptor) => {
|
|
||||||
if (debugHasLoggedFollowingPrefs.current) return
|
|
||||||
if (feed !== 'following') return
|
|
||||||
logEvent('debug:followingPrefs', {
|
|
||||||
followingShowRepliesFromPref: preferences.feedViewPrefs.hideReplies
|
|
||||||
? 'off'
|
|
||||||
: preferences.feedViewPrefs.hideRepliesByUnfollowed
|
|
||||||
? 'following'
|
|
||||||
: 'all',
|
|
||||||
followingRepliesMinLikePref:
|
|
||||||
preferences.feedViewPrefs.hideRepliesByLikeCount,
|
|
||||||
})
|
|
||||||
debugHasLoggedFollowingPrefs.current = true
|
|
||||||
},
|
|
||||||
[
|
|
||||||
preferences.feedViewPrefs.hideReplies,
|
|
||||||
preferences.feedViewPrefs.hideRepliesByLikeCount,
|
|
||||||
preferences.feedViewPrefs.hideRepliesByUnfollowed,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
|
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
|
||||||
@@ -141,16 +115,10 @@ function HomeScreenReady({
|
|||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
setMinimalShellMode(false)
|
setMinimalShellMode(false)
|
||||||
setDrawerSwipeDisabled(selectedIndex > 0)
|
setDrawerSwipeDisabled(selectedIndex > 0)
|
||||||
triggerTourIfQueued()
|
|
||||||
return () => {
|
return () => {
|
||||||
setDrawerSwipeDisabled(false)
|
setDrawerSwipeDisabled(false)
|
||||||
}
|
}
|
||||||
}, [
|
}, [setDrawerSwipeDisabled, selectedIndex, setMinimalShellMode]),
|
||||||
setDrawerSwipeDisabled,
|
|
||||||
selectedIndex,
|
|
||||||
setMinimalShellMode,
|
|
||||||
triggerTourIfQueued,
|
|
||||||
]),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
@@ -162,7 +130,6 @@ function HomeScreenReady({
|
|||||||
feedUrl: selectedFeed,
|
feedUrl: selectedFeed,
|
||||||
reason: 'focus',
|
reason: 'focus',
|
||||||
})
|
})
|
||||||
debugLogFollowingPrefs(selectedFeed)
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -213,9 +180,8 @@ function HomeScreenReady({
|
|||||||
feedUrl: feed,
|
feedUrl: feed,
|
||||||
reason,
|
reason,
|
||||||
})
|
})
|
||||||
debugLogFollowingPrefs(feed)
|
|
||||||
},
|
},
|
||||||
[allFeeds, debugLogFollowingPrefs],
|
[allFeeds],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressSelected = React.useCallback(() => {
|
const onPressSelected = React.useCallback(() => {
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import {
|
|||||||
Message_Stroke2_Corner0_Rounded as Message,
|
Message_Stroke2_Corner0_Rounded as Message,
|
||||||
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
|
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
|
||||||
} from '#/components/icons/Message'
|
} from '#/components/icons/Message'
|
||||||
import {HomeTourExploreWrapper} from '#/tours/HomeTour'
|
|
||||||
import {styles} from './BottomBarStyles'
|
import {styles} from './BottomBarStyles'
|
||||||
|
|
||||||
type TabOptions =
|
type TabOptions =
|
||||||
@@ -163,19 +162,17 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
|||||||
<Btn
|
<Btn
|
||||||
testID="bottomBarSearchBtn"
|
testID="bottomBarSearchBtn"
|
||||||
icon={
|
icon={
|
||||||
<HomeTourExploreWrapper>
|
isAtSearch ? (
|
||||||
{isAtSearch ? (
|
<MagnifyingGlassFilled
|
||||||
<MagnifyingGlassFilled
|
width={iconWidth + 2}
|
||||||
width={iconWidth + 2}
|
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<MagnifyingGlass
|
||||||
<MagnifyingGlass
|
width={iconWidth + 2}
|
||||||
width={iconWidth + 2}
|
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
/>
|
||||||
/>
|
)
|
||||||
)}
|
|
||||||
</HomeTourExploreWrapper>
|
|
||||||
}
|
}
|
||||||
onPress={onPressSearch}
|
onPress={onPressSearch}
|
||||||
accessibilityRole="search"
|
accessibilityRole="search"
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ import {
|
|||||||
UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
|
UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
|
||||||
UserCircle_Stroke2_Corner0_Rounded as UserCircle,
|
UserCircle_Stroke2_Corner0_Rounded as UserCircle,
|
||||||
} from '#/components/icons/UserCircle'
|
} from '#/components/icons/UserCircle'
|
||||||
import {HomeTourExploreWrapper} from '#/tours/HomeTour'
|
|
||||||
import {styles} from './BottomBarStyles'
|
import {styles} from './BottomBarStyles'
|
||||||
|
|
||||||
export function BottomBarWeb() {
|
export function BottomBarWeb() {
|
||||||
@@ -95,12 +94,10 @@ export function BottomBarWeb() {
|
|||||||
{({isActive}) => {
|
{({isActive}) => {
|
||||||
const Icon = isActive ? MagnifyingGlassFilled : MagnifyingGlass
|
const Icon = isActive ? MagnifyingGlassFilled : MagnifyingGlass
|
||||||
return (
|
return (
|
||||||
<HomeTourExploreWrapper>
|
<Icon
|
||||||
<Icon
|
width={iconWidth + 2}
|
||||||
width={iconWidth + 2}
|
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
/>
|
||||||
/>
|
|
||||||
</HomeTourExploreWrapper>
|
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ import {
|
|||||||
UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
|
UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
|
||||||
UserCircle_Stroke2_Corner0_Rounded as UserCircle,
|
UserCircle_Stroke2_Corner0_Rounded as UserCircle,
|
||||||
} from '#/components/icons/UserCircle'
|
} from '#/components/icons/UserCircle'
|
||||||
import {HomeTourExploreWrapper} from '#/tours/HomeTour'
|
|
||||||
import {router} from '../../../routes'
|
import {router} from '../../../routes'
|
||||||
|
|
||||||
const NAV_ICON_WIDTH = 28
|
const NAV_ICON_WIDTH = 28
|
||||||
@@ -341,19 +340,14 @@ export function DesktopLeftNav() {
|
|||||||
iconFilled={<HomeFilled width={NAV_ICON_WIDTH} style={pal.text} />}
|
iconFilled={<HomeFilled width={NAV_ICON_WIDTH} style={pal.text} />}
|
||||||
label={_(msg`Home`)}
|
label={_(msg`Home`)}
|
||||||
/>
|
/>
|
||||||
<HomeTourExploreWrapper>
|
<NavItem
|
||||||
<NavItem
|
href="/search"
|
||||||
href="/search"
|
icon={<MagnifyingGlass style={pal.text} width={NAV_ICON_WIDTH} />}
|
||||||
icon={<MagnifyingGlass style={pal.text} width={NAV_ICON_WIDTH} />}
|
iconFilled={
|
||||||
iconFilled={
|
<MagnifyingGlassFilled style={pal.text} width={NAV_ICON_WIDTH} />
|
||||||
<MagnifyingGlassFilled
|
}
|
||||||
style={pal.text}
|
label={_(msg`Search`)}
|
||||||
width={NAV_ICON_WIDTH}
|
/>
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={_(msg`Search`)}
|
|
||||||
/>
|
|
||||||
</HomeTourExploreWrapper>
|
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/notifications"
|
href="/notifications"
|
||||||
count={numUnreadNotifications}
|
count={numUnreadNotifications}
|
||||||
|
|||||||
@@ -257,6 +257,11 @@
|
|||||||
from { opacity: 1; }
|
from { opacity: 1; }
|
||||||
to { opacity: 0; }
|
to { opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.force-no-clicks > *,
|
||||||
|
.force-no-clicks * {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -72,15 +72,15 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106"
|
resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106"
|
||||||
integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==
|
integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==
|
||||||
|
|
||||||
"@atproto/api@0.13.3":
|
"@atproto/api@0.13.5":
|
||||||
version "0.13.3"
|
version "0.13.5"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.3.tgz#d84f2a0e25f38cca59b69d178901634f2d20b4ff"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.5.tgz#04305cdb0a467ba366305c5e95cebb7ce0d39735"
|
||||||
integrity sha512-/PEVTTEQXICOjZCujAPsjArhwR0tR3LiF0SxxpZlWOjaqjVbqnBI/j0MNmddBFgeljC4/DcBobcDJ9HkILn4yQ==
|
integrity sha512-yT/YimcKYkrI0d282Zxo7O30OSYR+KDW89f81C6oYZfDRBcShC1aniVV8kluP5LrEAg8O27yrOSnBgx2v7XPew==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.3.0"
|
"@atproto/common-web" "^0.3.0"
|
||||||
"@atproto/lexicon" "^0.4.1"
|
"@atproto/lexicon" "^0.4.1"
|
||||||
"@atproto/syntax" "^0.3.0"
|
"@atproto/syntax" "^0.3.0"
|
||||||
"@atproto/xrpc" "^0.6.0"
|
"@atproto/xrpc" "^0.6.1"
|
||||||
await-lock "^2.2.2"
|
await-lock "^2.2.2"
|
||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
tlds "^1.234.0"
|
tlds "^1.234.0"
|
||||||
@@ -443,6 +443,14 @@
|
|||||||
"@atproto/lexicon" "^0.4.1"
|
"@atproto/lexicon" "^0.4.1"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
|
"@atproto/xrpc@^0.6.1":
|
||||||
|
version "0.6.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5"
|
||||||
|
integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A==
|
||||||
|
dependencies:
|
||||||
|
"@atproto/lexicon" "^0.4.1"
|
||||||
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@aws-crypto/crc32@3.0.0":
|
"@aws-crypto/crc32@3.0.0":
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-3.0.0.tgz#07300eca214409c33e3ff769cd5697b57fdd38fa"
|
resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-3.0.0.tgz#07300eca214409c33e3ff769cd5697b57fdd38fa"
|
||||||
@@ -10292,11 +10300,6 @@ commander@11.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67"
|
resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67"
|
||||||
integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==
|
integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==
|
||||||
|
|
||||||
commander@2, commander@^2.20.0:
|
|
||||||
version "2.20.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
|
||||||
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
|
||||||
|
|
||||||
commander@2.20.0:
|
commander@2.20.0:
|
||||||
version "2.20.0"
|
version "2.20.0"
|
||||||
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422"
|
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422"
|
||||||
@@ -10307,6 +10310,11 @@ commander@^10.0.0, commander@^10.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06"
|
resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06"
|
||||||
integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==
|
integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==
|
||||||
|
|
||||||
|
commander@^2.20.0:
|
||||||
|
version "2.20.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
||||||
|
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
||||||
|
|
||||||
commander@^4.0.0:
|
commander@^4.0.0:
|
||||||
version "4.1.1"
|
version "4.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
|
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
|
||||||
@@ -10803,16 +10811,6 @@ csstype@^3.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
|
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
|
||||||
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
|
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
|
||||||
|
|
||||||
d3-array@^1.2.0:
|
|
||||||
version "1.2.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
|
|
||||||
integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
|
|
||||||
|
|
||||||
d3-polygon@^1.0.3:
|
|
||||||
version "1.0.6"
|
|
||||||
resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-1.0.6.tgz#0bf8cb8180a6dc107f518ddf7975e12abbfbd38e"
|
|
||||||
integrity sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==
|
|
||||||
|
|
||||||
dag-map@~1.0.0:
|
dag-map@~1.0.0:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/dag-map/-/dag-map-1.0.2.tgz#e8379f041000ed561fc515475c1ed2c85eece8d7"
|
resolved "https://registry.yarnpkg.com/dag-map/-/dag-map-1.0.2.tgz#e8379f041000ed561fc515475c1ed2c85eece8d7"
|
||||||
@@ -11294,11 +11292,6 @@ duplexer@^0.1.2:
|
|||||||
resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6"
|
resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6"
|
||||||
integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==
|
integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==
|
||||||
|
|
||||||
earcut@^2.1.1:
|
|
||||||
version "2.2.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/earcut/-/earcut-2.2.4.tgz#6d02fd4d68160c114825d06890a92ecaae60343a"
|
|
||||||
integrity sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==
|
|
||||||
|
|
||||||
eastasianwidth@^0.2.0:
|
eastasianwidth@^0.2.0:
|
||||||
version "0.2.0"
|
version "0.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
|
resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
|
||||||
@@ -12857,18 +12850,6 @@ flow-parser@0.*:
|
|||||||
resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.215.0.tgz#9b153fa27ab238bcc0bb1ff73b63bdb15d3f277d"
|
resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.215.0.tgz#9b153fa27ab238bcc0bb1ff73b63bdb15d3f277d"
|
||||||
integrity sha512-8bjwzy8vi+fNDy8YoTBNtQUSZa53i7UWJJTunJojOtjab9cMNhOCwohionuMgDQUU0y21QTTtPOX6OQEOQT72A==
|
integrity sha512-8bjwzy8vi+fNDy8YoTBNtQUSZa53i7UWJJTunJojOtjab9cMNhOCwohionuMgDQUU0y21QTTtPOX6OQEOQT72A==
|
||||||
|
|
||||||
flubber@~0.4.2:
|
|
||||||
version "0.4.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/flubber/-/flubber-0.4.2.tgz#14452d4a838cc3b9f2fb6175da94e35acd55fbaa"
|
|
||||||
integrity sha512-79RkJe3rA4nvRCVc2uXjj7U/BAUq84TS3KHn6c0Hr9K64vhj83ZNLUziNx4pJoBumSPhOl5VjH+Z0uhi+eE8Uw==
|
|
||||||
dependencies:
|
|
||||||
d3-array "^1.2.0"
|
|
||||||
d3-polygon "^1.0.3"
|
|
||||||
earcut "^2.1.1"
|
|
||||||
svg-path-properties "^0.2.1"
|
|
||||||
svgpath "^2.2.1"
|
|
||||||
topojson-client "^3.0.0"
|
|
||||||
|
|
||||||
follow-redirects@^1.0.0, follow-redirects@^1.14.9, follow-redirects@^1.15.0:
|
follow-redirects@^1.0.0, follow-redirects@^1.14.9, follow-redirects@^1.15.0:
|
||||||
version "1.15.2"
|
version "1.15.2"
|
||||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
|
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
|
||||||
@@ -13490,13 +13471,6 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react-is "^16.7.0"
|
react-is "^16.7.0"
|
||||||
|
|
||||||
hoist-non-react-statics@~3.0.1:
|
|
||||||
version "3.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.0.1.tgz#fba3e7df0210eb9447757ca1a7cb607162f0a364"
|
|
||||||
integrity sha512-1kXwPsOi0OGQIZNVMPvgWJ9tSnGMiMfJdihqEzrPEXlHOBh9AAHXX/QYmAJTXztnz/K+PQ8ryCb4eGaN6HlGbQ==
|
|
||||||
dependencies:
|
|
||||||
react-is "^16.3.2"
|
|
||||||
|
|
||||||
hoopy@^0.1.4:
|
hoopy@^0.1.4:
|
||||||
version "0.1.4"
|
version "0.1.4"
|
||||||
resolved "https://registry.yarnpkg.com/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d"
|
resolved "https://registry.yarnpkg.com/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d"
|
||||||
@@ -15997,11 +15971,6 @@ lodash.chunk@^4.2.0:
|
|||||||
resolved "https://registry.yarnpkg.com/lodash.chunk/-/lodash.chunk-4.2.0.tgz#66e5ce1f76ed27b4303d8c6512e8d1216e8106bc"
|
resolved "https://registry.yarnpkg.com/lodash.chunk/-/lodash.chunk-4.2.0.tgz#66e5ce1f76ed27b4303d8c6512e8d1216e8106bc"
|
||||||
integrity sha512-ZzydJKfUHJwHa+hF5X66zLFCBrWn5GeF28OHEr4WVWtNDXlQ/IjWKPBiikqKo2ne0+v6JgCgJ0GzJp8k8bHC7w==
|
integrity sha512-ZzydJKfUHJwHa+hF5X66zLFCBrWn5GeF28OHEr4WVWtNDXlQ/IjWKPBiikqKo2ne0+v6JgCgJ0GzJp8k8bHC7w==
|
||||||
|
|
||||||
lodash.clamp@~4.0.3:
|
|
||||||
version "4.0.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/lodash.clamp/-/lodash.clamp-4.0.3.tgz#5c24bedeeeef0753560dc2b4cb4671f90a6ddfaa"
|
|
||||||
integrity sha512-HvzRFWjtcguTW7yd8NJBshuNaCa8aqNFtnswdT7f/cMd/1YKy5Zzoq4W/Oxvnx9l7aeY258uSdDfM793+eLsVg==
|
|
||||||
|
|
||||||
lodash.debounce@^4.0.8:
|
lodash.debounce@^4.0.8:
|
||||||
version "4.0.8"
|
version "4.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||||
@@ -16286,11 +16255,6 @@ memfs@^3.1.2, memfs@^3.4.3:
|
|||||||
dependencies:
|
dependencies:
|
||||||
fs-monkey "^1.0.4"
|
fs-monkey "^1.0.4"
|
||||||
|
|
||||||
memoize-one@5.1.1:
|
|
||||||
version "5.1.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0"
|
|
||||||
integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA==
|
|
||||||
|
|
||||||
memoize-one@^5.0.0:
|
memoize-one@^5.0.0:
|
||||||
version "5.2.1"
|
version "5.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
|
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
|
||||||
@@ -16718,11 +16682,6 @@ minizlib@^2.1.1:
|
|||||||
minipass "^3.0.0"
|
minipass "^3.0.0"
|
||||||
yallist "^4.0.0"
|
yallist "^4.0.0"
|
||||||
|
|
||||||
mitt@~1.1.3:
|
|
||||||
version "1.1.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/mitt/-/mitt-1.1.3.tgz#528c506238a05dce11cd914a741ea2cc332da9b8"
|
|
||||||
integrity sha512-mUDCnVNsAi+eD6qA0HkRkwYczbLHJ49z17BGe2PYRhZL4wpZUFZGJHU7/5tmvohoma+Hdn0Vh/oJTiPEmgSruA==
|
|
||||||
|
|
||||||
mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
|
mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
|
||||||
version "0.5.3"
|
version "0.5.3"
|
||||||
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
|
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
|
||||||
@@ -19003,7 +18962,7 @@ react-freeze@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
||||||
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
|
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
|
||||||
|
|
||||||
react-is@^16.13.0, react-is@^16.13.1, react-is@^16.3.2, react-is@^16.7.0, react-is@^16.8.4:
|
react-is@^16.13.0, react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.4:
|
||||||
version "16.13.1"
|
version "16.13.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||||
@@ -19813,16 +19772,6 @@ rn-fetch-blob@^0.12.0:
|
|||||||
base-64 "0.1.0"
|
base-64 "0.1.0"
|
||||||
glob "7.0.6"
|
glob "7.0.6"
|
||||||
|
|
||||||
rn-tourguide@bluesky-social/rn-tourguide:
|
|
||||||
version "3.3.0"
|
|
||||||
resolved "https://codeload.github.com/bluesky-social/rn-tourguide/tar.gz/a14bb85536b317b94d82801900df4cf57f81aef7"
|
|
||||||
dependencies:
|
|
||||||
flubber "~0.4.2"
|
|
||||||
hoist-non-react-statics "~3.0.1"
|
|
||||||
lodash.clamp "~4.0.3"
|
|
||||||
memoize-one "5.1.1"
|
|
||||||
mitt "~1.1.3"
|
|
||||||
|
|
||||||
roarr@^7.0.4:
|
roarr@^7.0.4:
|
||||||
version "7.15.1"
|
version "7.15.1"
|
||||||
resolved "https://registry.yarnpkg.com/roarr/-/roarr-7.15.1.tgz#e4d93105c37b5ea7dd1200d96a3500f757ddc39f"
|
resolved "https://registry.yarnpkg.com/roarr/-/roarr-7.15.1.tgz#e4d93105c37b5ea7dd1200d96a3500f757ddc39f"
|
||||||
@@ -20966,11 +20915,6 @@ svg-parser@^2.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5"
|
resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5"
|
||||||
integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==
|
integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==
|
||||||
|
|
||||||
svg-path-properties@^0.2.1:
|
|
||||||
version "0.2.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/svg-path-properties/-/svg-path-properties-0.2.2.tgz#b073d81be7292eae0e233ab8a83f58dc27113296"
|
|
||||||
integrity sha512-GmrB+b6woz6CCdQe6w1GHs/1lt25l7SR5hmhF8jRdarpv/OgjLyuQygLu1makJapixeb1aQhP/Oa1iKi93o/aQ==
|
|
||||||
|
|
||||||
svgo@^1.2.2:
|
svgo@^1.2.2:
|
||||||
version "1.3.2"
|
version "1.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"
|
resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"
|
||||||
@@ -21003,11 +20947,6 @@ svgo@^2.7.0:
|
|||||||
picocolors "^1.0.0"
|
picocolors "^1.0.0"
|
||||||
stable "^0.1.8"
|
stable "^0.1.8"
|
||||||
|
|
||||||
svgpath@^2.2.1:
|
|
||||||
version "2.6.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/svgpath/-/svgpath-2.6.0.tgz#5b160ef3d742b7dfd2d721bf90588d3450d7a90d"
|
|
||||||
integrity sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg==
|
|
||||||
|
|
||||||
symbol-tree@^3.2.4:
|
symbol-tree@^3.2.4:
|
||||||
version "3.2.4"
|
version "3.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
|
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
|
||||||
@@ -21326,13 +21265,6 @@ token-types@^4.1.1:
|
|||||||
"@tokenizer/token" "^0.3.0"
|
"@tokenizer/token" "^0.3.0"
|
||||||
ieee754 "^1.2.1"
|
ieee754 "^1.2.1"
|
||||||
|
|
||||||
topojson-client@^3.0.0:
|
|
||||||
version "3.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/topojson-client/-/topojson-client-3.1.0.tgz#22e8b1ed08a2b922feeb4af6f53b6ef09a467b99"
|
|
||||||
integrity sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==
|
|
||||||
dependencies:
|
|
||||||
commander "2"
|
|
||||||
|
|
||||||
totalist@^3.0.0:
|
totalist@^3.0.0:
|
||||||
version "3.0.1"
|
version "3.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8"
|
resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8"
|
||||||
|
|||||||
Reference in New Issue
Block a user