diff --git a/__e2e__/flows/feed-reorder.yml b/__e2e__/flows/feed-reorder.yml index 2b892b53..449df065 100644 --- a/__e2e__/flows/feed-reorder.yml +++ b/__e2e__/flows/feed-reorder.yml @@ -10,13 +10,8 @@ appId: xyz.blueskyweb.app id: "e2eSignInAlice" # Pin alice's feed -- extendedWaitUntil: - visible: - id: "viewHeaderDrawerBtn" - tapOn: - id: "viewHeaderDrawerBtn" -- tapOn: - id: "profileCardButton" + id: "bottomBarProfileBtn" - swipe: from: id: "profilePager-selector" diff --git a/__e2e__/flows/home-screen.yml b/__e2e__/flows/home-screen.yml index 799a2021..c8d83fb1 100644 --- a/__e2e__/flows/home-screen.yml +++ b/__e2e__/flows/home-screen.yml @@ -9,9 +9,6 @@ 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 ✨" @@ -37,16 +34,26 @@ 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 251eca35..288a5d4f 100644 --- a/__e2e__/flows/profile-screen-edit.yml +++ b/__e2e__/flows/profile-screen-edit.yml @@ -11,9 +11,6 @@ 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 b9f95aca..7d2d43de 100644 --- a/__e2e__/flows/profile-screen.yml +++ b/__e2e__/flows/profile-screen.yml @@ -10,9 +10,6 @@ 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 9120f4f6..fdc73259 100644 --- a/__e2e__/flows/thread-screen.yml +++ b/__e2e__/flows/thread-screen.yml @@ -11,8 +11,6 @@ appId: xyz.blueskyweb.app # Navigate to thread -- extendedWaitUntil: - visible: "Thread root" - tapOn: "Thread root" - assertVisible: "Thread reply" @@ -35,10 +33,18 @@ 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 25014ee8..cd8a4b03 100644 --- a/app.config.js +++ b/app.config.js @@ -191,7 +191,7 @@ module.exports = function (config) { 'expo-build-properties', { ios: { - deploymentTarget: '15.1', + deploymentTarget: '14.0', newArchEnabled: false, }, android: { diff --git a/assets/icons/crop_stroke2_corner0_rounded.svg b/assets/icons/crop_stroke2_corner0_rounded.svg deleted file mode 100644 index 118d148f..00000000 --- a/assets/icons/crop_stroke2_corner0_rounded.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/bskyembed/assets/play_filled_corner2_rounded.svg b/bskyembed/assets/play_filled_corner2_rounded.svg deleted file mode 100644 index 48da4add..00000000 --- a/bskyembed/assets/play_filled_corner2_rounded.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/bskyembed/package.json b/bskyembed/package.json index e269b1c9..72d2b6df 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.6", + "@atproto/api": "0.13.1", "@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 3b4f5e77..600c7c2c 100644 --- a/bskyembed/src/components/embed.tsx +++ b/bskyembed/src/components/embed.tsx @@ -3,7 +3,6 @@ import { AppBskyEmbedImages, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, - AppBskyEmbedVideo, AppBskyFeedDefs, AppBskyFeedPost, AppBskyGraphDefs, @@ -15,7 +14,6 @@ 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' @@ -162,12 +160,7 @@ export function Embed({ return null } - // Case 4: Video - if (AppBskyEmbedVideo.isView(content)) { - return - } - - // Case 5: Record with media + // Case 4: Record with media if ( AppBskyEmbedRecordWithMedia.isView(content) && AppBskyEmbedRecord.isViewRecord(content.record.record) @@ -361,31 +354,6 @@ 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, }: { @@ -442,7 +410,3 @@ 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 46c8519b..3c5ef5ae 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.6": - version "0.13.6" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.6.tgz#2500e9d7143e6718089632300c42ce50149f8cd5" - integrity sha512-58emFFZhqY8nVWD3xFWK0yYqAmJ2un+NaTtZxBbRo00mGq1rz9VXTpVmfoHFcuXL1hoDQN3WyJfsub8r6xGOgg== +"@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== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/lexicon" "^0.4.1" "@atproto/syntax" "^0.3.0" - "@atproto/xrpc" "^0.6.1" + "@atproto/xrpc" "^0.6.0" 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.1": - version "0.6.1" - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5" - integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A== +"@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== 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 3f46c4b0..d9235afd 100644 --- a/bskyweb/cmd/bskyweb/main.go +++ b/bskyweb/cmd/bskyweb/main.go @@ -60,12 +60,6 @@ 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 afd9247a..203ed62f 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -1,17 +1,12 @@ 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" @@ -46,7 +41,6 @@ type Config struct { appviewHost string ogcardHost string linkHost string - ipccHost string } func serve(cctx *cli.Context) error { @@ -55,7 +49,6 @@ 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 @@ -98,7 +91,6 @@ func serve(cctx *cli.Context) error { appviewHost: appviewHost, ogcardHost: ogcardHost, linkHost: linkHost, - ipccHost: ipccHost, }, } @@ -269,9 +261,6 @@ 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 { @@ -531,61 +520,3 @@ 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 796969ab..14a94b5d 100644 --- a/lingui.config.js +++ b/lingui.config.js @@ -14,7 +14,6 @@ module.exports = { 'ja', 'ko', 'pt-BR', - 'ru', 'tr', 'uk', 'zh-CN', diff --git a/package.json b/package.json index eff665a6..eaa03829 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": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz", + "expo-video": "^1.2.4", "expo-web-browser": "~13.0.3", "fast-text-encoding": "^1.0.6", "history": "^5.3.0", @@ -180,7 +180,6 @@ "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 new file mode 100644 index 00000000..13fe25ed --- /dev/null +++ b/patches/expo-video+1.2.4.patch @@ -0,0 +1,557 @@ +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 new file mode 100644 index 00000000..99c14c28 --- /dev/null +++ b/patches/expo-video+1.2.4.patch.md @@ -0,0 +1,18 @@ +## 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 c14d2729..61d5f81b 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 = '15.1' + buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0' buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon' } } diff --git a/src/App.native.tsx b/src/App.native.tsx index 780d4058..609d316d 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -58,7 +58,6 @@ 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' @@ -106,50 +105,52 @@ function InnerApp() { }, [_]) return ( - - - - - - - - - - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } @@ -183,12 +184,7 @@ function App() { - - - - - + diff --git a/src/App.web.tsx b/src/App.web.tsx index 3017a3a2..8531dc88 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -47,7 +47,6 @@ 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' @@ -163,9 +162,7 @@ function App() { - - - + diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 2beba4f9..0bf0e9f9 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -661,15 +661,16 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { linking={LINKING} theme={theme} onStateChange={() => { - const routeName = getCurrentRouteName() - if (routeName === 'Notifications') { - logEvent('router:navigate:notifications:sampled', {}) - } + logEvent('router:navigate:sampled', { + from: prevLoggedRouteName.current, + }) + prevLoggedRouteName.current = getCurrentRouteName() }} onReady={() => { attachRouteToLogEvents(getCurrentRouteName) logModuleInitTime() onReady() + logEvent('router:navigate:sampled', {}) }}> {children} diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 5031f584..65e981f7 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -8,6 +8,7 @@ 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' @@ -176,9 +177,14 @@ function useExperimentalSuggestedUsersQuery() { } export function SuggestedFollows({feed}: {feed: FeedDescriptor}) { + const gate = useGate() const [feedType, feedUri] = feed.split('|') if (feedType === 'author') { - return + if (gate('show_follow_suggestions_in_profile')) { + return + } else { + return null + } } else { return } diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx deleted file mode 100644 index 7d7cb2b4..00000000 --- a/src/components/MediaPreview.tsx +++ /dev/null @@ -1,172 +0,0 @@ -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 6f6d6804..a263d194 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:sampled']['logContext'] & - LogEvents['profile:unfollow:sampled']['logContext'] + logContext: LogEvents['profile:follow']['logContext'] & + LogEvents['profile:unfollow']['logContext'] } & Partial export function FollowButton(props: FollowButtonProps) { diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 86cb5c31..315ad0df 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -8,10 +8,7 @@ import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {Text} from '#/components/Typography' -export { - type DialogControlProps as PromptControlProps, - useDialogControl as usePromptControl, -} from '#/components/Dialog' +export {useDialogControl as usePromptControl} from '#/components/Dialog' const Context = React.createContext<{ titleId: string @@ -26,7 +23,7 @@ export function Outer({ control, testID, }: React.PropsWithChildren<{ - control: Dialog.DialogControlProps + control: Dialog.DialogOuterProps['control'] testID?: string }>) { const {gtMobile} = useBreakpoints() diff --git a/src/components/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx index a5c7cd1b..6174bff0 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(300) + const bottomBarOffset = useBottomBarOffset(200) 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 3db00aec..aefd62b9 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, PostEmbedViewContext} from '#/view/com/util/post-embeds' +import {PostEmbeds} from '#/view/com/util/post-embeds' import {atoms as a, native, useTheme} from '#/alf' let MessageItemEmbed = ({ @@ -14,11 +14,7 @@ let MessageItemEmbed = ({ return ( - + ) } diff --git a/src/components/hooks/dates.ts b/src/components/hooks/dates.ts index 00b70f76..b0f94133 100644 --- a/src/components/hooks/dates.ts +++ b/src/components/hooks/dates.ts @@ -21,7 +21,6 @@ import { ja, ko, ptBR, - ru, tr, uk, zhCN, @@ -48,7 +47,6 @@ 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 31a1e43d..d67c3690 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:sampled']['logContext'] & - LogEvents['profile:unfollow:sampled']['logContext'] + logContext: LogEvents['profile:follow']['logContext'] & + LogEvents['profile:unfollow']['logContext'] }) { const {_} = useLingui() const requireAuth = useRequireAuth() diff --git a/src/components/icons/Crop.tsx b/src/components/icons/Crop.tsx deleted file mode 100644 index 4b3fc560..00000000 --- a/src/components/icons/Crop.tsx +++ /dev/null @@ -1,5 +0,0 @@ -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 e83f96f0..387115d3 100644 --- a/src/components/icons/common.tsx +++ b/src/components/icons/common.tsx @@ -19,7 +19,6 @@ 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 deleted file mode 100644 index 24485037..00000000 --- a/src/components/intents/IntentDialogs.tsx +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 4dca8bd9..00000000 --- a/src/components/intents/VerifyEmailIntentDialog.tsx +++ /dev/null @@ -1,140 +0,0 @@ -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 33ede3ed..95b5853c 100644 --- a/src/components/moderation/LabelsOnMe.tsx +++ b/src/components/moderation/LabelsOnMe.tsx @@ -14,18 +14,19 @@ import { } from '#/components/moderation/LabelsOnMeDialog' export function LabelsOnMe({ - type, + details, labels, size, style, }: { - type: 'account' | 'content' + details: {did: string} | {uri: string; cid: string} labels: ComAtprotoLabelDefs.Label[] | undefined size?: ButtonSize style?: StyleProp }) { const {_} = useLingui() const {currentAccount} = useSession() + const isAccount = 'did' in details const control = useLabelsOnMeDialogControl() if (!labels || !currentAccount) { @@ -38,7 +39,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 3034f029..c0e78e97 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -29,10 +29,6 @@ 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' @@ -101,12 +97,6 @@ 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. @@ -432,22 +422,13 @@ export function MessagesList({ setEmojiPickerState({isOpen: true, pos})}> + setEmbed={setEmbed}> )} - {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 ded473ff..0108a537 100644 --- a/src/screens/Onboarding/StepInterests/index.tsx +++ b/src/screens/Onboarding/StepInterests/index.tsx @@ -6,8 +6,10 @@ 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 { @@ -27,16 +29,23 @@ 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) @@ -134,6 +143,12 @@ 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. ) : ( @@ -171,8 +186,13 @@ export function StepInterests() { {title} {description} + {isMinimumInterestsEnabled && ( + + Choose 3 or more: + + )} - + {isLoading ? ( ) : isError || !data ? ( @@ -248,7 +268,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 cf5fcb97..2036023c 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -6,9 +6,11 @@ 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' @@ -21,9 +23,10 @@ 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} from '#/alf' +import {atoms as a, useTheme} 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' @@ -56,6 +59,8 @@ 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() @@ -64,6 +69,7 @@ let ProfileHeaderStandard = ({ () => moderateProfile(profile, moderationOpts), [profile, moderationOpts], ) + const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( profile, 'ProfileHeader', @@ -196,7 +202,34 @@ let ProfileHeaderStandard = ({ ) ) : !profile.viewer?.blockedBy ? ( <> - {hasSession && } + {hasSession && ( + <> + + {!gate('show_follow_suggestions_in_profile') && ( + + )} + + )} + + + )} )} - - {hasVideo && ( - - {videoUploadState.asset && - (videoUploadState.status === 'compressing' ? ( - - ) : videoUploadState.video ? ( - - ) : null)} - - - )} - - + + {quote ? ( @@ -772,6 +715,29 @@ export const ComposePost = observer(function ComposePost({ )} ) : null} + {videoUploadState.asset && + (videoUploadState.status === 'compressing' ? ( + + ) : videoUploadState.video ? ( + + ) : null)} + {(videoUploadState.asset || videoUploadState.video) && ( + + )} @@ -992,10 +958,6 @@ const styles = StyleSheet.create({ paddingVertical: 6, marginLeft: 12, }, - postBtnWrapper: { - flexDirection: 'row', - gap: 14, - }, errorLine: { flexDirection: 'row', alignItems: 'center', @@ -1056,80 +1018,6 @@ 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, @@ -1151,31 +1039,6 @@ 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 = '' @@ -1194,22 +1057,21 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) { break } - if (state.error) { - text = _('Error') - wheelProgress = 100 - } + // 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 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 c477ada0..3c4aaf73 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,6 +46,8 @@ 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 deleted file mode 100644 index fb037cac..00000000 --- a/src/view/com/composer/text-input/textInputWebEmitter.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 ad3bb30e..1f4178f7 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,41 +26,22 @@ export type Emoji = { unified: string } -export interface EmojiPickerPosition { - top: number - left: number - right: number - bottom: number -} - export interface EmojiPickerState { isOpen: boolean - pos: EmojiPickerPosition + pos: {top: number; left: number; right: number; bottom: number} } 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, pinToTop}: IProps) { +export function EmojiPicker({state, close}: 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 @@ -83,7 +64,7 @@ export function EmojiPicker({state, close, pinToTop}: IProps) { : undefined, } } - }, [state.pos, height, width, pinToTop]) + }, [state.pos, height, width]) 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 6e294ba9..d8accd06 100644 --- a/src/view/com/composer/videos/SelectVideoBtn.tsx +++ b/src/view/com/composer/videos/SelectVideoBtn.tsx @@ -1,5 +1,4 @@ import React, {useCallback} from 'react' -import {Keyboard} from 'react-native' import { ImagePickerAsset, launchImageLibraryAsync, @@ -11,14 +10,11 @@ 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 = 60 +const VIDEO_MAX_DURATION = 90 type Props = { onSelectVideo: (video: ImagePickerAsset) => void @@ -30,47 +26,33 @@ 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 } - 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`)) - } + 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, - _, - control, - currentAccount?.emailConfirmed, - ]) + }, [onSelectVideo, requestVideoAccessIfNeeded, setError, _]) return ( <> @@ -89,32 +71,6 @@ 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 00908745..90a29b25 100644 --- a/src/view/com/composer/videos/SubtitleDialog.tsx +++ b/src/view/com/composer/videos/SubtitleDialog.tsx @@ -1,5 +1,5 @@ -import React, {useCallback, useState} from 'react' -import {Keyboard, StyleProp, View, ViewStyle} from 'react-native' +import React, {useCallback} from 'react' +import {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 {isAndroid, isWeb} from '#/platform/detection' +import {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 { - defaultAltText: string + altText: string captions: {lang: string; file: File}[] - saveAltText: (altText: string) => void + setAltText: (altText: string) => void setCaptions: React.Dispatch< React.SetStateAction<{lang: string; file: File}[]> > @@ -34,7 +34,7 @@ export function SubtitleDialogBtn(props: Props) { const {_} = useLingui() return ( - + - + @@ -65,8 +60,8 @@ export function SubtitleDialogBtn(props: Props) { } function SubtitleDialogInner({ - defaultAltText, - saveAltText, + altText, + setAltText, captions, setCaptions, }: Props) { @@ -76,8 +71,6 @@ function SubtitleDialogInner({ const enforceLen = useEnforceMaxGraphemeCount() const {primaryLanguage} = useLanguagePrefs() - const [altText, setAltText] = useState(defaultAltText) - const handleSelectFile = useCallback( (file: File) => { setCaptions(subs => [ @@ -109,7 +102,6 @@ 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') { @@ -152,26 +144,22 @@ 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 79dd2f49..c62ac5ed 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -7,7 +7,6 @@ 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' @@ -50,7 +49,7 @@ function ListImpl( ) { const isScrolledDown = useSharedValue(false) const pal = usePalette('default') - const dedupe = useDedupe(400) + const dedupe = useDedupe() function handleScrolledDownChange(didScrollDown: boolean) { onScrolledDownChange?.(didScrollDown) @@ -69,7 +68,6 @@ function ListImpl( onBeginDragFromContext?.(e, ctx) }, onEndDrag(e, ctx) { - runOnJS(updateActiveViewAsync)() onEndDragFromContext?.(e, ctx) }, onScroll(e, ctx) { @@ -83,14 +81,11 @@ function ListImpl( } } - if (isIOS) { - runOnJS(dedupe)(updateActiveViewAsync) - } + 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 c87ee209..3163d854 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -9,6 +9,7 @@ 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' @@ -22,10 +23,12 @@ function clamp(num: number, min: number, max: number) { export function MainScrollProvider({children}: {children: React.ReactNode}) { const {headerHeight} = useShellLayout() - const {headerMode} = useMinimalShellMode() + const {headerMode, footerMode} = 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) => { @@ -34,8 +37,14 @@ 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], + [headerMode, footerMode, isFixedBottomBar], ) useEffect(() => { @@ -138,6 +147,10 @@ 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) { @@ -160,10 +173,12 @@ 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 51e76bdc..f7c6bc2c 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -59,6 +59,7 @@ 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 ee8e1f47..e6fb0ad4 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -79,7 +79,6 @@ 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 f57ab4e3..61cb6f69 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -1,224 +1,106 @@ import React from 'react' -import {DimensionValue, Pressable, View} from 'react-native' +import {StyleProp, StyleSheet, Pressable, View, ViewStyle} from 'react-native' import {Image} from 'expo-image' -import {AppBskyEmbedImages} from '@atproto/api' +import {clamp} from 'lib/numbers' +import {Dimensions} from 'lib/media/types' +import * as imageSizes from 'lib/media/image-sizes' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -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' +const MIN_ASPECT_RATIO = 0.33 // 1/3 +const MAX_ASPECT_RATIO = 10 // 10/1 -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({ - image, - crop = 'constrained', - hideBadge, - onPress, - onLongPress, - onPressIn, -}: { - image: AppBskyEmbedImages.ViewImage - crop?: 'none' | 'square' | 'constrained' - hideBadge?: boolean +interface Props { + alt?: string + uri: string + dimensionsHint?: Dimensions onPress?: () => void onLongPress?: () => void onPressIn?: () => void -}) { - const t = useTheme() + style?: StyleProp + children?: React.ReactNode +} + +export function AutoSizedImage({ + alt, + uri, + dimensionsHint, + onPress, + onLongPress, + onPressIn, + style, + children = null, +}: Props) { const {_} = useLingui() - 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 - - const contents = ( - <> - - - {(hasAlt || isCropped) && !hideBadge ? ( - - {isCropped && ( - - )} - {hasAlt && ( - - ALT - - )} - - ) : null} - + 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]) - if (cropDisabled) { + if (onPress || onLongPress || onPressIn) { 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 - {contents} + style={[styles.container, style]}> + + {children} ) - } else { - return ( - - - {contents} - - - ) } + + return ( + + + {children} + + ) } function calc(dim: Dimensions) { if (dim.width === 0 || dim.height === 0) { return 1 } - return dim.width / dim.height + return clamp(dim.width / dim.height, MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) } + +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 839674c8..9bbb2ac1 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -1,14 +1,13 @@ import React, {ComponentProps, FC} from 'react' -import {Pressable, View} from 'react-native' +import {Pressable, StyleSheet, Text, 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 {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' -import {atoms as a, useTheme} from '#/alf' -import {Text} from '#/components/Typography' +import {atoms as a} from '#/alf' type EventFunction = (index: number) => void @@ -18,8 +17,7 @@ interface GalleryItemProps { onPress?: EventFunction onLongPress?: EventFunction onPressIn?: EventFunction - imageStyle?: ComponentProps['style'] - viewContext?: PostEmbedViewContext + imageStyle: ComponentProps['style'] } export const GalleryItem: FC = ({ @@ -29,69 +27,57 @@ 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, - a.rounded_xs, - a.overflow_hidden, - t.atoms.bg_contrast_25, - imageStyle, - ]} + style={a.flex_1} accessibilityRole="button" accessibilityLabel={image.alt || _(msg`Image`)} accessibilityHint=""> - {hasAlt && !hideBadges ? ( - + {image.alt === '' ? null : ( + + style={[styles.alt, largeAltBadge && a.text_xs]} + accessible={false}> 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 new file mode 100644 index 00000000..bade2a44 --- /dev/null +++ b/src/view/com/util/images/ImageHorzList.tsx @@ -0,0 +1,61 @@ +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 45da7f07..ba6c04f5 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -1,10 +1,8 @@ import React from 'react' -import {StyleProp, View, ViewStyle} from 'react-native' +import {StyleProp, StyleSheet, 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[] @@ -12,25 +10,13 @@ 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 ( - - + + ) @@ -41,39 +27,36 @@ 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 ( - - - + + + - - - + + + - - + + @@ -82,20 +65,20 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { case 4: return ( <> - - - + + + - - + + - - - + + + - - + + @@ -105,3 +88,39 @@ 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 1cad5e09..de73103c 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -244,7 +244,6 @@ 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 8c4928df..d924adbe 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -53,9 +53,6 @@ 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 95fa0bb0..77616d78 100644 --- a/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx +++ b/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx @@ -1,12 +1,11 @@ import React from 'react' import {useVideoPlayer, VideoPlayer} from 'expo-video' -import {isAndroid, isNative} from '#/platform/detection' +import {isNative} from '#/platform/detection' const Context = React.createContext<{ - activeSource: string - activeViewId: string | undefined - setActiveSource: (src: string | null, viewId: string | null) => void + activeSource: string | null + setActiveSource: (src: string) => void player: VideoPlayer } | null>(null) @@ -16,39 +15,15 @@ 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 111867fc..9fdede87 100644 --- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx @@ -17,6 +17,7 @@ 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' @@ -28,7 +29,6 @@ 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 5b6e1c9d..31c4419b 100644 --- a/src/view/com/util/post-embeds/GifEmbed.tsx +++ b/src/view/com/util/post-embeds/GifEmbed.tsx @@ -8,6 +8,7 @@ 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' @@ -21,7 +22,6 @@ 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,7 +69,24 @@ function PlaybackControls({ ) : !isPlaying ? ( - + + + ) : undefined} ) @@ -138,6 +155,7 @@ export function GifEmbed({ accessibilityHint={_(msg`Animated GIF`)} accessibilityLabel={parsedAlt.alt} /> + {!hideAlt && parsedAlt.isPreferred && } @@ -165,6 +183,7 @@ 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 87a6edeb..c61cda68 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -41,20 +41,17 @@ 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() @@ -70,7 +67,6 @@ export function MaybeQuoteEmbed({ onOpen={onOpen} style={style} allowNestedQuotes={allowNestedQuotes} - viewContext={viewContext} /> ) } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) { @@ -117,14 +113,12 @@ 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(() => { @@ -150,7 +144,6 @@ function QuoteEmbedModerated({ onOpen={onOpen} style={style} allowNestedQuotes={allowNestedQuotes} - viewContext={viewContext} /> ) } @@ -167,7 +160,6 @@ 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 3175266e..55ac1882 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -1,38 +1,42 @@ -import React, {useCallback, useEffect, useId, useState} from 'react' +import React, {useCallback, useState} from 'react' import {View} from 'react-native' -import {ImageBackground} from 'expo-image' -import {PlayerError, VideoPlayerStatus} from 'expo-video' +import {Image} from 'expo-image' import {AppBskyEmbedVideo} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {clamp} from '#/lib/numbers' -import {useAutoplayDisabled} from 'state/preferences' +import {useGate} from '#/lib/statsig/statsig' import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' -import {Loader} from '#/components/Loader' -import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' +import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army' import {ErrorBoundary} from '../ErrorBoundary' import {useActiveVideoNative} from './ActiveVideoNativeContext' import * as VideoFallback from './VideoEmbedInner/VideoFallback' -interface Props { - embed: AppBskyEmbedVideo.View -} +export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { + const t = useTheme() + const {activeSource, setActiveSource} = useActiveVideoNative() + const isActive = embed.playlist === activeSource + const {_} = useLingui() -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 @@ -50,162 +54,41 @@ export function VideoEmbed({embed}: Props) { 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 a25f9464..0001a7af 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx @@ -1,15 +1,13 @@ import React, {useCallback, useEffect, useRef, useState} from 'react' import {View} from 'react-native' import {AppBskyEmbedVideo} from '@atproto/api' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' +import {Trans} from '@lingui/macro' 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' @@ -154,26 +152,23 @@ function ViewportObserver({ } function VideoError({error, retry}: {error: unknown; retry: () => void}) { - 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.`) - } + const isHLS = error instanceof HLSUnsupportedError return ( - {text} - {showRetryButton && } + + {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 && } ) } diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx index de9a2c74..b9c0a99a 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, useRef} from 'react' +import React, {useCallback, useEffect, useRef, useState} from 'react' import {Pressable, View} from 'react-native' import Animated, {FadeInDown} from 'react-native-reanimated' import {VideoPlayer, VideoView} from 'expo-video' @@ -8,7 +8,6 @@ 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' @@ -21,20 +20,13 @@ 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() @@ -54,23 +46,23 @@ export function VideoEmbedInnerNative({ ref={ref} player={player} style={[a.flex_1, a.rounded_sm]} - contentFit="cover" + contentFit="contain" nativeControls={isFullscreen} accessibilityIgnoresInvertColors - onFullscreenEnter={() => { + onEnterFullscreen={() => { PlatformInfo.setAudioCategory(AudioCategory.Playback) PlatformInfo.setAudioActive(true) player.muted = false setIsFullscreen(true) - if (isAndroid) { - player.play() - } }} - onFullscreenExit={() => { + onExitFullscreen={() => { PlatformInfo.setAudioCategory(AudioCategory.Ambient) PlatformInfo.setAudioActive(false) player.muted = true player.playbackRate = 1 + if (!player.playing) { + player.play() + } setIsFullscreen(false) }} accessibilityLabel={ @@ -78,12 +70,7 @@ export function VideoEmbedInnerNative({ } accessibilityHint="" /> - + ) } @@ -91,16 +78,31 @@ 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 a30c0e1e..77295c00 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -23,12 +23,6 @@ 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(() => { @@ -44,25 +38,12 @@ 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() @@ -123,9 +104,3 @@ 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 82c0ab7a..d9b99ef3 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) { + if (active && !autoplayDisabled) { if (onScreen) { - if (!autoplayDisabled) play() + play() } else { pause() } @@ -151,11 +151,10 @@ export function Controls({ const onPressEmptySpace = useCallback(() => { if (!focused) { drawFocus() - if (autoplayDisabled) play() } else { togglePlayPause() } - }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play]) + }, [togglePlayPause, drawFocus, focused]) const onPressPlayPause = useCallback(() => { drawFocus() @@ -241,8 +240,7 @@ export function Controls({ }, []) const showControls = - ((focused || autoplayDisabled) && !playing) || - (interactingViaKeypress ? hasFocus : hovered) + (focused && !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 b4a6cf82..d9e075e7 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -3,6 +3,7 @@ import { InteractionManager, StyleProp, StyleSheet, + Text, View, ViewStyle, } from 'react-native' @@ -21,6 +22,7 @@ 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' @@ -32,11 +34,8 @@ 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 @@ -51,16 +50,15 @@ 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 // = @@ -71,17 +69,8 @@ export function PostEmbeds({ embed={embed.media} moderation={moderation} onOpen={onOpen} - viewContext={viewContext} - /> - + ) } @@ -135,26 +124,27 @@ export function PostEmbeds({ } if (images.length === 1) { - const image = images[0] + const {alt, thumb, aspectRatio} = images[0] return ( _openLightbox(0)} onPressIn={() => onPressIn(0)} - hideBadge={ - viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia - } - /> + style={a.rounded_sm}> + {alt === '' ? null : ( + + + ALT + + + )} + ) @@ -167,7 +157,6 @@ 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 deleted file mode 100644 index 08e90327..00000000 --- a/src/view/com/util/post-embeds/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 2992e5c7..5cb5c6a3 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 c790a815..fb487ad6 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,18 +1,24 @@ import React from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' +import {ActivityIndicator, AppState, 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 {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' +import { + useMinimalShellMode, + 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' @@ -81,6 +87,7 @@ function HomeScreenReady({ const selectedIndex = Math.max(0, maybeFoundIndex) const selectedFeed = allFeeds[selectedIndex] const requestNotificationsPermission = useRequestNotificationsPermission() + const gate = useGate() useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName) useOTAUpdates() @@ -127,6 +134,29 @@ 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 42696139..5d80dc42 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} - openEmojiPicker={onOpenPicker} + openPicker={onOpenPicker} text={state.text} /> diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index bea0c602..d238f4da 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -160,6 +160,7 @@ export function BottomBar({navigation}: BottomTabBarProps) { accessibilityHint="" /> ) : ( diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index aed92cbb..7d080e57 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 {useNavigation, useNavigationState} from '@react-navigation/native' +import {useNavigationState} from '@react-navigation/native' import {useSession} from '#/state/session' import { @@ -20,7 +20,6 @@ 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' @@ -34,7 +33,6 @@ 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' @@ -78,27 +76,6 @@ 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 ( <>