Merge remote-tracking branch 'upstream/main' into Improve-notification-localization
This commit is contained in:
@@ -253,6 +253,11 @@
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.force-no-clicks > *,
|
||||
.force-no-clicks * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
{% include "scripts.html" %}
|
||||
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.90.0",
|
||||
"version": "1.91.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -52,7 +52,7 @@
|
||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.13.3",
|
||||
"@atproto/api": "0.13.5",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
@@ -199,7 +199,6 @@
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"rn-fetch-blob": "^0.12.0",
|
||||
"rn-tourguide": "bluesky-social/rn-tourguide",
|
||||
"sentry-expo": "~7.0.1",
|
||||
"statsig-react-native-expo": "^4.6.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
|
||||
+256
-11
@@ -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
|
||||
index 9905e13..47342ff 100644
|
||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
||||
@@ -8,10 +32,10 @@ index 9905e13..47342ff 100644
|
||||
setTimeBarInteractive(requireLinearPlayback)
|
||||
+ setShowSubtitleButton(true)
|
||||
}
|
||||
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
|
||||
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
|
||||
- val fullscreenButton = findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
|
||||
@@ -20,6 +44,42 @@ index 9905e13..47342ff 100644
|
||||
fullscreenButton?.visibility = if (visible) {
|
||||
android.view.View.VISIBLE
|
||||
} 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
|
||||
index ec3da2a..5a1397a 100644
|
||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
||||
@@ -33,8 +93,76 @@ index ec3da2a..5a1397a 100644
|
||||
+ "onEnterFullscreen",
|
||||
+ "onExitFullscreen"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
index a951d80..3932535 100644
|
||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
||||
@@ -45,7 +173,7 @@ index a951d80..3932535 100644
|
||||
val onPictureInPictureStop by EventDispatcher<Unit>()
|
||||
+ val onEnterFullscreen by EventDispatcher()
|
||||
+ val onExitFullscreen by EventDispatcher()
|
||||
|
||||
|
||||
var willEnterPiP: Boolean = false
|
||||
var isInFullscreen: Boolean = false
|
||||
@@ -154,6 +156,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
||||
@@ -55,7 +183,7 @@ index a951d80..3932535 100644
|
||||
+ onEnterFullscreen(mapOf())
|
||||
isInFullscreen = true
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +165,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
||||
val fullScreenButton: ImageButton = playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen)
|
||||
fullScreenButton.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter)
|
||||
@@ -63,9 +191,22 @@ index a951d80..3932535 100644
|
||||
+ this.onExitFullscreen(mapOf())
|
||||
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
|
||||
index cb9ca6d..60e9f4e 100644
|
||||
index cb9ca6d..ed8bb7e 100644
|
||||
--- a/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 {
|
||||
@@ -77,6 +218,7 @@ index cb9ca6d..60e9f4e 100644
|
||||
+ onExitFullscreen?: () => void;
|
||||
}
|
||||
//# 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
|
||||
index c537a12..e4a918f 100644
|
||||
--- a/node_modules/expo-video/ios/VideoModule.swift
|
||||
@@ -90,19 +232,109 @@ index c537a12..e4a918f 100644
|
||||
+ "onEnterFullscreen",
|
||||
+ "onExitFullscreen"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
index f4579e4..10c5908 100644
|
||||
--- a/node_modules/expo-video/ios/VideoView.swift
|
||||
+++ b/node_modules/expo-video/ios/VideoView.swift
|
||||
@@ -41,6 +41,8 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
||||
|
||||
|
||||
let onPictureInPictureStart = EventDispatcher()
|
||||
let onPictureInPictureStop = EventDispatcher()
|
||||
+ let onEnterFullscreen = EventDispatcher()
|
||||
+ let onExitFullscreen = EventDispatcher()
|
||||
|
||||
|
||||
public override var bounds: CGRect {
|
||||
didSet {
|
||||
@@ -163,6 +165,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
||||
@@ -112,7 +344,7 @@ index f4579e4..10c5908 100644
|
||||
+ onEnterFullscreen()
|
||||
isFullscreen = true
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
||||
if wasPlaying {
|
||||
self.player?.pointer.play()
|
||||
@@ -121,6 +353,19 @@ index f4579e4..10c5908 100644
|
||||
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
|
||||
index 29fe5db..e1fbf59 100644
|
||||
--- 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 HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
||||
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 {Shell} from '#/view/shell'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
@@ -60,7 +60,6 @@ import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Splash} from '#/Splash'
|
||||
import {Provider as TourProvider} from '#/tours'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
|
||||
|
||||
@@ -127,15 +126,13 @@ function InnerApp() {
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<TourProvider>
|
||||
<ProgressGuideProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</ProgressGuideProvider>
|
||||
</TourProvider>
|
||||
<ProgressGuideProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</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 StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
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 {ToastContainer} from '#/view/com/util/Toast.web'
|
||||
import {Shell} from '#/view/shell/index'
|
||||
@@ -48,7 +48,6 @@ import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as TourProvider} from '#/tours'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
|
||||
function InnerApp() {
|
||||
@@ -111,11 +110,9 @@ function InnerApp() {
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<TourProvider>
|
||||
<ProgressGuideProvider>
|
||||
<Shell />
|
||||
</ProgressGuideProvider>
|
||||
</TourProvider>
|
||||
<ProgressGuideProvider>
|
||||
<Shell />
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
|
||||
@@ -178,7 +178,7 @@ let ListMaybePlaceholder = ({
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.h_full_vh,
|
||||
a.align_center,
|
||||
!gtMobile ? a.justify_between : a.gap_5xl,
|
||||
t.atoms.border_contrast_low,
|
||||
|
||||
@@ -5,10 +5,10 @@ export function useInteractionState() {
|
||||
|
||||
const onIn = React.useCallback(() => {
|
||||
setState(true)
|
||||
}, [setState])
|
||||
}, [])
|
||||
const onOut = React.useCallback(() => {
|
||||
setState(false)
|
||||
}, [setState])
|
||||
}, [])
|
||||
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -392,27 +392,20 @@ export class FeedTuner {
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
): FeedViewPostsSlice[] => {
|
||||
const candidateSlices = slices.slice()
|
||||
|
||||
// early return if no languages have been specified
|
||||
if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) {
|
||||
return slices
|
||||
}
|
||||
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
let hasPreferredLang = false
|
||||
for (const item of slices[i].items) {
|
||||
const candidateSlices = slices.filter(slice => {
|
||||
for (const item of slice.items) {
|
||||
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
||||
hasPreferredLang = true
|
||||
break
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// if item does not fit preferred language, remove it
|
||||
if (!hasPreferredLang) {
|
||||
candidateSlices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// if the language filter cleared out the entire page, return the original set
|
||||
// so that something always shows
|
||||
|
||||
+27
-11
@@ -3,12 +3,14 @@ import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedVideo,
|
||||
AppBskyFeedPostgate,
|
||||
AtUri,
|
||||
BlobRef,
|
||||
BskyAgent,
|
||||
ComAtprotoLabelDefs,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {writePostgateRecord} from '#/state/queries/postgate'
|
||||
@@ -43,10 +45,7 @@ interface PostOpts {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
video?: {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
video?: BlobRef
|
||||
extLink?: ExternalEmbedDraft
|
||||
images?: ImageModel[]
|
||||
labels?: string[]
|
||||
@@ -61,18 +60,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
| AppBskyEmbedImages.Main
|
||||
| AppBskyEmbedExternal.Main
|
||||
| AppBskyEmbedRecord.Main
|
||||
| AppBskyEmbedVideo.Main
|
||||
| AppBskyEmbedRecordWithMedia.Main
|
||||
| undefined
|
||||
let reply
|
||||
let rt = new RichText(
|
||||
{text: opts.rawText.trimEnd()},
|
||||
{
|
||||
cleanNewlines: true,
|
||||
},
|
||||
)
|
||||
let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true})
|
||||
|
||||
opts.onStateChange?.('Processing...')
|
||||
|
||||
await rt.detectFacets(agent)
|
||||
|
||||
rt = shortenLinks(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
|
||||
if (opts.extLink && !opts.images?.length) {
|
||||
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 {Dimensions} from 'react-native'
|
||||
import {useWindowDimensions} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||
|
||||
const MIN_POST_HEIGHT = 100
|
||||
|
||||
export function useInitialNumToRender(minItemHeight: number = MIN_POST_HEIGHT) {
|
||||
return React.useMemo(() => {
|
||||
const screenHeight = Dimensions.get('window').height
|
||||
return Math.ceil(screenHeight / minItemHeight) + 1
|
||||
}, [minItemHeight])
|
||||
export function useInitialNumToRender({
|
||||
minItemHeight = MIN_POST_HEIGHT,
|
||||
screenHeightOffset = 0,
|
||||
}: {minItemHeight?: number; screenHeightOffset?: number} = {}) {
|
||||
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(
|
||||
file: string,
|
||||
opts?: {
|
||||
getCancellationId?: (id: string) => void
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: number) => void
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const {onProgress, getCancellationId} = opts || {}
|
||||
const {onProgress, signal} = opts || {}
|
||||
|
||||
const compressed = await Video.compress(
|
||||
file,
|
||||
{
|
||||
getCancellationId,
|
||||
compressionMethod: 'manual',
|
||||
bitrate: 3_000_000, // 3mbps
|
||||
maxSize: 1920,
|
||||
getCancellationId: id => {
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => {
|
||||
Video.cancelCompression(id)
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
onProgress,
|
||||
)
|
||||
|
||||
@@ -10,8 +10,9 @@ export type CompressedVideo = {
|
||||
// doesn't actually compress, but throws if >100MB
|
||||
export async function compressVideo(
|
||||
file: string,
|
||||
_callbacks?: {
|
||||
onProgress: (progress: number) => void
|
||||
_opts?: {
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: number) => void
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
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': {}
|
||||
|
||||
'debug:followingPrefs': {
|
||||
followingShowRepliesFromPref: 'all' | 'following' | 'off'
|
||||
followingRepliesMinLikePref: number
|
||||
}
|
||||
'debug:followingDisplayed': {}
|
||||
|
||||
'test:all:always': {}
|
||||
'test:all:sometimes': {}
|
||||
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
|
||||
|
||||
@@ -2,9 +2,7 @@ export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'debug_show_feedcontext'
|
||||
| 'fixed_bottom_bar'
|
||||
| 'new_user_guided_tour'
|
||||
| 'onboarding_minimum_interests'
|
||||
| 'show_follow_back_label_v2'
|
||||
| 'suggested_feeds_interstitial'
|
||||
| 'video_debug'
|
||||
| 'videos'
|
||||
|
||||
@@ -339,3 +339,21 @@ export function shortLinkToHref(url: string): string {
|
||||
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: 'amh', code2: 'am', name: 'Amharic'},
|
||||
{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: 'ara', code2: 'ar', name: 'Arabic'},
|
||||
{
|
||||
@@ -233,7 +233,7 @@ export const LANGUAGES: Language[] = [
|
||||
{code3: 'gre', code2: 'el', name: 'Greek, Modern (1453-)'},
|
||||
{code3: 'grn', code2: 'gn', name: 'Guarani'},
|
||||
{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: 'hai', code2: '', name: 'Haida'},
|
||||
{code3: 'hat', code2: 'ht', name: 'Haitian; Haitian Creole'},
|
||||
@@ -339,8 +339,8 @@ export const LANGUAGES: Language[] = [
|
||||
{code3: 'lun', code2: '', name: 'Lunda'},
|
||||
{
|
||||
code3: 'luo',
|
||||
code2: ' Luo (Kenya and Tanzania)',
|
||||
name: 'luo (Kenya et Tanzanie)',
|
||||
code2: '',
|
||||
name: 'Luo (Kenya and Tanzania)',
|
||||
},
|
||||
{code3: 'lus', code2: '', name: 'Lushai'},
|
||||
{code3: 'mac', code2: 'mk', name: 'Macedonian'},
|
||||
@@ -430,162 +430,162 @@ export const LANGUAGES: Language[] = [
|
||||
{code3: 'oto', code2: '', name: 'Otomian languages'},
|
||||
{code3: 'paa', code2: '', name: 'Papuan languages'},
|
||||
{code3: 'pag', code2: '', name: 'Pangasinan'},
|
||||
{code3: 'pal', code2: ' ', name: 'Pahlavi'},
|
||||
{code3: 'pam', code2: ' ', name: 'Pampanga; Kapampangan'},
|
||||
{code3: 'pan', code2: 'paPanjabi; Punjabi', name: 'pendjabi'},
|
||||
{code3: 'pap', code2: ' ', name: 'Papiamento'},
|
||||
{code3: 'pau', code2: ' ', name: 'Palauan'},
|
||||
{code3: 'peo', code2: ' ', name: 'Persian, Old (ca.600-400 B.C.)'},
|
||||
{code3: 'pal', code2: '', name: 'Pahlavi'},
|
||||
{code3: 'pam', code2: '', name: 'Pampanga; Kapampangan'},
|
||||
{code3: 'pan', code2: 'pa', name: 'Panjabi; Punjabi'},
|
||||
{code3: 'pap', code2: '', name: 'Papiamento'},
|
||||
{code3: 'pau', code2: '', name: 'Palauan'},
|
||||
{code3: 'peo', code2: '', name: 'Persian, Old (ca.600-400 B.C.)'},
|
||||
{code3: 'per', code2: 'fa', name: 'Persian'},
|
||||
{code3: 'phi', code2: ' ', name: 'Philippine languages'},
|
||||
{code3: 'phn', code2: ' ', name: 'Phoenician'},
|
||||
{code3: 'phi', code2: '', name: 'Philippine languages'},
|
||||
{code3: 'phn', code2: '', name: 'Phoenician'},
|
||||
{code3: 'pli', code2: 'pi', name: 'Pali'},
|
||||
{code3: 'pol', code2: 'pl', name: 'Polish'},
|
||||
{code3: 'pon', code2: ' ', name: 'Pohnpeian'},
|
||||
{code3: 'pon', code2: '', name: 'Pohnpeian'},
|
||||
{code3: 'por', code2: 'pt', name: 'Portuguese'},
|
||||
{code3: 'pra', code2: ' ', name: 'Prakrit languages'},
|
||||
{code3: 'pra', code2: '', name: 'Prakrit languages'},
|
||||
{
|
||||
code3: 'pro',
|
||||
code2: ' ',
|
||||
code2: '',
|
||||
name: 'Provençal, Old (to 1500);Occitan, Old (to 1500)',
|
||||
},
|
||||
{code3: 'pus', code2: 'ps', name: 'Pushto; Pashto'},
|
||||
{code3: 'que', code2: 'qu', name: 'Quechua'},
|
||||
{code3: 'raj', code2: ' ', name: 'Rajasthani'},
|
||||
{code3: 'rap', code2: ' ', name: 'Rapanui'},
|
||||
{code3: 'rar', code2: ' ', name: 'Rarotongan; Cook Islands Maori'},
|
||||
{code3: 'roa', code2: ' ', name: 'Romance languages'},
|
||||
{code3: 'raj', code2: '', name: 'Rajasthani'},
|
||||
{code3: 'rap', code2: '', name: 'Rapanui'},
|
||||
{code3: 'rar', code2: '', name: 'Rarotongan; Cook Islands Maori'},
|
||||
{code3: 'roa', code2: '', name: 'Romance languages'},
|
||||
{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: 'ron', code2: 'ro', name: 'Romanian; Moldavian; Moldovan'},
|
||||
{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: 'sad', code2: ' ', name: 'Sandawe'},
|
||||
{code3: 'sad', code2: '', name: 'Sandawe'},
|
||||
{code3: 'sag', code2: 'sg', name: 'Sango'},
|
||||
{code3: 'sah', code2: ' ', name: 'Yakut'},
|
||||
{code3: 'sai', code2: ' ', name: 'South American Indian languages'},
|
||||
{code3: 'sal', code2: ' ', name: 'Salishan languages'},
|
||||
{code3: 'sam', code2: ' ', name: 'Samaritan Aramaic'},
|
||||
{code3: 'sah', code2: '', name: 'Yakut'},
|
||||
{code3: 'sai', code2: '', name: 'South American Indian languages'},
|
||||
{code3: 'sal', code2: '', name: 'Salishan languages'},
|
||||
{code3: 'sam', code2: '', name: 'Samaritan Aramaic'},
|
||||
{code3: 'san', code2: 'sa', name: 'Sanskrit'},
|
||||
{code3: 'sas', code2: ' ', name: 'Sasak'},
|
||||
{code3: 'sat', code2: ' ', name: 'Santali'},
|
||||
{code3: 'scn', code2: ' ', name: 'Sicilian'},
|
||||
{code3: 'sco', code2: ' ', name: 'Scots'},
|
||||
{code3: 'sel', code2: ' ', name: 'Selkup'},
|
||||
{code3: 'sem', code2: ' ', name: 'Semitic languages'},
|
||||
{code3: 'sga', code2: ' ', name: 'Irish, Old (to 900)'},
|
||||
{code3: 'sgn', code2: ' ', name: 'Sign Languages'},
|
||||
{code3: 'shn', code2: ' ', name: 'Shan'},
|
||||
{code3: 'sid', code2: ' ', name: 'Sidamo'},
|
||||
{code3: 'sas', code2: '', name: 'Sasak'},
|
||||
{code3: 'sat', code2: '', name: 'Santali'},
|
||||
{code3: 'scn', code2: '', name: 'Sicilian'},
|
||||
{code3: 'sco', code2: '', name: 'Scots'},
|
||||
{code3: 'sel', code2: '', name: 'Selkup'},
|
||||
{code3: 'sem', code2: '', name: 'Semitic languages'},
|
||||
{code3: 'sga', code2: '', name: 'Irish, Old (to 900)'},
|
||||
{code3: 'sgn', code2: '', name: 'Sign Languages'},
|
||||
{code3: 'shn', code2: '', name: 'Shan'},
|
||||
{code3: 'sid', code2: '', name: 'Sidamo'},
|
||||
{code3: 'sin', code2: 'si', name: 'Sinhala; Sinhalese'},
|
||||
{code3: 'sio', code2: ' ', name: 'Siouan languages'},
|
||||
{code3: 'sit', code2: ' ', name: 'Sino-Tibetan languages'},
|
||||
{code3: 'sla', code2: ' ', name: 'Slavic languages'},
|
||||
{code3: 'sio', code2: '', name: 'Siouan languages'},
|
||||
{code3: 'sit', code2: '', name: 'Sino-Tibetan languages'},
|
||||
{code3: 'sla', code2: '', name: 'Slavic languages'},
|
||||
{code3: 'slo', code2: 'sk', name: 'Slovak'},
|
||||
{code3: 'slk', code2: 'sk', name: 'Slovak'},
|
||||
{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: 'smi', code2: ' ', name: 'Sami languages'},
|
||||
{code3: 'smj', code2: ' ', name: 'Lule Sami'},
|
||||
{code3: 'smn', code2: ' ', name: 'Inari Sami'},
|
||||
{code3: 'smi', code2: '', name: 'Sami languages'},
|
||||
{code3: 'smj', code2: '', name: 'Lule Sami'},
|
||||
{code3: 'smn', code2: '', name: 'Inari Sami'},
|
||||
{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: 'snd', code2: 'sd', name: 'Sindhi'},
|
||||
{code3: 'snk', code2: ' ', name: 'Soninke'},
|
||||
{code3: 'sog', code2: ' ', name: 'Sogdian'},
|
||||
{code3: 'snk', code2: '', name: 'Soninke'},
|
||||
{code3: 'sog', code2: '', name: 'Sogdian'},
|
||||
{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: 'spa', code2: 'es', name: 'Spanish'},
|
||||
{code3: 'sqi', code2: 'sq', name: 'Albanian'},
|
||||
{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: 'srr', code2: ' ', name: 'Serer'},
|
||||
{code3: 'ssa', code2: ' ', name: 'Nilo-Saharan languages'},
|
||||
{code3: 'srr', code2: '', name: 'Serer'},
|
||||
{code3: 'ssa', code2: '', name: 'Nilo-Saharan languages'},
|
||||
{code3: 'ssw', code2: 'ss', name: 'Swati'},
|
||||
{code3: 'suk', code2: ' ', name: 'Sukuma'},
|
||||
{code3: 'suk', code2: '', name: 'Sukuma'},
|
||||
{code3: 'sun', code2: 'su', name: 'Sundanese'},
|
||||
{code3: 'sus', code2: ' ', name: 'Susu'},
|
||||
{code3: 'sux', code2: ' ', name: 'Sumerian'},
|
||||
{code3: 'sus', code2: '', name: 'Susu'},
|
||||
{code3: 'sux', code2: '', name: 'Sumerian'},
|
||||
{code3: 'swa', code2: 'sw', name: 'Swahili'},
|
||||
{code3: 'swe', code2: 'sv', name: 'Swedish'},
|
||||
{code3: 'syc', code2: ' ', name: 'Classical Syriac'},
|
||||
{code3: 'syr', code2: ' ', name: 'Syriac'},
|
||||
{code3: 'syc', code2: '', name: 'Classical Syriac'},
|
||||
{code3: 'syr', code2: '', name: 'Syriac'},
|
||||
{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: 'tat', code2: 'tt', name: 'Tatar'},
|
||||
{code3: 'tel', code2: 'te', name: 'Telugu'},
|
||||
{code3: 'tem', code2: ' ', name: 'Timne'},
|
||||
{code3: 'ter', code2: ' ', name: 'Tereno'},
|
||||
{code3: 'tet', code2: ' ', name: 'Tetum'},
|
||||
{code3: 'tem', code2: '', name: 'Timne'},
|
||||
{code3: 'ter', code2: '', name: 'Tereno'},
|
||||
{code3: 'tet', code2: '', name: 'Tetum'},
|
||||
{code3: 'tgk', code2: 'tg', name: 'Tajik'},
|
||||
{code3: 'tgl', code2: 'tl', name: 'Tagalog'},
|
||||
{code3: 'tha', code2: 'th', name: 'Thai'},
|
||||
{code3: 'tib', code2: 'bo', name: 'Tibetan'},
|
||||
{code3: 'tig', code2: ' ', name: 'Tigre'},
|
||||
{code3: 'tig', code2: '', name: 'Tigre'},
|
||||
{code3: 'tir', code2: 'ti', name: 'Tigrinya'},
|
||||
{code3: 'tiv', code2: ' ', name: 'Tiv'},
|
||||
{code3: 'tkl', code2: ' ', name: 'Tokelau'},
|
||||
{code3: 'tlh', code2: ' ', name: 'Klingon; tlhIngan-Hol'},
|
||||
{code3: 'tli', code2: ' ', name: 'Tlingit'},
|
||||
{code3: 'tmh', code2: ' ', name: 'Tamashek'},
|
||||
{code3: 'tog', code2: ' ', name: 'Tonga (Nyasa)'},
|
||||
{code3: 'tiv', code2: '', name: 'Tiv'},
|
||||
{code3: 'tkl', code2: '', name: 'Tokelau'},
|
||||
{code3: 'tlh', code2: '', name: 'Klingon; tlhIngan-Hol'},
|
||||
{code3: 'tli', code2: '', name: 'Tlingit'},
|
||||
{code3: 'tmh', code2: '', name: 'Tamashek'},
|
||||
{code3: 'tog', code2: '', name: 'Tonga (Nyasa)'},
|
||||
{code3: 'ton', code2: 'to', name: 'Tonga (Tonga Islands)'},
|
||||
{code3: 'tpi', code2: ' ', name: 'Tok Pisin'},
|
||||
{code3: 'tsi', code2: ' ', name: 'Tsimshian'},
|
||||
{code3: 'tpi', code2: '', name: 'Tok Pisin'},
|
||||
{code3: 'tsi', code2: '', name: 'Tsimshian'},
|
||||
{code3: 'tsn', code2: 'tn', name: 'Tswana'},
|
||||
{code3: 'tso', code2: 'ts', name: 'Tsonga'},
|
||||
{code3: 'tuk', code2: 'tk', name: 'Turkmen'},
|
||||
{code3: 'tum', code2: ' ', name: 'Tumbuka'},
|
||||
{code3: 'tup', code2: ' ', name: 'Tupi languages'},
|
||||
{code3: 'tum', code2: '', name: 'Tumbuka'},
|
||||
{code3: 'tup', code2: '', name: 'Tupi languages'},
|
||||
{code3: 'tur', code2: 'tr', name: 'Turkish'},
|
||||
{code3: 'tut', code2: ' ', name: 'Altaic languages'},
|
||||
{code3: 'tvl', code2: ' ', name: 'Tuvalu'},
|
||||
{code3: 'tut', code2: '', name: 'Altaic languages'},
|
||||
{code3: 'tvl', code2: '', name: 'Tuvalu'},
|
||||
{code3: 'twi', code2: 'tw', name: 'Twi'},
|
||||
{code3: 'tyv', code2: ' ', name: 'Tuvinian'},
|
||||
{code3: 'udm', code2: ' ', name: 'Udmurt'},
|
||||
{code3: 'uga', code2: ' ', name: 'Ugaritic'},
|
||||
{code3: 'tyv', code2: '', name: 'Tuvinian'},
|
||||
{code3: 'udm', code2: '', name: 'Udmurt'},
|
||||
{code3: 'uga', code2: '', name: 'Ugaritic'},
|
||||
{code3: 'uig', code2: 'ug', name: 'Uighur; Uyghur'},
|
||||
{code3: 'ukr', code2: 'uk', name: 'Ukrainian'},
|
||||
{code3: 'umb', code2: ' ', name: 'Umbundu'},
|
||||
{code3: 'und', code2: ' ', name: 'Undetermined'},
|
||||
{code3: 'umb', code2: '', name: 'Umbundu'},
|
||||
{code3: 'und', code2: '', name: 'Undetermined'},
|
||||
{code3: 'urd', code2: 'ur', name: 'Urdu'},
|
||||
{code3: 'uzb', code2: 'uz', name: 'Uzbek'},
|
||||
{code3: 'vai', code2: ' ', name: 'Vai'},
|
||||
{code3: 'vai', code2: '', name: 'Vai'},
|
||||
{code3: 'ven', code2: 've', name: 'Venda'},
|
||||
{code3: 'vie', code2: 'vi', name: 'Vietnamese'},
|
||||
{code3: 'vol', code2: 'vo', name: 'Volapük'},
|
||||
{code3: 'vot', code2: ' ', name: 'Votic'},
|
||||
{code3: 'wak', code2: ' ', name: 'Wakashan languages'},
|
||||
{code3: 'wal', code2: ' ', name: 'Wolaitta; Wolaytta'},
|
||||
{code3: 'war', code2: ' ', name: 'Waray'},
|
||||
{code3: 'was', code2: ' ', name: 'Washo'},
|
||||
{code3: 'vot', code2: '', name: 'Votic'},
|
||||
{code3: 'wak', code2: '', name: 'Wakashan languages'},
|
||||
{code3: 'wal', code2: '', name: 'Wolaitta; Wolaytta'},
|
||||
{code3: 'war', code2: '', name: 'Waray'},
|
||||
{code3: 'was', code2: '', name: 'Washo'},
|
||||
{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: 'wol', code2: 'wo', name: 'Wolof'},
|
||||
{code3: 'xal', code2: ' ', name: 'Kalmyk; Oirat'},
|
||||
{code3: 'xal', code2: '', name: 'Kalmyk; Oirat'},
|
||||
{code3: 'xho', code2: 'xh', name: 'Xhosa'},
|
||||
{code3: 'yao', code2: ' ', name: 'Yao'},
|
||||
{code3: 'yap', code2: ' ', name: 'Yapese'},
|
||||
{code3: 'yao', code2: '', name: 'Yao'},
|
||||
{code3: 'yap', code2: '', name: 'Yapese'},
|
||||
{code3: 'yid', code2: 'yi', name: 'Yiddish'},
|
||||
{code3: 'yor', code2: 'yo', name: 'Yoruba'},
|
||||
{code3: 'ypk', code2: ' ', name: 'Yupik languages'},
|
||||
{code3: 'zap', code2: ' ', name: 'Zapotec'},
|
||||
{code3: 'zbl', code2: ' ', name: 'Blissymbols; Blissymbolics; Bliss'},
|
||||
{code3: 'zen', code2: ' ', name: 'Zenaga'},
|
||||
{code3: 'zgh', code2: ' ', name: 'Standard Moroccan Tamazight'},
|
||||
{code3: 'ypk', code2: '', name: 'Yupik languages'},
|
||||
{code3: 'zap', code2: '', name: 'Zapotec'},
|
||||
{code3: 'zbl', code2: '', name: 'Blissymbols; Blissymbolics; Bliss'},
|
||||
{code3: 'zen', code2: '', name: 'Zenaga'},
|
||||
{code3: 'zgh', code2: '', name: 'Standard Moroccan Tamazight'},
|
||||
{code3: 'zha', code2: 'za', name: 'Zhuang; Chuang'},
|
||||
{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: 'zun', code2: ' ', name: 'Zuni'},
|
||||
{code3: 'zun', code2: '', name: 'Zuni'},
|
||||
{
|
||||
code3: 'zza',
|
||||
code2: '',
|
||||
|
||||
+13
-15
@@ -1,12 +1,11 @@
|
||||
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 {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {CommonNavigatorParams} from 'lib/routes/types'
|
||||
@@ -39,7 +38,6 @@ export default function HashtagScreen({
|
||||
}: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) {
|
||||
const {tag, author} = route.params
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
|
||||
const fullTag = React.useMemo(() => {
|
||||
return `#${decodeURIComponent(tag)}`
|
||||
@@ -111,7 +109,7 @@ export default function HashtagScreen({
|
||||
|
||||
return (
|
||||
<>
|
||||
<CenteredView sideBorders style={[pal.border, pal.view]}>
|
||||
<CenteredView sideBorders={true}>
|
||||
<ViewHeader
|
||||
showOnDesktop
|
||||
title={headerTitle}
|
||||
@@ -138,8 +136,17 @@ export default function HashtagScreen({
|
||||
onPageSelected={onPageSelected}
|
||||
renderTabBar={props => (
|
||||
<CenteredView
|
||||
sideBorders
|
||||
style={[pal.border, pal.view, styles.tabBarContainer]}>
|
||||
sideBorders={true}
|
||||
// @ts-ignore web only
|
||||
style={
|
||||
isWeb
|
||||
? {
|
||||
position: isWeb ? 'sticky' : '',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
}
|
||||
: undefined
|
||||
}>
|
||||
<TabBar items={sections.map(section => section.title)} {...props} />
|
||||
</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])
|
||||
|
||||
const initialNumToRender = useInitialNumToRender(80)
|
||||
const initialNumToRender = useInitialNumToRender({minItemHeight: 80})
|
||||
const [isPTRing, setIsPTRing] = useState(false)
|
||||
|
||||
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 {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {TOURS, useSetQueuedTour} from '#/tours'
|
||||
|
||||
export function StepFinished() {
|
||||
const {_} = useLingui()
|
||||
@@ -59,7 +58,6 @@ export function StepFinished() {
|
||||
const activeStarterPack = useActiveStarterPack()
|
||||
const setActiveStarterPack = useSetActiveStarterPack()
|
||||
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
|
||||
const setQueuedTour = useSetQueuedTour()
|
||||
const {startProgressGuide} = useProgressGuideControls()
|
||||
|
||||
const finishOnboarding = React.useCallback(async () => {
|
||||
@@ -189,7 +187,6 @@ export function StepFinished() {
|
||||
setSaving(false)
|
||||
setActiveStarterPack(undefined)
|
||||
setHasCheckedForStarterPack(true)
|
||||
setQueuedTour(TOURS.HOME)
|
||||
startProgressGuide('like-10-and-follow-7')
|
||||
dispatch({type: 'finish'})
|
||||
onboardDispatch({type: 'finish'})
|
||||
@@ -223,7 +220,6 @@ export function StepFinished() {
|
||||
requestNotificationsPermission,
|
||||
setActiveStarterPack,
|
||||
setHasCheckedForStarterPack,
|
||||
setQueuedTour,
|
||||
startProgressGuide,
|
||||
])
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {isNative} from '#/platform/detection'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {Feed} from 'view/com/posts/Feed'
|
||||
@@ -42,6 +43,10 @@ export const ProfileFeedSection = React.forwardRef<
|
||||
const queryClient = useQueryClient()
|
||||
const [hasNew, setHasNew] = 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(() => {
|
||||
scrollElRef.current?.scrollToOffset({
|
||||
@@ -79,7 +84,9 @@ export const ProfileFeedSection = React.forwardRef<
|
||||
headerOffset={headerHeight}
|
||||
renderEndOfFeed={ProfileEndOfFeed}
|
||||
ignoreFilterFor={ignoreFilterFor}
|
||||
outsideHeaderOffset={headerHeight}
|
||||
initialNumToRender={
|
||||
shouldUseAdjustedNumToRender ? adjustedInitialNumToRender : undefined
|
||||
}
|
||||
/>
|
||||
{(isScrolledDown || hasNew) && (
|
||||
<LoadLatestBtn
|
||||
|
||||
@@ -41,10 +41,9 @@ export function StepCaptcha() {
|
||||
(code: string) => {
|
||||
setCompleted(true)
|
||||
logEvent('signup:captchaSuccess', {})
|
||||
const submitTask = {code, mutableProcessed: false}
|
||||
dispatch({
|
||||
type: 'submit',
|
||||
task: submitTask,
|
||||
task: {verificationCode: code, mutableProcessed: false},
|
||||
})
|
||||
},
|
||||
[dispatch],
|
||||
|
||||
@@ -65,8 +65,10 @@ export function StepHandle() {
|
||||
})
|
||||
// phoneVerificationRequired is actually whether a captcha is required
|
||||
if (!state.serviceDescription?.phoneVerificationRequired) {
|
||||
const submitTask = {code: undefined, mutableProcessed: false}
|
||||
dispatch({type: 'submit', task: submitTask})
|
||||
dispatch({
|
||||
type: 'submit',
|
||||
task: {verificationCode: undefined, mutableProcessed: false},
|
||||
})
|
||||
return
|
||||
}
|
||||
dispatch({type: 'next'})
|
||||
|
||||
@@ -27,7 +27,7 @@ export enum SignupStep {
|
||||
}
|
||||
|
||||
type SubmitTask = {
|
||||
code: string | undefined
|
||||
verificationCode: string | undefined
|
||||
mutableProcessed: boolean // OK to mutate assuming it's never read in render.
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ export type SignupAction =
|
||||
| {type: 'setDateOfBirth'; value: Date}
|
||||
| {type: 'setInviteCode'; value: string}
|
||||
| {type: 'setHandle'; value: string}
|
||||
| {type: 'setVerificationCode'; value: string}
|
||||
| {type: 'setError'; value: string}
|
||||
| {type: 'setIsLoading'; value: boolean}
|
||||
| {type: 'submit'; task: SubmitTask}
|
||||
@@ -189,11 +188,7 @@ export function useSubmitSignup() {
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
|
||||
return useCallback(
|
||||
async (
|
||||
state: SignupState,
|
||||
dispatch: (action: SignupAction) => void,
|
||||
verificationCode?: string,
|
||||
) => {
|
||||
async (state: SignupState, dispatch: (action: SignupAction) => void) => {
|
||||
if (!state.email) {
|
||||
dispatch({type: 'setStep', value: SignupStep.INFO})
|
||||
return dispatch({
|
||||
@@ -224,7 +219,7 @@ export function useSubmitSignup() {
|
||||
}
|
||||
if (
|
||||
state.serviceDescription?.phoneVerificationRequired &&
|
||||
!verificationCode
|
||||
!state.pendingSubmit?.verificationCode
|
||||
) {
|
||||
dispatch({type: 'setStep', value: SignupStep.CAPTCHA})
|
||||
logger.error('Signup Flow Error', {
|
||||
@@ -247,7 +242,7 @@ export function useSubmitSignup() {
|
||||
password: state.password,
|
||||
birthDate: state.dateOfBirth,
|
||||
inviteCode: state.inviteCode.trim(),
|
||||
verificationCode: verificationCode,
|
||||
verificationCode: state.pendingSubmit?.verificationCode,
|
||||
})
|
||||
/*
|
||||
* Must happen last so that if the user has multiple tabs open and
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {cancelable} from '#/lib/async/cancelable'
|
||||
import {CompressedVideo, compressVideo} from 'lib/media/video/compress'
|
||||
|
||||
export function useCompressVideoMutation({
|
||||
onProgress,
|
||||
onSuccess,
|
||||
onError,
|
||||
signal,
|
||||
}: {
|
||||
onProgress: (progress: number) => void
|
||||
onError: (e: any) => void
|
||||
onSuccess: (video: CompressedVideo) => void
|
||||
signal: AbortSignal
|
||||
}) {
|
||||
return useMutation({
|
||||
mutationFn: async (asset: ImagePickerAsset) => {
|
||||
return await compressVideo(asset.uri, {
|
||||
onProgress: num => onProgress(trunc2dp(num)),
|
||||
})
|
||||
},
|
||||
mutationKey: ['video', 'compress'],
|
||||
mutationFn: cancelable(
|
||||
(asset: ImagePickerAsset) =>
|
||||
compressVideo(asset.uri, {
|
||||
onProgress: num => onProgress(trunc2dp(num)),
|
||||
signal,
|
||||
}),
|
||||
signal,
|
||||
),
|
||||
onError,
|
||||
onSuccess,
|
||||
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 = (
|
||||
route: string,
|
||||
@@ -13,3 +16,11 @@ export const createVideoEndpointUrl = (
|
||||
}
|
||||
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 {AppBskyVideoDefs} from '@atproto/api'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {cancelable} from '#/lib/async/cancelable'
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
import {UploadVideoResponse} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
|
||||
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
|
||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
setProgress,
|
||||
signal,
|
||||
}: {
|
||||
onSuccess: (response: UploadVideoResponse) => void
|
||||
onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
|
||||
onError: (e: any) => void
|
||||
setProgress: (progress: number) => void
|
||||
signal: AbortSignal
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (video: CompressedVideo) => {
|
||||
const uri = createVideoEndpointUrl('/upload', {
|
||||
mutationKey: ['video', 'upload'],
|
||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||
did: currentAccount!.did,
|
||||
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 (!agent.pdsUrl) {
|
||||
if (!currentAccount?.service) {
|
||||
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')
|
||||
}
|
||||
|
||||
const {data: serviceAuth} =
|
||||
await agent.api.com.atproto.server.getServiceAuth({
|
||||
aud: `did:web:${agent.pdsUrl.hostname}`,
|
||||
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
|
||||
{
|
||||
aud: serviceAuthAud,
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const uploadTask = createUploadTask(
|
||||
uri,
|
||||
video.uri,
|
||||
{
|
||||
headers: {
|
||||
'dev-key': UPLOAD_HEADER,
|
||||
'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4?
|
||||
'content-type': 'video/mp4',
|
||||
Authorization: `Bearer ${serviceAuth.token}`,
|
||||
},
|
||||
httpMethod: 'POST',
|
||||
@@ -59,12 +66,9 @@ export const useUploadVideoMutation = ({
|
||||
throw new Error('No response')
|
||||
}
|
||||
|
||||
// @TODO rm, useful for debugging/getting video cid
|
||||
console.log('[VIDEO]', res.body)
|
||||
const responseBody = JSON.parse(res.body) as UploadVideoResponse
|
||||
onSuccess(responseBody)
|
||||
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
|
||||
return responseBody
|
||||
},
|
||||
}, signal),
|
||||
onError,
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
@@ -1,79 +1,85 @@
|
||||
import {AppBskyVideoDefs} from '@atproto/api'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {cancelable} from '#/lib/async/cancelable'
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
import {UploadVideoResponse} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
|
||||
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
|
||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
setProgress,
|
||||
signal,
|
||||
}: {
|
||||
onSuccess: (response: UploadVideoResponse) => void
|
||||
onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
|
||||
onError: (e: any) => void
|
||||
setProgress: (progress: number) => void
|
||||
signal: AbortSignal
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (video: CompressedVideo) => {
|
||||
const uri = createVideoEndpointUrl('/upload', {
|
||||
mutationKey: ['video', 'upload'],
|
||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||
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 (!agent.pdsUrl) {
|
||||
if (!currentAccount?.service) {
|
||||
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')
|
||||
}
|
||||
|
||||
const {data: serviceAuth} =
|
||||
await agent.api.com.atproto.server.getServiceAuth({
|
||||
aud: `did:web:${agent.pdsUrl.hostname}`,
|
||||
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
|
||||
{
|
||||
aud: serviceAuthAud,
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
const res = (await new Promise((resolve, reject) => {
|
||||
xhr.upload.addEventListener('progress', e => {
|
||||
const progress = e.loaded / e.total
|
||||
setProgress(progress)
|
||||
})
|
||||
xhr.onloadend = () => {
|
||||
if (xhr.readyState === 4) {
|
||||
const uploadRes = JSON.parse(
|
||||
xhr.responseText,
|
||||
) as UploadVideoResponse
|
||||
resolve(uploadRes)
|
||||
onSuccess(uploadRes)
|
||||
} else {
|
||||
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
|
||||
(resolve, reject) => {
|
||||
xhr.upload.addEventListener('progress', e => {
|
||||
const progress = e.loaded / e.total
|
||||
setProgress(progress)
|
||||
})
|
||||
xhr.onloadend = () => {
|
||||
if (xhr.readyState === 4) {
|
||||
const uploadRes = JSON.parse(
|
||||
xhr.responseText,
|
||||
) as AppBskyVideoDefs.JobStatus
|
||||
resolve(uploadRes)
|
||||
onSuccess(uploadRes)
|
||||
} else {
|
||||
reject()
|
||||
onError(new Error('Failed to upload video'))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
reject()
|
||||
onError(new Error('Failed to upload video'))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
reject()
|
||||
onError(new Error('Failed to upload video'))
|
||||
}
|
||||
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
|
||||
xhr.open('POST', uri)
|
||||
xhr.setRequestHeader('Content-Type', 'video/mp4')
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
|
||||
xhr.send(bytes)
|
||||
},
|
||||
)
|
||||
|
||||
// @TODO rm for prod
|
||||
console.log('[VIDEO]', res)
|
||||
return res
|
||||
},
|
||||
}, signal),
|
||||
onError,
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
@@ -1,68 +1,72 @@
|
||||
import React from 'react'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {AppBskyVideoDefs, BlobRef} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {CompressedVideo} from 'lib/media/video/compress'
|
||||
import {VideoTooLargeError} from 'lib/media/video/errors'
|
||||
import {JobState, JobStatus} from 'lib/media/video/types'
|
||||
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'
|
||||
|
||||
type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done'
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: 'SetStatus'
|
||||
status: Status
|
||||
}
|
||||
| {
|
||||
type: 'SetProgress'
|
||||
progress: number
|
||||
}
|
||||
| {
|
||||
type: 'SetError'
|
||||
error: string | undefined
|
||||
}
|
||||
| {type: 'SetStatus'; status: Status}
|
||||
| {type: 'SetProgress'; progress: number}
|
||||
| {type: 'SetError'; error: string | undefined}
|
||||
| {type: 'Reset'}
|
||||
| {type: 'SetAsset'; asset: ImagePickerAsset}
|
||||
| {type: 'SetVideo'; video: CompressedVideo}
|
||||
| {type: 'SetJobStatus'; jobStatus: JobStatus}
|
||||
| {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus}
|
||||
| {type: 'SetBlobRef'; blobRef: BlobRef}
|
||||
|
||||
export interface State {
|
||||
status: Status
|
||||
progress: number
|
||||
asset?: ImagePickerAsset
|
||||
video: CompressedVideo | null
|
||||
jobStatus?: JobStatus
|
||||
jobStatus?: AppBskyVideoDefs.JobStatus
|
||||
blobRef?: BlobRef
|
||||
error?: string
|
||||
abortController: AbortController
|
||||
}
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
let updatedState = state
|
||||
if (action.type === 'SetStatus') {
|
||||
updatedState = {...state, status: action.status}
|
||||
} else if (action.type === 'SetProgress') {
|
||||
updatedState = {...state, progress: action.progress}
|
||||
} else if (action.type === 'SetError') {
|
||||
updatedState = {...state, error: action.error}
|
||||
} else if (action.type === 'Reset') {
|
||||
updatedState = {
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
video: null,
|
||||
function reducer(queryClient: QueryClient) {
|
||||
return (state: State, action: Action): State => {
|
||||
let updatedState = state
|
||||
if (action.type === 'SetStatus') {
|
||||
updatedState = {...state, status: action.status}
|
||||
} else if (action.type === 'SetProgress') {
|
||||
updatedState = {...state, progress: action.progress}
|
||||
} else if (action.type === 'SetError') {
|
||||
updatedState = {...state, error: action.error}
|
||||
} else if (action.type === 'Reset') {
|
||||
state.abortController.abort()
|
||||
queryClient.cancelQueries({
|
||||
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') {
|
||||
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
|
||||
}
|
||||
return updatedState
|
||||
}
|
||||
|
||||
export function useUploadVideo({
|
||||
@@ -73,14 +77,16 @@ export function useUploadVideo({
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const [state, dispatch] = React.useReducer(reducer, {
|
||||
const queryClient = useQueryClient()
|
||||
const [state, dispatch] = React.useReducer(reducer(queryClient), {
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
video: null,
|
||||
abortController: new AbortController(),
|
||||
})
|
||||
|
||||
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
|
||||
// Leaving it for now though
|
||||
dispatch({
|
||||
@@ -89,7 +95,11 @@ export function useUploadVideo({
|
||||
})
|
||||
setStatus(status.state.toString())
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: blobRef => {
|
||||
dispatch({
|
||||
type: 'SetBlobRef',
|
||||
blobRef,
|
||||
})
|
||||
dispatch({
|
||||
type: 'SetStatus',
|
||||
status: 'idle',
|
||||
@@ -104,7 +114,7 @@ export function useUploadVideo({
|
||||
type: 'SetStatus',
|
||||
status: 'processing',
|
||||
})
|
||||
setJobId(response.job_id)
|
||||
setJobId(response.jobId)
|
||||
},
|
||||
onError: e => {
|
||||
dispatch({
|
||||
@@ -116,6 +126,7 @@ export function useUploadVideo({
|
||||
setProgress: p => {
|
||||
dispatch({type: 'SetProgress', progress: p})
|
||||
},
|
||||
signal: state.abortController.signal,
|
||||
})
|
||||
|
||||
const {mutate: onSelectVideo} = useCompressVideoMutation({
|
||||
@@ -148,6 +159,7 @@ export function useUploadVideo({
|
||||
})
|
||||
onVideoCompressed(video)
|
||||
},
|
||||
signal: state.abortController.signal,
|
||||
})
|
||||
|
||||
const selectVideo = (asset: ImagePickerAsset) => {
|
||||
@@ -163,7 +175,6 @@ export function useUploadVideo({
|
||||
}
|
||||
|
||||
const clearVideo = () => {
|
||||
// @TODO cancel any running jobs
|
||||
dispatch({type: 'Reset'})
|
||||
}
|
||||
|
||||
@@ -179,21 +190,27 @@ const useUploadStatusQuery = ({
|
||||
onStatusChange,
|
||||
onSuccess,
|
||||
}: {
|
||||
onStatusChange: (status: JobStatus) => void
|
||||
onSuccess: () => void
|
||||
onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void
|
||||
onSuccess: (blobRef: BlobRef) => void
|
||||
}) => {
|
||||
const videoAgent = useVideoAgent()
|
||||
const [enabled, setEnabled] = React.useState(true)
|
||||
const [jobId, setJobId] = React.useState<string>()
|
||||
|
||||
const {isLoading, isError} = useQuery({
|
||||
queryKey: ['video-upload'],
|
||||
queryKey: ['video', 'upload status', jobId],
|
||||
queryFn: async () => {
|
||||
const url = createVideoEndpointUrl(`/job/${jobId}/status`)
|
||||
const res = await fetch(url)
|
||||
const status = (await res.json()) as JobStatus
|
||||
if (status.state === JobState.JOB_STATE_COMPLETED) {
|
||||
if (!jobId) return // this won't happen, can ignore
|
||||
|
||||
const {data} = await videoAgent.app.bsky.video.getJobStatus({jobId})
|
||||
const status = data.jobStatus
|
||||
if (status.state === 'JOB_STATE_COMPLETED') {
|
||||
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)
|
||||
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, {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
@@ -178,7 +177,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
clearVideo,
|
||||
state: videoUploadState,
|
||||
} = useUploadVideo({
|
||||
setStatus: (status: string) => setProcessingState(status),
|
||||
setStatus: setProcessingState,
|
||||
onSuccess: () => {
|
||||
if (publishOnUpload) {
|
||||
onPressPublish(true)
|
||||
@@ -348,6 +347,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
postgate,
|
||||
onStateChange: setProcessingState,
|
||||
langs: toPostLanguages(langPrefs.postLanguage),
|
||||
video: videoUploadState.blobRef,
|
||||
})
|
||||
).uri
|
||||
try {
|
||||
@@ -699,15 +699,10 @@ export const ComposePost = observer(function ComposePost({
|
||||
<VideoTranscodeProgress
|
||||
asset={videoUploadState.asset}
|
||||
progress={videoUploadState.progress}
|
||||
clear={clearVideo}
|
||||
/>
|
||||
) : videoUploadState.video ? (
|
||||
// remove suspense when we get rid of lazy
|
||||
<Suspense fallback={null}>
|
||||
<VideoPreview
|
||||
video={videoUploadState.video}
|
||||
clear={clearVideo}
|
||||
/>
|
||||
</Suspense>
|
||||
<VideoPreview video={videoUploadState.video} clear={clearVideo} />
|
||||
) : null}
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
|
||||
@@ -25,8 +25,8 @@ export function ExternalEmbedRemoveBtn({onRemove}: {onRemove: () => void}) {
|
||||
}}
|
||||
onPress={onRemove}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Remove image preview`)}
|
||||
accessibilityHint={_(msg`Removes the image preview`)}
|
||||
accessibilityLabel={_(msg`Remove attachment`)}
|
||||
accessibilityHint={_(msg`Removes the attachment`)}
|
||||
onAccessibilityEscape={onRemove}>
|
||||
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -3,18 +3,19 @@ import {View} from 'react-native'
|
||||
// @ts-expect-error no type definition
|
||||
import ProgressPie from 'react-native-progress/Pie'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn'
|
||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||
|
||||
export function VideoTranscodeProgress({
|
||||
asset,
|
||||
progress,
|
||||
clear,
|
||||
}: {
|
||||
asset: ImagePickerAsset
|
||||
progress: number
|
||||
clear: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -41,16 +42,14 @@ export function VideoTranscodeProgress({
|
||||
a.inset_0,
|
||||
]}>
|
||||
<ProgressPie
|
||||
size={64}
|
||||
borderWidth={4}
|
||||
size={48}
|
||||
borderWidth={3}
|
||||
borderColor={t.atoms.text.color}
|
||||
color={t.atoms.text.color}
|
||||
progress={progress}
|
||||
/>
|
||||
<Text>
|
||||
<Trans>Compressing...</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -428,6 +428,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
|
||||
(item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1
|
||||
const hasUnrevealedParents =
|
||||
index === 0 && skeleton?.parents && maxParents < skeleton.parents.length
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
@@ -48,7 +47,6 @@ function PostThreadFollowBtnLoaded({
|
||||
'PostThreadItem',
|
||||
)
|
||||
const requireAuth = useRequireAuth()
|
||||
const gate = useGate()
|
||||
|
||||
const isFollowing = !!profile.viewer?.following
|
||||
const isFollowedBy = !!profile.viewer?.followedBy
|
||||
@@ -140,7 +138,7 @@ function PostThreadFollowBtnLoaded({
|
||||
style={[!isFollowing ? palInverted.text : pal.text, s.bold]}
|
||||
numberOfLines={1}>
|
||||
{!isFollowing ? (
|
||||
isFollowedBy && gate('show_follow_back_label_v2') ? (
|
||||
isFollowedBy ? (
|
||||
<Trans>Follow Back</Trans>
|
||||
) : (
|
||||
<Trans>Follow</Trans>
|
||||
|
||||
@@ -398,7 +398,9 @@ let PostThreadItemLoaded = ({
|
||||
</Text>
|
||||
</Link>
|
||||
) : null}
|
||||
{post.quoteCount != null && post.quoteCount !== 0 ? (
|
||||
{post.quoteCount != null &&
|
||||
post.quoteCount !== 0 &&
|
||||
!post.viewer?.embeddingDisabled ? (
|
||||
<Link
|
||||
style={styles.expandedInfoItem}
|
||||
href={quotesHref}
|
||||
|
||||
@@ -161,6 +161,7 @@ let Feed = ({
|
||||
ListHeaderComponent,
|
||||
extraData,
|
||||
savedFeedConfig,
|
||||
initialNumToRender: initialNumToRenderOverride,
|
||||
}: {
|
||||
feed: FeedDescriptor
|
||||
feedParams?: FeedParams
|
||||
@@ -180,7 +181,7 @@ let Feed = ({
|
||||
ListHeaderComponent?: () => JSX.Element
|
||||
extraData?: any
|
||||
savedFeedConfig?: AppBskyActorDefs.SavedFeed
|
||||
outsideHeaderOffset?: number
|
||||
initialNumToRender?: number
|
||||
}): React.ReactNode => {
|
||||
const theme = useTheme()
|
||||
const {track} = useAnalytics()
|
||||
@@ -545,7 +546,7 @@ let Feed = ({
|
||||
desktopFixedHeight={
|
||||
desktopFixedHeightOffset ? desktopFixedHeightOffset : true
|
||||
}
|
||||
initialNumToRender={initialNumToRender}
|
||||
initialNumToRender={initialNumToRenderOverride ?? initialNumToRender}
|
||||
windowSize={11}
|
||||
onItemSeen={feedFeedback.onItemSeen}
|
||||
/>
|
||||
|
||||
@@ -17,37 +17,37 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
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 {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 {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||
import {precacheProfile} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||
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 {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {countLines} from 'lib/strings/helpers'
|
||||
import {s} from 'lib/styles'
|
||||
import {precacheProfile} from 'state/queries/profile'
|
||||
import {FeedNameText} from '#/view/com/util/FeedInfoText'
|
||||
import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls'
|
||||
import {PostEmbeds} from '#/view/com/util/post-embeds'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {AppModerationCause} from '#/components/Pills'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
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 {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
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'
|
||||
|
||||
interface FeedItemProps {
|
||||
@@ -571,7 +571,11 @@ function VideoDebug() {
|
||||
|
||||
return (
|
||||
<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'
|
||||
import {useWindowDimensions} from 'react-native'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {VideoPlayerProvider} from './VideoPlayerContext'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
|
||||
const ActiveVideoContext = React.createContext<{
|
||||
const Context = React.createContext<{
|
||||
activeViewId: string | null
|
||||
setActiveView: (viewId: string, src: string) => void
|
||||
setActiveView: (viewId: string) => void
|
||||
sendViewPosition: (viewId: string, y: number) => void
|
||||
} | 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 activeViewLocationRef = useRef(Infinity)
|
||||
const [source, setSource] = useState<string | null>(null)
|
||||
const {height: windowHeight} = useWindowDimensions()
|
||||
|
||||
// minimising re-renders by using refs
|
||||
@@ -31,9 +33,8 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
|
||||
}, [activeViewId])
|
||||
|
||||
const setActiveView = useCallback(
|
||||
(viewId: string, src: string) => {
|
||||
(viewId: string) => {
|
||||
setActiveViewId(viewId)
|
||||
setSource(src)
|
||||
manuallySetRef.current = true
|
||||
// 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
|
||||
@@ -88,32 +89,26 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
|
||||
[activeViewId, setActiveView, sendViewPosition],
|
||||
)
|
||||
|
||||
return (
|
||||
<ActiveVideoContext.Provider value={value}>
|
||||
<VideoPlayerProvider source={source ?? ''}>
|
||||
{children}
|
||||
</VideoPlayerProvider>
|
||||
</ActiveVideoContext.Provider>
|
||||
)
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function useActiveVideoView({source}: {source: string}) {
|
||||
const context = React.useContext(ActiveVideoContext)
|
||||
export function useActiveVideoWeb() {
|
||||
const context = React.useContext(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()
|
||||
|
||||
return {
|
||||
active: context.activeViewId === id,
|
||||
setActive: useCallback(
|
||||
() => context.setActiveView(id, source),
|
||||
[context, id, source],
|
||||
),
|
||||
currentActiveView: context.activeViewId,
|
||||
sendPosition: useCallback(
|
||||
(y: number) => context.sendViewPosition(id, y),
|
||||
[context, id],
|
||||
),
|
||||
active: activeViewId === id,
|
||||
setActive: () => {
|
||||
setActiveView(id)
|
||||
},
|
||||
currentActiveView: activeViewId,
|
||||
sendPosition: (y: number) => sendViewPosition(id, y),
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
import React, {useCallback, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
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 {Button, ButtonIcon} from '#/components/Button'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
|
||||
import {ErrorBoundary} from '../ErrorBoundary'
|
||||
import {useActiveVideoView} from './ActiveVideoContext'
|
||||
import {useActiveVideoNative} from './ActiveVideoNativeContext'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
export function VideoEmbed({source}: {source: string}) {
|
||||
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
const t = useTheme()
|
||||
const {active, setActive} = useActiveVideoView({source})
|
||||
const {activeSource, setActiveSource} = useActiveVideoNative()
|
||||
const isActive = embed.playlist === activeSource
|
||||
const {_} = useLingui()
|
||||
|
||||
const [key, setKey] = useState(0)
|
||||
@@ -24,37 +29,61 @@ export function VideoEmbed({source}: {source: string}) {
|
||||
),
|
||||
[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 (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 16 / 9},
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
{aspectRatio},
|
||||
{backgroundColor: t.palette.black},
|
||||
a.my_xs,
|
||||
]}>
|
||||
<ErrorBoundary renderError={renderError} key={key}>
|
||||
<VisibilityView
|
||||
enabled={true}
|
||||
onChangeStatus={isActive => {
|
||||
if (isActive) {
|
||||
setActive()
|
||||
onChangeStatus={isVisible => {
|
||||
if (isVisible) {
|
||||
setActiveSource(embed.playlist)
|
||||
}
|
||||
}}>
|
||||
{active ? (
|
||||
<VideoEmbedInnerNative />
|
||||
{isActive ? (
|
||||
<VideoEmbedInnerNative embed={embed} />
|
||||
) : (
|
||||
<Button
|
||||
style={[a.flex_1, t.atoms.bg_contrast_25]}
|
||||
onPress={setActive}
|
||||
label={_(msg`Play video`)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="large">
|
||||
<ButtonIcon icon={PlayIcon} />
|
||||
</Button>
|
||||
<>
|
||||
<Image
|
||||
source={{uri: embed.thumbnail}}
|
||||
alt={embed.alt}
|
||||
style={a.flex_1}
|
||||
contentFit="contain"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
<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>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {
|
||||
HLSUnsupportedError,
|
||||
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 {ErrorBoundary} from '../ErrorBoundary'
|
||||
import {useActiveVideoView} from './ActiveVideoContext'
|
||||
import {useActiveVideoWeb} from './ActiveVideoWebContext'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
export function VideoEmbed({source}: {source: string}) {
|
||||
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
const t = useTheme()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const gate = useGate()
|
||||
const {active, setActive, sendPosition, currentActiveView} =
|
||||
useActiveVideoView({source})
|
||||
useActiveVideoWeb()
|
||||
const [onScreen, setOnScreen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,12 +47,25 @@ export function VideoEmbed({source}: {source: string}) {
|
||||
[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 (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
{aspectRatio: 16 / 9},
|
||||
t.atoms.bg_contrast_25,
|
||||
{aspectRatio},
|
||||
{backgroundColor: t.palette.black},
|
||||
a.relative,
|
||||
a.rounded_sm,
|
||||
a.my_xs,
|
||||
]}>
|
||||
@@ -61,7 +78,7 @@ export function VideoEmbed({source}: {source: string}) {
|
||||
sendPosition={sendPosition}
|
||||
isAnyViewActive={currentActiveView !== null}>
|
||||
<VideoEmbedInnerWeb
|
||||
source={source}
|
||||
embed={embed}
|
||||
active={active}
|
||||
setActive={setActive}
|
||||
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 {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 {AppBskyEmbedVideo} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useIsFocused} from '@react-navigation/native'
|
||||
|
||||
import {HITSLOP_30} from '#/lib/constants'
|
||||
import {useAppState} from '#/lib/hooks/useAppState'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {logger} from '#/logger'
|
||||
import {useVideoPlayer} from '#/view/com/util/post-embeds/VideoPlayerContext'
|
||||
import {android, atoms as a, useTheme} from '#/alf'
|
||||
import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
AudioCategory,
|
||||
PlatformInfo,
|
||||
} from '../../../../../../modules/expo-bluesky-swiss-army'
|
||||
import {TimeIndicator} from './TimeIndicator'
|
||||
|
||||
export function VideoEmbedInnerNative() {
|
||||
const player = useVideoPlayer()
|
||||
export function VideoEmbedInnerNative({
|
||||
embed,
|
||||
}: {
|
||||
embed: AppBskyEmbedVideo.View
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {player} = useActiveVideoNative()
|
||||
const ref = useRef<VideoView>(null)
|
||||
const isScreenFocused = useIsFocused()
|
||||
const isAppFocused = useAppState()
|
||||
@@ -47,13 +54,23 @@ export function VideoEmbedInnerNative() {
|
||||
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 (
|
||||
<View style={[a.flex_1, a.relative]}>
|
||||
<View style={[a.flex_1, a.relative, {aspectRatio}]}>
|
||||
<VideoView
|
||||
ref={ref}
|
||||
player={player}
|
||||
style={[a.flex_1, a.rounded_sm]}
|
||||
contentFit="contain"
|
||||
nativeControls={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
onEnterFullscreen={() => {
|
||||
PlatformInfo.setAudioCategory(AudioCategory.Playback)
|
||||
PlatformInfo.setAudioActive(true)
|
||||
@@ -65,13 +82,17 @@ export function VideoEmbedInnerNative() {
|
||||
player.muted = true
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function Controls({
|
||||
function VideoControls({
|
||||
player,
|
||||
enterFullscreen,
|
||||
}: {
|
||||
@@ -81,33 +102,22 @@ function Controls({
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const [isMuted, setIsMuted] = useState(player.muted)
|
||||
const [duration, setDuration] = useState(() => Math.floor(player.duration))
|
||||
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')
|
||||
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
||||
|
||||
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
|
||||
const sub = player.addListener('volumeChange', ({isMuted}) => {
|
||||
const volumeSub = player.addListener('volumeChange', ({isMuted}) => {
|
||||
setIsMuted(isMuted)
|
||||
})
|
||||
|
||||
const timeSub = player.addListener(
|
||||
'timeRemainingChange',
|
||||
secondsRemaining => {
|
||||
setTimeRemaining(secondsRemaining)
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
sub.remove()
|
||||
volumeSub.remove()
|
||||
timeSub.remove()
|
||||
}
|
||||
}, [player])
|
||||
|
||||
@@ -143,37 +153,11 @@ function Controls({
|
||||
// 1. timeRemaining is a number - was seeing NaNs
|
||||
// 2. duration is greater than 0 - means metadata has loaded
|
||||
// 3. we're less than 5 second into the video
|
||||
const showTime = !isNaN(timeRemaining) && duration > 0 && currentTime <= 5
|
||||
const showTime = !isNaN(timeRemaining)
|
||||
|
||||
return (
|
||||
<View style={[a.absolute, a.inset_0]}>
|
||||
{showTime && (
|
||||
<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>
|
||||
)}
|
||||
{showTime && <TimeIndicator time={timeRemaining} />}
|
||||
<Pressable
|
||||
onPress={onPressFullscreen}
|
||||
style={a.flex_1}
|
||||
@@ -181,35 +165,33 @@ function Controls({
|
||||
accessibilityHint={_(msg`Tap to enter full screen`)}
|
||||
accessibilityRole="button"
|
||||
/>
|
||||
{duration > 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300)}
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
bottom: 5,
|
||||
right: 5,
|
||||
minHeight: 20,
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Pressable
|
||||
onPress={toggleMuted}
|
||||
style={a.flex_1}
|
||||
accessibilityLabel={isMuted ? _(msg`Muted`) : _(msg`Unmuted`)}
|
||||
accessibilityHint={_(msg`Tap to toggle sound`)}
|
||||
accessibilityRole="button"
|
||||
hitSlop={HITSLOP_30}>
|
||||
{isMuted ? (
|
||||
<MuteIcon width={14} fill={t.palette.white} />
|
||||
) : (
|
||||
<UnmuteIcon width={14} fill={t.palette.white} />
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
)}
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300)}
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
bottom: 5,
|
||||
right: 5,
|
||||
minHeight: 20,
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Pressable
|
||||
onPress={toggleMuted}
|
||||
style={a.flex_1}
|
||||
accessibilityLabel={isMuted ? _(msg`Muted`) : _(msg`Unmuted`)}
|
||||
accessibilityHint={_(msg`Tap to toggle sound`)}
|
||||
accessibilityRole="button"
|
||||
hitSlop={HITSLOP_30}>
|
||||
{isMuted ? (
|
||||
<MuteIcon width={14} fill={t.palette.white} />
|
||||
) : (
|
||||
<UnmuteIcon width={14} fill={t.palette.white} />
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.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 {AppBskyEmbedVideo} from '@atproto/api'
|
||||
import Hls from 'hls.js'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Controls} from './VideoWebControls'
|
||||
|
||||
export function VideoEmbedInnerWeb({
|
||||
source,
|
||||
embed,
|
||||
active,
|
||||
setActive,
|
||||
onScreen,
|
||||
}: {
|
||||
source: string
|
||||
active?: boolean
|
||||
setActive?: () => void
|
||||
onScreen?: boolean
|
||||
embed: AppBskyEmbedVideo.View
|
||||
active: boolean
|
||||
setActive: () => void
|
||||
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 ref = useRef<HTMLVideoElement>(null)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
|
||||
const figId = useId()
|
||||
|
||||
const hlsRef = useRef<Hls | undefined>(undefined)
|
||||
|
||||
@@ -37,7 +33,7 @@ export function VideoEmbedInnerWeb({
|
||||
hlsRef.current = hls
|
||||
|
||||
hls.attachMedia(ref.current)
|
||||
hls.loadSource(source)
|
||||
hls.loadSource(embed.playlist)
|
||||
|
||||
// initial value, later on it's managed by Controls
|
||||
hls.autoLevelCapping = 0
|
||||
@@ -53,29 +49,40 @@ export function VideoEmbedInnerWeb({
|
||||
hls.detachMedia()
|
||||
hls.destroy()
|
||||
}
|
||||
}, [source])
|
||||
}, [embed.playlist])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
// TODO: get from embed metadata
|
||||
// max should be 1 / 1
|
||||
{aspectRatio: 16 / 9},
|
||||
a.overflow_hidden,
|
||||
]}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{width: '100%', height: '100%', display: 'flex'}}>
|
||||
<video
|
||||
ref={ref}
|
||||
style={{width: '100%', height: '100%', objectFit: 'contain'}}
|
||||
playsInline
|
||||
preload="none"
|
||||
loop
|
||||
muted={!focused}
|
||||
/>
|
||||
<View style={[a.flex_1, a.rounded_sm, a.overflow_hidden]}>
|
||||
<div ref={containerRef} style={{height: '100%', width: '100%'}}>
|
||||
<figure style={{margin: 0, position: 'absolute', inset: 0}}>
|
||||
<video
|
||||
ref={ref}
|
||||
poster={embed.thumbnail}
|
||||
style={{width: '100%', height: '100%', objectFit: 'contain'}}
|
||||
playsInline
|
||||
preload="none"
|
||||
loop
|
||||
muted={!focused}
|
||||
aria-labelledby={embed.alt ? figId : undefined}
|
||||
/>
|
||||
{embed.alt && (
|
||||
<figcaption
|
||||
id={figId}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
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
|
||||
videoRef={ref}
|
||||
hlsRef={hlsRef}
|
||||
|
||||
@@ -6,17 +6,19 @@ import React, {
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
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 {useLingui} from '@lingui/react'
|
||||
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 {
|
||||
useAutoplayDisabled,
|
||||
useSetSubtitlesEnabled,
|
||||
useSubtitlesEnabled,
|
||||
} from 'state/preferences'
|
||||
} from '#/state/preferences'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
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 {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {TimeIndicator} from './TimeIndicator'
|
||||
|
||||
export function Controls({
|
||||
videoRef,
|
||||
@@ -173,6 +176,50 @@ export function Controls({
|
||||
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 =
|
||||
(focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
|
||||
|
||||
@@ -197,7 +244,7 @@ export function Controls({
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={_(
|
||||
focused
|
||||
!focused
|
||||
? msg`Unmute video`
|
||||
: playing
|
||||
? msg`Pause video`
|
||||
@@ -206,107 +253,87 @@ export function Controls({
|
||||
style={a.flex_1}
|
||||
onPress={onPressEmptySpace}
|
||||
/>
|
||||
{active && !showControls && !focused && (
|
||||
<TimeIndicator time={Math.floor(duration - currentTime)} />
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
a.flex_shrink_0,
|
||||
a.w_full,
|
||||
a.px_sm,
|
||||
a.pt_sm,
|
||||
a.pb_md,
|
||||
a.gap_md,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.px_xs,
|
||||
web({
|
||||
background:
|
||||
'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
|
||||
label={_(playing ? msg`Pause` : msg`Play`)}
|
||||
onPress={onPressPlayPause}
|
||||
{...btnProps}>
|
||||
{playing ? (
|
||||
<PauseIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<PlayIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
<View style={a.flex_1} />
|
||||
<Text style={{color: t.palette.white}}>
|
||||
{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)}
|
||||
<Scrubber
|
||||
duration={duration}
|
||||
currentTime={currentTime}
|
||||
onSeek={onSeek}
|
||||
onSeekStart={onSeekStart}
|
||||
onSeekEnd={onSeekEnd}
|
||||
seekLeft={seekLeft}
|
||||
seekRight={seekRight}
|
||||
togglePlayPause={togglePlayPause}
|
||||
drawFocus={drawFocus}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
height: 5,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: 'rgba(255,255,255,0.4)',
|
||||
},
|
||||
a.flex_1,
|
||||
a.px_xs,
|
||||
a.pt_sm,
|
||||
a.pb_md,
|
||||
a.gap_md,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
]}>
|
||||
{duration > 0 && (
|
||||
<View
|
||||
style={[
|
||||
a.h_full,
|
||||
a.mr_auto,
|
||||
{
|
||||
backgroundColor: t.palette.white,
|
||||
width: `${(currentTime / duration) * 100}%`,
|
||||
opacity: 0.8,
|
||||
},
|
||||
]}
|
||||
<ControlButton
|
||||
active={playing}
|
||||
activeLabel={_(msg`Pause`)}
|
||||
inactiveLabel={_(msg`Play`)}
|
||||
activeIcon={PauseIcon}
|
||||
inactiveIcon={PlayIcon}
|
||||
onPress={onPressPlayPause}
|
||||
/>
|
||||
<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) && (
|
||||
<Animated.View
|
||||
<View
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.delay(1000).duration(200)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
{buffering && <Loader fill={t.palette.white} size="lg" />}
|
||||
{error && (
|
||||
@@ -314,19 +341,278 @@ export function Controls({
|
||||
<Trans>An error occurred</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const btnProps = {
|
||||
variant: 'ghost',
|
||||
shape: 'round',
|
||||
size: 'medium',
|
||||
style: a.p_2xs,
|
||||
hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'},
|
||||
} as const
|
||||
function ControlButton({
|
||||
active,
|
||||
activeLabel,
|
||||
inactiveLabel,
|
||||
activeIcon: ActiveIcon,
|
||||
inactiveIcon: InactiveIcon,
|
||||
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) {
|
||||
if (isNaN(time)) {
|
||||
@@ -421,14 +707,6 @@ function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) {
|
||||
setError(false)
|
||||
}
|
||||
|
||||
const handleSeeking = () => {
|
||||
setBuffering(true)
|
||||
}
|
||||
|
||||
const handleSeeked = () => {
|
||||
setBuffering(false)
|
||||
}
|
||||
|
||||
const handleStalled = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
bufferingTimeout = setTimeout(() => {
|
||||
@@ -474,12 +752,6 @@ function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) {
|
||||
ref.current.addEventListener('playing', handlePlaying, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('seeking', handleSeeking, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('seeked', handleSeeked, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('stalled', handleStalled, {
|
||||
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,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedVideo,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyGraphDefs,
|
||||
moderateFeedGenerator,
|
||||
@@ -33,10 +34,12 @@ import {AutoSizedImage} from '../images/AutoSizedImage'
|
||||
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
||||
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
||||
import {MaybeQuoteEmbed} from './QuoteEmbed'
|
||||
import {VideoEmbed} from './VideoEmbed'
|
||||
|
||||
type Embed =
|
||||
| AppBskyEmbedRecord.View
|
||||
| AppBskyEmbedImages.View
|
||||
| AppBskyEmbedVideo.View
|
||||
| AppBskyEmbedExternal.View
|
||||
| AppBskyEmbedRecordWithMedia.View
|
||||
| {$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 />
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {logEvent, LogEvents} from '#/lib/statsig/statsig'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {emitSoftReset} from '#/state/events'
|
||||
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 {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
||||
import {useSession} from '#/state/session'
|
||||
@@ -29,7 +29,6 @@ import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
|
||||
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
|
||||
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
|
||||
import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned'
|
||||
import {TOURS, useTriggerTourIfQueued} from '#/tours'
|
||||
import {HomeHeader} from '../com/home/HomeHeader'
|
||||
|
||||
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home' | 'Start'>
|
||||
@@ -88,7 +87,6 @@ function HomeScreenReady({
|
||||
const selectedIndex = Math.max(0, maybeFoundIndex)
|
||||
const selectedFeed = allFeeds[selectedIndex]
|
||||
const requestNotificationsPermission = useRequestNotificationsPermission()
|
||||
const triggerTourIfQueued = useTriggerTourIfQueued(TOURS.HOME)
|
||||
const gate = useGate()
|
||||
|
||||
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
|
||||
@@ -110,30 +108,6 @@ function HomeScreenReady({
|
||||
}
|
||||
}, [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 setMinimalShellMode = useSetMinimalShellMode()
|
||||
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
|
||||
@@ -141,16 +115,10 @@ function HomeScreenReady({
|
||||
React.useCallback(() => {
|
||||
setMinimalShellMode(false)
|
||||
setDrawerSwipeDisabled(selectedIndex > 0)
|
||||
triggerTourIfQueued()
|
||||
return () => {
|
||||
setDrawerSwipeDisabled(false)
|
||||
}
|
||||
}, [
|
||||
setDrawerSwipeDisabled,
|
||||
selectedIndex,
|
||||
setMinimalShellMode,
|
||||
triggerTourIfQueued,
|
||||
]),
|
||||
}, [setDrawerSwipeDisabled, selectedIndex, setMinimalShellMode]),
|
||||
)
|
||||
|
||||
useFocusEffect(
|
||||
@@ -162,7 +130,6 @@ function HomeScreenReady({
|
||||
feedUrl: selectedFeed,
|
||||
reason: 'focus',
|
||||
})
|
||||
debugLogFollowingPrefs(selectedFeed)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -213,9 +180,8 @@ function HomeScreenReady({
|
||||
feedUrl: feed,
|
||||
reason,
|
||||
})
|
||||
debugLogFollowingPrefs(feed)
|
||||
},
|
||||
[allFeeds, debugLogFollowingPrefs],
|
||||
[allFeeds],
|
||||
)
|
||||
|
||||
const onPressSelected = React.useCallback(() => {
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
Message_Stroke2_Corner0_Rounded as Message,
|
||||
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
|
||||
} from '#/components/icons/Message'
|
||||
import {HomeTourExploreWrapper} from '#/tours/HomeTour'
|
||||
import {styles} from './BottomBarStyles'
|
||||
|
||||
type TabOptions =
|
||||
@@ -163,19 +162,17 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
<Btn
|
||||
testID="bottomBarSearchBtn"
|
||||
icon={
|
||||
<HomeTourExploreWrapper>
|
||||
{isAtSearch ? (
|
||||
<MagnifyingGlassFilled
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
/>
|
||||
) : (
|
||||
<MagnifyingGlass
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
/>
|
||||
)}
|
||||
</HomeTourExploreWrapper>
|
||||
isAtSearch ? (
|
||||
<MagnifyingGlassFilled
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
/>
|
||||
) : (
|
||||
<MagnifyingGlass
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onPress={onPressSearch}
|
||||
accessibilityRole="search"
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
|
||||
UserCircle_Stroke2_Corner0_Rounded as UserCircle,
|
||||
} from '#/components/icons/UserCircle'
|
||||
import {HomeTourExploreWrapper} from '#/tours/HomeTour'
|
||||
import {styles} from './BottomBarStyles'
|
||||
|
||||
export function BottomBarWeb() {
|
||||
@@ -95,12 +94,10 @@ export function BottomBarWeb() {
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? MagnifyingGlassFilled : MagnifyingGlass
|
||||
return (
|
||||
<HomeTourExploreWrapper>
|
||||
<Icon
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
/>
|
||||
</HomeTourExploreWrapper>
|
||||
<Icon
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
|
||||
UserCircle_Stroke2_Corner0_Rounded as UserCircle,
|
||||
} from '#/components/icons/UserCircle'
|
||||
import {HomeTourExploreWrapper} from '#/tours/HomeTour'
|
||||
import {router} from '../../../routes'
|
||||
|
||||
const NAV_ICON_WIDTH = 28
|
||||
@@ -341,19 +340,14 @@ export function DesktopLeftNav() {
|
||||
iconFilled={<HomeFilled width={NAV_ICON_WIDTH} style={pal.text} />}
|
||||
label={_(msg`Home`)}
|
||||
/>
|
||||
<HomeTourExploreWrapper>
|
||||
<NavItem
|
||||
href="/search"
|
||||
icon={<MagnifyingGlass style={pal.text} width={NAV_ICON_WIDTH} />}
|
||||
iconFilled={
|
||||
<MagnifyingGlassFilled
|
||||
style={pal.text}
|
||||
width={NAV_ICON_WIDTH}
|
||||
/>
|
||||
}
|
||||
label={_(msg`Search`)}
|
||||
/>
|
||||
</HomeTourExploreWrapper>
|
||||
<NavItem
|
||||
href="/search"
|
||||
icon={<MagnifyingGlass style={pal.text} width={NAV_ICON_WIDTH} />}
|
||||
iconFilled={
|
||||
<MagnifyingGlassFilled style={pal.text} width={NAV_ICON_WIDTH} />
|
||||
}
|
||||
label={_(msg`Search`)}
|
||||
/>
|
||||
<NavItem
|
||||
href="/notifications"
|
||||
count={numUnreadNotifications}
|
||||
|
||||
@@ -257,6 +257,11 @@
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.force-no-clicks > *,
|
||||
.force-no-clicks * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -72,15 +72,15 @@
|
||||
resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106"
|
||||
integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==
|
||||
|
||||
"@atproto/api@0.13.3":
|
||||
version "0.13.3"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.3.tgz#d84f2a0e25f38cca59b69d178901634f2d20b4ff"
|
||||
integrity sha512-/PEVTTEQXICOjZCujAPsjArhwR0tR3LiF0SxxpZlWOjaqjVbqnBI/j0MNmddBFgeljC4/DcBobcDJ9HkILn4yQ==
|
||||
"@atproto/api@0.13.5":
|
||||
version "0.13.5"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.5.tgz#04305cdb0a467ba366305c5e95cebb7ce0d39735"
|
||||
integrity sha512-yT/YimcKYkrI0d282Zxo7O30OSYR+KDW89f81C6oYZfDRBcShC1aniVV8kluP5LrEAg8O27yrOSnBgx2v7XPew==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.3.0"
|
||||
"@atproto/lexicon" "^0.4.1"
|
||||
"@atproto/syntax" "^0.3.0"
|
||||
"@atproto/xrpc" "^0.6.0"
|
||||
"@atproto/xrpc" "^0.6.1"
|
||||
await-lock "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
tlds "^1.234.0"
|
||||
@@ -443,6 +443,14 @@
|
||||
"@atproto/lexicon" "^0.4.1"
|
||||
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":
|
||||
version "3.0.0"
|
||||
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"
|
||||
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:
|
||||
version "2.20.0"
|
||||
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"
|
||||
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:
|
||||
version "4.1.1"
|
||||
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"
|
||||
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:
|
||||
version "1.0.2"
|
||||
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"
|
||||
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:
|
||||
version "0.2.0"
|
||||
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"
|
||||
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:
|
||||
version "1.15.2"
|
||||
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:
|
||||
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:
|
||||
version "0.1.4"
|
||||
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"
|
||||
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:
|
||||
version "4.0.8"
|
||||
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:
|
||||
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:
|
||||
version "5.2.1"
|
||||
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"
|
||||
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:
|
||||
version "0.5.3"
|
||||
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"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
@@ -19813,16 +19772,6 @@ rn-fetch-blob@^0.12.0:
|
||||
base-64 "0.1.0"
|
||||
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:
|
||||
version "7.15.1"
|
||||
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"
|
||||
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:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"
|
||||
@@ -21003,11 +20947,6 @@ svgo@^2.7.0:
|
||||
picocolors "^1.0.0"
|
||||
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:
|
||||
version "3.2.4"
|
||||
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"
|
||||
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:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8"
|
||||
|
||||
Reference in New Issue
Block a user