diff --git a/app.config.js b/app.config.js
index cd8a4b034b..25014ee8f1 100644
--- a/app.config.js
+++ b/app.config.js
@@ -191,7 +191,7 @@ module.exports = function (config) {
'expo-build-properties',
{
ios: {
- deploymentTarget: '14.0',
+ deploymentTarget: '15.1',
newArchEnabled: false,
},
android: {
diff --git a/assets/icons/crop_stroke2_corner0_rounded.svg b/assets/icons/crop_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..118d148f3c
--- /dev/null
+++ b/assets/icons/crop_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/package.json b/package.json
index eaa038292e..3f9f0bced7 100644
--- a/package.json
+++ b/package.json
@@ -139,7 +139,7 @@
"expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14",
- "expo-video": "^1.2.4",
+ "expo-video": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-1.tgz",
"expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
@@ -180,6 +180,7 @@
"react-native-image-crop-picker": "0.40.3",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.12.1",
+ "react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.2.3",
"react-native-picker-select": "^9.1.3",
"react-native-progress": "bluesky-social/react-native-progress",
diff --git a/patches/expo-video+1.2.4.patch b/patches/expo-video+1.2.4.patch
deleted file mode 100644
index bc20fa0ea4..0000000000
--- a/patches/expo-video+1.2.4.patch
+++ /dev/null
@@ -1,608 +0,0 @@
-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
-+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
-@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole
- setShowPreviousButton(!requireLinearPlayback)
- setShowNextButton(!requireLinearPlayback)
- 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)
-+ val fullscreenButton =
-+ findViewById(androidx.media3.ui.R.id.exo_fullscreen)
- 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/VideoManager.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt
-index 4b6c6d8..e20f51a 100644
---- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt
-+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt
-@@ -1,5 +1,6 @@
- package expo.modules.video
-
-+import android.provider.MediaStore.Video
- import androidx.annotation.OptIn
- import androidx.media3.common.util.UnstableApi
- import expo.modules.kotlin.AppContext
-@@ -15,6 +16,8 @@ object VideoManager {
- // Keeps track of all existing VideoPlayers, and whether they are attached to a VideoView
- private var videoPlayersToVideoViews = mutableMapOf>()
-
-+ private var previouslyPlayingViews: MutableList? = null
-+
- private lateinit var audioFocusManager: AudioFocusManager
-
- fun onModuleCreated(appContext: AppContext) {
-@@ -69,16 +72,24 @@ object VideoManager {
- return videoPlayersToVideoViews[videoPlayer]?.isNotEmpty() ?: false
- }
-
-- fun onAppForegrounded() = Unit
-+ fun onAppForegrounded() {
-+ val previouslyPlayingViews = this.previouslyPlayingViews ?: return
-+ for (videoView in previouslyPlayingViews) {
-+ val player = videoView.videoPlayer?.player ?: continue
-+ player.play()
-+ }
-+ this.previouslyPlayingViews = null
-+ }
-
- fun onAppBackgrounded() {
-+ val previouslyPlayingViews = mutableListOf()
- for (videoView in videoViews.values) {
-- if (videoView.videoPlayer?.staysActiveInBackground == false &&
-- !videoView.willEnterPiP &&
-- !videoView.isInFullscreen
-- ) {
-- videoView.videoPlayer?.player?.pause()
-+ val player = videoView.videoPlayer?.player ?: continue
-+ if (player.isPlaying) {
-+ player.pause()
-+ previouslyPlayingViews.add(videoView)
- }
- }
-+ this.previouslyPlayingViews = previouslyPlayingViews
- }
- }
-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
-+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
-@@ -43,7 +43,9 @@ class VideoModule : Module() {
- View(VideoView::class) {
- Events(
- "onPictureInPictureStart",
-- "onPictureInPictureStop"
-+ "onPictureInPictureStop",
-+ "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
-+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
-@@ -36,6 +36,8 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
- val playerView: PlayerView = PlayerView(context.applicationContext)
- val onPictureInPictureStart by EventDispatcher()
- 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
- @Suppress("DEPRECATION")
- currentActivity.overridePendingTransition(0, 0)
- }
-+ 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)
- videoPlayer?.changePlayerView(playerView)
-+ 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..46cbae7 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.
-@@ -136,7 +138,7 @@ export type VideoPlayerEvents = {
- * - `readyToPlay`: The player has loaded enough data to start playing or to continue playback.
- * - `error`: The player has encountered an error while loading or playing the video.
- */
--export type VideoPlayerStatus = 'idle' | 'loading' | 'readyToPlay' | 'error';
-+export type VideoPlayerStatus = 'idle' | 'loading' | 'readyToPlay' | 'error' | 'waitingToPlayAtSpecifiedRate';
- export type VideoSource = string | {
- /**
- * The URI of the video.
-diff --git a/node_modules/expo-video/build/VideoView.types.d.ts b/node_modules/expo-video/build/VideoView.types.d.ts
-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 {
- * @platform ios 16.0+
- */
- allowsVideoFrameAnalysis?: boolean;
-+
-+ onEnterFullscreen?: () => void;
-+ onExitFullscreen?: () => void;
- }
- //# sourceMappingURL=VideoView.types.d.ts.map
-\ No newline at end of file
-diff --git a/node_modules/expo-video/ios/Enums/PlayerStatus.swift b/node_modules/expo-video/ios/Enums/PlayerStatus.swift
-index 6af69ca..189fbbe 100644
---- a/node_modules/expo-video/ios/Enums/PlayerStatus.swift
-+++ b/node_modules/expo-video/ios/Enums/PlayerStatus.swift
-@@ -6,5 +6,8 @@ internal enum PlayerStatus: String, Enumerable {
- case idle
- case loading
- case readyToPlay
-+ case waitingToPlayAtSpecifiedRate
-+ case unlikeToKeepUp
-+ case playbackBufferEmpty
- case error
- }
-diff --git a/node_modules/expo-video/ios/VideoManager.swift b/node_modules/expo-video/ios/VideoManager.swift
-index 094a8b0..16e7081 100644
---- a/node_modules/expo-video/ios/VideoManager.swift
-+++ b/node_modules/expo-video/ios/VideoManager.swift
-@@ -12,6 +12,7 @@ class VideoManager {
-
- private var videoViews = NSHashTable.weakObjects()
- private var videoPlayers = NSHashTable.weakObjects()
-+ private var previouslyPlayingPlayers: [VideoPlayer]?
-
- func register(videoPlayer: VideoPlayer) {
- videoPlayers.add(videoPlayer)
-@@ -33,63 +34,70 @@ class VideoManager {
- for videoPlayer in videoPlayers.allObjects {
- videoPlayer.setTracksEnabled(true)
- }
-+
-+ if let previouslyPlayingPlayers = self.previouslyPlayingPlayers {
-+ previouslyPlayingPlayers.forEach { player in
-+ player.pointer.play()
-+ }
-+ }
- }
-
- func onAppBackgrounded() {
-+ var previouslyPlayingPlayers: [VideoPlayer] = []
- for videoView in videoViews.allObjects {
- guard let player = videoView.player else {
- continue
- }
-- if player.staysActiveInBackground == true {
-- player.setTracksEnabled(videoView.isInPictureInPicture)
-- } else if !videoView.isInPictureInPicture {
-+ if player.isPlaying {
- player.pointer.pause()
-+ previouslyPlayingPlayers.append(player)
- }
- }
-+ self.previouslyPlayingPlayers = previouslyPlayingPlayers
- }
-
- // MARK: - Audio Session Management
-
- internal func setAppropriateAudioSessionOrWarn() {
-- let audioSession = AVAudioSession.sharedInstance()
-- var audioSessionCategoryOptions: AVAudioSession.CategoryOptions = []
--
-- let isAnyPlayerPlaying = videoPlayers.allObjects.contains { player in
-- player.isPlaying
-- }
-- let areAllPlayersMuted = videoPlayers.allObjects.allSatisfy { player in
-- player.isMuted
-- }
-- let needsPiPSupport = videoViews.allObjects.contains { view in
-- view.allowPictureInPicture
-- }
-- let anyPlayerShowsNotification = videoPlayers.allObjects.contains { player in
-- player.showNowPlayingNotification
-- }
-- // The notification won't be shown if we allow the audio to mix with others
-- let shouldAllowMixing = (!isAnyPlayerPlaying || areAllPlayersMuted) && !anyPlayerShowsNotification
-- let isOutputtingAudio = !areAllPlayersMuted && isAnyPlayerPlaying
-- let shouldUpdateToAllowMixing = !audioSession.categoryOptions.contains(.mixWithOthers) && shouldAllowMixing
--
-- if shouldAllowMixing {
-- audioSessionCategoryOptions.insert(.mixWithOthers)
-- }
--
-- if isOutputtingAudio || needsPiPSupport || shouldUpdateToAllowMixing || anyPlayerShowsNotification {
-- do {
-- try audioSession.setCategory(.playback, mode: .moviePlayback)
-- } catch {
-- log.warn("Failed to set audio session category. This might cause issues with audio playback and Picture in Picture. \(error.localizedDescription)")
-- }
-- }
--
-- // Make sure audio session is active if any video is playing
-- if isAnyPlayerPlaying {
-- do {
-- try audioSession.setActive(true)
-- } catch {
-- log.warn("Failed to activate the audio session. This might cause issues with audio playback. \(error.localizedDescription)")
-- }
-- }
-+// let audioSession = AVAudioSession.sharedInstance()
-+// var audioSessionCategoryOptions: AVAudioSession.CategoryOptions = []
-+//
-+// let isAnyPlayerPlaying = videoPlayers.allObjects.contains { player in
-+// player.isPlaying
-+// }
-+// let areAllPlayersMuted = videoPlayers.allObjects.allSatisfy { player in
-+// player.isMuted
-+// }
-+// let needsPiPSupport = videoViews.allObjects.contains { view in
-+// view.allowPictureInPicture
-+// }
-+// let anyPlayerShowsNotification = videoPlayers.allObjects.contains { player in
-+// player.showNowPlayingNotification
-+// }
-+// // The notification won't be shown if we allow the audio to mix with others
-+// let shouldAllowMixing = (!isAnyPlayerPlaying || areAllPlayersMuted) && !anyPlayerShowsNotification
-+// let isOutputtingAudio = !areAllPlayersMuted && isAnyPlayerPlaying
-+// let shouldUpdateToAllowMixing = !audioSession.categoryOptions.contains(.mixWithOthers) && shouldAllowMixing
-+//
-+// if shouldAllowMixing {
-+// audioSessionCategoryOptions.insert(.mixWithOthers)
-+// }
-+//
-+// if isOutputtingAudio || needsPiPSupport || shouldUpdateToAllowMixing || anyPlayerShowsNotification {
-+// do {
-+// try audioSession.setCategory(.playback, mode: .moviePlayback)
-+// } catch {
-+// log.warn("Failed to set audio session category. This might cause issues with audio playback and Picture in Picture. \(error.localizedDescription)")
-+// }
-+// }
-+//
-+// // Make sure audio session is active if any video is playing
-+// if isAnyPlayerPlaying {
-+// do {
-+// try audioSession.setActive(true)
-+// } catch {
-+// log.warn("Failed to activate the audio session. This might cause issues with audio playback. \(error.localizedDescription)")
-+// }
-+// }
- }
- }
-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
-+++ b/node_modules/expo-video/ios/VideoModule.swift
-@@ -16,7 +16,9 @@ public final class VideoModule: Module {
- View(VideoView.self) {
- Events(
- "onPictureInPictureStart",
-- "onPictureInPictureStop"
-+ "onPictureInPictureStop",
-+ "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..733ab1f 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..7de8cbf 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) {
-@@ -265,23 +271,24 @@ class VideoPlayerObserver {
- if player.timeControlStatus != .waitingToPlayAtSpecifiedRate && player.status == .readyToPlay && currentItem?.isPlaybackBufferEmpty != true {
- status = .readyToPlay
- } else if player.timeControlStatus == .waitingToPlayAtSpecifiedRate {
-- status = .loading
-+ status = .waitingToPlayAtSpecifiedRate
- }
-
- if isPlaying != (player.timeControlStatus == .playing) {
- isPlaying = player.timeControlStatus == .playing
-+ addPeriodicTimeObserverIfNeeded()
- }
- }
-
- private func onIsBufferEmptyChanged(_ playerItem: AVPlayerItem, _ change: NSKeyValueObservedChange) {
- if playerItem.isPlaybackBufferEmpty {
-- status = .loading
-+ status = .playbackBufferEmpty
- }
- }
-
- private func onPlayerLikelyToKeepUpChanged(_ playerItem: AVPlayerItem, _ change: NSKeyValueObservedChange) {
- if !playerItem.isPlaybackLikelyToKeepUp && playerItem.isPlaybackBufferEmpty {
-- status = .loading
-+ status = .unlikeToKeepUp
- } else if playerItem.isPlaybackLikelyToKeepUp {
- status = .readyToPlay
- }
-@@ -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 {
- _ playerViewController: AVPlayerViewController,
- willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
- ) {
-+ onEnterFullscreen()
- isFullscreen = true
- }
-
-@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
- if wasPlaying {
- self.player?.pointer.play()
- }
-+ self.onExitFullscreen()
- 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..5ff6b7a 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;
- };
-
- /**
-@@ -160,7 +162,7 @@ export type VideoPlayerEvents = {
- * - `readyToPlay`: The player has loaded enough data to start playing or to continue playback.
- * - `error`: The player has encountered an error while loading or playing the video.
- */
--export type VideoPlayerStatus = 'idle' | 'loading' | 'readyToPlay' | 'error';
-+export type VideoPlayerStatus = 'idle' | 'loading' | 'readyToPlay' | 'error' | 'waitingToPlayAtSpecifiedRate';
-
- export type VideoSource =
- | string
-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
-+++ b/node_modules/expo-video/src/VideoView.types.ts
-@@ -100,4 +100,7 @@ export interface VideoViewProps extends ViewProps {
- * @platform ios 16.0+
- */
- allowsVideoFrameAnalysis?: boolean;
-+
-+ onEnterFullscreen?: () => void;
-+ onExitFullscreen?: () => void;
- }
diff --git a/patches/expo-video+1.2.4.patch.md b/patches/expo-video+1.2.4.patch.md
deleted file mode 100644
index 7cd4d363a5..0000000000
--- a/patches/expo-video+1.2.4.patch.md
+++ /dev/null
@@ -1,31 +0,0 @@
-## uwu woad beawing, do not wemove
-
-## `expo-video` Patch
-
-### `onEnterFullScreen`/`onExitFullScreen`
-
-Adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on
-the tin.
-
-### Removing audio session management
-
-This patch also removes the audio session management that Expo does on its own, as we handle audio session management
-ourselves.
-
-### Pausing/playing on background/foreground
-
-Instead of handling the pausing/playing of videos in React, we'll handle them here. There's some logic that we do not
-need (around PIP mode) that we can remove, and just pause any playing players on background and then resume them on
-foreground.
-
-### Additional `statusChange` Events
-
-`expo-video` uses the `loading` status for a variety of cases where the video is not actually "loading". We're making
-those status events more specific here, so that we can determine if a video is truly loading or not. These statuses are:
-
-- `waitingToPlayAtSpecifiedRate`
-- `unlikelyToKeepUp`
-- `playbackBufferEmpty`
-
-It's unlikely we will ever need to pay attention to these statuses, so they are not being include in the TypeScript
-types.
diff --git a/plugins/starterPackAppClipExtension/withXcodeTarget.js b/plugins/starterPackAppClipExtension/withXcodeTarget.js
index 61d5f81b07..c14d27291a 100644
--- a/plugins/starterPackAppClipExtension/withXcodeTarget.js
+++ b/plugins/starterPackAppClipExtension/withXcodeTarget.js
@@ -57,7 +57,7 @@ const withXcodeTarget = (config, {targetName}) => {
buildSettingsObj.SWIFT_VERSION = '5.0'
buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"`
buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
- buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0'
+ buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '15.1'
buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon'
}
}
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 315ad0dfda..86cb5c315a 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -8,7 +8,10 @@ import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
-export {useDialogControl as usePromptControl} from '#/components/Dialog'
+export {
+ type DialogControlProps as PromptControlProps,
+ useDialogControl as usePromptControl,
+} from '#/components/Dialog'
const Context = React.createContext<{
titleId: string
@@ -23,7 +26,7 @@ export function Outer({
control,
testID,
}: React.PropsWithChildren<{
- control: Dialog.DialogOuterProps['control']
+ control: Dialog.DialogControlProps
testID?: string
}>) {
const {gtMobile} = useBreakpoints()
diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx
index aefd62b9ac..3db00aece6 100644
--- a/src/components/dms/MessageItemEmbed.tsx
+++ b/src/components/dms/MessageItemEmbed.tsx
@@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native'
import {AppBskyEmbedRecord} from '@atproto/api'
-import {PostEmbeds} from '#/view/com/util/post-embeds'
+import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
import {atoms as a, native, useTheme} from '#/alf'
let MessageItemEmbed = ({
@@ -14,7 +14,11 @@ let MessageItemEmbed = ({
return (
-
+
)
}
diff --git a/src/components/icons/Crop.tsx b/src/components/icons/Crop.tsx
new file mode 100644
index 0000000000..4b3fc560f9
--- /dev/null
+++ b/src/components/icons/Crop.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Crop_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M6 2a1 1 0 0 1 1 1v2h11a1 1 0 0 1 1 1v11h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H6a1 1 0 0 1-1-1V7H3a1 1 0 0 1 0-2h2V3a1 1 0 0 1 1-1Zm1 5v10h10V7H7Z',
+})
diff --git a/src/locale/i18n.ts b/src/locale/i18n.ts
index 332b9309aa..2a6cfae913 100644
--- a/src/locale/i18n.ts
+++ b/src/locale/i18n.ts
@@ -37,82 +37,130 @@ export async function dynamicActivate(locale: AppLanguage) {
switch (locale) {
case AppLanguage.ca: {
i18n.loadAndActivate({locale, messages: messagesCa})
- await import('@formatjs/intl-pluralrules/locale-data/ca')
+ await Promise.all([
+ import('@formatjs/intl-pluralrules/locale-data/ca'),
+ import('@formatjs/intl-numberformat/locale-data/ca'),
+ ])
break
}
case AppLanguage.de: {
i18n.loadAndActivate({locale, messages: messagesDe})
- await import('@formatjs/intl-pluralrules/locale-data/de')
+ await Promise.all([
+ import('@formatjs/intl-pluralrules/locale-data/de'),
+ import('@formatjs/intl-numberformat/locale-data/de'),
+ ])
break
}
case AppLanguage.es: {
i18n.loadAndActivate({locale, messages: messagesEs})
- await import('@formatjs/intl-pluralrules/locale-data/es')
+ await Promise.all([
+ import('@formatjs/intl-pluralrules/locale-data/es'),
+ import('@formatjs/intl-numberformat/locale-data/es'),
+ ])
break
}
case AppLanguage.fi: {
i18n.loadAndActivate({locale, messages: messagesFi})
- await import('@formatjs/intl-pluralrules/locale-data/fi')
+ await Promise.all([
+ import('@formatjs/intl-pluralrules/locale-data/fi'),
+ import('@formatjs/intl-numberformat/locale-data/fi'),
+ ])
break
}
case AppLanguage.fr: {
i18n.loadAndActivate({locale, messages: messagesFr})
- await import('@formatjs/intl-pluralrules/locale-data/fr')
+ await Promise.all([
+ import('@formatjs/intl-pluralrules/locale-data/fr'),
+ import('@formatjs/intl-numberformat/locale-data/fr'),
+ ])
break
}
case AppLanguage.ga: {
i18n.loadAndActivate({locale, messages: messagesGa})
- await import('@formatjs/intl-pluralrules/locale-data/ga')
+ await Promise.all([
+ import('@formatjs/intl-pluralrules/locale-data/ga'),
+ import('@formatjs/intl-numberformat/locale-data/ga'),
+ ])
break
}
case AppLanguage.hi: {
i18n.loadAndActivate({locale, messages: messagesHi})
- await import('@formatjs/intl-pluralrules/locale-data/hi')
+ await Promise.all([
+ import('@formatjs/intl-pluralrules/locale-data/hi'),
+ import('@formatjs/intl-numberformat/locale-data/hi'),
+ ])
break
}
case AppLanguage.id: {
i18n.loadAndActivate({locale, messages: messagesId})
- await import('@formatjs/intl-pluralrules/locale-data/id')
+ await Promise.all([
+ import('@formatjs/intl-pluralrules/locale-data/id'),
+ import('@formatjs/intl-numberformat/locale-data/id'),
+ ])
break
}
case AppLanguage.it: {
i18n.loadAndActivate({locale, messages: messagesIt})
- await import('@formatjs/intl-pluralrules/locale-data/it')
+ await Promise.all([
+ import('@formatjs/intl-pluralrules/locale-data/it'),
+ import('@formatjs/intl-numberformat/locale-data/it'),
+ ])
break
}
case AppLanguage.ja: {
i18n.loadAndActivate({locale, messages: messagesJa})
- await import('@formatjs/intl-pluralrules/locale-data/ja')
+ await Promise.all([
+ import('@formatjs/intl-pluralrules/locale-data/ja'),
+ import('@formatjs/intl-numberformat/locale-data/ja'),
+ ])
break
}
case AppLanguage.ko: {
i18n.loadAndActivate({locale, messages: messagesKo})
- await import('@formatjs/intl-pluralrules/locale-data/ko')
+ await Promise.all([
+ import('@formatjs/intl-pluralrules/locale-data/ko'),
+ import('@formatjs/intl-numberformat/locale-data/ko'),
+ ])
break
}
case AppLanguage.pt_BR: {
i18n.loadAndActivate({locale, messages: messagesPt_BR})
- await import('@formatjs/intl-pluralrules/locale-data/pt')
+ await Promise.all([
+ import('@formatjs/intl-pluralrules/locale-data/pt'),
+ import('@formatjs/intl-numberformat/locale-data/pt'),
+ ])
break
}
case AppLanguage.tr: {
i18n.loadAndActivate({locale, messages: messagesTr})
- await import('@formatjs/intl-pluralrules/locale-data/tr')
+ await Promise.all([
+ import('@formatjs/intl-pluralrules/locale-data/tr'),
+ import('@formatjs/intl-numberformat/locale-data/tr'),
+ ])
break
}
case AppLanguage.uk: {
i18n.loadAndActivate({locale, messages: messagesUk})
- await import('@formatjs/intl-pluralrules/locale-data/uk')
+ await Promise.all([
+ import('@formatjs/intl-pluralrules/locale-data/uk'),
+ import('@formatjs/intl-numberformat/locale-data/uk'),
+ ])
break
}
case AppLanguage.zh_CN: {
i18n.loadAndActivate({locale, messages: messagesZh_CN})
- await import('@formatjs/intl-pluralrules/locale-data/zh')
+ await Promise.all([
+ import('@formatjs/intl-pluralrules/locale-data/zh'),
+ import('@formatjs/intl-numberformat/locale-data/zh'),
+ ])
break
}
case AppLanguage.zh_TW: {
i18n.loadAndActivate({locale, messages: messagesZh_TW})
- await import('@formatjs/intl-pluralrules/locale-data/zh')
+ await Promise.all([
+ import('@formatjs/intl-pluralrules/locale-data/zh'),
+ import('@formatjs/intl-numberformat/locale-data/zh'),
+ ])
break
}
default: {
diff --git a/src/locale/locales/pt-BR/messages.po b/src/locale/locales/pt-BR/messages.po
index 98560bf97b..3a67499b4c 100644
--- a/src/locale/locales/pt-BR/messages.po
+++ b/src/locale/locales/pt-BR/messages.po
@@ -5576,7 +5576,7 @@ msgstr "Repostado por <0><1/>0>"
#: src/view/com/posts/FeedItem.tsx:292
#: src/view/com/posts/FeedItem.tsx:311
msgid "Reposted by you"
-msgstr "repostou para vocĂȘ"
+msgstr "Repostado por vocĂȘ"
#: src/view/com/notifications/FeedItem.tsx:184
msgid "reposted your post"
diff --git a/src/storage/README.md b/src/storage/README.md
new file mode 100644
index 0000000000..b7d8d35610
--- /dev/null
+++ b/src/storage/README.md
@@ -0,0 +1,62 @@
+# `#/storage`
+
+## Usage
+
+Import the correctly scoped store from `#/storage`. Each instance of `Storage`
+(the base class, not to be used directly), has the following interface:
+
+- `set([...scope, key], value)`
+- `get([...scope, key])`
+- `remove([...scope, key])`
+- `removeMany([...scope], [...keys])`
+
+For example, using our `device` store looks like this, since it's scoped to the
+device (the most base level scope):
+
+```typescript
+import { device } from '#/storage';
+
+device.set(['foobar'], true);
+device.get(['foobar']);
+device.remove(['foobar']);
+device.removeMany([], ['foobar']);
+```
+
+## TypeScript
+
+Stores are strongly typed, and when setting a given value, it will need to
+conform to the schemas defined in `#/storage/schema`. When getting a value, it
+will be returned to you as the type defined in its schema.
+
+## Scoped Stores
+
+Some stores are (or might be) scoped to an account or other identifier. In this
+case, storage instances are created with type-guards, like this:
+
+```typescript
+type AccountSchema = {
+ language: `${string}-${string}`;
+};
+
+type DID = `did:${string}`;
+
+const account = new Storage<
+ [DID],
+ AccountSchema
+>({
+ id: 'account',
+});
+
+account.set(
+ ['did:plc:abc', 'language'],
+ 'en-US',
+);
+
+const language = account.get([
+ 'did:plc:abc',
+ 'language',
+]);
+```
+
+Here, if `['did:plc:abc']` is not supplied along with the key of
+`language`, the `get` will return undefined (and TS will yell at you).
diff --git a/src/storage/__tests__/index.test.ts b/src/storage/__tests__/index.test.ts
new file mode 100644
index 0000000000..e11affa7a8
--- /dev/null
+++ b/src/storage/__tests__/index.test.ts
@@ -0,0 +1,81 @@
+import {beforeEach, expect, jest, test} from '@jest/globals'
+
+import {Storage} from '#/storage'
+
+jest.mock('react-native-mmkv', () => ({
+ MMKV: class MMKVMock {
+ _store = new Map()
+
+ set(key: string, value: unknown) {
+ this._store.set(key, value)
+ }
+
+ getString(key: string) {
+ return this._store.get(key)
+ }
+
+ delete(key: string) {
+ return this._store.delete(key)
+ }
+ },
+}))
+
+type Schema = {
+ boo: boolean
+ str: string | null
+ num: number
+ obj: Record
+}
+
+const scope = `account`
+const store = new Storage<['account'], Schema>({id: 'test'})
+
+beforeEach(() => {
+ store.removeMany([scope], ['boo', 'str', 'num', 'obj'])
+})
+
+test(`stores and retrieves data`, () => {
+ store.set([scope, 'boo'], true)
+ store.set([scope, 'str'], 'string')
+ store.set([scope, 'num'], 1)
+ expect(store.get([scope, 'boo'])).toEqual(true)
+ expect(store.get([scope, 'str'])).toEqual('string')
+ expect(store.get([scope, 'num'])).toEqual(1)
+})
+
+test(`removes data`, () => {
+ store.set([scope, 'boo'], true)
+ expect(store.get([scope, 'boo'])).toEqual(true)
+ store.remove([scope, 'boo'])
+ expect(store.get([scope, 'boo'])).toEqual(undefined)
+})
+
+test(`removes multiple keys at once`, () => {
+ store.set([scope, 'boo'], true)
+ store.set([scope, 'str'], 'string')
+ store.set([scope, 'num'], 1)
+ store.removeMany([scope], ['boo', 'str', 'num'])
+ expect(store.get([scope, 'boo'])).toEqual(undefined)
+ expect(store.get([scope, 'str'])).toEqual(undefined)
+ expect(store.get([scope, 'num'])).toEqual(undefined)
+})
+
+test(`concatenates keys`, () => {
+ store.remove([scope, 'str'])
+ store.set([scope, 'str'], 'concat')
+ // @ts-ignore accessing these properties for testing purposes only
+ expect(store.store.getString(`${scope}${store.sep}str`)).toBeTruthy()
+})
+
+test(`can store falsy values`, () => {
+ store.set([scope, 'str'], null)
+ store.set([scope, 'num'], 0)
+ expect(store.get([scope, 'str'])).toEqual(null)
+ expect(store.get([scope, 'num'])).toEqual(0)
+})
+
+test(`can store objects`, () => {
+ const obj = {foo: true}
+ store.set([scope, 'obj'], obj)
+ expect(store.get([scope, 'obj'])).toEqual(obj)
+})
diff --git a/src/storage/index.ts b/src/storage/index.ts
new file mode 100644
index 0000000000..819ffab7ec
--- /dev/null
+++ b/src/storage/index.ts
@@ -0,0 +1,72 @@
+import {MMKV} from 'react-native-mmkv'
+
+import {Device} from '#/storage/schema'
+
+/**
+ * Generic storage class. DO NOT use this directly. Instead, use the exported
+ * storage instances below.
+ */
+export class Storage {
+ protected sep = ':'
+ protected store: MMKV
+
+ constructor({id}: {id: string}) {
+ this.store = new MMKV({id})
+ }
+
+ /**
+ * Store a value in storage based on scopes and/or keys
+ *
+ * `set([key], value)`
+ * `set([scope, key], value)`
+ */
+ set(
+ scopes: [...Scopes, Key],
+ data: Schema[Key],
+ ): void {
+ // stored as `{ data: }` structure to ease stringification
+ this.store.set(scopes.join(this.sep), JSON.stringify({data}))
+ }
+
+ /**
+ * Get a value from storage based on scopes and/or keys
+ *
+ * `get([key])`
+ * `get([scope, key])`
+ */
+ get(
+ scopes: [...Scopes, Key],
+ ): Schema[Key] | undefined {
+ const res = this.store.getString(scopes.join(this.sep))
+ if (!res) return undefined
+ // parsed from storage structure `{ data: }`
+ return JSON.parse(res).data
+ }
+
+ /**
+ * Remove a value from storage based on scopes and/or keys
+ *
+ * `remove([key])`
+ * `remove([scope, key])`
+ */
+ remove(scopes: [...Scopes, Key]) {
+ this.store.delete(scopes.join(this.sep))
+ }
+
+ /**
+ * Remove many values from the same storage scope by keys
+ *
+ * `removeMany([], [key])`
+ * `removeMany([scope], [key])`
+ */
+ removeMany(scopes: [...Scopes], keys: Key[]) {
+ keys.forEach(key => this.remove([...scopes, key]))
+ }
+}
+
+/**
+ * Device data that's specific to the device and does not vary based on account
+ *
+ * `device.set([key], true)`
+ */
+export const device = new Storage<[], Device>({id: 'device'})
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
new file mode 100644
index 0000000000..6522d75a36
--- /dev/null
+++ b/src/storage/schema.ts
@@ -0,0 +1,4 @@
+/**
+ * Device data that's specific to the device and does not vary based account
+ */
+export type Device = {}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 6a6ac72674..3ca709a1e0 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -24,11 +24,14 @@ import Animated, {
FadeIn,
FadeOut,
interpolateColor,
+ LayoutAnimationConfig,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withRepeat,
withTiming,
+ ZoomIn,
+ ZoomOut,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {
@@ -84,7 +87,7 @@ import {GalleryModel} from 'state/models/media/gallery'
import {State as VideoUploadState} from 'state/queries/video/video'
import {ComposerOpts} from 'state/shell/composer'
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a, native, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
@@ -766,29 +769,36 @@ export const ComposePost = observer(function ComposePost({
)}
) : null}
- {videoUploadState.asset &&
- (videoUploadState.status === 'compressing' ? (
-
- ) : videoUploadState.video ? (
-
- ) : null)}
- {(videoUploadState.asset || videoUploadState.video) && (
-
- )}
+
+ {(videoUploadState.asset || videoUploadState.video) && (
+
+ {videoUploadState.asset &&
+ (videoUploadState.status === 'compressing' ? (
+
+ ) : videoUploadState.video ? (
+
+ ) : null)}
+
+
+ )}
+
diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx
index d8accd062b..6e294ba9c3 100644
--- a/src/view/com/composer/videos/SelectVideoBtn.tsx
+++ b/src/view/com/composer/videos/SelectVideoBtn.tsx
@@ -1,4 +1,5 @@
import React, {useCallback} from 'react'
+import {Keyboard} from 'react-native'
import {
ImagePickerAsset,
launchImageLibraryAsync,
@@ -10,11 +11,14 @@ import {useLingui} from '@lingui/react'
import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
import {isNative} from '#/platform/detection'
+import {useModalControls} from '#/state/modals'
+import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
+import * as Prompt from '#/components/Prompt'
-const VIDEO_MAX_DURATION = 90
+const VIDEO_MAX_DURATION = 60
type Props = {
onSelectVideo: (video: ImagePickerAsset) => void
@@ -26,33 +30,47 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
const {_} = useLingui()
const t = useTheme()
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
+ const control = Prompt.usePromptControl()
+ const {currentAccount} = useSession()
const onPressSelectVideo = useCallback(async () => {
if (isNative && !(await requestVideoAccessIfNeeded())) {
return
}
- const response = await launchImageLibraryAsync({
- exif: false,
- mediaTypes: MediaTypeOptions.Videos,
- videoMaxDuration: VIDEO_MAX_DURATION,
- quality: 1,
- legacy: true,
- preferredAssetRepresentationMode:
- UIImagePickerPreferredAssetRepresentationMode.Current,
- })
- if (response.assets && response.assets.length > 0) {
- try {
- onSelectVideo(response.assets[0])
- } catch (err) {
- if (err instanceof Error) {
- setError(err.message)
- } else {
- setError(_(msg`An error occurred while selecting the video`))
+ if (!currentAccount?.emailConfirmed) {
+ Keyboard.dismiss()
+ control.open()
+ } else {
+ const response = await launchImageLibraryAsync({
+ exif: false,
+ mediaTypes: MediaTypeOptions.Videos,
+ videoMaxDuration: VIDEO_MAX_DURATION,
+ quality: 1,
+ legacy: true,
+ preferredAssetRepresentationMode:
+ UIImagePickerPreferredAssetRepresentationMode.Current,
+ })
+ if (response.assets && response.assets.length > 0) {
+ try {
+ onSelectVideo(response.assets[0])
+ } catch (err) {
+ if (err instanceof Error) {
+ setError(err.message)
+ } else {
+ setError(_(msg`An error occurred while selecting the video`))
+ }
}
}
}
- }, [onSelectVideo, requestVideoAccessIfNeeded, setError, _])
+ }, [
+ onSelectVideo,
+ requestVideoAccessIfNeeded,
+ setError,
+ _,
+ control,
+ currentAccount?.emailConfirmed,
+ ])
return (
<>
@@ -71,6 +89,32 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
style={disabled && t.atoms.text_contrast_low}
/>
+
>
)
}
+
+function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
+ const {_} = useLingui()
+ const {openModal} = useModalControls()
+
+ return (
+ {
+ control.close(() => {
+ openModal({
+ name: 'verify-email',
+ showReminder: false,
+ })
+ })
+ }}
+ />
+ )
+}
diff --git a/src/view/com/composer/videos/SubtitleDialog.tsx b/src/view/com/composer/videos/SubtitleDialog.tsx
index 9cd8eae470..a1cdb906d2 100644
--- a/src/view/com/composer/videos/SubtitleDialog.tsx
+++ b/src/view/com/composer/videos/SubtitleDialog.tsx
@@ -34,7 +34,7 @@ export function SubtitleDialogBtn(props: Props) {
const {_} = useLingui()
return (
-
+
)
}
diff --git a/src/view/com/composer/videos/VideoPreview.web.tsx b/src/view/com/composer/videos/VideoPreview.web.tsx
index 4c44781cfb..9473be0746 100644
--- a/src/view/com/composer/videos/VideoPreview.web.tsx
+++ b/src/view/com/composer/videos/VideoPreview.web.tsx
@@ -6,9 +6,11 @@ import {useLingui} from '@lingui/react'
import {CompressedVideo} from '#/lib/media/video/types'
import {clamp} from '#/lib/numbers'
+import {useAutoplayDisabled} from '#/state/preferences'
import * as Toast from '#/view/com/util/Toast'
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
import {atoms as a} from '#/alf'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
export function VideoPreview({
asset,
@@ -23,6 +25,7 @@ export function VideoPreview({
}) {
const ref = useRef(null)
const {_} = useLingui()
+ const autoplayDisabled = useAutoplayDisabled()
useEffect(() => {
if (!ref.current) return
@@ -66,17 +69,23 @@ export function VideoPreview({
{aspectRatio},
a.overflow_hidden,
{backgroundColor: 'black'},
+ a.relative,
]}>
+ {autoplayDisabled && (
+
+
+
+ )}
)
}
diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
index 3e26230ffd..f6f0f7ccf9 100644
--- a/src/view/com/composer/videos/VideoTranscodeProgress.tsx
+++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
@@ -35,7 +35,6 @@ export function VideoTranscodeProgress({
-
+
)}
@@ -591,7 +595,11 @@ let PostThreadItemLoaded = ({
) : undefined}
{post.embed && (
-
+
)}
) : undefined}
{post.embed ? (
-
+
) : null}
) : null}
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index 61cb6f69f3..f4fb3a1b3d 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -1,106 +1,219 @@
import React from 'react'
-import {StyleProp, StyleSheet, Pressable, View, ViewStyle} from 'react-native'
+import {DimensionValue, Pressable, View} from 'react-native'
import {Image} from 'expo-image'
-import {clamp} from 'lib/numbers'
-import {Dimensions} from 'lib/media/types'
-import * as imageSizes from 'lib/media/image-sizes'
+import {AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-const MIN_ASPECT_RATIO = 0.33 // 1/3
-const MAX_ASPECT_RATIO = 10 // 10/1
+import * as imageSizes from '#/lib/media/image-sizes'
+import {Dimensions} from '#/lib/media/types'
+import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
+import {atoms as a, useTheme} from '#/alf'
+import {Crop_Stroke2_Corner0_Rounded as Crop} from '#/components/icons/Crop'
+import {Text} from '#/components/Typography'
-interface Props {
- alt?: string
- uri: string
- dimensionsHint?: Dimensions
- onPress?: () => void
- onLongPress?: () => void
- onPressIn?: () => void
- style?: StyleProp
- children?: React.ReactNode
+export function useImageAspectRatio({
+ src,
+ dimensions,
+}: {
+ src: string
+ dimensions: Dimensions | undefined
+}) {
+ const [raw, setAspectRatio] = React.useState(
+ dimensions ? calc(dimensions) : 1,
+ )
+ const {isCropped, constrained, max} = React.useMemo(() => {
+ const a34 = 0.75 // max of 3:4 ratio in feeds
+ const constrained = Math.max(raw, a34)
+ const max = Math.max(raw, 0.25) // max of 1:4 in thread
+ const isCropped = raw < constrained
+ return {
+ isCropped,
+ constrained,
+ max,
+ }
+ }, [raw])
+
+ React.useEffect(() => {
+ let aborted = false
+ if (dimensions) return
+ imageSizes.fetch(src).then(newDim => {
+ if (aborted) return
+ setAspectRatio(calc(newDim))
+ })
+ return () => {
+ aborted = true
+ }
+ }, [dimensions, setAspectRatio, src])
+
+ return {
+ dimensions,
+ raw,
+ constrained,
+ max,
+ isCropped,
+ }
+}
+
+export function ConstrainedImage({
+ aspectRatio,
+ fullBleed,
+ children,
+}: {
+ aspectRatio: number
+ fullBleed?: boolean
+ children: React.ReactNode
+}) {
+ const t = useTheme()
+ /**
+ * Computed as a % value to apply as `paddingTop`
+ */
+ const outerAspectRatio = React.useMemo(() => {
+ // capped to square or shorter
+ const ratio = Math.min(1 / aspectRatio, 1)
+ return `${ratio * 100}%`
+ }, [aspectRatio])
+
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
}
export function AutoSizedImage({
- alt,
- uri,
- dimensionsHint,
+ image,
+ crop = 'constrained',
+ hideBadge,
onPress,
onLongPress,
onPressIn,
- style,
- children = null,
-}: Props) {
+}: {
+ image: AppBskyEmbedImages.ViewImage
+ crop?: 'none' | 'square' | 'constrained'
+ hideBadge?: boolean
+ onPress?: () => void
+ onLongPress?: () => void
+ onPressIn?: () => void
+}) {
+ const t = useTheme()
const {_} = useLingui()
- const [dim, setDim] = React.useState(
- dimensionsHint || imageSizes.get(uri),
- )
- const [aspectRatio, setAspectRatio] = React.useState(
- dim ? calc(dim) : 1,
- )
- React.useEffect(() => {
- let aborted = false
- if (dim) {
- return
- }
- imageSizes.fetch(uri).then(newDim => {
- if (aborted) {
- return
- }
- setDim(newDim)
- setAspectRatio(calc(newDim))
- })
- }, [dim, setDim, setAspectRatio, uri])
+ const largeAlt = useLargeAltBadgeEnabled()
+ const {
+ constrained,
+ max,
+ isCropped: rawIsCropped,
+ } = useImageAspectRatio({
+ src: image.thumb,
+ dimensions: image.aspectRatio,
+ })
+ const cropDisabled = crop === 'none'
+ const isCropped = rawIsCropped && !cropDisabled
+ const hasAlt = !!image.alt
- if (onPress || onLongPress || onPressIn) {
+ const contents = (
+ <>
+
+
+ {(hasAlt || isCropped) && !hideBadge ? (
+
+ {isCropped && (
+
+ )}
+ {hasAlt && (
+
+ ALT
+
+ )}
+
+ ) : null}
+ >
+ )
+
+ if (cropDisabled) {
return (
- // disable a11y rule because in this case we want the tags on the image (#1640)
- // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors
-
- {children}
+ // alt here is what screen readers actually use
+ accessibilityLabel={image.alt}
+ accessibilityHint={_(msg`Tap to view full image`)}
+ style={[
+ a.w_full,
+ a.rounded_sm,
+ a.overflow_hidden,
+ t.atoms.bg_contrast_25,
+ {aspectRatio: max},
+ ]}>
+ {contents}
)
+ } else {
+ return (
+
+
+ {contents}
+
+
+ )
}
-
- return (
-
-
- {children}
-
- )
}
function calc(dim: Dimensions) {
if (dim.width === 0 || dim.height === 0) {
return 1
}
- return clamp(dim.width / dim.height, MIN_ASPECT_RATIO, MAX_ASPECT_RATIO)
+ return dim.width / dim.height
}
-
-const styles = StyleSheet.create({
- container: {
- overflow: 'hidden',
- },
- image: {
- width: '100%',
- },
-})
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 9bbb2ac100..839674c8cd 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -1,13 +1,14 @@
import React, {ComponentProps, FC} from 'react'
-import {Pressable, StyleSheet, Text, View} from 'react-native'
+import {Pressable, View} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {isWeb} from '#/platform/detection'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
-import {atoms as a} from '#/alf'
+import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
type EventFunction = (index: number) => void
@@ -17,7 +18,8 @@ interface GalleryItemProps {
onPress?: EventFunction
onLongPress?: EventFunction
onPressIn?: EventFunction
- imageStyle: ComponentProps['style']
+ imageStyle?: ComponentProps['style']
+ viewContext?: PostEmbedViewContext
}
export const GalleryItem: FC = ({
@@ -27,57 +29,69 @@ export const GalleryItem: FC = ({
onPress,
onPressIn,
onLongPress,
+ viewContext,
}) => {
+ const t = useTheme()
const {_} = useLingui()
const largeAltBadge = useLargeAltBadgeEnabled()
const image = images[index]
+ const hasAlt = !!image.alt
+ const hideBadges =
+ viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
return (
onPress(index) : undefined}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
- style={a.flex_1}
+ style={[
+ a.flex_1,
+ a.rounded_xs,
+ a.overflow_hidden,
+ t.atoms.bg_contrast_25,
+ imageStyle,
+ ]}
accessibilityRole="button"
accessibilityLabel={image.alt || _(msg`Image`)}
accessibilityHint="">
- {image.alt === '' ? null : (
-
+ {hasAlt && !hideBadges ? (
+
+ style={[a.font_heavy, largeAltBadge ? a.text_xs : {fontSize: 8}]}>
ALT
- )}
+ ) : null}
)
}
-
-const styles = StyleSheet.create({
- altContainer: {
- backgroundColor: 'rgba(0, 0, 0, 0.75)',
- borderRadius: 6,
- paddingHorizontal: 6,
- paddingVertical: 3,
- position: 'absolute',
- // Related to margin/gap hack. This keeps the alt label in the same position
- // on all platforms
- right: isWeb ? 8 : 5,
- bottom: isWeb ? 8 : 5,
- },
- alt: {
- color: 'white',
- fontSize: 7,
- fontWeight: 'bold',
- },
-})
diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx
index ba6c04f505..45da7f076d 100644
--- a/src/view/com/util/images/ImageLayoutGrid.tsx
+++ b/src/view/com/util/images/ImageLayoutGrid.tsx
@@ -1,8 +1,10 @@
import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyEmbedImages} from '@atproto/api'
+
+import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
+import {atoms as a, useBreakpoints} from '#/alf'
import {GalleryItem} from './Gallery'
-import {isWeb} from 'platform/detection'
interface ImageLayoutGridProps {
images: AppBskyEmbedImages.ViewImage[]
@@ -10,13 +12,25 @@ interface ImageLayoutGridProps {
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
style?: StyleProp
+ viewContext?: PostEmbedViewContext
}
export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
+ const {gtMobile} = useBreakpoints()
+ const gap =
+ props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
+ ? gtMobile
+ ? a.gap_xs
+ : a.gap_2xs
+ : gtMobile
+ ? a.gap_sm
+ : a.gap_xs
+ const count = props.images.length
+ const aspectRatio = count === 2 ? 2 : count === 3 ? 1.5 : 1
return (
-
-
+
+
)
@@ -27,36 +41,39 @@ interface ImageLayoutGridInnerProps {
onPress?: (index: number) => void
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
+ viewContext?: PostEmbedViewContext
+ gap: {gap: number}
}
function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
+ const gap = props.gap
const count = props.images.length
switch (count) {
case 2:
return (
-
-
-
+
+
+
-
-
+
+
)
case 3:
return (
-
-
-
+
+
+
-
-
-
+
+
+
-
-
+
+
@@ -65,20 +82,20 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
case 4:
return (
<>
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
>
@@ -88,39 +105,3 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
return null
}
}
-
-// On web we use margin to calculate gap, as aspectRatio does not properly size
-// all images on web. On native though we cannot rely on margin, since the
-// negative margin interferes with the swipe controls on pagers.
-// https://github.com/facebook/yoga/issues/1418
-// https://github.com/bluesky-social/social-app/issues/2601
-const IMAGE_GAP = 5
-
-const styles = StyleSheet.create({
- container: isWeb
- ? {
- marginHorizontal: -IMAGE_GAP / 2,
- marginVertical: -IMAGE_GAP / 2,
- }
- : {
- gap: IMAGE_GAP,
- },
- flexRow: {
- flexDirection: 'row',
- gap: isWeb ? undefined : IMAGE_GAP,
- },
- smallItem: {flex: 1, aspectRatio: 1},
- image: isWeb
- ? {
- margin: IMAGE_GAP / 2,
- }
- : {},
- threeSingle: {
- flex: 2,
- aspectRatio: isWeb ? 1 : undefined,
- },
- threeDouble: {
- flex: 1,
- gap: isWeb ? undefined : IMAGE_GAP,
- },
-})
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index d924adbe43..8c4928dfcd 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -53,6 +53,9 @@ let RepostButton = ({
onPress={() => {
requireAuth(() => dialogControl.open())
}}
+ onLongPress={() => {
+ requireAuth(() => onQuote())
+ }}
style={[
a.flex_row,
a.align_center,
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index c61cda68c1..53cc3b8a15 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -33,7 +33,7 @@ import {InfoCircleIcon} from 'lib/icons'
import {makeProfileLink} from 'lib/routes/links'
import {precacheProfile} from 'state/queries/profile'
import {ComposerOptsQuote} from 'state/shell/composer'
-import {atoms as a} from '#/alf'
+import {atoms as a, useBreakpoints} from '#/alf'
import {RichText} from '#/components/RichText'
import {ContentHider} from '../../../../components/moderation/ContentHider'
import {PostAlerts} from '../../../../components/moderation/PostAlerts'
@@ -41,17 +41,20 @@ import {Link} from '../Link'
import {PostMeta} from '../PostMeta'
import {Text} from '../text/Text'
import {PostEmbeds} from '.'
+import {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
export function MaybeQuoteEmbed({
embed,
onOpen,
style,
allowNestedQuotes,
+ viewContext,
}: {
embed: AppBskyEmbedRecord.View
onOpen?: () => void
style?: StyleProp
allowNestedQuotes?: boolean
+ viewContext?: QuoteEmbedViewContext
}) {
const pal = usePalette('default')
const {currentAccount} = useSession()
@@ -67,6 +70,7 @@ export function MaybeQuoteEmbed({
onOpen={onOpen}
style={style}
allowNestedQuotes={allowNestedQuotes}
+ viewContext={viewContext}
/>
)
} else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
@@ -113,12 +117,14 @@ function QuoteEmbedModerated({
onOpen,
style,
allowNestedQuotes,
+ viewContext,
}: {
viewRecord: AppBskyEmbedRecord.ViewRecord
postRecord: AppBskyFeedPost.Record
onOpen?: () => void
style?: StyleProp
allowNestedQuotes?: boolean
+ viewContext?: QuoteEmbedViewContext
}) {
const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => {
@@ -144,6 +150,7 @@ function QuoteEmbedModerated({
onOpen={onOpen}
style={style}
allowNestedQuotes={allowNestedQuotes}
+ viewContext={viewContext}
/>
)
}
@@ -154,18 +161,21 @@ export function QuoteEmbed({
onOpen,
style,
allowNestedQuotes,
+ viewContext,
}: {
quote: ComposerOptsQuote
moderation?: ModerationDecision
onOpen?: () => void
style?: StyleProp
allowNestedQuotes?: boolean
+ viewContext?: QuoteEmbedViewContext
}) {
const queryClient = useQueryClient()
const pal = usePalette('default')
const itemUrip = new AtUri(quote.uri)
const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
const itemTitle = `Post by ${quote.author.handle}`
+ const {gtMobile} = useBreakpoints()
const richText = React.useMemo(
() =>
@@ -197,6 +207,7 @@ export function QuoteEmbed({
}
}
}, [quote.embeds, allowNestedQuotes])
+ const isImagesEmbed = AppBskyEmbedImages.isView(embed)
const onBeforePress = React.useCallback(() => {
precacheProfile(queryClient, quote.author)
@@ -226,15 +237,43 @@ export function QuoteEmbed({
{moderation ? (
) : null}
- {richText ? (
-
- ) : null}
- {embed && }
+
+ {viewContext === QuoteEmbedViewContext.FeedEmbedRecordWithMedia &&
+ isImagesEmbed ? (
+
+ {embed && (
+
+
+
+ )}
+ {richText ? (
+
+
+
+ ) : null}
+
+ ) : (
+ <>
+ {richText ? (
+
+ ) : null}
+ {embed && }
+ >
+ )}
)
diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx
index d10a6fe69b..7db25b4d0f 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.tsx
@@ -1,7 +1,7 @@
import React, {useCallback, useEffect, useId, useState} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
-import {VideoPlayerStatus} from 'expo-video'
+import {PlayerError, VideoPlayerStatus} from 'expo-video'
import {AppBskyEmbedVideo} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -68,7 +68,9 @@ function InnerWrapper({embed}: Props) {
useActiveVideoNative()
const viewId = useId()
- const [playerStatus, setPlayerStatus] = useState('loading')
+ const [playerStatus, setPlayerStatus] = useState<
+ VideoPlayerStatus | 'switching'
+ >('loading')
const [isMuted, setIsMuted] = useState(player.muted)
const [isFullscreen, setIsFullscreen] = React.useState(false)
const [timeRemaining, setTimeRemaining] = React.useState(0)
@@ -77,6 +79,14 @@ function InnerWrapper({embed}: Props) {
isActive &&
(playerStatus === 'waitingToPlayAtSpecifiedRate' ||
playerStatus === 'loading')
+ const isSwitching = playerStatus === 'switching'
+ const showOverlay = !isActive || isLoading || isSwitching
+
+ // send error up to error boundary
+ const [error, setError] = useState(null)
+ if (error) {
+ throw error
+ }
useEffect(() => {
if (isActive) {
@@ -92,10 +102,10 @@ function InnerWrapper({embed}: Props) {
)
const statusSub = player.addListener(
'statusChange',
- (status, _oldStatus, error) => {
+ (status, _oldStatus, playerError) => {
setPlayerStatus(status)
if (status === 'error') {
- throw error
+ setError(playerError ?? new Error('Unknown player error'))
}
},
)
@@ -124,6 +134,7 @@ function InnerWrapper({embed}: Props) {
player.play()
}
} else {
+ setPlayerStatus('switching')
player.muted = true
if (player.playing) {
player.pause()
@@ -142,48 +153,47 @@ function InnerWrapper({embed}: Props) {
setIsFullscreen={setIsFullscreen}
/>
) : null}
- {!isActive || isLoading ? (
-
-
-
-
- ) : null}
+
+
+
+
)
}
diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx
index 0001a7af5a..a25f946416 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx
@@ -1,13 +1,15 @@
import React, {useCallback, useEffect, useRef, useState} from 'react'
import {View} from 'react-native'
import {AppBskyEmbedVideo} from '@atproto/api'
-import {Trans} from '@lingui/macro'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
import {clamp} from '#/lib/numbers'
import {useGate} from '#/lib/statsig/statsig'
import {
HLSUnsupportedError,
VideoEmbedInnerWeb,
+ VideoNotFoundError,
} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
import {atoms as a} from '#/alf'
import {ErrorBoundary} from '../ErrorBoundary'
@@ -152,23 +154,26 @@ function ViewportObserver({
}
function VideoError({error, retry}: {error: unknown; retry: () => void}) {
- const isHLS = error instanceof HLSUnsupportedError
+ const {_} = useLingui()
+
+ let showRetryButton = true
+ let text = null
+
+ if (error instanceof VideoNotFoundError) {
+ text = _(msg`Video not found.`)
+ } else if (error instanceof HLSUnsupportedError) {
+ showRetryButton = false
+ text = _(
+ msg`Your browser does not support the video format. Please try a different browser.`,
+ )
+ } else {
+ text = _(msg`An error occurred while loading the video. Please try again.`)
+ }
return (
-
- {isHLS ? (
-
- Your browser does not support the video format. Please try a
- different browser.
-
- ) : (
-
- An error occurred while loading the video. Please try again later.
-
- )}
-
- {!isHLS && }
+ {text}
+ {showRetryButton && }
)
}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
index 3fa159267d..b747223baa 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
@@ -56,13 +56,13 @@ export function VideoEmbedInnerNative({
contentFit="cover"
nativeControls={isFullscreen}
accessibilityIgnoresInvertColors
- onEnterFullscreen={() => {
+ onFullscreenEnter={() => {
PlatformInfo.setAudioCategory(AudioCategory.Playback)
PlatformInfo.setAudioActive(true)
player.muted = false
setIsFullscreen(true)
}}
- onExitFullscreen={() => {
+ onFullscreenExit={() => {
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
PlatformInfo.setAudioActive(false)
player.muted = true
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
index 77295c00c7..a30c0e1e9b 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
@@ -23,6 +23,12 @@ export function VideoEmbedInnerWeb({
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
const figId = useId()
+ // send error up to error boundary
+ const [error, setError] = useState(null)
+ if (error) {
+ throw error
+ }
+
const hlsRef = useRef(undefined)
useEffect(() => {
@@ -38,12 +44,25 @@ export function VideoEmbedInnerWeb({
// initial value, later on it's managed by Controls
hls.autoLevelCapping = 0
- hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => {
+ hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => {
if (data.subtitleTracks.length > 0) {
setHasSubtitleTrack(true)
}
})
+ hls.on(Hls.Events.ERROR, (_event, data) => {
+ if (data.fatal) {
+ if (
+ data.details === 'manifestLoadError' &&
+ data.response?.code === 404
+ ) {
+ setError(new VideoNotFoundError())
+ } else {
+ setError(data.error)
+ }
+ }
+ })
+
return () => {
hlsRef.current = undefined
hls.detachMedia()
@@ -104,3 +123,9 @@ export class HLSUnsupportedError extends Error {
super('HLS is not supported')
}
}
+
+export class VideoNotFoundError extends Error {
+ constructor() {
+ super('Video not found')
+ }
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
index d9b99ef3ad..82c0ab7a66 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
@@ -111,9 +111,9 @@ export function Controls({
// autoplay/pause based on visibility
const autoplayDisabled = useAutoplayDisabled()
useEffect(() => {
- if (active && !autoplayDisabled) {
+ if (active) {
if (onScreen) {
- play()
+ if (!autoplayDisabled) play()
} else {
pause()
}
@@ -151,10 +151,11 @@ export function Controls({
const onPressEmptySpace = useCallback(() => {
if (!focused) {
drawFocus()
+ if (autoplayDisabled) play()
} else {
togglePlayPause()
}
- }, [togglePlayPause, drawFocus, focused])
+ }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play])
const onPressPlayPause = useCallback(() => {
drawFocus()
@@ -240,7 +241,8 @@ export function Controls({
}, [])
const showControls =
- (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
+ ((focused || autoplayDisabled) && !playing) ||
+ (interactingViaKeypress ? hasFocus : hovered)
return (
{!showControls && !focused && duration > 0 && (
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index d9e075e772..b4a6cf8251 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -3,7 +3,6 @@ import {
InteractionManager,
StyleProp,
StyleSheet,
- Text,
View,
ViewStyle,
} from 'react-native'
@@ -22,7 +21,6 @@ import {
} from '@atproto/api'
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
-import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {usePalette} from 'lib/hooks/usePalette'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
@@ -34,8 +32,11 @@ import {AutoSizedImage} from '../images/AutoSizedImage'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed'
+import {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
import {VideoEmbed} from './VideoEmbed'
+export * from './types'
+
type Embed =
| AppBskyEmbedRecord.View
| AppBskyEmbedImages.View
@@ -50,15 +51,16 @@ export function PostEmbeds({
onOpen,
style,
allowNestedQuotes,
+ viewContext,
}: {
embed?: Embed
moderation?: ModerationDecision
onOpen?: () => void
style?: StyleProp
allowNestedQuotes?: boolean
+ viewContext?: PostEmbedViewContext
}) {
const {openLightbox} = useLightboxControls()
- const largeAltBadge = useLargeAltBadgeEnabled()
// quote post with media
// =
@@ -69,8 +71,17 @@ export function PostEmbeds({
embed={embed.media}
moderation={moderation}
onOpen={onOpen}
+ viewContext={viewContext}
+ />
+
-
)
}
@@ -124,27 +135,26 @@ export function PostEmbeds({
}
if (images.length === 1) {
- const {alt, thumb, aspectRatio} = images[0]
+ const image = images[0]
return (
_openLightbox(0)}
onPressIn={() => onPressIn(0)}
- style={a.rounded_sm}>
- {alt === '' ? null : (
-
-
- ALT
-
-
- )}
-
+ hideBadge={
+ viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
+ }
+ />
)
@@ -157,6 +167,7 @@ export function PostEmbeds({
images={embed.images}
onPress={_openLightbox}
onPressIn={onPressIn}
+ viewContext={viewContext}
/>
diff --git a/src/view/com/util/post-embeds/types.ts b/src/view/com/util/post-embeds/types.ts
new file mode 100644
index 0000000000..08e9032768
--- /dev/null
+++ b/src/view/com/util/post-embeds/types.ts
@@ -0,0 +1,9 @@
+export enum PostEmbedViewContext {
+ ThreadHighlighted = 'ThreadHighlighted',
+ Feed = 'Feed',
+ FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
+}
+
+export enum QuoteEmbedViewContext {
+ FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia,
+}
diff --git a/src/view/screens/AccessibilitySettings.tsx b/src/view/screens/AccessibilitySettings.tsx
index 5cb5c6a39f..2992e5c7e9 100644
--- a/src/view/screens/AccessibilitySettings.tsx
+++ b/src/view/screens/AccessibilitySettings.tsx
@@ -108,7 +108,7 @@ export function AccessibilitySettingsScreen({}: Props) {
setAutoplayDisabled(!autoplayDisabled)}
diff --git a/yarn.lock b/yarn.lock
index b084ca26fe..b241ab57ba 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9917,12 +9917,7 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
-caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520:
- version "1.0.30001655"
- resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz"
- integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==
-
-caniuse-lite@^1.0.30001587:
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520, caniuse-lite@^1.0.30001587:
version "1.0.30001655"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz"
integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==
@@ -12419,10 +12414,9 @@ expo-updates@~0.25.14:
ignore "^5.3.1"
resolve-from "^5.0.0"
-expo-video@^1.2.4:
+"expo-video@https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-1.tgz":
version "1.2.4"
- resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-1.2.4.tgz#787342aded4295a1b6864f59227d178b93e1bb53"
- integrity sha512-pBK9mt7vYAbuPQjCSQxHQ7xrNjbmRheJep7JIStEg57O183/JRfP2blKuXniiSt1HBdZYPdoQnGRa3jGMXB9pg==
+ resolved "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-1.tgz#57f61a72f41b86e5a587d9782d32bd32487a551e"
expo-web-browser@~13.0.3:
version "13.0.3"
@@ -19053,6 +19047,11 @@ react-native-keyboard-controller@^1.12.1:
resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.1.tgz#6de22ed4d060528a0dd25621eeaa7f71772ce35f"
integrity sha512-2OpQcesiYsMilrTzgcTafSGexd9UryRQRuHudIcOn0YaqvvzNpnhVZMVuJMH93fJv/iaZYp3138rgUKOdHhtSw==
+react-native-mmkv@^2.12.2:
+ version "2.12.2"
+ resolved "https://registry.yarnpkg.com/react-native-mmkv/-/react-native-mmkv-2.12.2.tgz#4bba0f5f04e2cf222494cce3a9794ba6a4894dee"
+ integrity sha512-6058Aq0p57chPrUutLGe9fYoiDVDNMU2PKV+lLFUJ3GhoHvUrLdsS1PDSCLr00yqzL4WJQ7TTzH+V8cpyrNcfg==
+
react-native-pager-view@6.2.3:
version "6.2.3"
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.3.tgz#698f6387fdf06cecc3d8d4792604419cb89cb775"