Merge remote-tracking branch 'upstream/main' into Improve-notification-localization

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