diff --git a/__e2e__/flows/feed-reorder.yml b/__e2e__/flows/feed-reorder.yml index 449df065..2b892b53 100644 --- a/__e2e__/flows/feed-reorder.yml +++ b/__e2e__/flows/feed-reorder.yml @@ -10,8 +10,13 @@ appId: xyz.blueskyweb.app id: "e2eSignInAlice" # Pin alice's feed +- extendedWaitUntil: + visible: + id: "viewHeaderDrawerBtn" - tapOn: - id: "bottomBarProfileBtn" + id: "viewHeaderDrawerBtn" +- tapOn: + id: "profileCardButton" - swipe: from: id: "profilePager-selector" diff --git a/__e2e__/flows/home-screen.yml b/__e2e__/flows/home-screen.yml index c8d83fb1..799a2021 100644 --- a/__e2e__/flows/home-screen.yml +++ b/__e2e__/flows/home-screen.yml @@ -9,6 +9,9 @@ appId: xyz.blueskyweb.app - tapOn: id: "e2eSignInAlice" +- extendedWaitUntil: + visible: + text: "Feeds ✨" - tapOn: label: "Can go to feeds page using feeds button in tab bar" text: "Feeds ✨" @@ -34,26 +37,16 @@ appId: xyz.blueskyweb.app - tapOn: label: "Can like posts" id: "likeBtn" -- assertVisible: - id: "likeCount" - text: "1" - tapOn: id: "likeBtn" -- assertNotVisible: - id: "likeCount" - tapOn: label: "Can repost posts" id: "repostBtn" - tapOn: "Repost" -- assertVisible: - id: "repostCount" - text: "1" - tapOn: id: "repostBtn" - tapOn: "Remove repost" -- assertNotVisible: - id: "repostCount" - tapOn: label: "Can delete posts" diff --git a/__e2e__/flows/profile-screen-edit.yml b/__e2e__/flows/profile-screen-edit.yml index 288a5d4f..251eca35 100644 --- a/__e2e__/flows/profile-screen-edit.yml +++ b/__e2e__/flows/profile-screen-edit.yml @@ -11,6 +11,9 @@ appId: xyz.blueskyweb.app # Navigate to my profile +- extendedWaitUntil: + visible: + id: "bottomBarSearchBtn" - tapOn: id: "bottomBarProfileBtn" diff --git a/__e2e__/flows/profile-screen.yml b/__e2e__/flows/profile-screen.yml index 7d2d43de..b9f95aca 100644 --- a/__e2e__/flows/profile-screen.yml +++ b/__e2e__/flows/profile-screen.yml @@ -10,6 +10,9 @@ appId: xyz.blueskyweb.app id: "e2eSignInAlice" # Navigate to another user profile +- extendedWaitUntil: + visible: + id: "bottomBarSearchBtn" - tapOn: id: "bottomBarSearchBtn" - tapOn: diff --git a/__e2e__/flows/thread-screen.yml b/__e2e__/flows/thread-screen.yml index fdc73259..9120f4f6 100644 --- a/__e2e__/flows/thread-screen.yml +++ b/__e2e__/flows/thread-screen.yml @@ -11,6 +11,8 @@ appId: xyz.blueskyweb.app # Navigate to thread +- extendedWaitUntil: + visible: "Thread root" - tapOn: "Thread root" - assertVisible: "Thread reply" @@ -33,18 +35,10 @@ appId: xyz.blueskyweb.app id: "likeBtn" childOf: id: "postThreadItem-by-carla.test" -- assertVisible: - id: "likeCount" - childOf: - id: "postThreadItem-by-carla.test" - tapOn: id: "likeBtn" childOf: id: "postThreadItem-by-carla.test" -- assertNotVisible: - id: "likeCount" - childOf: - id: "postThreadItem-by-carla.test" # Can repost the root post - tapOn: diff --git a/app.config.js b/app.config.js index cd8a4b03..25014ee8 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 00000000..118d148f --- /dev/null +++ b/assets/icons/crop_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/bskyembed/assets/play_filled_corner2_rounded.svg b/bskyembed/assets/play_filled_corner2_rounded.svg new file mode 100644 index 00000000..48da4add --- /dev/null +++ b/bskyembed/assets/play_filled_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/bskyembed/package.json b/bskyembed/package.json index 72d2b6df..e269b1c9 100644 --- a/bskyembed/package.json +++ b/bskyembed/package.json @@ -9,7 +9,7 @@ "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src" }, "dependencies": { - "@atproto/api": "0.13.1", + "@atproto/api": "0.13.6", "@preact/preset-vite": "^2.8.2", "@vitejs/plugin-legacy": "^5.3.2", "preact": "^10.4.8", diff --git a/bskyembed/src/components/embed.tsx b/bskyembed/src/components/embed.tsx index 600c7c2c..3b4f5e77 100644 --- a/bskyembed/src/components/embed.tsx +++ b/bskyembed/src/components/embed.tsx @@ -3,6 +3,7 @@ import { AppBskyEmbedImages, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, + AppBskyEmbedVideo, AppBskyFeedDefs, AppBskyFeedPost, AppBskyGraphDefs, @@ -14,6 +15,7 @@ import {ComponentChildren, h} from 'preact' import {useMemo} from 'preact/hooks' import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg' +import playIcon from '../../assets/play_filled_corner2_rounded.svg' import starterPackIcon from '../../assets/starterPack.svg' import {CONTENT_LABELS, labelsToInfo} from '../labels' import {getRkey} from '../utils' @@ -160,7 +162,12 @@ export function Embed({ return null } - // Case 4: Record with media + // Case 4: Video + if (AppBskyEmbedVideo.isView(content)) { + return + } + + // Case 5: Record with media if ( AppBskyEmbedRecordWithMedia.isView(content) && AppBskyEmbedRecord.isViewRecord(content.record.record) @@ -354,6 +361,31 @@ function GenericWithImageEmbed({ ) } +// just the thumbnail and a play button +function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) { + let aspectRatio = 1 + + if (content.aspectRatio) { + const {width, height} = content.aspectRatio + aspectRatio = clamp(width / height, 1 / 1, 3 / 1) + } + + return ( +
+ {content.alt} +
+ +
+
+ ) +} + function StarterPackEmbed({ content, }: { @@ -410,3 +442,7 @@ function getStarterPackHref( const handleOrDid = starterPack.creator.handle || starterPack.creator.did return `/starter-pack/${handleOrDid}/${rkey}` } + +function clamp(num: number, min: number, max: number) { + return Math.max(min, Math.min(num, max)) +} diff --git a/bskyembed/yarn.lock b/bskyembed/yarn.lock index 3c5ef5ae..46c8519b 100644 --- a/bskyembed/yarn.lock +++ b/bskyembed/yarn.lock @@ -20,15 +20,15 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" -"@atproto/api@0.13.1": - version "0.13.1" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.1.tgz#fbf4306e4465d5467aaf031308c1b47dcc8039d0" - integrity sha512-DL3iBfavn8Nnl48FmnAreQB0k0cIkW531DJ5JAHUCQZo10Nq0ZLk2/WFxcs0KuBG5wuLnGUdo+Y6/GQPVq8dYw== +"@atproto/api@0.13.6": + version "0.13.6" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.6.tgz#2500e9d7143e6718089632300c42ce50149f8cd5" + integrity sha512-58emFFZhqY8nVWD3xFWK0yYqAmJ2un+NaTtZxBbRo00mGq1rz9VXTpVmfoHFcuXL1hoDQN3WyJfsub8r6xGOgg== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/lexicon" "^0.4.1" "@atproto/syntax" "^0.3.0" - "@atproto/xrpc" "^0.6.0" + "@atproto/xrpc" "^0.6.1" await-lock "^2.2.2" multiformats "^9.9.0" tlds "^1.234.0" @@ -59,10 +59,10 @@ resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3" integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA== -"@atproto/xrpc@^0.6.0": - version "0.6.0" - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.0.tgz#668c3262e67e2afa65951ea79a03bfe3720ddf5c" - integrity sha512-5BbhBTv5j6MC3iIQ4+vYxQE7nLy2dDGQ+LYJrH8PptOCUdq0Pwg6aRccQ3y52kUZlhE/mzOTZ8Ngiy9pSAyfVQ== +"@atproto/xrpc@^0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5" + integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A== dependencies: "@atproto/lexicon" "^0.4.1" zod "^3.23.8" diff --git a/bskyweb/cmd/bskyweb/main.go b/bskyweb/cmd/bskyweb/main.go index d9235afd..3f46c4b0 100644 --- a/bskyweb/cmd/bskyweb/main.go +++ b/bskyweb/cmd/bskyweb/main.go @@ -60,6 +60,12 @@ func run(args []string) { Value: "", EnvVars: []string{"LINK_HOST"}, }, + &cli.StringFlag{ + Name: "ipcc-host", + Usage: "scheme, hostname, and port of ipcc service", + Value: "https://localhost:8730", + EnvVars: []string{"IPCC_HOST"}, + }, &cli.BoolFlag{ Name: "debug", Usage: "Enable debug mode", diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 203ed62f..afd9247a 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -1,12 +1,17 @@ package main import ( + "bytes" "context" "crypto/subtle" + "crypto/tls" + "encoding/base64" + "encoding/json" "errors" "fmt" "io/fs" "net/http" + "net/netip" "net/url" "os" "os/signal" @@ -41,6 +46,7 @@ type Config struct { appviewHost string ogcardHost string linkHost string + ipccHost string } func serve(cctx *cli.Context) error { @@ -49,6 +55,7 @@ func serve(cctx *cli.Context) error { appviewHost := cctx.String("appview-host") ogcardHost := cctx.String("ogcard-host") linkHost := cctx.String("link-host") + ipccHost := cctx.String("ipcc-host") basicAuthPassword := cctx.String("basic-auth-password") // Echo @@ -91,6 +98,7 @@ func serve(cctx *cli.Context) error { appviewHost: appviewHost, ogcardHost: ogcardHost, linkHost: linkHost, + ipccHost: ipccHost, }, } @@ -261,6 +269,9 @@ func serve(cctx *cli.Context) error { e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack) e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack) + // ipcc + e.GET("/ipcc", server.WebIpCC) + if linkHost != "" { linkUrl, err := url.Parse(linkHost) if err != nil { @@ -520,3 +531,61 @@ func (srv *Server) WebProfile(c echo.Context) error { data["requestHost"] = req.Host return c.Render(http.StatusOK, "profile.html", data) } + +type IPCCRequest struct { + IP string `json:"ip"` +} +type IPCCResponse struct { + CC string `json:"countryCode"` +} + +func (srv *Server) WebIpCC(c echo.Context) error { + realIP := c.RealIP() + addr, err := netip.ParseAddr(realIP) + if err != nil { + log.Warnf("could not parse IP %q %s", realIP, err) + return c.JSON(400, IPCCResponse{}) + } + var request []byte + if addr.Is4() { + ip4 := addr.As4() + var dest [8]byte + base64.StdEncoding.Encode(dest[:], ip4[:]) + request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])}) + } else if addr.Is6() { + ip6 := addr.As16() + var dest [24]byte + base64.StdEncoding.Encode(dest[:], ip6[:]) + request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])}) + } + + ipccUrlBuilder, err := url.Parse(srv.cfg.ipccHost) + if err != nil { + log.Errorf("ipcc misconfigured bad url %s", err) + return c.JSON(500, IPCCResponse{}) + } + ipccUrlBuilder.Path = "ipccdata.IpCcService/Lookup" + ipccUrl := ipccUrlBuilder.String() + cl := http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + postBodyReader := bytes.NewReader(request) + response, err := cl.Post(ipccUrl, "application/json", postBodyReader) + if err != nil { + log.Warnf("ipcc backend error %s", err) + return c.JSON(500, IPCCResponse{}) + } + defer response.Body.Close() + dec := json.NewDecoder(response.Body) + var outResponse IPCCResponse + err = dec.Decode(&outResponse) + if err != nil { + log.Warnf("ipcc bad response %s", err) + return c.JSON(500, IPCCResponse{}) + } + return c.JSON(200, outResponse) +} diff --git a/lingui.config.js b/lingui.config.js index 14a94b5d..796969ab 100644 --- a/lingui.config.js +++ b/lingui.config.js @@ -14,6 +14,7 @@ module.exports = { 'ja', 'ko', 'pt-BR', + 'ru', 'tr', 'uk', 'zh-CN', diff --git a/package.json b/package.json index eaa03829..eff665a6 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-2.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 13fe25ed..00000000 --- a/patches/expo-video+1.2.4.patch +++ /dev/null @@ -1,557 +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..5eac9e5 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..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/VideoManager.swift b/node_modules/expo-video/ios/VideoManager.swift -index 094a8b0..3f00525 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..ea4d96f 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 { - _ 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..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 -+++ 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 99c14c28..00000000 --- a/patches/expo-video+1.2.4.patch.md +++ /dev/null @@ -1,18 +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. diff --git a/plugins/starterPackAppClipExtension/withXcodeTarget.js b/plugins/starterPackAppClipExtension/withXcodeTarget.js index 61d5f81b..c14d2729 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/App.native.tsx b/src/App.native.tsx index 609d316d..780d4058 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -58,6 +58,7 @@ import {Shell} from '#/view/shell' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' +import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' import {Provider as PortalProvider} from '#/components/Portal' import {Splash} from '#/Splash' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' @@ -105,52 +106,50 @@ function InnerApp() { }, [_]) return ( - - - - - - - - - - - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } @@ -184,7 +183,12 @@ function App() { - + + + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index 8531dc88..3017a3a2 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -47,6 +47,7 @@ import {Shell} from '#/view/shell/index' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' +import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' import {Provider as PortalProvider} from '#/components/Portal' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' @@ -162,7 +163,9 @@ function App() { - + + + diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 0bf0e9f9..2beba4f9 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -661,16 +661,15 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { linking={LINKING} theme={theme} onStateChange={() => { - logEvent('router:navigate:sampled', { - from: prevLoggedRouteName.current, - }) - prevLoggedRouteName.current = getCurrentRouteName() + const routeName = getCurrentRouteName() + if (routeName === 'Notifications') { + logEvent('router:navigate:notifications:sampled', {}) + } }} onReady={() => { attachRouteToLogEvents(getCurrentRouteName) logModuleInitTime() onReady() - logEvent('router:navigate:sampled', {}) }}> {children} diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 65e981f7..5031f584 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -8,7 +8,6 @@ import {useNavigation} from '@react-navigation/native' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {NavigationProp} from '#/lib/routes/types' -import {useGate} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {useModerationOpts} from '#/state/preferences/moderation-opts' @@ -177,14 +176,9 @@ function useExperimentalSuggestedUsersQuery() { } export function SuggestedFollows({feed}: {feed: FeedDescriptor}) { - const gate = useGate() const [feedType, feedUri] = feed.split('|') if (feedType === 'author') { - if (gate('show_follow_suggestions_in_profile')) { - return - } else { - return null - } + return } else { return } diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx new file mode 100644 index 00000000..7d7cb2b4 --- /dev/null +++ b/src/components/MediaPreview.tsx @@ -0,0 +1,172 @@ +import React from 'react' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {Image} from 'expo-image' +import { + AppBskyEmbedExternal, + AppBskyEmbedImages, + AppBskyEmbedRecordWithMedia, + AppBskyEmbedVideo, +} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {parseTenorGif} from '#/lib/strings/embed-player' +import {atoms as a} from '#/alf' +import {Text} from '#/components/Typography' +import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' + +/** + * Streamlined MediaPreview component which just handles images, gifs, and videos + */ +export function Embed({ + embed, + style, +}: { + embed?: + | AppBskyEmbedImages.View + | AppBskyEmbedRecordWithMedia.View + | AppBskyEmbedExternal.View + | AppBskyEmbedVideo.View + | {[k: string]: unknown} + style?: StyleProp +}) { + let media = AppBskyEmbedRecordWithMedia.isView(embed) ? embed.media : embed + + if (AppBskyEmbedImages.isView(media)) { + return ( + + {media.images.map(image => ( + + ))} + + ) + } else if (AppBskyEmbedExternal.isView(embed) && embed.external.thumb) { + let url: URL | undefined + try { + url = new URL(embed.external.uri) + } catch {} + if (url) { + const {success} = parseTenorGif(url) + if (success) { + return ( + + + + ) + } + } + } else if (AppBskyEmbedVideo.isView(embed)) { + return ( + + + + ) + } + + return null +} + +export function Outer({ + children, + style, +}: { + children?: React.ReactNode + style?: StyleProp +}) { + return {children} +} + +export function ImageItem({ + thumbnail, + alt, + children, +}: { + thumbnail: string + alt?: string + children?: React.ReactNode +}) { + return ( + + + {children} + + ) +} + +export function GifItem({thumbnail, alt}: {thumbnail: string; alt?: string}) { + return ( + + + + + + + GIF + + + + ) +} + +export function VideoItem({ + thumbnail, + alt, +}: { + thumbnail?: string + alt?: string +}) { + if (!thumbnail) { + return ( + + + + ) + } + return ( + + + + + + ) +} + +const styles = StyleSheet.create({ + altContainer: { + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderRadius: 6, + paddingHorizontal: 6, + paddingVertical: 3, + position: 'absolute', + right: 5, + bottom: 5, + zIndex: 2, + }, + alt: { + color: 'white', + fontSize: 7, + fontWeight: 'bold', + }, +}) diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index a263d194..6f6d6804 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -276,8 +276,8 @@ export function DescriptionPlaceholder() { export type FollowButtonProps = { profile: AppBskyActorDefs.ProfileViewBasic moderationOpts: ModerationOpts - logContext: LogEvents['profile:follow']['logContext'] & - LogEvents['profile:unfollow']['logContext'] + logContext: LogEvents['profile:follow:sampled']['logContext'] & + LogEvents['profile:unfollow:sampled']['logContext'] } & Partial export function FollowButton(props: FollowButtonProps) { diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 315ad0df..86cb5c31 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/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx index 6174bff0..a5c7cd1b 100644 --- a/src/components/StarterPack/Main/ProfilesList.tsx +++ b/src/components/StarterPack/Main/ProfilesList.tsx @@ -40,7 +40,7 @@ export const ProfilesList = React.forwardRef( ref, ) { const t = useTheme() - const bottomBarOffset = useBottomBarOffset(200) + const bottomBarOffset = useBottomBarOffset(300) const initialNumToRender = useInitialNumToRender() const {currentAccount} = useSession() const {data, refetch, isError} = useAllListMembersQuery(listUri) diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index aefd62b9..3db00aec 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/hooks/dates.ts b/src/components/hooks/dates.ts index b0f94133..00b70f76 100644 --- a/src/components/hooks/dates.ts +++ b/src/components/hooks/dates.ts @@ -21,6 +21,7 @@ import { ja, ko, ptBR, + ru, tr, uk, zhCN, @@ -47,6 +48,7 @@ const locales: Record = { ja, ko, ['pt-BR']: ptBR, + ru, tr, uk, ['zh-CN']: zhCN, diff --git a/src/components/hooks/useFollowMethods.ts b/src/components/hooks/useFollowMethods.ts index d67c3690..31a1e43d 100644 --- a/src/components/hooks/useFollowMethods.ts +++ b/src/components/hooks/useFollowMethods.ts @@ -15,8 +15,8 @@ export function useFollowMethods({ logContext, }: { profile: Shadow - logContext: LogEvents['profile:follow']['logContext'] & - LogEvents['profile:unfollow']['logContext'] + logContext: LogEvents['profile:follow:sampled']['logContext'] & + LogEvents['profile:unfollow:sampled']['logContext'] }) { const {_} = useLingui() const requireAuth = useRequireAuth() diff --git a/src/components/icons/Crop.tsx b/src/components/icons/Crop.tsx new file mode 100644 index 00000000..4b3fc560 --- /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/components/icons/common.tsx b/src/components/icons/common.tsx index 387115d3..e83f96f0 100644 --- a/src/components/icons/common.tsx +++ b/src/components/icons/common.tsx @@ -19,6 +19,7 @@ export const sizes = { md: 20, lg: 24, xl: 28, + '2xl': 32, } export function useCommonSVGProps(props: Props) { diff --git a/src/components/intents/IntentDialogs.tsx b/src/components/intents/IntentDialogs.tsx new file mode 100644 index 00000000..24485037 --- /dev/null +++ b/src/components/intents/IntentDialogs.tsx @@ -0,0 +1,37 @@ +import React from 'react' + +import * as Dialog from '#/components/Dialog' +import {DialogControlProps} from '#/components/Dialog' +import {VerifyEmailIntentDialog} from '#/components/intents/VerifyEmailIntentDialog' + +interface Context { + verifyEmailDialogControl: DialogControlProps + verifyEmailState: {code: string} | undefined + setVerifyEmailState: (state: {code: string} | undefined) => void +} + +const Context = React.createContext({} as Context) +export const useIntentDialogs = () => React.useContext(Context) + +export function Provider({children}: {children: React.ReactNode}) { + const verifyEmailDialogControl = Dialog.useDialogControl() + const [verifyEmailState, setVerifyEmailState] = React.useState< + {code: string} | undefined + >() + + const value = React.useMemo( + () => ({ + verifyEmailDialogControl, + verifyEmailState, + setVerifyEmailState, + }), + [verifyEmailDialogControl, verifyEmailState, setVerifyEmailState], + ) + + return ( + + {children} + + + ) +} diff --git a/src/components/intents/VerifyEmailIntentDialog.tsx b/src/components/intents/VerifyEmailIntentDialog.tsx new file mode 100644 index 00000000..4dca8bd9 --- /dev/null +++ b/src/components/intents/VerifyEmailIntentDialog.tsx @@ -0,0 +1,140 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useAgent, useSession} from 'state/session' +import {atoms as a} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {DialogControlProps} from '#/components/Dialog' +import {useIntentDialogs} from '#/components/intents/IntentDialogs' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +export function VerifyEmailIntentDialog() { + const {verifyEmailDialogControl: control} = useIntentDialogs() + + return ( + + + + + ) +} + +function Inner({control}: {control: DialogControlProps}) { + const {_} = useLingui() + const {verifyEmailState: state} = useIntentDialogs() + const [status, setStatus] = React.useState< + 'loading' | 'success' | 'failure' | 'resent' + >('loading') + const [sending, setSending] = React.useState(false) + const agent = useAgent() + const {currentAccount} = useSession() + + React.useEffect(() => { + ;(async () => { + if (!state?.code) { + return + } + try { + await agent.com.atproto.server.confirmEmail({ + email: (currentAccount?.email || '').trim(), + token: state.code.trim(), + }) + setStatus('success') + } catch (e) { + setStatus('failure') + } + })() + }, [agent.com.atproto.server, currentAccount?.email, state?.code]) + + const onPressResendEmail = async () => { + setSending(true) + await agent.com.atproto.server.requestEmailConfirmation() + setSending(false) + setStatus('resent') + } + + return ( + + + + {status === 'loading' ? ( + + + + ) : status === 'success' ? ( + <> + + Email Verified + + + + Thanks, you have successfully verified your email address. + + + + ) : status === 'failure' ? ( + <> + + Invalid Verification Code + + + + The verification code you have provided is invalid. Please make + sure that you have used the correct verification link or request + a new one. + + + + ) : ( + <> + + Email Resent + + + + We have sent another verification email to{' '} + + {currentAccount?.email} + + . + + + + )} + {status !== 'loading' ? ( + + + {status === 'failure' ? ( + + ) : null} + + ) : null} + + + ) +} diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx index 95b5853c..33ede3ed 100644 --- a/src/components/moderation/LabelsOnMe.tsx +++ b/src/components/moderation/LabelsOnMe.tsx @@ -14,19 +14,18 @@ import { } from '#/components/moderation/LabelsOnMeDialog' export function LabelsOnMe({ - details, + type, labels, size, style, }: { - details: {did: string} | {uri: string; cid: string} + type: 'account' | 'content' labels: ComAtprotoLabelDefs.Label[] | undefined size?: ButtonSize style?: StyleProp }) { const {_} = useLingui() const {currentAccount} = useSession() - const isAccount = 'did' in details const control = useLabelsOnMeDialogControl() if (!labels || !currentAccount) { @@ -39,7 +38,7 @@ export function LabelsOnMe({ return ( - + )} - {images && images?.length > 0 && ( - - )} + ) diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index c0e78e97..3034f029 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -29,6 +29,10 @@ import {useAgent} from '#/state/session' import {clamp} from 'lib/numbers' import {ScrollProvider} from 'lib/ScrollContext' import {isWeb} from 'platform/detection' +import { + EmojiPicker, + EmojiPickerState, +} from '#/view/com/composer/text-input/web/EmojiPicker.web' import {List} from 'view/com/util/List' import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled' import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' @@ -97,6 +101,12 @@ export function MessagesList({ startContentOffset: 0, }) + const [emojiPickerState, setEmojiPickerState] = + React.useState({ + isOpen: false, + pos: {top: 0, left: 0, right: 0, bottom: 0}, + }) + // We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items // are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to // the bottom. @@ -422,13 +432,22 @@ export function MessagesList({ + setEmbed={setEmbed} + openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}> )} + {isWeb && ( + setEmojiPickerState(prev => ({...prev, isOpen: false}))} + /> + )} + {newMessagesPill.show && } ) diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx index 0108a537..ded473ff 100644 --- a/src/screens/Onboarding/StepInterests/index.tsx +++ b/src/screens/Onboarding/StepInterests/index.tsx @@ -6,10 +6,8 @@ import {useQuery} from '@tanstack/react-query' import {useAnalytics} from '#/lib/analytics/analytics' import {logEvent} from '#/lib/statsig/statsig' -import {useGate} from '#/lib/statsig/statsig' import {capitalize} from '#/lib/strings/capitalize' import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' import {useAgent} from '#/state/session' import {useOnboardingDispatch} from '#/state/shell' import { @@ -29,23 +27,16 @@ import * as Toggle from '#/components/forms/Toggle' import {IconCircle} from '#/components/IconCircle' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji' import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' -const PROMPT_HEIGHT = isWeb ? 42 : 36 -// matches the padding of the OnboardingControls.Portal -const PROMPT_OFFSET = isWeb ? a.pb_2xl.paddingBottom : a.pb_lg.paddingBottom -const MIN_INTERESTS = 3 - export function StepInterests() { const {_} = useLingui() const t = useTheme() const {gtMobile} = useBreakpoints() const {track} = useAnalytics() - const gate = useGate() const interestsDisplayNames = useInterestsDisplayNames() const {state, dispatch} = React.useContext(Context) @@ -143,12 +134,6 @@ export function StepInterests() { track('OnboardingV2:StepInterests:Start') }, [track]) - const isMinimumInterestsEnabled = - gate('onboarding_minimum_interests') && data?.interests.length !== 0 - const meetsMinimumRequirement = isMinimumInterestsEnabled - ? interests.length >= MIN_INTERESTS - : true - const title = isError ? ( Oh no! Something went wrong. ) : ( @@ -186,13 +171,8 @@ export function StepInterests() { {title} {description} - {isMinimumInterestsEnabled && ( - - Choose 3 or more: - - )} - + {isLoading ? ( ) : isError || !data ? ( @@ -268,7 +248,7 @@ export function StepInterests() { ) : ( )} - - {!meetsMinimumRequirement && ( - - - - - - Choose at least {MIN_INTERESTS - interests.length} more - - - - - )} ) diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 2036023c..cf5fcb97 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -6,11 +6,9 @@ import { ModerationOpts, RichText as RichTextAPI, } from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {isIOS} from '#/platform/detection' import {Shadow} from '#/state/cache/types' @@ -23,10 +21,9 @@ import {useRequireAuth, useSession} from '#/state/session' import {useAnalytics} from 'lib/analytics/analytics' import {sanitizeDisplayName} from 'lib/strings/display-names' import {useProfileShadow} from 'state/cache/profile-shadow' -import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows' import {ProfileMenu} from '#/view/com/profile/ProfileMenu' import * as Toast from '#/view/com/util/Toast' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {MessageProfileButton} from '#/components/dms/MessageProfileButton' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' @@ -59,8 +56,6 @@ let ProfileHeaderStandard = ({ }: Props): React.ReactNode => { const profile: Shadow = useProfileShadow(profileUnshadowed) - const t = useTheme() - const gate = useGate() const {currentAccount, hasSession} = useSession() const {_} = useLingui() const {openModal} = useModalControls() @@ -69,7 +64,6 @@ let ProfileHeaderStandard = ({ () => moderateProfile(profile, moderationOpts), [profile, moderationOpts], ) - const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( profile, 'ProfileHeader', @@ -202,34 +196,7 @@ let ProfileHeaderStandard = ({ ) ) : !profile.viewer?.blockedBy ? ( <> - {hasSession && ( - <> - - {!gate('show_follow_suggestions_in_profile') && ( - - )} - - )} + {hasSession && } - - - )} + setError('')} + videoUploadDispatch={videoUploadDispatch} + /> )} - - + + {hasVideo && ( + + {videoUploadState.asset && + (videoUploadState.status === 'compressing' ? ( + + ) : videoUploadState.video ? ( + + ) : null)} + + + )} + + {quote ? ( @@ -715,29 +772,6 @@ export const ComposePost = observer(function ComposePost({ )} ) : null} - {videoUploadState.asset && - (videoUploadState.status === 'compressing' ? ( - - ) : videoUploadState.video ? ( - - ) : null)} - {(videoUploadState.asset || videoUploadState.video) && ( - - )} @@ -958,6 +992,10 @@ const styles = StyleSheet.create({ paddingVertical: 6, marginLeft: 12, }, + postBtnWrapper: { + flexDirection: 'row', + gap: 14, + }, errorLine: { flexDirection: 'row', alignItems: 'center', @@ -1018,6 +1056,80 @@ const styles = StyleSheet.create({ }, }) +function ErrorBanner({ + error: standardError, + videoUploadState, + clearError, + videoUploadDispatch, +}: { + error: string + videoUploadState: VideoUploadState + clearError: () => void + videoUploadDispatch: VideoUploadDispatch +}) { + const t = useTheme() + const {_} = useLingui() + + const videoError = + videoUploadState.status !== 'idle' ? videoUploadState.error : undefined + const error = standardError || videoError + + const onClearError = () => { + if (standardError) { + clearError() + } else { + videoUploadDispatch({type: 'Reset'}) + } + } + + if (!error) return null + + return ( + + + + + + {error} + + + + {videoError && videoUploadState.jobStatus?.jobId && ( + + Job ID: {videoUploadState.jobStatus.jobId} + + )} + + + ) +} + function ToolbarWrapper({ style, children, @@ -1039,6 +1151,31 @@ function ToolbarWrapper({ function VideoUploadToolbar({state}: {state: VideoUploadState}) { const t = useTheme() const {_} = useLingui() + const progress = state.jobStatus?.progress + ? state.jobStatus.progress / 100 + : state.progress + const shouldRotate = + state.status === 'processing' && (progress === 0 || progress === 1) + let wheelProgress = shouldRotate ? 0.33 : progress + + const rotate = useDerivedValue(() => { + if (shouldRotate) { + return withRepeat( + withTiming(360, { + duration: 2500, + easing: Easing.out(Easing.cubic), + }), + -1, + ) + } + return 0 + }) + + const animatedStyle = useAnimatedStyle(() => { + return { + transform: [{rotateZ: `${rotate.value}deg`}], + } + }) let text = '' @@ -1057,21 +1194,22 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) { break } - // we could use state.jobStatus?.progress but 99% of the time it jumps from 0 to 100 - const progress = - state.status === 'compressing' || state.status === 'uploading' - ? state.progress - : 100 + if (state.error) { + text = _('Error') + wheelProgress = 100 + } return ( - + + + {text} ) diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 3c4aaf73..c477ada0 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -12,12 +12,12 @@ import {Placeholder} from '@tiptap/extension-placeholder' import {Text as TiptapText} from '@tiptap/extension-text' import {generateJSON} from '@tiptap/html' import {EditorContent, JSONContent, useEditor} from '@tiptap/react' -import EventEmitter from 'eventemitter3' import {usePalette} from '#/lib/hooks/usePalette' import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {blobToDataUri, isUriImage} from 'lib/media/util' +import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' import { LinkFacetMatch, suggestLinkCardUri, @@ -46,8 +46,6 @@ interface TextInputProps { onError: (err: string) => void } -export const textInputWebEmitter = new EventEmitter() - export const TextInput = React.forwardRef(function TextInputImpl( { richtext, diff --git a/src/view/com/composer/text-input/textInputWebEmitter.ts b/src/view/com/composer/text-input/textInputWebEmitter.ts new file mode 100644 index 00000000..fb037cac --- /dev/null +++ b/src/view/com/composer/text-input/textInputWebEmitter.ts @@ -0,0 +1,3 @@ +import EventEmitter from 'eventemitter3' + +export const textInputWebEmitter = new EventEmitter() diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx index 1f4178f7..ad3bb30e 100644 --- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx +++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx @@ -7,8 +7,8 @@ import { } from 'react-native' import Picker from '@emoji-mart/react' +import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' import {atoms as a} from '#/alf' -import {textInputWebEmitter} from '../TextInput.web' const HEIGHT_OFFSET = 40 const WIDTH_OFFSET = 100 @@ -26,22 +26,41 @@ export type Emoji = { unified: string } +export interface EmojiPickerPosition { + top: number + left: number + right: number + bottom: number +} + export interface EmojiPickerState { isOpen: boolean - pos: {top: number; left: number; right: number; bottom: number} + pos: EmojiPickerPosition } interface IProps { state: EmojiPickerState close: () => void + /** + * If `true`, overrides position and ensures picker is pinned to the top of + * the target element. + */ + pinToTop?: boolean } -export function EmojiPicker({state, close}: IProps) { +export function EmojiPicker({state, close, pinToTop}: IProps) { const {height, width} = useWindowDimensions() const isShiftDown = React.useRef(false) const position = React.useMemo(() => { + if (pinToTop) { + return { + top: state.pos.top - PICKER_HEIGHT + HEIGHT_OFFSET - 10, + left: state.pos.left, + } + } + const fitsBelow = state.pos.top + PICKER_HEIGHT < height const fitsAbove = PICKER_HEIGHT < state.pos.top const placeOnLeft = PICKER_WIDTH < state.pos.left @@ -64,7 +83,7 @@ export function EmojiPicker({state, close}: IProps) { : undefined, } } - }, [state.pos, height, width]) + }, [state.pos, height, width, pinToTop]) React.useEffect(() => { if (!state.isOpen) return diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx index d8accd06..6e294ba9 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 90a29b25..00908745 100644 --- a/src/view/com/composer/videos/SubtitleDialog.tsx +++ b/src/view/com/composer/videos/SubtitleDialog.tsx @@ -1,5 +1,5 @@ -import React, {useCallback} from 'react' -import {StyleProp, View, ViewStyle} from 'react-native' +import React, {useCallback, useState} from 'react' +import {Keyboard, StyleProp, View, ViewStyle} from 'react-native' import RNPickerSelect from 'react-native-picker-select' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react' import {MAX_ALT_TEXT} from '#/lib/constants' import {useEnforceMaxGraphemeCount} from '#/lib/strings/helpers' import {LANGUAGES} from '#/locale/languages' -import {isWeb} from '#/platform/detection' +import {isAndroid, isWeb} from '#/platform/detection' import {useLanguagePrefs} from '#/state/preferences' import {atoms as a, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -21,9 +21,9 @@ import {Text} from '#/components/Typography' import {SubtitleFilePicker} from './SubtitleFilePicker' interface Props { - altText: string + defaultAltText: string captions: {lang: string; file: File}[] - setAltText: (altText: string) => void + saveAltText: (altText: string) => void setCaptions: React.Dispatch< React.SetStateAction<{lang: string; file: File}[]> > @@ -34,7 +34,7 @@ export function SubtitleDialogBtn(props: Props) { const {_} = useLingui() return ( - + - + @@ -60,8 +65,8 @@ export function SubtitleDialogBtn(props: Props) { } function SubtitleDialogInner({ - altText, - setAltText, + defaultAltText, + saveAltText, captions, setCaptions, }: Props) { @@ -71,6 +76,8 @@ function SubtitleDialogInner({ const enforceLen = useEnforceMaxGraphemeCount() const {primaryLanguage} = useLanguagePrefs() + const [altText, setAltText] = useState(defaultAltText) + const handleSelectFile = useCallback( (file: File) => { setCaptions(subs => [ @@ -102,6 +109,7 @@ function SubtitleDialogInner({ onChangeText={evt => setAltText(enforceLen(evt, MAX_ALT_TEXT))} maxLength={MAX_ALT_TEXT * 10} multiline + style={{maxHeight: 300}} numberOfLines={3} onKeyPress={({nativeEvent}) => { if (nativeEvent.key === 'Escape') { @@ -144,22 +152,26 @@ function SubtitleDialogInner({ /> ))} + {subtitleMissingLanguage && ( + + + Ensure you have selected a language for each subtitle file. + + + )} )} - {subtitleMissingLanguage && ( - - Ensure you have selected a language for each subtitle file. - - )} - - - - - - {isLoading ? ( - <> - - - - - - - ) : data ? ( - data.suggestions - .filter(s => (s.associated?.labeler ? false : true)) - .map(profile => ( - { - logEvent('profile:header:suggestedFollowsCard:press', {}) - }} - style={[a.flex_1]}> - {({hovered, pressed}) => ( - - - - - - - - - - - )} - - )) - ) : ( - - )} - - - - - ) -} diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index c62ac5ed..79dd2f49 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -7,6 +7,7 @@ import {usePalette} from '#/lib/hooks/usePalette' import {useScrollHandlers} from '#/lib/ScrollContext' import {useDedupe} from 'lib/hooks/useDedupe' import {addStyle} from 'lib/styles' +import {isIOS} from 'platform/detection' import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView' import {FlatList_INTERNAL} from './Views' @@ -49,7 +50,7 @@ function ListImpl( ) { const isScrolledDown = useSharedValue(false) const pal = usePalette('default') - const dedupe = useDedupe() + const dedupe = useDedupe(400) function handleScrolledDownChange(didScrollDown: boolean) { onScrolledDownChange?.(didScrollDown) @@ -68,6 +69,7 @@ function ListImpl( onBeginDragFromContext?.(e, ctx) }, onEndDrag(e, ctx) { + runOnJS(updateActiveViewAsync)() onEndDragFromContext?.(e, ctx) }, onScroll(e, ctx) { @@ -81,11 +83,14 @@ function ListImpl( } } - runOnJS(dedupe)(updateActiveViewAsync) + if (isIOS) { + runOnJS(dedupe)(updateActiveViewAsync) + } }, // Note: adding onMomentumBegin here makes simulator scroll // lag on Android. So either don't add it, or figure out why. onMomentumEnd(e, ctx) { + runOnJS(updateActiveViewAsync)() onMomentumEndFromContext?.(e, ctx) }, }) diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index 3163d854..c87ee209 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -9,7 +9,6 @@ import { import EventEmitter from 'eventemitter3' import {ScrollProvider} from '#/lib/ScrollContext' -import {useGate} from '#/lib/statsig/statsig' import {useMinimalShellMode} from '#/state/shell' import {useShellLayout} from '#/state/shell/shell-layout' import {isNative, isWeb} from 'platform/detection' @@ -23,12 +22,10 @@ function clamp(num: number, min: number, max: number) { export function MainScrollProvider({children}: {children: React.ReactNode}) { const {headerHeight} = useShellLayout() - const {headerMode, footerMode} = useMinimalShellMode() + const {headerMode} = useMinimalShellMode() const startDragOffset = useSharedValue(null) const startMode = useSharedValue(null) const didJustRestoreScroll = useSharedValue(false) - const gate = useGate() - const isFixedBottomBar = gate('fixed_bottom_bar') const setMode = React.useCallback( (v: boolean) => { @@ -37,14 +34,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { headerMode.value = withSpring(v ? 1 : 0, { overshootClamping: true, }) - if (!isFixedBottomBar) { - cancelAnimation(footerMode) - footerMode.value = withSpring(v ? 1 : 0, { - overshootClamping: true, - }) - } }, - [headerMode, footerMode, isFixedBottomBar], + [headerMode], ) useEffect(() => { @@ -147,10 +138,6 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { // Cancel any any existing animation cancelAnimation(headerMode) headerMode.value = newValue - if (!isFixedBottomBar) { - cancelAnimation(footerMode) - footerMode.value = newValue - } } } else { if (didJustRestoreScroll.value) { @@ -173,12 +160,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { [ headerHeight, headerMode, - footerMode, setMode, startDragOffset, startMode, didJustRestoreScroll, - isFixedBottomBar, ], ) diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index f7c6bc2c..51e76bdc 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -59,7 +59,6 @@ function Toast({ a.flex_1, t.atoms.bg, a.shadow_lg, - a.rounded_sm, t.atoms.border_contrast_medium, a.rounded_sm, a.px_md, diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx index e6fb0ad4..ee8e1f47 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -79,6 +79,7 @@ const styles = StyleSheet.create({ // @ts-ignore web-only position: isWeb ? 'fixed' : 'absolute', zIndex: 1, + cursor: 'pointer', }, inner: { justifyContent: 'center', diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 61cb6f69..f57ab4e3 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -1,106 +1,224 @@ 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 {isNative} from '#/platform/detection' +import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' +import {atoms as a, useBreakpoints, 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() + const {gtMobile} = useBreakpoints() + /** + * Computed as a % value to apply as `paddingTop` + */ + const outerAspectRatio = React.useMemo(() => { + // capped to square or shorter + const ratio = + isNative || !gtMobile + ? Math.min(1 / aspectRatio, 1.5) + : Math.min(1 / aspectRatio, 1) + return `${ratio * 100}%` + }, [aspectRatio, gtMobile]) + + 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 9bbb2ac1..839674c8 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/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx deleted file mode 100644 index bade2a44..00000000 --- a/src/view/com/util/images/ImageHorzList.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {Image} from 'expo-image' -import {AppBskyEmbedImages} from '@atproto/api' -import {Trans} from '@lingui/macro' - -import {atoms as a} from '#/alf' -import {Text} from '#/components/Typography' - -interface Props { - images: AppBskyEmbedImages.ViewImage[] - style?: StyleProp - gif?: boolean -} - -export function ImageHorzList({images, style, gif}: Props) { - return ( - - {images.map(({thumb, alt}) => ( - - - {gif && ( - - - GIF - - - )} - - ))} - - ) -} - -const styles = StyleSheet.create({ - altContainer: { - backgroundColor: 'rgba(0, 0, 0, 0.75)', - borderRadius: 6, - paddingHorizontal: 6, - paddingVertical: 3, - position: 'absolute', - right: 5, - bottom: 5, - zIndex: 2, - }, - 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 ba6c04f5..45da7f07 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/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index de73103c..1cad5e09 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -244,6 +244,7 @@ let PostCtrls = ({ a.flex_row, a.justify_center, a.align_center, + a.overflow_hidden, {padding: 5}, (pressed || hovered) && t.atoms.bg_contrast_25, ], diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index d924adbe..8c4928df 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/ActiveVideoNativeContext.tsx b/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx index 77616d78..95fa0bb0 100644 --- a/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx +++ b/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx @@ -1,11 +1,12 @@ import React from 'react' import {useVideoPlayer, VideoPlayer} from 'expo-video' -import {isNative} from '#/platform/detection' +import {isAndroid, isNative} from '#/platform/detection' const Context = React.createContext<{ - activeSource: string | null - setActiveSource: (src: string) => void + activeSource: string + activeViewId: string | undefined + setActiveSource: (src: string | null, viewId: string | null) => void player: VideoPlayer } | null>(null) @@ -15,15 +16,39 @@ export function Provider({children}: {children: React.ReactNode}) { } const [activeSource, setActiveSource] = React.useState('') + const [activeViewId, setActiveViewId] = React.useState() const player = useVideoPlayer(activeSource, p => { p.muted = true p.loop = true + // We want to immediately call `play` so we get the loading state p.play() }) + const setActiveSourceOuter = (src: string | null, viewId: string | null) => { + // HACK + // expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually + // unregister that player to let the new screen register it. This is only a problem on Android, so we only need to + // apply it there. + if (src === activeSource && isAndroid) { + setActiveSource('') + setTimeout(() => { + setActiveSource(src ? src : '') + }, 100) + } else { + setActiveSource(src ? src : '') + } + setActiveViewId(viewId ? viewId : '') + } + return ( - + {children} ) diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx index 9fdede87..111867fc 100644 --- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx @@ -17,7 +17,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context' import {WebView} from 'react-native-webview' import {Image} from 'expo-image' import {AppBskyEmbedExternal} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' @@ -29,6 +28,7 @@ import {useExternalEmbedsPrefs} from '#/state/preferences' import {atoms as a} from '#/alf' import {useDialogControl} from '#/components/Dialog' import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent' +import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {EventStopper} from '../EventStopper' interface ShouldStartLoadRequest { @@ -59,7 +59,7 @@ function PlaceholderOverlay({ onPress={onPress} style={[styles.overlayContainer, styles.topRadius]}> {!isPlayerActive ? ( - + ) : ( )} diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx index 31c4419b..5b6e1c9d 100644 --- a/src/view/com/util/post-embeds/GifEmbed.tsx +++ b/src/view/com/util/post-embeds/GifEmbed.tsx @@ -8,7 +8,6 @@ import { ViewStyle, } from 'react-native' import {AppBskyEmbedExternal} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -22,6 +21,7 @@ import {atoms as a, useTheme} from '#/alf' import {Loader} from '#/components/Loader' import * as Prompt from '#/components/Prompt' import {Text} from '#/components/Typography' +import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {GifView} from '../../../../../modules/expo-bluesky-gif-view' import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types' @@ -69,24 +69,7 @@ function PlaybackControls({ ) : !isPlaying ? ( - - - + ) : undefined} ) @@ -155,7 +138,6 @@ export function GifEmbed({ accessibilityHint={_(msg`Animated GIF`)} accessibilityLabel={parsedAlt.alt} /> - {!hideAlt && parsedAlt.isPreferred && } @@ -183,7 +165,6 @@ function AltText({text}: {text: string}) { ALT - Alt Text diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index c61cda68..87a6edeb 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -41,17 +41,20 @@ import {Link} from '../Link' import {PostMeta} from '../PostMeta' import {Text} from '../text/Text' import {PostEmbeds} from '.' +import {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} /> ) } @@ -160,6 +167,7 @@ export function QuoteEmbed({ onOpen?: () => void style?: StyleProp allowNestedQuotes?: boolean + viewContext?: QuoteEmbedViewContext }) { const queryClient = useQueryClient() const pal = usePalette('default') diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx index 55ac1882..3175266e 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -1,42 +1,38 @@ -import React, {useCallback, useState} from 'react' +import React, {useCallback, useEffect, useId, useState} from 'react' import {View} from 'react-native' -import {Image} from 'expo-image' +import {ImageBackground} from 'expo-image' +import {PlayerError, VideoPlayerStatus} from 'expo-video' import {AppBskyEmbedVideo} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {clamp} from '#/lib/numbers' -import {useGate} from '#/lib/statsig/statsig' +import {useAutoplayDisabled} from 'state/preferences' import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a} from '#/alf' import {Button} from '#/components/Button' -import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' +import {Loader} from '#/components/Loader' +import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army' import {ErrorBoundary} from '../ErrorBoundary' import {useActiveVideoNative} from './ActiveVideoNativeContext' import * as VideoFallback from './VideoEmbedInner/VideoFallback' -export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { - const t = useTheme() - const {activeSource, setActiveSource} = useActiveVideoNative() - const isActive = embed.playlist === activeSource - const {_} = useLingui() +interface Props { + embed: AppBskyEmbedVideo.View +} +export function VideoEmbed({embed}: Props) { const [key, setKey] = useState(0) + const renderError = useCallback( (error: unknown) => ( setKey(key + 1)} /> ), [key], ) - const gate = useGate() - - if (!gate('video_view_on_posts')) { - return null - } let aspectRatio = 16 / 9 - if (embed.aspectRatio) { const {width, height} = embed.aspectRatio aspectRatio = width / height @@ -54,41 +50,162 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { a.my_xs, ]}> - { - if (isVisible) { - setActiveSource(embed.playlist) - } - }}> - {isActive ? ( - - ) : ( - <> - {embed.alt} - - - )} - + ) } +function InnerWrapper({embed}: Props) { + const {_} = useLingui() + const {activeSource, activeViewId, setActiveSource, player} = + useActiveVideoNative() + const viewId = useId() + + const [playerStatus, setPlayerStatus] = useState< + VideoPlayerStatus | 'paused' + >('paused') + const [isMuted, setIsMuted] = useState(player.muted) + const [isFullscreen, setIsFullscreen] = React.useState(false) + const [timeRemaining, setTimeRemaining] = React.useState(0) + const disableAutoplay = useAutoplayDisabled() + const isActive = embed.playlist === activeSource && activeViewId === viewId + // There are some different loading states that we should pay attention to and show a spinner for + const isLoading = + isActive && + (playerStatus === 'waitingToPlayAtSpecifiedRate' || + playerStatus === 'loading') + // This happens whenever the visibility view decides that another video should start playing + const showOverlay = !isActive || isLoading || playerStatus === 'paused' + + // send error up to error boundary + const [error, setError] = useState(null) + if (error) { + throw error + } + + useEffect(() => { + if (isActive) { + // eslint-disable-next-line @typescript-eslint/no-shadow + const volumeSub = player.addListener('volumeChange', ({isMuted}) => { + setIsMuted(isMuted) + }) + const timeSub = player.addListener( + 'timeRemainingChange', + secondsRemaining => { + setTimeRemaining(secondsRemaining) + }, + ) + const statusSub = player.addListener( + 'statusChange', + (status, oldStatus, playerError) => { + setPlayerStatus(status) + if (status === 'error') { + setError(playerError ?? new Error('Unknown player error')) + } + if (status === 'readyToPlay' && oldStatus !== 'readyToPlay') { + player.play() + } + }, + ) + return () => { + volumeSub.remove() + timeSub.remove() + statusSub.remove() + } + } + }, [player, isActive, disableAutoplay]) + + // The source might already be active (for example, if you are scrolling a list of quotes and its all the same + // video). In those cases, just start playing. Otherwise, setting the active source will result in the video + // start playback immediately + const startPlaying = (ignoreAutoplayPreference: boolean) => { + if (disableAutoplay && !ignoreAutoplayPreference) { + return + } + + if (isActive) { + player.play() + } else { + setActiveSource(embed.playlist, viewId) + } + } + + const onVisibilityStatusChange = (isVisible: boolean) => { + // When `isFullscreen` is true, it means we're actually still exiting the fullscreen player. Ignore these change + // events + if (isFullscreen) { + return + } + if (isVisible) { + startPlaying(false) + } else { + // Clear the active source so the video view unmounts when autoplay is disabled. Otherwise, leave it mounted + // until it gets replaced by another video + if (disableAutoplay) { + setActiveSource(null, null) + } else { + player.muted = true + if (player.playing) { + player.pause() + } + } + } + } + + return ( + + {isActive ? ( + + ) : null} + + + + + ) +} + function VideoError({retry}: {error: unknown; retry: () => void}) { return ( diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx index 0001a7af..a25f9464 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 b9c0a99a..de9a2c74 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react' +import React, {useCallback, useRef} from 'react' import {Pressable, View} from 'react-native' import Animated, {FadeInDown} from 'react-native-reanimated' import {VideoPlayer, VideoView} from 'expo-video' @@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react' import {HITSLOP_30} from '#/lib/constants' import {clamp} from '#/lib/numbers' +import {isAndroid} from 'platform/detection' 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' @@ -20,13 +21,20 @@ import {TimeIndicator} from './TimeIndicator' export function VideoEmbedInnerNative({ embed, + isFullscreen, + setIsFullscreen, + isMuted, + timeRemaining, }: { embed: AppBskyEmbedVideo.View + isFullscreen: boolean + setIsFullscreen: (isFullscreen: boolean) => void + timeRemaining: number + isMuted: boolean }) { const {_} = useLingui() const {player} = useActiveVideoNative() const ref = useRef(null) - const [isFullscreen, setIsFullscreen] = useState(false) const enterFullscreen = useCallback(() => { ref.current?.enterFullscreen() @@ -46,23 +54,23 @@ export function VideoEmbedInnerNative({ ref={ref} player={player} style={[a.flex_1, a.rounded_sm]} - contentFit="contain" + contentFit="cover" nativeControls={isFullscreen} accessibilityIgnoresInvertColors - onEnterFullscreen={() => { + onFullscreenEnter={() => { PlatformInfo.setAudioCategory(AudioCategory.Playback) PlatformInfo.setAudioActive(true) player.muted = false setIsFullscreen(true) + if (isAndroid) { + player.play() + } }} - onExitFullscreen={() => { + onFullscreenExit={() => { PlatformInfo.setAudioCategory(AudioCategory.Ambient) PlatformInfo.setAudioActive(false) player.muted = true player.playbackRate = 1 - if (!player.playing) { - player.play() - } setIsFullscreen(false) }} accessibilityLabel={ @@ -70,7 +78,12 @@ export function VideoEmbedInnerNative({ } accessibilityHint="" /> - + ) } @@ -78,31 +91,16 @@ export function VideoEmbedInnerNative({ function VideoControls({ player, enterFullscreen, + timeRemaining, + isMuted, }: { player: VideoPlayer enterFullscreen: () => void + timeRemaining: number + isMuted: boolean }) { const {_} = useLingui() const t = useTheme() - const [isMuted, setIsMuted] = useState(player.muted) - const [timeRemaining, setTimeRemaining] = React.useState(0) - - useEffect(() => { - // eslint-disable-next-line @typescript-eslint/no-shadow - const volumeSub = player.addListener('volumeChange', ({isMuted}) => { - setIsMuted(isMuted) - }) - const timeSub = player.addListener( - 'timeRemainingChange', - secondsRemaining => { - setTimeRemaining(secondsRemaining) - }, - ) - return () => { - volumeSub.remove() - timeSub.remove() - } - }, [player]) const onPressFullscreen = useCallback(() => { switch (player.status) { diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 77295c00..a30c0e1e 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 d9b99ef3..82c0ab7a 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 d9e075e7..b4a6cf82 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 00000000..08e90327 --- /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 5cb5c6a3..2992e5c7 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/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index fb487ad6..c790a815 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,24 +1,18 @@ import React from 'react' -import {ActivityIndicator, AppState, StyleSheet, View} from 'react-native' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {PROD_DEFAULT_FEED} from '#/lib/constants' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useSetTitle} from '#/lib/hooks/useSetTitle' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {logEvent, LogEvents} from '#/lib/statsig/statsig' -import {useGate} from '#/lib/statsig/statsig' import {emitSoftReset} from '#/state/events' import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed' import {FeedParams} from '#/state/queries/post-feed' import {usePreferencesQuery} from '#/state/queries/preferences' import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {useSession} from '#/state/session' -import { - useMinimalShellMode, - useSetDrawerSwipeDisabled, - useSetMinimalShellMode, -} from '#/state/shell' +import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' import {useOTAUpdates} from 'lib/hooks/useOTAUpdates' import {useRequestNotificationsPermission} from 'lib/notifications/notifications' @@ -87,7 +81,6 @@ function HomeScreenReady({ const selectedIndex = Math.max(0, maybeFoundIndex) const selectedFeed = allFeeds[selectedIndex] const requestNotificationsPermission = useRequestNotificationsPermission() - const gate = useGate() useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName) useOTAUpdates() @@ -134,29 +127,6 @@ function HomeScreenReady({ }), ) - const {footerMode} = useMinimalShellMode() - const {isMobile} = useWebMediaQueries() - useFocusEffect( - React.useCallback(() => { - if (gate('fixed_bottom_bar')) { - // Unnecessary because it's always there. - return - } - const listener = AppState.addEventListener('change', nextAppState => { - if (nextAppState === 'active') { - if (isMobile && footerMode.value === 1) { - // Reveal the bottom bar so you don't miss notifications or messages. - // TODO: Experiment with only doing it when unread > 0. - setMinimalShellMode(false) - } - } - }) - return () => { - listener.remove() - } - }, [setMinimalShellMode, footerMode, isMobile, gate]), - ) - const onPageSelected = React.useCallback( (index: number) => { setMinimalShellMode(false) diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index 5d80dc42..42696139 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -61,7 +61,7 @@ export function Composer({}: {winHeight: number}) { quoteCount={state?.quoteCount} onPost={state.onPost} mention={state.mention} - openPicker={onOpenPicker} + openEmojiPicker={onOpenPicker} text={state.text} /> diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index d238f4da..bea0c602 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -160,7 +160,6 @@ export function BottomBar({navigation}: BottomTabBarProps) { accessibilityHint="" /> ) : ( diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 7d080e57..aed92cbb 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -11,7 +11,7 @@ import Animated from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import * as NavigationBar from 'expo-navigation-bar' import {StatusBar} from 'expo-status-bar' -import {useNavigationState} from '@react-navigation/native' +import {useNavigation, useNavigationState} from '@react-navigation/native' import {useSession} from '#/state/session' import { @@ -20,6 +20,7 @@ import { useSetDrawerOpen, } from '#/state/shell' import {useCloseAnyActiveElement} from '#/state/util' +import {useDedupe} from 'lib/hooks/useDedupe' import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler' import {usePalette} from 'lib/hooks/usePalette' import {useNotificationsRegistration} from 'lib/notifications/notifications' @@ -33,6 +34,7 @@ import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {MutedWordsDialog} from '#/components/dialogs/MutedWords' import {SigninDialog} from '#/components/dialogs/Signin' import {Outlet as PortalOutlet} from '#/components/Portal' +import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView' import {RoutesContainer, TabsNavigator} from '../../Navigation' import {Composer} from './Composer' import {DrawerContent} from './Drawer' @@ -76,6 +78,27 @@ function ShellInner() { } }, [closeAnyActiveElement]) + // HACK + // expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually + // unregister that player to let the new screen register it. This is only a problem on Android, so we only need to + // apply it there. + // The `state` event should only fire whenever we push or pop to a screen, and should not fire consecutively quickly. + // To be certain though, we will also dedupe these calls. + const navigation = useNavigation() + const dedupe = useDedupe(1000) + React.useEffect(() => { + if (!isAndroid) return + const onFocusOrBlur = () => { + setTimeout(() => { + dedupe(updateActiveViewAsync) + }, 500) + } + navigation.addListener('state', onFocusOrBlur) + return () => { + navigation.removeListener('state', onFocusOrBlur) + } + }, [dedupe, navigation]) + return ( <>