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 ? ( + ) : ( - + <> + {embed.alt} + + )} 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 ( - -
-