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