diff --git a/__e2e__/flows/feed-reorder.yml b/__e2e__/flows/feed-reorder.yml
index 449df065..2b892b53 100644
--- a/__e2e__/flows/feed-reorder.yml
+++ b/__e2e__/flows/feed-reorder.yml
@@ -10,8 +10,13 @@ appId: xyz.blueskyweb.app
id: "e2eSignInAlice"
# Pin alice's feed
+- extendedWaitUntil:
+ visible:
+ id: "viewHeaderDrawerBtn"
- tapOn:
- id: "bottomBarProfileBtn"
+ id: "viewHeaderDrawerBtn"
+- tapOn:
+ id: "profileCardButton"
- swipe:
from:
id: "profilePager-selector"
diff --git a/__e2e__/flows/home-screen.yml b/__e2e__/flows/home-screen.yml
index c8d83fb1..799a2021 100644
--- a/__e2e__/flows/home-screen.yml
+++ b/__e2e__/flows/home-screen.yml
@@ -9,6 +9,9 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "e2eSignInAlice"
+- extendedWaitUntil:
+ visible:
+ text: "Feeds ✨"
- tapOn:
label: "Can go to feeds page using feeds button in tab bar"
text: "Feeds ✨"
@@ -34,26 +37,16 @@ appId: xyz.blueskyweb.app
- tapOn:
label: "Can like posts"
id: "likeBtn"
-- assertVisible:
- id: "likeCount"
- text: "1"
- tapOn:
id: "likeBtn"
-- assertNotVisible:
- id: "likeCount"
- tapOn:
label: "Can repost posts"
id: "repostBtn"
- tapOn: "Repost"
-- assertVisible:
- id: "repostCount"
- text: "1"
- tapOn:
id: "repostBtn"
- tapOn: "Remove repost"
-- assertNotVisible:
- id: "repostCount"
- tapOn:
label: "Can delete posts"
diff --git a/__e2e__/flows/profile-screen-edit.yml b/__e2e__/flows/profile-screen-edit.yml
index 288a5d4f..251eca35 100644
--- a/__e2e__/flows/profile-screen-edit.yml
+++ b/__e2e__/flows/profile-screen-edit.yml
@@ -11,6 +11,9 @@ appId: xyz.blueskyweb.app
# Navigate to my profile
+- extendedWaitUntil:
+ visible:
+ id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarProfileBtn"
diff --git a/__e2e__/flows/profile-screen.yml b/__e2e__/flows/profile-screen.yml
index 7d2d43de..b9f95aca 100644
--- a/__e2e__/flows/profile-screen.yml
+++ b/__e2e__/flows/profile-screen.yml
@@ -10,6 +10,9 @@ appId: xyz.blueskyweb.app
id: "e2eSignInAlice"
# Navigate to another user profile
+- extendedWaitUntil:
+ visible:
+ id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarSearchBtn"
- tapOn:
diff --git a/__e2e__/flows/thread-screen.yml b/__e2e__/flows/thread-screen.yml
index fdc73259..9120f4f6 100644
--- a/__e2e__/flows/thread-screen.yml
+++ b/__e2e__/flows/thread-screen.yml
@@ -11,6 +11,8 @@ appId: xyz.blueskyweb.app
# Navigate to thread
+- extendedWaitUntil:
+ visible: "Thread root"
- tapOn: "Thread root"
- assertVisible: "Thread reply"
@@ -33,18 +35,10 @@ appId: xyz.blueskyweb.app
id: "likeBtn"
childOf:
id: "postThreadItem-by-carla.test"
-- assertVisible:
- id: "likeCount"
- childOf:
- id: "postThreadItem-by-carla.test"
- tapOn:
id: "likeBtn"
childOf:
id: "postThreadItem-by-carla.test"
-- assertNotVisible:
- id: "likeCount"
- childOf:
- id: "postThreadItem-by-carla.test"
# Can repost the root post
- tapOn:
diff --git a/app.config.js b/app.config.js
index cd8a4b03..25014ee8 100644
--- a/app.config.js
+++ b/app.config.js
@@ -191,7 +191,7 @@ module.exports = function (config) {
'expo-build-properties',
{
ios: {
- deploymentTarget: '14.0',
+ deploymentTarget: '15.1',
newArchEnabled: false,
},
android: {
diff --git a/assets/icons/crop_stroke2_corner0_rounded.svg b/assets/icons/crop_stroke2_corner0_rounded.svg
new file mode 100644
index 00000000..118d148f
--- /dev/null
+++ b/assets/icons/crop_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/bskyembed/assets/play_filled_corner2_rounded.svg b/bskyembed/assets/play_filled_corner2_rounded.svg
new file mode 100644
index 00000000..48da4add
--- /dev/null
+++ b/bskyembed/assets/play_filled_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/bskyembed/package.json b/bskyembed/package.json
index 72d2b6df..e269b1c9 100644
--- a/bskyembed/package.json
+++ b/bskyembed/package.json
@@ -9,7 +9,7 @@
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
},
"dependencies": {
- "@atproto/api": "0.13.1",
+ "@atproto/api": "0.13.6",
"@preact/preset-vite": "^2.8.2",
"@vitejs/plugin-legacy": "^5.3.2",
"preact": "^10.4.8",
diff --git a/bskyembed/src/components/embed.tsx b/bskyembed/src/components/embed.tsx
index 600c7c2c..3b4f5e77 100644
--- a/bskyembed/src/components/embed.tsx
+++ b/bskyembed/src/components/embed.tsx
@@ -3,6 +3,7 @@ import {
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
+ AppBskyEmbedVideo,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphDefs,
@@ -14,6 +15,7 @@ import {ComponentChildren, h} from 'preact'
import {useMemo} from 'preact/hooks'
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
+import playIcon from '../../assets/play_filled_corner2_rounded.svg'
import starterPackIcon from '../../assets/starterPack.svg'
import {CONTENT_LABELS, labelsToInfo} from '../labels'
import {getRkey} from '../utils'
@@ -160,7 +162,12 @@ export function Embed({
return null
}
- // Case 4: Record with media
+ // Case 4: Video
+ if (AppBskyEmbedVideo.isView(content)) {
+ return
+ }
+
+ // Case 5: Record with media
if (
AppBskyEmbedRecordWithMedia.isView(content) &&
AppBskyEmbedRecord.isViewRecord(content.record.record)
@@ -354,6 +361,31 @@ function GenericWithImageEmbed({
)
}
+// just the thumbnail and a play button
+function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
+ let aspectRatio = 1
+
+ if (content.aspectRatio) {
+ const {width, height} = content.aspectRatio
+ aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
+ }
+
+ return (
+
+
+
+
+
+
+ )
+}
+
function StarterPackEmbed({
content,
}: {
@@ -410,3 +442,7 @@ function getStarterPackHref(
const handleOrDid = starterPack.creator.handle || starterPack.creator.did
return `/starter-pack/${handleOrDid}/${rkey}`
}
+
+function clamp(num: number, min: number, max: number) {
+ return Math.max(min, Math.min(num, max))
+}
diff --git a/bskyembed/yarn.lock b/bskyembed/yarn.lock
index 3c5ef5ae..46c8519b 100644
--- a/bskyembed/yarn.lock
+++ b/bskyembed/yarn.lock
@@ -20,15 +20,15 @@
"@jridgewell/gen-mapping" "^0.3.5"
"@jridgewell/trace-mapping" "^0.3.24"
-"@atproto/api@0.13.1":
- version "0.13.1"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.1.tgz#fbf4306e4465d5467aaf031308c1b47dcc8039d0"
- integrity sha512-DL3iBfavn8Nnl48FmnAreQB0k0cIkW531DJ5JAHUCQZo10Nq0ZLk2/WFxcs0KuBG5wuLnGUdo+Y6/GQPVq8dYw==
+"@atproto/api@0.13.6":
+ version "0.13.6"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.6.tgz#2500e9d7143e6718089632300c42ce50149f8cd5"
+ integrity sha512-58emFFZhqY8nVWD3xFWK0yYqAmJ2un+NaTtZxBbRo00mGq1rz9VXTpVmfoHFcuXL1hoDQN3WyJfsub8r6xGOgg==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.1"
"@atproto/syntax" "^0.3.0"
- "@atproto/xrpc" "^0.6.0"
+ "@atproto/xrpc" "^0.6.1"
await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
@@ -59,10 +59,10 @@
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
-"@atproto/xrpc@^0.6.0":
- version "0.6.0"
- resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.0.tgz#668c3262e67e2afa65951ea79a03bfe3720ddf5c"
- integrity sha512-5BbhBTv5j6MC3iIQ4+vYxQE7nLy2dDGQ+LYJrH8PptOCUdq0Pwg6aRccQ3y52kUZlhE/mzOTZ8Ngiy9pSAyfVQ==
+"@atproto/xrpc@^0.6.1":
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5"
+ integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A==
dependencies:
"@atproto/lexicon" "^0.4.1"
zod "^3.23.8"
diff --git a/bskyweb/cmd/bskyweb/main.go b/bskyweb/cmd/bskyweb/main.go
index d9235afd..3f46c4b0 100644
--- a/bskyweb/cmd/bskyweb/main.go
+++ b/bskyweb/cmd/bskyweb/main.go
@@ -60,6 +60,12 @@ func run(args []string) {
Value: "",
EnvVars: []string{"LINK_HOST"},
},
+ &cli.StringFlag{
+ Name: "ipcc-host",
+ Usage: "scheme, hostname, and port of ipcc service",
+ Value: "https://localhost:8730",
+ EnvVars: []string{"IPCC_HOST"},
+ },
&cli.BoolFlag{
Name: "debug",
Usage: "Enable debug mode",
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 203ed62f..afd9247a 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -1,12 +1,17 @@
package main
import (
+ "bytes"
"context"
"crypto/subtle"
+ "crypto/tls"
+ "encoding/base64"
+ "encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
+ "net/netip"
"net/url"
"os"
"os/signal"
@@ -41,6 +46,7 @@ type Config struct {
appviewHost string
ogcardHost string
linkHost string
+ ipccHost string
}
func serve(cctx *cli.Context) error {
@@ -49,6 +55,7 @@ func serve(cctx *cli.Context) error {
appviewHost := cctx.String("appview-host")
ogcardHost := cctx.String("ogcard-host")
linkHost := cctx.String("link-host")
+ ipccHost := cctx.String("ipcc-host")
basicAuthPassword := cctx.String("basic-auth-password")
// Echo
@@ -91,6 +98,7 @@ func serve(cctx *cli.Context) error {
appviewHost: appviewHost,
ogcardHost: ogcardHost,
linkHost: linkHost,
+ ipccHost: ipccHost,
},
}
@@ -261,6 +269,9 @@ func serve(cctx *cli.Context) error {
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
+ // ipcc
+ e.GET("/ipcc", server.WebIpCC)
+
if linkHost != "" {
linkUrl, err := url.Parse(linkHost)
if err != nil {
@@ -520,3 +531,61 @@ func (srv *Server) WebProfile(c echo.Context) error {
data["requestHost"] = req.Host
return c.Render(http.StatusOK, "profile.html", data)
}
+
+type IPCCRequest struct {
+ IP string `json:"ip"`
+}
+type IPCCResponse struct {
+ CC string `json:"countryCode"`
+}
+
+func (srv *Server) WebIpCC(c echo.Context) error {
+ realIP := c.RealIP()
+ addr, err := netip.ParseAddr(realIP)
+ if err != nil {
+ log.Warnf("could not parse IP %q %s", realIP, err)
+ return c.JSON(400, IPCCResponse{})
+ }
+ var request []byte
+ if addr.Is4() {
+ ip4 := addr.As4()
+ var dest [8]byte
+ base64.StdEncoding.Encode(dest[:], ip4[:])
+ request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
+ } else if addr.Is6() {
+ ip6 := addr.As16()
+ var dest [24]byte
+ base64.StdEncoding.Encode(dest[:], ip6[:])
+ request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
+ }
+
+ ipccUrlBuilder, err := url.Parse(srv.cfg.ipccHost)
+ if err != nil {
+ log.Errorf("ipcc misconfigured bad url %s", err)
+ return c.JSON(500, IPCCResponse{})
+ }
+ ipccUrlBuilder.Path = "ipccdata.IpCcService/Lookup"
+ ipccUrl := ipccUrlBuilder.String()
+ cl := http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ },
+ },
+ }
+ postBodyReader := bytes.NewReader(request)
+ response, err := cl.Post(ipccUrl, "application/json", postBodyReader)
+ if err != nil {
+ log.Warnf("ipcc backend error %s", err)
+ return c.JSON(500, IPCCResponse{})
+ }
+ defer response.Body.Close()
+ dec := json.NewDecoder(response.Body)
+ var outResponse IPCCResponse
+ err = dec.Decode(&outResponse)
+ if err != nil {
+ log.Warnf("ipcc bad response %s", err)
+ return c.JSON(500, IPCCResponse{})
+ }
+ return c.JSON(200, outResponse)
+}
diff --git a/lingui.config.js b/lingui.config.js
index 14a94b5d..796969ab 100644
--- a/lingui.config.js
+++ b/lingui.config.js
@@ -14,6 +14,7 @@ module.exports = {
'ja',
'ko',
'pt-BR',
+ 'ru',
'tr',
'uk',
'zh-CN',
diff --git a/package.json b/package.json
index eaa03829..eff665a6 100644
--- a/package.json
+++ b/package.json
@@ -139,7 +139,7 @@
"expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14",
- "expo-video": "^1.2.4",
+ "expo-video": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz",
"expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
@@ -180,6 +180,7 @@
"react-native-image-crop-picker": "0.40.3",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.12.1",
+ "react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.2.3",
"react-native-picker-select": "^9.1.3",
"react-native-progress": "bluesky-social/react-native-progress",
diff --git a/patches/expo-video+1.2.4.patch b/patches/expo-video+1.2.4.patch
deleted file mode 100644
index 13fe25ed..00000000
--- a/patches/expo-video+1.2.4.patch
+++ /dev/null
@@ -1,557 +0,0 @@
-diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
-index 473f964..f37aff9 100644
---- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
-+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
-@@ -41,6 +41,11 @@ sealed class PlayerEvent {
- override val name = "playToEnd"
- }
-
-+ data class PlayerTimeRemainingChanged(val timeRemaining: Double): PlayerEvent() {
-+ override val name = "timeRemainingChange"
-+ override val arguments = arrayOf(timeRemaining)
-+ }
-+
- fun emit(player: VideoPlayer, listeners: List) {
- when (this) {
- is StatusChanged -> listeners.forEach { it.onStatusChanged(player, status, oldStatus, error) }
-@@ -49,6 +54,7 @@ sealed class PlayerEvent {
- is SourceChanged -> listeners.forEach { it.onSourceChanged(player, source, oldSource) }
- is PlaybackRateChanged -> listeners.forEach { it.onPlaybackRateChanged(player, rate, oldRate) }
- is PlayedToEnd -> listeners.forEach { it.onPlayedToEnd(player) }
-+ is PlayerTimeRemainingChanged -> listeners.forEach { it.onPlayerTimeRemainingChanged(player, timeRemaining) }
- }
- }
- }
-diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
-index 9905e13..47342ff 100644
---- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
-+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
-@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole
- setShowPreviousButton(!requireLinearPlayback)
- setShowNextButton(!requireLinearPlayback)
- setTimeBarInteractive(requireLinearPlayback)
-+ setShowSubtitleButton(true)
- }
-
- @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
-@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
-
- @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
- internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
-- val fullscreenButton = findViewById(androidx.media3.ui.R.id.exo_fullscreen)
-+ val fullscreenButton =
-+ findViewById(androidx.media3.ui.R.id.exo_fullscreen)
- fullscreenButton?.visibility = if (visible) {
- android.view.View.VISIBLE
- } else {
-diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt
-new file mode 100644
-index 0000000..0249e23
---- /dev/null
-+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt
-@@ -0,0 +1,29 @@
-+import android.os.Handler
-+import android.os.Looper
-+import androidx.annotation.OptIn
-+import androidx.media3.common.util.UnstableApi
-+import expo.modules.video.PlayerEvent
-+import expo.modules.video.VideoPlayer
-+import kotlin.math.floor
-+
-+@OptIn(UnstableApi::class)
-+class ProgressTracker(private val videoPlayer: VideoPlayer) : Runnable {
-+ private val handler: Handler = Handler(Looper.getMainLooper())
-+ private val player = videoPlayer.player
-+
-+ init {
-+ handler.post(this)
-+ }
-+
-+ override fun run() {
-+ val currentPosition = player.currentPosition
-+ val duration = player.duration
-+ val timeRemaining = floor(((duration - currentPosition) / 1000).toDouble())
-+ videoPlayer.sendEvent(PlayerEvent.PlayerTimeRemainingChanged(timeRemaining))
-+ handler.postDelayed(this, 1000 /* ms */)
-+ }
-+
-+ fun remove() {
-+ handler.removeCallbacks(this)
-+ }
-+}
-\ No newline at end of file
-diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt
-index 4b6c6d8..e20f51a 100644
---- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt
-+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt
-@@ -1,5 +1,6 @@
- package expo.modules.video
-
-+import android.provider.MediaStore.Video
- import androidx.annotation.OptIn
- import androidx.media3.common.util.UnstableApi
- import expo.modules.kotlin.AppContext
-@@ -15,6 +16,8 @@ object VideoManager {
- // Keeps track of all existing VideoPlayers, and whether they are attached to a VideoView
- private var videoPlayersToVideoViews = mutableMapOf>()
-
-+ private var previouslyPlayingViews: MutableList? = null
-+
- private lateinit var audioFocusManager: AudioFocusManager
-
- fun onModuleCreated(appContext: AppContext) {
-@@ -69,16 +72,24 @@ object VideoManager {
- return videoPlayersToVideoViews[videoPlayer]?.isNotEmpty() ?: false
- }
-
-- fun onAppForegrounded() = Unit
-+ fun onAppForegrounded() {
-+ val previouslyPlayingViews = this.previouslyPlayingViews ?: return
-+ for (videoView in previouslyPlayingViews) {
-+ val player = videoView.videoPlayer?.player ?: continue
-+ player.play()
-+ }
-+ this.previouslyPlayingViews = null
-+ }
-
- fun onAppBackgrounded() {
-+ val previouslyPlayingViews = mutableListOf()
- for (videoView in videoViews.values) {
-- if (videoView.videoPlayer?.staysActiveInBackground == false &&
-- !videoView.willEnterPiP &&
-- !videoView.isInFullscreen
-- ) {
-- videoView.videoPlayer?.player?.pause()
-+ val player = videoView.videoPlayer?.player ?: continue
-+ if (player.isPlaying) {
-+ player.pause()
-+ previouslyPlayingViews.add(videoView)
- }
- }
-+ this.previouslyPlayingViews = previouslyPlayingViews
- }
- }
-diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
-index ec3da2a..5a1397a 100644
---- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
-+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
-@@ -43,7 +43,9 @@ class VideoModule : Module() {
- View(VideoView::class) {
- Events(
- "onPictureInPictureStart",
-- "onPictureInPictureStop"
-+ "onPictureInPictureStop",
-+ "onEnterFullscreen",
-+ "onExitFullscreen"
- )
-
- Prop("player") { view: VideoView, player: VideoPlayer ->
-diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
-index 58f00af..5ad8237 100644
---- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
-+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
-@@ -1,5 +1,6 @@
- package expo.modules.video
-
-+import ProgressTracker
- import android.content.Context
- import android.view.SurfaceView
- import androidx.media3.common.MediaItem
-@@ -35,11 +36,13 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
- .Builder(context, renderersFactory)
- .setLooper(context.mainLooper)
- .build()
-+ var progressTracker: ProgressTracker? = null
-
- val serviceConnection = PlaybackServiceConnection(WeakReference(player))
-
- var playing by IgnoreSameSet(false) { new, old ->
- sendEvent(PlayerEvent.IsPlayingChanged(new, old))
-+ addOrRemoveProgressTracker()
- }
-
- var uncommittedSource: VideoSource? = source
-@@ -141,6 +144,9 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
- }
-
- override fun close() {
-+ this.progressTracker?.remove()
-+ this.progressTracker = null
-+
- appContext?.reactContext?.unbindService(serviceConnection)
- serviceConnection.playbackServiceBinder?.service?.unregisterPlayer(player)
- VideoManager.unregisterVideoPlayer(this@VideoPlayer)
-@@ -228,7 +234,7 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
- listeners.removeAll { it.get() == videoPlayerListener }
- }
-
-- private fun sendEvent(event: PlayerEvent) {
-+ fun sendEvent(event: PlayerEvent) {
- // Emits to the native listeners
- event.emit(this, listeners.mapNotNull { it.get() })
- // Emits to the JS side
-@@ -240,4 +246,13 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
- sendEvent(eventName, *args)
- }
- }
-+
-+ private fun addOrRemoveProgressTracker() {
-+ this.progressTracker?.remove()
-+ if (this.playing) {
-+ this.progressTracker = ProgressTracker(this)
-+ } else {
-+ this.progressTracker = null
-+ }
-+ }
- }
-diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt
-index f654254..dcfe3f0 100644
---- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt
-+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt
-@@ -15,4 +15,5 @@ interface VideoPlayerListener {
- fun onSourceChanged(player: VideoPlayer, source: VideoSource?, oldSource: VideoSource?) {}
- fun onPlaybackRateChanged(player: VideoPlayer, rate: Float, oldRate: Float?) {}
- fun onPlayedToEnd(player: VideoPlayer) {}
-+ fun onPlayerTimeRemainingChanged(player: VideoPlayer, timeRemaining: Double) {}
- }
-diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
-index a951d80..3932535 100644
---- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
-+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
-@@ -36,6 +36,8 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
- val playerView: PlayerView = PlayerView(context.applicationContext)
- val onPictureInPictureStart by EventDispatcher()
- val onPictureInPictureStop by EventDispatcher()
-+ val onEnterFullscreen by EventDispatcher()
-+ val onExitFullscreen by EventDispatcher()
-
- var willEnterPiP: Boolean = false
- var isInFullscreen: Boolean = false
-@@ -154,6 +156,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
- @Suppress("DEPRECATION")
- currentActivity.overridePendingTransition(0, 0)
- }
-+ onEnterFullscreen(mapOf())
- isInFullscreen = true
- }
-
-@@ -162,6 +165,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
- val fullScreenButton: ImageButton = playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen)
- fullScreenButton.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter)
- videoPlayer?.changePlayerView(playerView)
-+ this.onExitFullscreen(mapOf())
- isInFullscreen = false
- }
-
-diff --git a/node_modules/expo-video/build/VideoPlayer.types.d.ts b/node_modules/expo-video/build/VideoPlayer.types.d.ts
-index a09fcfe..5eac9e5 100644
---- a/node_modules/expo-video/build/VideoPlayer.types.d.ts
-+++ b/node_modules/expo-video/build/VideoPlayer.types.d.ts
-@@ -128,6 +128,8 @@ export type VideoPlayerEvents = {
- * Handler for an event emitted when the current media source of the player changes.
- */
- sourceChange(newSource: VideoSource, previousSource: VideoSource): void;
-+
-+ timeRemainingChange(timeRemaining: number): void;
- };
- /**
- * Describes the current status of the player.
-diff --git a/node_modules/expo-video/build/VideoView.types.d.ts b/node_modules/expo-video/build/VideoView.types.d.ts
-index cb9ca6d..ed8bb7e 100644
---- a/node_modules/expo-video/build/VideoView.types.d.ts
-+++ b/node_modules/expo-video/build/VideoView.types.d.ts
-@@ -89,5 +89,8 @@ export interface VideoViewProps extends ViewProps {
- * @platform ios 16.0+
- */
- allowsVideoFrameAnalysis?: boolean;
-+
-+ onEnterFullscreen?: () => void;
-+ onExitFullscreen?: () => void;
- }
- //# sourceMappingURL=VideoView.types.d.ts.map
-\ No newline at end of file
-diff --git a/node_modules/expo-video/ios/VideoManager.swift b/node_modules/expo-video/ios/VideoManager.swift
-index 094a8b0..3f00525 100644
---- a/node_modules/expo-video/ios/VideoManager.swift
-+++ b/node_modules/expo-video/ios/VideoManager.swift
-@@ -12,6 +12,7 @@ class VideoManager {
-
- private var videoViews = NSHashTable.weakObjects()
- private var videoPlayers = NSHashTable.weakObjects()
-+ private var previouslyPlayingPlayers: [VideoPlayer]?
-
- func register(videoPlayer: VideoPlayer) {
- videoPlayers.add(videoPlayer)
-@@ -33,63 +34,70 @@ class VideoManager {
- for videoPlayer in videoPlayers.allObjects {
- videoPlayer.setTracksEnabled(true)
- }
-+
-+ if let previouslyPlayingPlayers = self.previouslyPlayingPlayers {
-+ previouslyPlayingPlayers.forEach { player in
-+ player.pointer.play()
-+ }
-+ }
- }
-
- func onAppBackgrounded() {
-+ var previouslyPlayingPlayers: [VideoPlayer] = []
- for videoView in videoViews.allObjects {
- guard let player = videoView.player else {
- continue
- }
-- if player.staysActiveInBackground == true {
-- player.setTracksEnabled(videoView.isInPictureInPicture)
-- } else if !videoView.isInPictureInPicture {
-+ if player.isPlaying {
- player.pointer.pause()
-+ previouslyPlayingPlayers.append(player)
- }
- }
-+ self.previouslyPlayingPlayers = previouslyPlayingPlayers
- }
-
- // MARK: - Audio Session Management
-
- internal func setAppropriateAudioSessionOrWarn() {
-- let audioSession = AVAudioSession.sharedInstance()
-- var audioSessionCategoryOptions: AVAudioSession.CategoryOptions = []
--
-- let isAnyPlayerPlaying = videoPlayers.allObjects.contains { player in
-- player.isPlaying
-- }
-- let areAllPlayersMuted = videoPlayers.allObjects.allSatisfy { player in
-- player.isMuted
-- }
-- let needsPiPSupport = videoViews.allObjects.contains { view in
-- view.allowPictureInPicture
-- }
-- let anyPlayerShowsNotification = videoPlayers.allObjects.contains { player in
-- player.showNowPlayingNotification
-- }
-- // The notification won't be shown if we allow the audio to mix with others
-- let shouldAllowMixing = (!isAnyPlayerPlaying || areAllPlayersMuted) && !anyPlayerShowsNotification
-- let isOutputtingAudio = !areAllPlayersMuted && isAnyPlayerPlaying
-- let shouldUpdateToAllowMixing = !audioSession.categoryOptions.contains(.mixWithOthers) && shouldAllowMixing
--
-- if shouldAllowMixing {
-- audioSessionCategoryOptions.insert(.mixWithOthers)
-- }
--
-- if isOutputtingAudio || needsPiPSupport || shouldUpdateToAllowMixing || anyPlayerShowsNotification {
-- do {
-- try audioSession.setCategory(.playback, mode: .moviePlayback)
-- } catch {
-- log.warn("Failed to set audio session category. This might cause issues with audio playback and Picture in Picture. \(error.localizedDescription)")
-- }
-- }
--
-- // Make sure audio session is active if any video is playing
-- if isAnyPlayerPlaying {
-- do {
-- try audioSession.setActive(true)
-- } catch {
-- log.warn("Failed to activate the audio session. This might cause issues with audio playback. \(error.localizedDescription)")
-- }
-- }
-+// let audioSession = AVAudioSession.sharedInstance()
-+// var audioSessionCategoryOptions: AVAudioSession.CategoryOptions = []
-+//
-+// let isAnyPlayerPlaying = videoPlayers.allObjects.contains { player in
-+// player.isPlaying
-+// }
-+// let areAllPlayersMuted = videoPlayers.allObjects.allSatisfy { player in
-+// player.isMuted
-+// }
-+// let needsPiPSupport = videoViews.allObjects.contains { view in
-+// view.allowPictureInPicture
-+// }
-+// let anyPlayerShowsNotification = videoPlayers.allObjects.contains { player in
-+// player.showNowPlayingNotification
-+// }
-+// // The notification won't be shown if we allow the audio to mix with others
-+// let shouldAllowMixing = (!isAnyPlayerPlaying || areAllPlayersMuted) && !anyPlayerShowsNotification
-+// let isOutputtingAudio = !areAllPlayersMuted && isAnyPlayerPlaying
-+// let shouldUpdateToAllowMixing = !audioSession.categoryOptions.contains(.mixWithOthers) && shouldAllowMixing
-+//
-+// if shouldAllowMixing {
-+// audioSessionCategoryOptions.insert(.mixWithOthers)
-+// }
-+//
-+// if isOutputtingAudio || needsPiPSupport || shouldUpdateToAllowMixing || anyPlayerShowsNotification {
-+// do {
-+// try audioSession.setCategory(.playback, mode: .moviePlayback)
-+// } catch {
-+// log.warn("Failed to set audio session category. This might cause issues with audio playback and Picture in Picture. \(error.localizedDescription)")
-+// }
-+// }
-+//
-+// // Make sure audio session is active if any video is playing
-+// if isAnyPlayerPlaying {
-+// do {
-+// try audioSession.setActive(true)
-+// } catch {
-+// log.warn("Failed to activate the audio session. This might cause issues with audio playback. \(error.localizedDescription)")
-+// }
-+// }
- }
- }
-diff --git a/node_modules/expo-video/ios/VideoModule.swift b/node_modules/expo-video/ios/VideoModule.swift
-index c537a12..e4a918f 100644
---- a/node_modules/expo-video/ios/VideoModule.swift
-+++ b/node_modules/expo-video/ios/VideoModule.swift
-@@ -16,7 +16,9 @@ public final class VideoModule: Module {
- View(VideoView.self) {
- Events(
- "onPictureInPictureStart",
-- "onPictureInPictureStop"
-+ "onPictureInPictureStop",
-+ "onEnterFullscreen",
-+ "onExitFullscreen"
- )
-
- Prop("player") { (view, player: VideoPlayer?) in
-diff --git a/node_modules/expo-video/ios/VideoPlayer.swift b/node_modules/expo-video/ios/VideoPlayer.swift
-index 3315b88..733ab1f 100644
---- a/node_modules/expo-video/ios/VideoPlayer.swift
-+++ b/node_modules/expo-video/ios/VideoPlayer.swift
-@@ -185,6 +185,10 @@ internal final class VideoPlayer: SharedRef, Hashable, VideoPlayerObse
- safeEmit(event: "sourceChange", arguments: newVideoPlayerItem?.videoSource, oldVideoPlayerItem?.videoSource)
- }
-
-+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {
-+ safeEmit(event: "timeRemainingChange", arguments: timeRemaining)
-+ }
-+
- func safeEmit(event: String, arguments: repeat each A) {
- if self.appContext != nil {
- self.emit(event: event, arguments: repeat each arguments)
-diff --git a/node_modules/expo-video/ios/VideoPlayerObserver.swift b/node_modules/expo-video/ios/VideoPlayerObserver.swift
-index d289e26..ea4d96f 100644
---- a/node_modules/expo-video/ios/VideoPlayerObserver.swift
-+++ b/node_modules/expo-video/ios/VideoPlayerObserver.swift
-@@ -21,6 +21,7 @@ protocol VideoPlayerObserverDelegate: AnyObject {
- func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?)
- func onIsMutedChanged(player: AVPlayer, oldIsMuted: Bool?, newIsMuted: Bool)
- func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status)
-+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double)
- }
-
- // Default implementations for the delegate
-@@ -33,6 +34,7 @@ extension VideoPlayerObserverDelegate {
- func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?) {}
- func onIsMutedChanged(player: AVPlayer, oldIsMuted: Bool?, newIsMuted: Bool) {}
- func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status) {}
-+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {}
- }
-
- // Wrapper used to store WeakReferences to the observer delegate
-@@ -91,6 +93,7 @@ class VideoPlayerObserver {
- private var playerVolumeObserver: NSKeyValueObservation?
- private var playerCurrentItemObserver: NSKeyValueObservation?
- private var playerIsMutedObserver: NSKeyValueObservation?
-+ private var playerPeriodicTimeObserver: Any?
-
- // Current player item observers
- private var playbackBufferEmptyObserver: NSKeyValueObservation?
-@@ -152,6 +155,9 @@ class VideoPlayerObserver {
- playerVolumeObserver?.invalidate()
- playerIsMutedObserver?.invalidate()
- playerCurrentItemObserver?.invalidate()
-+ if let playerPeriodicTimeObserver = self.playerPeriodicTimeObserver {
-+ player?.removeTimeObserver(playerPeriodicTimeObserver)
-+ }
- }
-
- private func initializeCurrentPlayerItemObservers(player: AVPlayer, playerItem: AVPlayerItem) {
-@@ -270,6 +276,7 @@ class VideoPlayerObserver {
-
- if isPlaying != (player.timeControlStatus == .playing) {
- isPlaying = player.timeControlStatus == .playing
-+ addPeriodicTimeObserverIfNeeded()
- }
- }
-
-@@ -310,4 +317,28 @@ class VideoPlayerObserver {
- }
- }
- }
-+
-+ private func onPlayerTimeRemainingChanged(_ player: AVPlayer, _ timeRemaining: Double) {
-+ delegates.forEach { delegate in
-+ delegate.value?.onPlayerTimeRemainingChanged(player: player, timeRemaining: timeRemaining)
-+ }
-+ }
-+
-+ private func addPeriodicTimeObserverIfNeeded() {
-+ guard self.playerPeriodicTimeObserver == nil, let player = self.player else {
-+ return
-+ }
-+
-+ if isPlaying {
-+ // Add the time update listener
-+ playerPeriodicTimeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(1.0, preferredTimescale: Int32(NSEC_PER_SEC)), queue: nil) { event in
-+ guard let duration = player.currentItem?.duration else {
-+ return
-+ }
-+
-+ let timeRemaining = (duration.seconds - event.seconds).rounded()
-+ self.onPlayerTimeRemainingChanged(player, timeRemaining)
-+ }
-+ }
-+ }
- }
-diff --git a/node_modules/expo-video/ios/VideoView.swift b/node_modules/expo-video/ios/VideoView.swift
-index f4579e4..10c5908 100644
---- a/node_modules/expo-video/ios/VideoView.swift
-+++ b/node_modules/expo-video/ios/VideoView.swift
-@@ -41,6 +41,8 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
-
- let onPictureInPictureStart = EventDispatcher()
- let onPictureInPictureStop = EventDispatcher()
-+ let onEnterFullscreen = EventDispatcher()
-+ let onExitFullscreen = EventDispatcher()
-
- public override var bounds: CGRect {
- didSet {
-@@ -163,6 +165,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
- _ playerViewController: AVPlayerViewController,
- willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
- ) {
-+ onEnterFullscreen()
- isFullscreen = true
- }
-
-@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
- if wasPlaying {
- self.player?.pointer.play()
- }
-+ self.onExitFullscreen()
- self.isFullscreen = false
- }
- }
-diff --git a/node_modules/expo-video/src/VideoPlayer.types.ts b/node_modules/expo-video/src/VideoPlayer.types.ts
-index aaf4b63..f438196 100644
---- a/node_modules/expo-video/src/VideoPlayer.types.ts
-+++ b/node_modules/expo-video/src/VideoPlayer.types.ts
-@@ -151,6 +151,8 @@ export type VideoPlayerEvents = {
- * Handler for an event emitted when the current media source of the player changes.
- */
- sourceChange(newSource: VideoSource, previousSource: VideoSource): void;
-+
-+ timeRemainingChange(timeRemaining: number): void;
- };
-
- /**
-diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts
-index 29fe5db..e1fbf59 100644
---- a/node_modules/expo-video/src/VideoView.types.ts
-+++ b/node_modules/expo-video/src/VideoView.types.ts
-@@ -100,4 +100,7 @@ export interface VideoViewProps extends ViewProps {
- * @platform ios 16.0+
- */
- allowsVideoFrameAnalysis?: boolean;
-+
-+ onEnterFullscreen?: () => void;
-+ onExitFullscreen?: () => void;
- }
diff --git a/patches/expo-video+1.2.4.patch.md b/patches/expo-video+1.2.4.patch.md
deleted file mode 100644
index 99c14c28..00000000
--- a/patches/expo-video+1.2.4.patch.md
+++ /dev/null
@@ -1,18 +0,0 @@
-## uwu woad beawing, do not wemove
-
-## `expo-video` Patch
-
-### `onEnterFullScreen`/`onExitFullScreen`
-Adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on
-the tin.
-
-### Removing audio session management
-
-This patch also removes the audio session management that Expo does on its own, as we handle audio session management
-ourselves.
-
-### Pausing/playing on background/foreground
-
-Instead of handling the pausing/playing of videos in React, we'll handle them here. There's some logic that we do not
-need (around PIP mode) that we can remove, and just pause any playing players on background and then resume them on
-foreground.
diff --git a/plugins/starterPackAppClipExtension/withXcodeTarget.js b/plugins/starterPackAppClipExtension/withXcodeTarget.js
index 61d5f81b..c14d2729 100644
--- a/plugins/starterPackAppClipExtension/withXcodeTarget.js
+++ b/plugins/starterPackAppClipExtension/withXcodeTarget.js
@@ -57,7 +57,7 @@ const withXcodeTarget = (config, {targetName}) => {
buildSettingsObj.SWIFT_VERSION = '5.0'
buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"`
buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
- buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0'
+ buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '15.1'
buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon'
}
}
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 609d316d..780d4058 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -58,6 +58,7 @@ import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
+import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -105,52 +106,50 @@ function InnerApp() {
}, [_])
return (
-
-
-
-
-
-
-
-
-
-
- {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
}
@@ -184,7 +183,12 @@ function App() {
-
+
+
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 8531dc88..3017a3a2 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -47,6 +47,7 @@ import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
+import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -162,7 +163,9 @@ function App() {
-
+
+
+
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 0bf0e9f9..2beba4f9 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -661,16 +661,15 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
linking={LINKING}
theme={theme}
onStateChange={() => {
- logEvent('router:navigate:sampled', {
- from: prevLoggedRouteName.current,
- })
- prevLoggedRouteName.current = getCurrentRouteName()
+ const routeName = getCurrentRouteName()
+ if (routeName === 'Notifications') {
+ logEvent('router:navigate:notifications:sampled', {})
+ }
}}
onReady={() => {
attachRouteToLogEvents(getCurrentRouteName)
logModuleInitTime()
onReady()
- logEvent('router:navigate:sampled', {})
}}>
{children}
diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx
index 65e981f7..5031f584 100644
--- a/src/components/FeedInterstitials.tsx
+++ b/src/components/FeedInterstitials.tsx
@@ -8,7 +8,6 @@ import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types'
-import {useGate} from '#/lib/statsig/statsig'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -177,14 +176,9 @@ function useExperimentalSuggestedUsersQuery() {
}
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
- const gate = useGate()
const [feedType, feedUri] = feed.split('|')
if (feedType === 'author') {
- if (gate('show_follow_suggestions_in_profile')) {
- return
- } else {
- return null
- }
+ return
} else {
return
}
diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx
new file mode 100644
index 00000000..7d7cb2b4
--- /dev/null
+++ b/src/components/MediaPreview.tsx
@@ -0,0 +1,172 @@
+import React from 'react'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {Image} from 'expo-image'
+import {
+ AppBskyEmbedExternal,
+ AppBskyEmbedImages,
+ AppBskyEmbedRecordWithMedia,
+ AppBskyEmbedVideo,
+} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+
+import {parseTenorGif} from '#/lib/strings/embed-player'
+import {atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
+
+/**
+ * Streamlined MediaPreview component which just handles images, gifs, and videos
+ */
+export function Embed({
+ embed,
+ style,
+}: {
+ embed?:
+ | AppBskyEmbedImages.View
+ | AppBskyEmbedRecordWithMedia.View
+ | AppBskyEmbedExternal.View
+ | AppBskyEmbedVideo.View
+ | {[k: string]: unknown}
+ style?: StyleProp
+}) {
+ let media = AppBskyEmbedRecordWithMedia.isView(embed) ? embed.media : embed
+
+ if (AppBskyEmbedImages.isView(media)) {
+ return (
+
+ {media.images.map(image => (
+
+ ))}
+
+ )
+ } else if (AppBskyEmbedExternal.isView(embed) && embed.external.thumb) {
+ let url: URL | undefined
+ try {
+ url = new URL(embed.external.uri)
+ } catch {}
+ if (url) {
+ const {success} = parseTenorGif(url)
+ if (success) {
+ return (
+
+
+
+ )
+ }
+ }
+ } else if (AppBskyEmbedVideo.isView(embed)) {
+ return (
+
+
+
+ )
+ }
+
+ return null
+}
+
+export function Outer({
+ children,
+ style,
+}: {
+ children?: React.ReactNode
+ style?: StyleProp
+}) {
+ return {children}
+}
+
+export function ImageItem({
+ thumbnail,
+ alt,
+ children,
+}: {
+ thumbnail: string
+ alt?: string
+ children?: React.ReactNode
+}) {
+ return (
+
+
+ {children}
+
+ )
+}
+
+export function GifItem({thumbnail, alt}: {thumbnail: string; alt?: string}) {
+ return (
+
+
+
+
+
+
+ GIF
+
+
+
+ )
+}
+
+export function VideoItem({
+ thumbnail,
+ alt,
+}: {
+ thumbnail?: string
+ alt?: string
+}) {
+ if (!thumbnail) {
+ return (
+
+
+
+ )
+ }
+ return (
+
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ altContainer: {
+ backgroundColor: 'rgba(0, 0, 0, 0.75)',
+ borderRadius: 6,
+ paddingHorizontal: 6,
+ paddingVertical: 3,
+ position: 'absolute',
+ right: 5,
+ bottom: 5,
+ zIndex: 2,
+ },
+ alt: {
+ color: 'white',
+ fontSize: 7,
+ fontWeight: 'bold',
+ },
+})
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index a263d194..6f6d6804 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -276,8 +276,8 @@ export function DescriptionPlaceholder() {
export type FollowButtonProps = {
profile: AppBskyActorDefs.ProfileViewBasic
moderationOpts: ModerationOpts
- logContext: LogEvents['profile:follow']['logContext'] &
- LogEvents['profile:unfollow']['logContext']
+ logContext: LogEvents['profile:follow:sampled']['logContext'] &
+ LogEvents['profile:unfollow:sampled']['logContext']
} & Partial
export function FollowButton(props: FollowButtonProps) {
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 315ad0df..86cb5c31 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -8,7 +8,10 @@ import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
-export {useDialogControl as usePromptControl} from '#/components/Dialog'
+export {
+ type DialogControlProps as PromptControlProps,
+ useDialogControl as usePromptControl,
+} from '#/components/Dialog'
const Context = React.createContext<{
titleId: string
@@ -23,7 +26,7 @@ export function Outer({
control,
testID,
}: React.PropsWithChildren<{
- control: Dialog.DialogOuterProps['control']
+ control: Dialog.DialogControlProps
testID?: string
}>) {
const {gtMobile} = useBreakpoints()
diff --git a/src/components/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx
index 6174bff0..a5c7cd1b 100644
--- a/src/components/StarterPack/Main/ProfilesList.tsx
+++ b/src/components/StarterPack/Main/ProfilesList.tsx
@@ -40,7 +40,7 @@ export const ProfilesList = React.forwardRef(
ref,
) {
const t = useTheme()
- const bottomBarOffset = useBottomBarOffset(200)
+ const bottomBarOffset = useBottomBarOffset(300)
const initialNumToRender = useInitialNumToRender()
const {currentAccount} = useSession()
const {data, refetch, isError} = useAllListMembersQuery(listUri)
diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx
index aefd62b9..3db00aec 100644
--- a/src/components/dms/MessageItemEmbed.tsx
+++ b/src/components/dms/MessageItemEmbed.tsx
@@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native'
import {AppBskyEmbedRecord} from '@atproto/api'
-import {PostEmbeds} from '#/view/com/util/post-embeds'
+import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
import {atoms as a, native, useTheme} from '#/alf'
let MessageItemEmbed = ({
@@ -14,7 +14,11 @@ let MessageItemEmbed = ({
return (
-
+
)
}
diff --git a/src/components/hooks/dates.ts b/src/components/hooks/dates.ts
index b0f94133..00b70f76 100644
--- a/src/components/hooks/dates.ts
+++ b/src/components/hooks/dates.ts
@@ -21,6 +21,7 @@ import {
ja,
ko,
ptBR,
+ ru,
tr,
uk,
zhCN,
@@ -47,6 +48,7 @@ const locales: Record = {
ja,
ko,
['pt-BR']: ptBR,
+ ru,
tr,
uk,
['zh-CN']: zhCN,
diff --git a/src/components/hooks/useFollowMethods.ts b/src/components/hooks/useFollowMethods.ts
index d67c3690..31a1e43d 100644
--- a/src/components/hooks/useFollowMethods.ts
+++ b/src/components/hooks/useFollowMethods.ts
@@ -15,8 +15,8 @@ export function useFollowMethods({
logContext,
}: {
profile: Shadow
- logContext: LogEvents['profile:follow']['logContext'] &
- LogEvents['profile:unfollow']['logContext']
+ logContext: LogEvents['profile:follow:sampled']['logContext'] &
+ LogEvents['profile:unfollow:sampled']['logContext']
}) {
const {_} = useLingui()
const requireAuth = useRequireAuth()
diff --git a/src/components/icons/Crop.tsx b/src/components/icons/Crop.tsx
new file mode 100644
index 00000000..4b3fc560
--- /dev/null
+++ b/src/components/icons/Crop.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Crop_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M6 2a1 1 0 0 1 1 1v2h11a1 1 0 0 1 1 1v11h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H6a1 1 0 0 1-1-1V7H3a1 1 0 0 1 0-2h2V3a1 1 0 0 1 1-1Zm1 5v10h10V7H7Z',
+})
diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx
index 387115d3..e83f96f0 100644
--- a/src/components/icons/common.tsx
+++ b/src/components/icons/common.tsx
@@ -19,6 +19,7 @@ export const sizes = {
md: 20,
lg: 24,
xl: 28,
+ '2xl': 32,
}
export function useCommonSVGProps(props: Props) {
diff --git a/src/components/intents/IntentDialogs.tsx b/src/components/intents/IntentDialogs.tsx
new file mode 100644
index 00000000..24485037
--- /dev/null
+++ b/src/components/intents/IntentDialogs.tsx
@@ -0,0 +1,37 @@
+import React from 'react'
+
+import * as Dialog from '#/components/Dialog'
+import {DialogControlProps} from '#/components/Dialog'
+import {VerifyEmailIntentDialog} from '#/components/intents/VerifyEmailIntentDialog'
+
+interface Context {
+ verifyEmailDialogControl: DialogControlProps
+ verifyEmailState: {code: string} | undefined
+ setVerifyEmailState: (state: {code: string} | undefined) => void
+}
+
+const Context = React.createContext({} as Context)
+export const useIntentDialogs = () => React.useContext(Context)
+
+export function Provider({children}: {children: React.ReactNode}) {
+ const verifyEmailDialogControl = Dialog.useDialogControl()
+ const [verifyEmailState, setVerifyEmailState] = React.useState<
+ {code: string} | undefined
+ >()
+
+ const value = React.useMemo(
+ () => ({
+ verifyEmailDialogControl,
+ verifyEmailState,
+ setVerifyEmailState,
+ }),
+ [verifyEmailDialogControl, verifyEmailState, setVerifyEmailState],
+ )
+
+ return (
+
+ {children}
+
+
+ )
+}
diff --git a/src/components/intents/VerifyEmailIntentDialog.tsx b/src/components/intents/VerifyEmailIntentDialog.tsx
new file mode 100644
index 00000000..4dca8bd9
--- /dev/null
+++ b/src/components/intents/VerifyEmailIntentDialog.tsx
@@ -0,0 +1,140 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAgent, useSession} from 'state/session'
+import {atoms as a} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {DialogControlProps} from '#/components/Dialog'
+import {useIntentDialogs} from '#/components/intents/IntentDialogs'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+export function VerifyEmailIntentDialog() {
+ const {verifyEmailDialogControl: control} = useIntentDialogs()
+
+ return (
+
+
+
+
+ )
+}
+
+function Inner({control}: {control: DialogControlProps}) {
+ const {_} = useLingui()
+ const {verifyEmailState: state} = useIntentDialogs()
+ const [status, setStatus] = React.useState<
+ 'loading' | 'success' | 'failure' | 'resent'
+ >('loading')
+ const [sending, setSending] = React.useState(false)
+ const agent = useAgent()
+ const {currentAccount} = useSession()
+
+ React.useEffect(() => {
+ ;(async () => {
+ if (!state?.code) {
+ return
+ }
+ try {
+ await agent.com.atproto.server.confirmEmail({
+ email: (currentAccount?.email || '').trim(),
+ token: state.code.trim(),
+ })
+ setStatus('success')
+ } catch (e) {
+ setStatus('failure')
+ }
+ })()
+ }, [agent.com.atproto.server, currentAccount?.email, state?.code])
+
+ const onPressResendEmail = async () => {
+ setSending(true)
+ await agent.com.atproto.server.requestEmailConfirmation()
+ setSending(false)
+ setStatus('resent')
+ }
+
+ return (
+
+
+
+ {status === 'loading' ? (
+
+
+
+ ) : status === 'success' ? (
+ <>
+
+ Email Verified
+
+
+
+ Thanks, you have successfully verified your email address.
+
+
+ >
+ ) : status === 'failure' ? (
+ <>
+
+ Invalid Verification Code
+
+
+
+ The verification code you have provided is invalid. Please make
+ sure that you have used the correct verification link or request
+ a new one.
+
+
+ >
+ ) : (
+ <>
+
+ Email Resent
+
+
+
+ We have sent another verification email to{' '}
+
+ {currentAccount?.email}
+
+ .
+
+
+ >
+ )}
+ {status !== 'loading' ? (
+
+
+ {status === 'failure' ? (
+
+ ) : null}
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx
index 95b5853c..33ede3ed 100644
--- a/src/components/moderation/LabelsOnMe.tsx
+++ b/src/components/moderation/LabelsOnMe.tsx
@@ -14,19 +14,18 @@ import {
} from '#/components/moderation/LabelsOnMeDialog'
export function LabelsOnMe({
- details,
+ type,
labels,
size,
style,
}: {
- details: {did: string} | {uri: string; cid: string}
+ type: 'account' | 'content'
labels: ComAtprotoLabelDefs.Label[] | undefined
size?: ButtonSize
style?: StyleProp
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
- const isAccount = 'did' in details
const control = useLabelsOnMeDialogControl()
if (!labels || !currentAccount) {
@@ -39,7 +38,7 @@ export function LabelsOnMe({
return (
-
+
)}
- {images && images?.length > 0 && (
-
- )}
+
)
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
index c0e78e97..3034f029 100644
--- a/src/screens/Messages/Conversation/MessagesList.tsx
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -29,6 +29,10 @@ import {useAgent} from '#/state/session'
import {clamp} from 'lib/numbers'
import {ScrollProvider} from 'lib/ScrollContext'
import {isWeb} from 'platform/detection'
+import {
+ EmojiPicker,
+ EmojiPickerState,
+} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import {List} from 'view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
@@ -97,6 +101,12 @@ export function MessagesList({
startContentOffset: 0,
})
+ const [emojiPickerState, setEmojiPickerState] =
+ React.useState({
+ isOpen: false,
+ pos: {top: 0, left: 0, right: 0, bottom: 0},
+ })
+
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
// the bottom.
@@ -422,13 +432,22 @@ export function MessagesList({
+ setEmbed={setEmbed}
+ openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}>
>
)}
+ {isWeb && (
+ setEmojiPickerState(prev => ({...prev, isOpen: false}))}
+ />
+ )}
+
{newMessagesPill.show && }
>
)
diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx
index 0108a537..ded473ff 100644
--- a/src/screens/Onboarding/StepInterests/index.tsx
+++ b/src/screens/Onboarding/StepInterests/index.tsx
@@ -6,10 +6,8 @@ import {useQuery} from '@tanstack/react-query'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
-import {useGate} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize'
import {logger} from '#/logger'
-import {isWeb} from '#/platform/detection'
import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {
@@ -29,23 +27,16 @@ import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
-import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
-const PROMPT_HEIGHT = isWeb ? 42 : 36
-// matches the padding of the OnboardingControls.Portal
-const PROMPT_OFFSET = isWeb ? a.pb_2xl.paddingBottom : a.pb_lg.paddingBottom
-const MIN_INTERESTS = 3
-
export function StepInterests() {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {track} = useAnalytics()
- const gate = useGate()
const interestsDisplayNames = useInterestsDisplayNames()
const {state, dispatch} = React.useContext(Context)
@@ -143,12 +134,6 @@ export function StepInterests() {
track('OnboardingV2:StepInterests:Start')
}, [track])
- const isMinimumInterestsEnabled =
- gate('onboarding_minimum_interests') && data?.interests.length !== 0
- const meetsMinimumRequirement = isMinimumInterestsEnabled
- ? interests.length >= MIN_INTERESTS
- : true
-
const title = isError ? (
Oh no! Something went wrong.
) : (
@@ -186,13 +171,8 @@ export function StepInterests() {
{title}
{description}
- {isMinimumInterestsEnabled && (
-
- Choose 3 or more:
-
- )}
-
+
{isLoading ? (
) : isError || !data ? (
@@ -268,7 +248,7 @@ export function StepInterests() {
) : (
)}
-
- {!meetsMinimumRequirement && (
-
-
-
-
-
- Choose at least {MIN_INTERESTS - interests.length} more
-
-
-
-
- )}
)
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index 2036023c..cf5fcb97 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -6,11 +6,9 @@ import {
ModerationOpts,
RichText as RichTextAPI,
} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
@@ -23,10 +21,9 @@ import {useRequireAuth, useSession} from '#/state/session'
import {useAnalytics} from 'lib/analytics/analytics'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {useProfileShadow} from 'state/cache/profile-shadow'
-import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
import * as Toast from '#/view/com/util/Toast'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
@@ -59,8 +56,6 @@ let ProfileHeaderStandard = ({
}: Props): React.ReactNode => {
const profile: Shadow =
useProfileShadow(profileUnshadowed)
- const t = useTheme()
- const gate = useGate()
const {currentAccount, hasSession} = useSession()
const {_} = useLingui()
const {openModal} = useModalControls()
@@ -69,7 +64,6 @@ let ProfileHeaderStandard = ({
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
- const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
'ProfileHeader',
@@ -202,34 +196,7 @@ let ProfileHeaderStandard = ({
)
) : !profile.viewer?.blockedBy ? (
<>
- {hasSession && (
- <>
-
- {!gate('show_follow_suggestions_in_profile') && (
-
- setShowSuggestedFollows(!showSuggestedFollows)
- }
- label={_(msg`Show follows similar to ${profile.handle}`)}
- style={{width: 36, height: 36}}>
-
-
- )}
- >
- )}
+ {hasSession && }
)}
- {showSuggestedFollows && (
- {
- if (showSuggestedFollows) {
- setShowSuggestedFollows(false)
- } else {
- track('ProfileHeader:SuggestedFollowsOpened')
- setShowSuggestedFollows(true)
- }
- }}
- />
- )}
{isMe ? (
-
+
) : (
)}
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index 197903be..982d224a 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -99,8 +99,8 @@ export function useGetPosts() {
export function usePostLikeMutationQueue(
post: Shadow,
- logContext: LogEvents['post:like']['logContext'] &
- LogEvents['post:unlike']['logContext'],
+ logContext: LogEvents['post:like:sampled']['logContext'] &
+ LogEvents['post:unlike:sampled']['logContext'],
) {
const queryClient = useQueryClient()
const postUri = post.uri
@@ -158,7 +158,7 @@ export function usePostLikeMutationQueue(
}
function usePostLikeMutation(
- logContext: LogEvents['post:like']['logContext'],
+ logContext: LogEvents['post:like:sampled']['logContext'],
post: Shadow,
) {
const {currentAccount} = useSession()
@@ -175,7 +175,7 @@ function usePostLikeMutation(
if (currentAccount) {
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
}
- logEvent('post:like', {
+ logEvent('post:like:sampled', {
logContext,
doesPosterFollowLiker: postAuthor.viewer
? Boolean(postAuthor.viewer.followedBy)
@@ -200,12 +200,12 @@ function usePostLikeMutation(
}
function usePostUnlikeMutation(
- logContext: LogEvents['post:unlike']['logContext'],
+ logContext: LogEvents['post:unlike:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation({
mutationFn: ({likeUri}) => {
- logEvent('post:unlike', {logContext})
+ logEvent('post:unlike:sampled', {logContext})
return agent.deleteLike(likeUri)
},
onSuccess() {
@@ -216,8 +216,8 @@ function usePostUnlikeMutation(
export function usePostRepostMutationQueue(
post: Shadow,
- logContext: LogEvents['post:repost']['logContext'] &
- LogEvents['post:unrepost']['logContext'],
+ logContext: LogEvents['post:repost:sampled']['logContext'] &
+ LogEvents['post:unrepost:sampled']['logContext'],
) {
const queryClient = useQueryClient()
const postUri = post.uri
@@ -273,7 +273,7 @@ export function usePostRepostMutationQueue(
}
function usePostRepostMutation(
- logContext: LogEvents['post:repost']['logContext'],
+ logContext: LogEvents['post:repost:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation<
@@ -282,7 +282,7 @@ function usePostRepostMutation(
{uri: string; cid: string} // the post's uri and cid
>({
mutationFn: post => {
- logEvent('post:repost', {logContext})
+ logEvent('post:repost:sampled', {logContext})
return agent.repost(post.uri, post.cid)
},
onSuccess() {
@@ -292,12 +292,12 @@ function usePostRepostMutation(
}
function usePostUnrepostMutation(
- logContext: LogEvents['post:unrepost']['logContext'],
+ logContext: LogEvents['post:unrepost:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation({
mutationFn: ({repostUri}) => {
- logEvent('post:unrepost', {logContext})
+ logEvent('post:unrepost:sampled', {logContext})
return agent.deleteRepost(repostUri)
},
onSuccess() {
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index 6682cf3c..532b005c 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -219,8 +219,8 @@ export function useProfileUpdateMutation() {
export function useProfileFollowMutationQueue(
profile: Shadow,
- logContext: LogEvents['profile:follow']['logContext'] &
- LogEvents['profile:unfollow']['logContext'],
+ logContext: LogEvents['profile:follow:sampled']['logContext'] &
+ LogEvents['profile:follow:sampled']['logContext'],
) {
const agent = useAgent()
const queryClient = useQueryClient()
@@ -291,7 +291,7 @@ export function useProfileFollowMutationQueue(
}
function useProfileFollowMutation(
- logContext: LogEvents['profile:follow']['logContext'],
+ logContext: LogEvents['profile:follow:sampled']['logContext'],
profile: Shadow,
) {
const {currentAccount} = useSession()
@@ -306,7 +306,7 @@ function useProfileFollowMutation(
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
}
captureAction(ProgressGuideAction.Follow)
- logEvent('profile:follow', {
+ logEvent('profile:follow:sampled', {
logContext,
didBecomeMutual: profile.viewer
? Boolean(profile.viewer.followedBy)
@@ -323,12 +323,12 @@ function useProfileFollowMutation(
}
function useProfileUnfollowMutation(
- logContext: LogEvents['profile:unfollow']['logContext'],
+ logContext: LogEvents['profile:unfollow:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation({
mutationFn: async ({followUri}) => {
- logEvent('profile:unfollow', {logContext})
+ logEvent('profile:unfollow:sampled', {logContext})
track('Profile:Unfollow', {username: followUri})
return await agent.deleteFollow(followUri)
},
diff --git a/src/state/queries/video/compress-video.ts b/src/state/queries/video/compress-video.ts
index 533b5841..cefbf940 100644
--- a/src/state/queries/video/compress-video.ts
+++ b/src/state/queries/video/compress-video.ts
@@ -20,7 +20,7 @@ export function useCompressVideoMutation({
mutationKey: ['video', 'compress'],
mutationFn: cancelable(
(asset: ImagePickerAsset) =>
- compressVideo(asset.uri, {
+ compressVideo(asset, {
onProgress: num => onProgress(trunc2dp(num)),
signal,
}),
diff --git a/src/state/queries/video/util.ts b/src/state/queries/video/util.ts
index e019848a..2c1298ab 100644
--- a/src/state/queries/video/util.ts
+++ b/src/state/queries/video/util.ts
@@ -1,15 +1,13 @@
import {useMemo} from 'react'
import {AtpAgent} from '@atproto/api'
-import {SupportedMimeTypes} from '#/lib/constants'
-
-const UPLOAD_ENDPOINT = 'https://video.bsky.app/'
+import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
export const createVideoEndpointUrl = (
route: string,
params?: Record,
) => {
- const url = new URL(`${UPLOAD_ENDPOINT}`)
+ const url = new URL(VIDEO_SERVICE)
url.pathname = route
if (params) {
for (const key in params) {
@@ -22,7 +20,7 @@ export const createVideoEndpointUrl = (
export function useVideoAgent() {
return useMemo(() => {
return new AtpAgent({
- service: UPLOAD_ENDPOINT,
+ service: VIDEO_SERVICE,
})
}, [])
}
@@ -41,3 +39,18 @@ export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
throw new Error(`Unsupported mime type: ${mimeType}`)
}
}
+
+export function extToMime(ext: string) {
+ switch (ext) {
+ case 'mp4':
+ return 'video/mp4'
+ case 'webm':
+ return 'video/webm'
+ case 'mpeg':
+ return 'video/mpeg'
+ case 'mov':
+ return 'video/quicktime'
+ default:
+ throw new Error(`Unsupported file extension: ${ext}`)
+ }
+}
diff --git a/src/state/queries/video/video-upload.shared.ts b/src/state/queries/video/video-upload.shared.ts
new file mode 100644
index 00000000..6b633bf2
--- /dev/null
+++ b/src/state/queries/video/video-upload.shared.ts
@@ -0,0 +1,73 @@
+import {useCallback} from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {VIDEO_SERVICE_DID} from '#/lib/constants'
+import {UploadLimitError} from '#/lib/media/video/errors'
+import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
+import {useAgent} from '#/state/session'
+import {useVideoAgent} from './util'
+
+export function useServiceAuthToken({
+ aud,
+ lxm,
+ exp,
+}: {
+ aud?: string
+ lxm: string
+ exp?: number
+}) {
+ const agent = useAgent()
+
+ return useCallback(async () => {
+ const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
+
+ if (!pdsAud) {
+ throw new Error('Agent does not have a PDS URL')
+ }
+
+ const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
+ aud: aud ?? pdsAud,
+ lxm,
+ exp,
+ })
+
+ return serviceAuth.token
+ }, [agent, aud, lxm, exp])
+}
+
+export function useVideoUploadLimits() {
+ const agent = useVideoAgent()
+ const getToken = useServiceAuthToken({
+ lxm: 'app.bsky.video.getUploadLimits',
+ aud: VIDEO_SERVICE_DID,
+ })
+ const {_} = useLingui()
+
+ return useCallback(async () => {
+ const {data: limits} = await agent.app.bsky.video
+ .getUploadLimits(
+ {},
+ {headers: {Authorization: `Bearer ${await getToken()}`}},
+ )
+ .catch(err => {
+ if (err instanceof Error) {
+ throw new UploadLimitError(err.message)
+ } else {
+ throw err
+ }
+ })
+
+ if (!limits.canUpload) {
+ if (limits.message) {
+ throw new UploadLimitError(limits.message)
+ } else {
+ throw new UploadLimitError(
+ _(
+ msg`You have temporarily reached the limit for video uploads. Please try again later.`,
+ ),
+ )
+ }
+ }
+ }, [agent, _, getToken])
+}
diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts
index 23e04316..170b5389 100644
--- a/src/state/queries/video/video-upload.ts
+++ b/src/state/queries/video/video-upload.ts
@@ -9,8 +9,8 @@ import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
-import {useAgent, useSession} from '#/state/session'
-import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
+import {useSession} from '#/state/session'
+import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export const useUploadVideoMutation = ({
onSuccess,
@@ -24,38 +24,30 @@ export const useUploadVideoMutation = ({
signal: AbortSignal
}) => {
const {currentAccount} = useSession()
- const agent = useAgent()
+ const getToken = useServiceAuthToken({
+ lxm: 'com.atproto.repo.uploadBlob',
+ exp: Date.now() / 1000 + 60 * 30, // 30 minutes
+ })
+ const checkLimits = useVideoUploadLimits()
const {_} = useLingui()
return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
+ await checkLimits()
+
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
- const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
-
- if (!serviceAuthAud) {
- throw new Error('Agent does not have a PDS URL')
- }
-
- const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
- {
- aud: serviceAuthAud,
- lxm: 'com.atproto.repo.uploadBlob',
- exp: Date.now() / 1000 + 60 * 30, // 30 minutes
- },
- )
-
const uploadTask = createUploadTask(
uri,
video.uri,
{
headers: {
'content-type': video.mimeType,
- Authorization: `Bearer ${serviceAuth.token}`,
+ Authorization: `Bearer ${await getToken()}`,
},
httpMethod: 'POST',
uploadType: FileSystemUploadType.BINARY_CONTENT,
diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts
index 40f58645..c93e2060 100644
--- a/src/state/queries/video/video-upload.web.ts
+++ b/src/state/queries/video/video-upload.web.ts
@@ -8,8 +8,8 @@ import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
-import {useAgent, useSession} from '#/state/session'
-import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
+import {useSession} from '#/state/session'
+import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export const useUploadVideoMutation = ({
onSuccess,
@@ -23,37 +23,30 @@ export const useUploadVideoMutation = ({
signal: AbortSignal
}) => {
const {currentAccount} = useSession()
- const agent = useAgent()
+ const getToken = useServiceAuthToken({
+ lxm: 'com.atproto.repo.uploadBlob',
+ exp: Date.now() / 1000 + 60 * 30, // 30 minutes
+ })
+ const checkLimits = useVideoUploadLimits()
const {_} = useLingui()
return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
+ await checkLimits()
+
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
- const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
-
- if (!serviceAuthAud) {
- throw new Error('Agent does not have a PDS URL')
- }
-
- const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
- {
- aud: serviceAuthAud,
- lxm: 'com.atproto.repo.uploadBlob',
- exp: Date.now() / 1000 + 60 * 30, // 30 minutes
- },
- )
-
let bytes = video.bytes
-
if (!bytes) {
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
}
+ const token = await getToken()
+
const xhr = new XMLHttpRequest()
const res = await new Promise(
(resolve, reject) => {
@@ -76,7 +69,7 @@ export const useUploadVideoMutation = ({
}
xhr.open('POST', uri)
xhr.setRequestHeader('Content-Type', video.mimeType)
- xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
+ xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.send(bytes)
},
)
diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts
index 5e36ce35..95fc0b68 100644
--- a/src/state/queries/video/video.ts
+++ b/src/state/queries/video/video.ts
@@ -1,14 +1,19 @@
-import React, {useCallback} from 'react'
+import React, {useCallback, useEffect} from 'react'
import {ImagePickerAsset} from 'expo-image-picker'
import {AppBskyVideoDefs, BlobRef} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
+import {AbortError} from '#/lib/async/cancelable'
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
-import {ServerError, VideoTooLargeError} from 'lib/media/video/errors'
+import {
+ ServerError,
+ UploadLimitError,
+ VideoTooLargeError,
+} from 'lib/media/video/errors'
import {CompressedVideo} from 'lib/media/video/types'
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
import {useVideoAgent} from 'state/queries/video/util'
@@ -25,7 +30,7 @@ type Action =
| {type: 'SetDimensions'; width: number; height: number}
| {type: 'SetVideo'; video: CompressedVideo}
| {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus}
- | {type: 'SetBlobRef'; blobRef: BlobRef}
+ | {type: 'SetComplete'; blobRef: BlobRef}
export interface State {
status: Status
@@ -36,8 +41,11 @@ export interface State {
blobRef?: BlobRef
error?: string
abortController: AbortController
+ pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean}
}
+export type VideoUploadDispatch = (action: Action) => void
+
function reducer(queryClient: QueryClient) {
return (state: State, action: Action): State => {
let updatedState = state
@@ -77,8 +85,15 @@ function reducer(queryClient: QueryClient) {
updatedState = {...state, video: action.video, status: 'uploading'}
} else if (action.type === 'SetJobStatus') {
updatedState = {...state, jobStatus: action.jobStatus}
- } else if (action.type === 'SetBlobRef') {
- updatedState = {...state, blobRef: action.blobRef, status: 'done'}
+ } else if (action.type === 'SetComplete') {
+ updatedState = {
+ ...state,
+ pendingPublish: {
+ blobRef: action.blobRef,
+ mutableProcessed: false,
+ },
+ status: 'done',
+ }
}
return updatedState
}
@@ -86,7 +101,6 @@ function reducer(queryClient: QueryClient) {
export function useUploadVideo({
setStatus,
- onSuccess,
}: {
setStatus: (status: string) => void
onSuccess: () => void
@@ -112,11 +126,20 @@ export function useUploadVideo({
},
onSuccess: blobRef => {
dispatch({
- type: 'SetBlobRef',
+ type: 'SetComplete',
blobRef,
})
- onSuccess()
},
+ onError: useCallback(
+ error => {
+ logger.error('Error processing video', {safeMessage: error})
+ dispatch({
+ type: 'SetError',
+ error: _(msg`Video failed to process`),
+ })
+ },
+ [_],
+ ),
})
const {mutate: onVideoCompressed} = useUploadVideoMutation({
@@ -128,10 +151,42 @@ export function useUploadVideo({
setJobId(response.jobId)
},
onError: e => {
- if (e instanceof ServerError) {
+ if (e instanceof AbortError) {
+ return
+ } else if (e instanceof ServerError || e instanceof UploadLimitError) {
+ let message
+ // https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
+ switch (e.message) {
+ case 'User is not allowed to upload videos':
+ message = _(msg`You are not allowed to upload videos.`)
+ break
+ case 'Uploading is disabled at the moment':
+ message = _(
+ msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`,
+ )
+ break
+ case "Failed to get user's upload stats":
+ message = _(
+ msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
+ )
+ break
+ case 'User has exceeded daily upload bytes limit':
+ message = _(
+ msg`You've reached your daily limit for video uploads (too many bytes)`,
+ )
+ break
+ case 'User has exceeded daily upload videos limit':
+ message = _(
+ msg`You've reached your daily limit for video uploads (too many videos)`,
+ )
+ break
+ default:
+ message = e.message
+ break
+ }
dispatch({
type: 'SetError',
- error: e.message,
+ error: message,
})
} else {
dispatch({
@@ -159,7 +214,9 @@ export function useUploadVideo({
onVideoCompressed(video)
},
onError: e => {
- if (e instanceof VideoTooLargeError) {
+ if (e instanceof AbortError) {
+ return
+ } else if (e instanceof VideoTooLargeError) {
dispatch({
type: 'SetError',
error: _(msg`The selected video is larger than 100MB.`),
@@ -215,15 +272,17 @@ export function useUploadVideo({
const useUploadStatusQuery = ({
onStatusChange,
onSuccess,
+ onError,
}: {
onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void
onSuccess: (blobRef: BlobRef) => void
+ onError: (error: Error) => void
}) => {
const videoAgent = useVideoAgent()
const [enabled, setEnabled] = React.useState(true)
const [jobId, setJobId] = React.useState()
- const {isLoading, isError} = useQuery({
+ const {error} = useQuery({
queryKey: ['video', 'upload status', jobId],
queryFn: async () => {
if (!jobId) return // this won't happen, can ignore
@@ -236,7 +295,7 @@ const useUploadStatusQuery = ({
throw new Error('Job completed, but did not return a blob')
onSuccess(status.blob)
} else if (status.state === 'JOB_STATE_FAILED') {
- throw new Error('Job failed to process')
+ throw new Error(status.error ?? 'Job failed to process')
}
onStatusChange(status)
return status
@@ -245,9 +304,14 @@ const useUploadStatusQuery = ({
refetchInterval: 1500,
})
+ useEffect(() => {
+ if (error) {
+ onError(error)
+ setEnabled(false)
+ }
+ }, [error, onError])
+
return {
- isLoading,
- isError,
setJobId: (_jobId: string) => {
setJobId(_jobId)
setEnabled(true)
diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx
index 74802a99..612388ff 100644
--- a/src/state/shell/composer.tsx
+++ b/src/state/shell/composer.tsx
@@ -34,7 +34,7 @@ export interface ComposerOpts {
quote?: ComposerOptsQuote
quoteCount?: number
mention?: string // handle of user to mention
- openPicker?: (pos: DOMRect | undefined) => void
+ openEmojiPicker?: (pos: DOMRect | undefined) => void
text?: string
imageUris?: {uri: string; width: number; height: number}[]
}
diff --git a/src/storage/README.md b/src/storage/README.md
new file mode 100644
index 00000000..b7d8d356
--- /dev/null
+++ b/src/storage/README.md
@@ -0,0 +1,62 @@
+# `#/storage`
+
+## Usage
+
+Import the correctly scoped store from `#/storage`. Each instance of `Storage`
+(the base class, not to be used directly), has the following interface:
+
+- `set([...scope, key], value)`
+- `get([...scope, key])`
+- `remove([...scope, key])`
+- `removeMany([...scope], [...keys])`
+
+For example, using our `device` store looks like this, since it's scoped to the
+device (the most base level scope):
+
+```typescript
+import { device } from '#/storage';
+
+device.set(['foobar'], true);
+device.get(['foobar']);
+device.remove(['foobar']);
+device.removeMany([], ['foobar']);
+```
+
+## TypeScript
+
+Stores are strongly typed, and when setting a given value, it will need to
+conform to the schemas defined in `#/storage/schema`. When getting a value, it
+will be returned to you as the type defined in its schema.
+
+## Scoped Stores
+
+Some stores are (or might be) scoped to an account or other identifier. In this
+case, storage instances are created with type-guards, like this:
+
+```typescript
+type AccountSchema = {
+ language: `${string}-${string}`;
+};
+
+type DID = `did:${string}`;
+
+const account = new Storage<
+ [DID],
+ AccountSchema
+>({
+ id: 'account',
+});
+
+account.set(
+ ['did:plc:abc', 'language'],
+ 'en-US',
+);
+
+const language = account.get([
+ 'did:plc:abc',
+ 'language',
+]);
+```
+
+Here, if `['did:plc:abc']` is not supplied along with the key of
+`language`, the `get` will return undefined (and TS will yell at you).
diff --git a/src/storage/__tests__/index.test.ts b/src/storage/__tests__/index.test.ts
new file mode 100644
index 00000000..e11affa7
--- /dev/null
+++ b/src/storage/__tests__/index.test.ts
@@ -0,0 +1,81 @@
+import {beforeEach, expect, jest, test} from '@jest/globals'
+
+import {Storage} from '#/storage'
+
+jest.mock('react-native-mmkv', () => ({
+ MMKV: class MMKVMock {
+ _store = new Map()
+
+ set(key: string, value: unknown) {
+ this._store.set(key, value)
+ }
+
+ getString(key: string) {
+ return this._store.get(key)
+ }
+
+ delete(key: string) {
+ return this._store.delete(key)
+ }
+ },
+}))
+
+type Schema = {
+ boo: boolean
+ str: string | null
+ num: number
+ obj: Record
+}
+
+const scope = `account`
+const store = new Storage<['account'], Schema>({id: 'test'})
+
+beforeEach(() => {
+ store.removeMany([scope], ['boo', 'str', 'num', 'obj'])
+})
+
+test(`stores and retrieves data`, () => {
+ store.set([scope, 'boo'], true)
+ store.set([scope, 'str'], 'string')
+ store.set([scope, 'num'], 1)
+ expect(store.get([scope, 'boo'])).toEqual(true)
+ expect(store.get([scope, 'str'])).toEqual('string')
+ expect(store.get([scope, 'num'])).toEqual(1)
+})
+
+test(`removes data`, () => {
+ store.set([scope, 'boo'], true)
+ expect(store.get([scope, 'boo'])).toEqual(true)
+ store.remove([scope, 'boo'])
+ expect(store.get([scope, 'boo'])).toEqual(undefined)
+})
+
+test(`removes multiple keys at once`, () => {
+ store.set([scope, 'boo'], true)
+ store.set([scope, 'str'], 'string')
+ store.set([scope, 'num'], 1)
+ store.removeMany([scope], ['boo', 'str', 'num'])
+ expect(store.get([scope, 'boo'])).toEqual(undefined)
+ expect(store.get([scope, 'str'])).toEqual(undefined)
+ expect(store.get([scope, 'num'])).toEqual(undefined)
+})
+
+test(`concatenates keys`, () => {
+ store.remove([scope, 'str'])
+ store.set([scope, 'str'], 'concat')
+ // @ts-ignore accessing these properties for testing purposes only
+ expect(store.store.getString(`${scope}${store.sep}str`)).toBeTruthy()
+})
+
+test(`can store falsy values`, () => {
+ store.set([scope, 'str'], null)
+ store.set([scope, 'num'], 0)
+ expect(store.get([scope, 'str'])).toEqual(null)
+ expect(store.get([scope, 'num'])).toEqual(0)
+})
+
+test(`can store objects`, () => {
+ const obj = {foo: true}
+ store.set([scope, 'obj'], obj)
+ expect(store.get([scope, 'obj'])).toEqual(obj)
+})
diff --git a/src/storage/index.ts b/src/storage/index.ts
new file mode 100644
index 00000000..819ffab7
--- /dev/null
+++ b/src/storage/index.ts
@@ -0,0 +1,72 @@
+import {MMKV} from 'react-native-mmkv'
+
+import {Device} from '#/storage/schema'
+
+/**
+ * Generic storage class. DO NOT use this directly. Instead, use the exported
+ * storage instances below.
+ */
+export class Storage {
+ protected sep = ':'
+ protected store: MMKV
+
+ constructor({id}: {id: string}) {
+ this.store = new MMKV({id})
+ }
+
+ /**
+ * Store a value in storage based on scopes and/or keys
+ *
+ * `set([key], value)`
+ * `set([scope, key], value)`
+ */
+ set(
+ scopes: [...Scopes, Key],
+ data: Schema[Key],
+ ): void {
+ // stored as `{ data: }` structure to ease stringification
+ this.store.set(scopes.join(this.sep), JSON.stringify({data}))
+ }
+
+ /**
+ * Get a value from storage based on scopes and/or keys
+ *
+ * `get([key])`
+ * `get([scope, key])`
+ */
+ get(
+ scopes: [...Scopes, Key],
+ ): Schema[Key] | undefined {
+ const res = this.store.getString(scopes.join(this.sep))
+ if (!res) return undefined
+ // parsed from storage structure `{ data: }`
+ return JSON.parse(res).data
+ }
+
+ /**
+ * Remove a value from storage based on scopes and/or keys
+ *
+ * `remove([key])`
+ * `remove([scope, key])`
+ */
+ remove(scopes: [...Scopes, Key]) {
+ this.store.delete(scopes.join(this.sep))
+ }
+
+ /**
+ * Remove many values from the same storage scope by keys
+ *
+ * `removeMany([], [key])`
+ * `removeMany([scope], [key])`
+ */
+ removeMany(scopes: [...Scopes], keys: Key[]) {
+ keys.forEach(key => this.remove([...scopes, key]))
+ }
+}
+
+/**
+ * Device data that's specific to the device and does not vary based on account
+ *
+ * `device.set([key], true)`
+ */
+export const device = new Storage<[], Device>({id: 'device'})
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
new file mode 100644
index 00000000..6522d75a
--- /dev/null
+++ b/src/storage/schema.ts
@@ -0,0 +1,4 @@
+/**
+ * Device data that's specific to the device and does not vary based account
+ */
+export type Device = {}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index e42e23ba..a637b599 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -20,12 +20,19 @@ import {
// @ts-expect-error no type definition
import ProgressCircle from 'react-native-progress/Circle'
import Animated, {
+ Easing,
FadeIn,
FadeOut,
interpolateColor,
+ LayoutAnimationConfig,
+ LinearTransition,
useAnimatedStyle,
+ useDerivedValue,
useSharedValue,
+ withRepeat,
withTiming,
+ ZoomIn,
+ ZoomOut,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {
@@ -39,18 +46,31 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {observer} from 'mobx-react-lite'
+import {useAnalytics} from '#/lib/analytics/analytics'
+import * as apilib from '#/lib/api/index'
import {until} from '#/lib/async/until'
+import {MAX_GRAPHEME_LENGTH} from '#/lib/constants'
import {
createGIFDescription,
parseAltFromGIFDescription,
} from '#/lib/gif-alt-text'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
+import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {LikelyType} from '#/lib/link-meta/link-meta'
import {logEvent, useGate} from '#/lib/statsig/statsig'
+import {cleanError} from '#/lib/strings/errors'
+import {insertMentionAt} from '#/lib/strings/mention-manip'
+import {shortenLinks} from '#/lib/strings/rich-text-manip'
+import {colors, s} from '#/lib/styles'
import {logger} from '#/logger'
+import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
+import {useDialogStateControlContext} from '#/state/dialogs'
import {emitPostCreated} from '#/state/events'
import {useModalControls} from '#/state/modals'
import {useModals} from '#/state/modals'
+import {GalleryModel} from '#/state/models/media/gallery'
import {useRequireAltTextEnabled} from '#/state/preferences'
import {
toPostLanguages,
@@ -62,55 +82,45 @@ import {useProfileQuery} from '#/state/queries/profile'
import {Gif} from '#/state/queries/tenor'
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util'
-import {useUploadVideo} from '#/state/queries/video/video'
+import {
+ State as VideoUploadState,
+ useUploadVideo,
+ VideoUploadDispatch,
+} from '#/state/queries/video/video'
import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
-import {useAnalytics} from 'lib/analytics/analytics'
-import * as apilib from 'lib/api/index'
-import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
-import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {cleanError} from 'lib/strings/errors'
-import {insertMentionAt} from 'lib/strings/mention-manip'
-import {shortenLinks} from 'lib/strings/rich-text-manip'
-import {colors, s} from 'lib/styles'
-import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
-import {useDialogStateControlContext} from 'state/dialogs'
-import {GalleryModel} from 'state/models/media/gallery'
-import {State as VideoUploadState} from 'state/queries/video/video'
-import {ComposerOpts} from 'state/shell/composer'
-import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
-import {atoms as a, useTheme} from '#/alf'
+import {ComposerOpts} from '#/state/shell/composer'
+import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
+import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
+import {ExternalEmbed} from '#/view/com/composer/ExternalEmbed'
+import {GifAltText} from '#/view/com/composer/GifAltText'
+import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
+import {Gallery} from '#/view/com/composer/photos/Gallery'
+import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
+import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
+import {SelectPhotoBtn} from '#/view/com/composer/photos/SelectPhotoBtn'
+import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn'
+import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
+// TODO: Prevent naming components that coincide with RN primitives
+// due to linting false positives
+import {TextInput, TextInputRef} from '#/view/com/composer/text-input/TextInput'
+import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
+import {useExternalLinkFetch} from '#/view/com/composer/useExternalLinkFetch'
+import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn'
+import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
+import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
+import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
+import {QuoteEmbed, QuoteX} from '#/view/com/util/post-embeds/QuoteEmbed'
+import {Text} from '#/view/com/util/text/Text'
+import * as Toast from '#/view/com/util/Toast'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, native, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as Prompt from '#/components/Prompt'
import {Text as NewText} from '#/components/Typography'
-import {QuoteEmbed, QuoteX} from '../util/post-embeds/QuoteEmbed'
-import {Text} from '../util/text/Text'
-import * as Toast from '../util/Toast'
-import {UserAvatar} from '../util/UserAvatar'
-import {CharProgress} from './char-progress/CharProgress'
-import {ExternalEmbed} from './ExternalEmbed'
-import {GifAltText} from './GifAltText'
-import {LabelsBtn} from './labels/LabelsBtn'
-import {Gallery} from './photos/Gallery'
-import {OpenCameraBtn} from './photos/OpenCameraBtn'
-import {SelectGifBtn} from './photos/SelectGifBtn'
-import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
-import {SelectLangBtn} from './select-language/SelectLangBtn'
-import {SuggestedLanguage} from './select-language/SuggestedLanguage'
-// TODO: Prevent naming components that coincide with RN primitives
-// due to linting false positives
-import {TextInput, TextInputRef} from './text-input/TextInput'
-import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
-import {useExternalLinkFetch} from './useExternalLinkFetch'
-import {SelectVideoBtn} from './videos/SelectVideoBtn'
-import {SubtitleDialogBtn} from './videos/SubtitleDialog'
-import {VideoPreview} from './videos/VideoPreview'
-import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
type CancelRef = {
onPressCancel: () => void
@@ -123,7 +133,7 @@ export const ComposePost = observer(function ComposePost({
quote: initQuote,
quoteCount,
mention: initMention,
- openPicker,
+ openEmojiPicker,
text: initText,
imageUris: initImageUris,
cancelRef,
@@ -190,6 +200,8 @@ export const ComposePost = observer(function ComposePost({
}
},
})
+ const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video)
+
const [publishOnUpload, setPublishOnUpload] = useState(false)
const {extLink, setExtLink} = useExternalLinkFetch({setQuote, setError})
@@ -220,7 +232,12 @@ export const ComposePost = observer(function ComposePost({
)
const onPressCancel = useCallback(() => {
- if (graphemeLength > 0 || !gallery.isEmpty || extGif) {
+ if (
+ graphemeLength > 0 ||
+ !gallery.isEmpty ||
+ extGif ||
+ videoUploadState.status !== 'idle'
+ ) {
closeAllDialogs()
Keyboard.dismiss()
discardPromptControl.open()
@@ -234,6 +251,7 @@ export const ComposePost = observer(function ComposePost({
closeAllDialogs,
discardPromptControl,
onClose,
+ videoUploadState.status,
])
useImperativeHandle(cancelRef, () => ({onPressCancel}))
@@ -303,147 +321,188 @@ export const ComposePost = observer(function ComposePost({
return false
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
- const onPressPublish = async (finishedUploading?: boolean) => {
- if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
- return
- }
+ const onPressPublish = React.useCallback(
+ async (finishedUploading?: boolean) => {
+ if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
+ return
+ }
- if (isAltTextRequiredAndMissing) {
- return
- }
+ if (isAltTextRequiredAndMissing) {
+ return
+ }
- if (
- !finishedUploading &&
- videoUploadState.asset &&
- videoUploadState.status !== 'done'
- ) {
- setPublishOnUpload(true)
- return
- }
+ if (
+ !finishedUploading &&
+ videoUploadState.asset &&
+ videoUploadState.status !== 'done'
+ ) {
+ setPublishOnUpload(true)
+ return
+ }
- setError('')
+ setError('')
- if (
- richtext.text.trim().length === 0 &&
- gallery.isEmpty &&
- !extLink &&
- !quote
- ) {
- setError(_(msg`Did you want to say anything?`))
- return
- }
- if (extLink?.isLoading) {
- setError(_(msg`Please wait for your link card to finish loading`))
- return
- }
+ if (
+ richtext.text.trim().length === 0 &&
+ gallery.isEmpty &&
+ !extLink &&
+ !quote &&
+ videoUploadState.status === 'idle'
+ ) {
+ setError(_(msg`Did you want to say anything?`))
+ return
+ }
+ if (extLink?.isLoading) {
+ setError(_(msg`Please wait for your link card to finish loading`))
+ return
+ }
- setIsProcessing(true)
+ setIsProcessing(true)
- let postUri
- try {
- postUri = (
- await apilib.post(agent, {
- rawText: richtext.text,
- replyTo: replyTo?.uri,
- images: gallery.images,
- quote,
- extLink,
- labels,
- threadgate: threadgateAllowUISettings,
- postgate,
- onStateChange: setProcessingState,
- langs: toPostLanguages(langPrefs.postLanguage),
- video: videoUploadState.blobRef
- ? {
- blobRef: videoUploadState.blobRef,
- altText: videoAltText,
- captions: captions,
- aspectRatio: videoUploadState.asset
- ? {
- width: videoUploadState.asset?.width,
- height: videoUploadState.asset?.height,
- }
- : undefined,
- }
- : undefined,
- })
- ).uri
+ let postUri
try {
- await whenAppViewReady(agent, postUri, res => {
- const thread = res.data.thread
- return AppBskyFeedDefs.isThreadViewPost(thread)
- })
- } catch (waitErr: any) {
- logger.error(waitErr, {
- message: `Waiting for app view failed`,
- })
- // Keep going because the post *was* published.
- }
- } catch (e: any) {
- logger.error(e, {
- message: `Composer: create post failed`,
- hasImages: gallery.size > 0,
- })
-
- if (extLink) {
- setExtLink({
- ...extLink,
- isLoading: true,
- localThumb: undefined,
- } as apilib.ExternalEmbedDraft)
- }
- let err = cleanError(e.message)
- if (err.includes('not locate record')) {
- err = _(
- msg`We're sorry! The post you are replying to has been deleted.`,
- )
- }
- setError(err)
- setIsProcessing(false)
- return
- } finally {
- if (postUri) {
- logEvent('post:create', {
- imageCount: gallery.size,
- isReply: replyTo != null,
- hasLink: extLink != null,
- hasQuote: quote != null,
- langs: langPrefs.postLanguage,
- logContext: 'Composer',
- })
- }
- track('Create Post', {
- imageCount: gallery.size,
- })
- if (replyTo && replyTo.uri) track('Post:Reply')
- }
- if (postUri && !replyTo) {
- emitPostCreated()
- }
- setLangPrefs.savePostLanguageToHistory()
- if (quote) {
- // We want to wait for the quote count to update before we call `onPost`, which will refetch data
- whenAppViewReady(agent, quote.uri, res => {
- const thread = res.data.thread
- if (
- AppBskyFeedDefs.isThreadViewPost(thread) &&
- thread.post.quoteCount !== quoteCount
- ) {
- onPost?.(postUri)
- return true
+ postUri = (
+ await apilib.post(agent, {
+ rawText: richtext.text,
+ replyTo: replyTo?.uri,
+ images: gallery.images,
+ quote,
+ extLink,
+ labels,
+ threadgate: threadgateAllowUISettings,
+ postgate,
+ onStateChange: setProcessingState,
+ langs: toPostLanguages(langPrefs.postLanguage),
+ video: videoUploadState.pendingPublish?.blobRef
+ ? {
+ blobRef: videoUploadState.pendingPublish.blobRef,
+ altText: videoAltText,
+ captions: captions,
+ aspectRatio: videoUploadState.asset
+ ? {
+ width: videoUploadState.asset?.width,
+ height: videoUploadState.asset?.height,
+ }
+ : undefined,
+ }
+ : undefined,
+ })
+ ).uri
+ try {
+ await whenAppViewReady(agent, postUri, res => {
+ const thread = res.data.thread
+ return AppBskyFeedDefs.isThreadViewPost(thread)
+ })
+ } catch (waitErr: any) {
+ logger.error(waitErr, {
+ message: `Waiting for app view failed`,
+ })
+ // Keep going because the post *was* published.
}
- return false
- })
- } else {
- onPost?.(postUri)
+ } catch (e: any) {
+ logger.error(e, {
+ message: `Composer: create post failed`,
+ hasImages: gallery.size > 0,
+ })
+
+ if (extLink) {
+ setExtLink({
+ ...extLink,
+ isLoading: true,
+ localThumb: undefined,
+ } as apilib.ExternalEmbedDraft)
+ }
+ let err = cleanError(e.message)
+ if (err.includes('not locate record')) {
+ err = _(
+ msg`We're sorry! The post you are replying to has been deleted.`,
+ )
+ }
+ setError(err)
+ setIsProcessing(false)
+ return
+ } finally {
+ if (postUri) {
+ logEvent('post:create', {
+ imageCount: gallery.size,
+ isReply: replyTo != null,
+ hasLink: extLink != null,
+ hasQuote: quote != null,
+ langs: langPrefs.postLanguage,
+ logContext: 'Composer',
+ })
+ }
+ track('Create Post', {
+ imageCount: gallery.size,
+ })
+ if (replyTo && replyTo.uri) track('Post:Reply')
+ }
+ if (postUri && !replyTo) {
+ emitPostCreated()
+ }
+ setLangPrefs.savePostLanguageToHistory()
+ if (quote) {
+ // We want to wait for the quote count to update before we call `onPost`, which will refetch data
+ whenAppViewReady(agent, quote.uri, res => {
+ const thread = res.data.thread
+ if (
+ AppBskyFeedDefs.isThreadViewPost(thread) &&
+ thread.post.quoteCount !== quoteCount
+ ) {
+ onPost?.(postUri)
+ return true
+ }
+ return false
+ })
+ } else {
+ onPost?.(postUri)
+ }
+ onClose()
+ Toast.show(
+ replyTo
+ ? _(msg`Your reply has been published`)
+ : _(msg`Your post has been published`),
+ )
+ },
+ [
+ _,
+ agent,
+ captions,
+ extLink,
+ gallery.images,
+ gallery.isEmpty,
+ gallery.size,
+ graphemeLength,
+ isAltTextRequiredAndMissing,
+ isProcessing,
+ labels,
+ langPrefs.postLanguage,
+ onClose,
+ onPost,
+ postgate,
+ quote,
+ quoteCount,
+ replyTo,
+ richtext.text,
+ setExtLink,
+ setLangPrefs,
+ threadgateAllowUISettings,
+ track,
+ videoAltText,
+ videoUploadState.asset,
+ videoUploadState.pendingPublish,
+ videoUploadState.status,
+ ],
+ )
+
+ React.useEffect(() => {
+ if (videoUploadState.pendingPublish && publishOnUpload) {
+ if (!videoUploadState.pendingPublish.mutableProcessed) {
+ videoUploadState.pendingPublish.mutableProcessed = true
+ onPressPublish(true)
+ }
}
- onClose()
- Toast.show(
- replyTo
- ? _(msg`Your reply has been published`)
- : _(msg`Your post has been published`),
- )
- }
+ }, [onPressPublish, publishOnUpload, videoUploadState.pendingPublish])
const canPost = useMemo(
() => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing,
@@ -462,8 +521,8 @@ export const ComposePost = observer(function ComposePost({
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
const onEmojiButtonPress = useCallback(() => {
- openPicker?.(textInput.current?.getCursorPosition())
- }, [openPicker])
+ openEmojiPicker?.(textInput.current?.getCursorPosition())
+ }, [openEmojiPicker])
const focusTextInput = useCallback(() => {
textInput.current?.focus()
@@ -524,7 +583,9 @@ export const ComposePost = observer(function ComposePost({
keyboardVerticalOffset={keyboardVerticalOffset}
style={a.flex_1}>
-
+
>
) : (
- <>
+
)}
- >
+
)}
@@ -608,48 +669,15 @@ export const ComposePost = observer(function ComposePost({
)}
- {(error !== '' || videoUploadState.error) && (
-
-
-
-
- {error || videoUploadState.error}
-
- {
- if (error) setError('')
- else videoUploadDispatch({type: 'Reset'})
- }}>
-
-
-
-
- )}
+ setError('')}
+ videoUploadDispatch={videoUploadDispatch}
+ />
)}
-
-
+
+ {hasVideo && (
+
+ {videoUploadState.asset &&
+ (videoUploadState.status === 'compressing' ? (
+
+ ) : videoUploadState.video ? (
+
+ ) : null)}
+
+
+ )}
+
+
{quote ? (
@@ -715,29 +772,6 @@ export const ComposePost = observer(function ComposePost({
)}
) : null}
- {videoUploadState.asset &&
- (videoUploadState.status === 'compressing' ? (
-
- ) : videoUploadState.video ? (
-
- ) : null)}
- {(videoUploadState.asset || videoUploadState.video) && (
-
- )}
@@ -958,6 +992,10 @@ const styles = StyleSheet.create({
paddingVertical: 6,
marginLeft: 12,
},
+ postBtnWrapper: {
+ flexDirection: 'row',
+ gap: 14,
+ },
errorLine: {
flexDirection: 'row',
alignItems: 'center',
@@ -1018,6 +1056,80 @@ const styles = StyleSheet.create({
},
})
+function ErrorBanner({
+ error: standardError,
+ videoUploadState,
+ clearError,
+ videoUploadDispatch,
+}: {
+ error: string
+ videoUploadState: VideoUploadState
+ clearError: () => void
+ videoUploadDispatch: VideoUploadDispatch
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+
+ const videoError =
+ videoUploadState.status !== 'idle' ? videoUploadState.error : undefined
+ const error = standardError || videoError
+
+ const onClearError = () => {
+ if (standardError) {
+ clearError()
+ } else {
+ videoUploadDispatch({type: 'Reset'})
+ }
+ }
+
+ if (!error) return null
+
+ return (
+
+
+
+
+
+ {error}
+
+
+
+
+
+ {videoError && videoUploadState.jobStatus?.jobId && (
+
+ Job ID: {videoUploadState.jobStatus.jobId}
+
+ )}
+
+
+ )
+}
+
function ToolbarWrapper({
style,
children,
@@ -1039,6 +1151,31 @@ function ToolbarWrapper({
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
const t = useTheme()
const {_} = useLingui()
+ const progress = state.jobStatus?.progress
+ ? state.jobStatus.progress / 100
+ : state.progress
+ const shouldRotate =
+ state.status === 'processing' && (progress === 0 || progress === 1)
+ let wheelProgress = shouldRotate ? 0.33 : progress
+
+ const rotate = useDerivedValue(() => {
+ if (shouldRotate) {
+ return withRepeat(
+ withTiming(360, {
+ duration: 2500,
+ easing: Easing.out(Easing.cubic),
+ }),
+ -1,
+ )
+ }
+ return 0
+ })
+
+ const animatedStyle = useAnimatedStyle(() => {
+ return {
+ transform: [{rotateZ: `${rotate.value}deg`}],
+ }
+ })
let text = ''
@@ -1057,21 +1194,22 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
break
}
- // we could use state.jobStatus?.progress but 99% of the time it jumps from 0 to 100
- const progress =
- state.status === 'compressing' || state.status === 'uploading'
- ? state.progress
- : 100
+ if (state.error) {
+ text = _('Error')
+ wheelProgress = 100
+ }
return (
-
+
+
+
{text}
)
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 3c4aaf73..c477ada0 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -12,12 +12,12 @@ import {Placeholder} from '@tiptap/extension-placeholder'
import {Text as TiptapText} from '@tiptap/extension-text'
import {generateJSON} from '@tiptap/html'
import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
-import EventEmitter from 'eventemitter3'
import {usePalette} from '#/lib/hooks/usePalette'
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {blobToDataUri, isUriImage} from 'lib/media/util'
+import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {
LinkFacetMatch,
suggestLinkCardUri,
@@ -46,8 +46,6 @@ interface TextInputProps {
onError: (err: string) => void
}
-export const textInputWebEmitter = new EventEmitter()
-
export const TextInput = React.forwardRef(function TextInputImpl(
{
richtext,
diff --git a/src/view/com/composer/text-input/textInputWebEmitter.ts b/src/view/com/composer/text-input/textInputWebEmitter.ts
new file mode 100644
index 00000000..fb037cac
--- /dev/null
+++ b/src/view/com/composer/text-input/textInputWebEmitter.ts
@@ -0,0 +1,3 @@
+import EventEmitter from 'eventemitter3'
+
+export const textInputWebEmitter = new EventEmitter()
diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
index 1f4178f7..ad3bb30e 100644
--- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
+++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
@@ -7,8 +7,8 @@ import {
} from 'react-native'
import Picker from '@emoji-mart/react'
+import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {atoms as a} from '#/alf'
-import {textInputWebEmitter} from '../TextInput.web'
const HEIGHT_OFFSET = 40
const WIDTH_OFFSET = 100
@@ -26,22 +26,41 @@ export type Emoji = {
unified: string
}
+export interface EmojiPickerPosition {
+ top: number
+ left: number
+ right: number
+ bottom: number
+}
+
export interface EmojiPickerState {
isOpen: boolean
- pos: {top: number; left: number; right: number; bottom: number}
+ pos: EmojiPickerPosition
}
interface IProps {
state: EmojiPickerState
close: () => void
+ /**
+ * If `true`, overrides position and ensures picker is pinned to the top of
+ * the target element.
+ */
+ pinToTop?: boolean
}
-export function EmojiPicker({state, close}: IProps) {
+export function EmojiPicker({state, close, pinToTop}: IProps) {
const {height, width} = useWindowDimensions()
const isShiftDown = React.useRef(false)
const position = React.useMemo(() => {
+ if (pinToTop) {
+ return {
+ top: state.pos.top - PICKER_HEIGHT + HEIGHT_OFFSET - 10,
+ left: state.pos.left,
+ }
+ }
+
const fitsBelow = state.pos.top + PICKER_HEIGHT < height
const fitsAbove = PICKER_HEIGHT < state.pos.top
const placeOnLeft = PICKER_WIDTH < state.pos.left
@@ -64,7 +83,7 @@ export function EmojiPicker({state, close}: IProps) {
: undefined,
}
}
- }, [state.pos, height, width])
+ }, [state.pos, height, width, pinToTop])
React.useEffect(() => {
if (!state.isOpen) return
diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx
index d8accd06..6e294ba9 100644
--- a/src/view/com/composer/videos/SelectVideoBtn.tsx
+++ b/src/view/com/composer/videos/SelectVideoBtn.tsx
@@ -1,4 +1,5 @@
import React, {useCallback} from 'react'
+import {Keyboard} from 'react-native'
import {
ImagePickerAsset,
launchImageLibraryAsync,
@@ -10,11 +11,14 @@ import {useLingui} from '@lingui/react'
import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
import {isNative} from '#/platform/detection'
+import {useModalControls} from '#/state/modals'
+import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
+import * as Prompt from '#/components/Prompt'
-const VIDEO_MAX_DURATION = 90
+const VIDEO_MAX_DURATION = 60
type Props = {
onSelectVideo: (video: ImagePickerAsset) => void
@@ -26,33 +30,47 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
const {_} = useLingui()
const t = useTheme()
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
+ const control = Prompt.usePromptControl()
+ const {currentAccount} = useSession()
const onPressSelectVideo = useCallback(async () => {
if (isNative && !(await requestVideoAccessIfNeeded())) {
return
}
- const response = await launchImageLibraryAsync({
- exif: false,
- mediaTypes: MediaTypeOptions.Videos,
- videoMaxDuration: VIDEO_MAX_DURATION,
- quality: 1,
- legacy: true,
- preferredAssetRepresentationMode:
- UIImagePickerPreferredAssetRepresentationMode.Current,
- })
- if (response.assets && response.assets.length > 0) {
- try {
- onSelectVideo(response.assets[0])
- } catch (err) {
- if (err instanceof Error) {
- setError(err.message)
- } else {
- setError(_(msg`An error occurred while selecting the video`))
+ if (!currentAccount?.emailConfirmed) {
+ Keyboard.dismiss()
+ control.open()
+ } else {
+ const response = await launchImageLibraryAsync({
+ exif: false,
+ mediaTypes: MediaTypeOptions.Videos,
+ videoMaxDuration: VIDEO_MAX_DURATION,
+ quality: 1,
+ legacy: true,
+ preferredAssetRepresentationMode:
+ UIImagePickerPreferredAssetRepresentationMode.Current,
+ })
+ if (response.assets && response.assets.length > 0) {
+ try {
+ onSelectVideo(response.assets[0])
+ } catch (err) {
+ if (err instanceof Error) {
+ setError(err.message)
+ } else {
+ setError(_(msg`An error occurred while selecting the video`))
+ }
}
}
}
- }, [onSelectVideo, requestVideoAccessIfNeeded, setError, _])
+ }, [
+ onSelectVideo,
+ requestVideoAccessIfNeeded,
+ setError,
+ _,
+ control,
+ currentAccount?.emailConfirmed,
+ ])
return (
<>
@@ -71,6 +89,32 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
style={disabled && t.atoms.text_contrast_low}
/>
+
>
)
}
+
+function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
+ const {_} = useLingui()
+ const {openModal} = useModalControls()
+
+ return (
+ {
+ control.close(() => {
+ openModal({
+ name: 'verify-email',
+ showReminder: false,
+ })
+ })
+ }}
+ />
+ )
+}
diff --git a/src/view/com/composer/videos/SubtitleDialog.tsx b/src/view/com/composer/videos/SubtitleDialog.tsx
index 90a29b25..00908745 100644
--- a/src/view/com/composer/videos/SubtitleDialog.tsx
+++ b/src/view/com/composer/videos/SubtitleDialog.tsx
@@ -1,5 +1,5 @@
-import React, {useCallback} from 'react'
-import {StyleProp, View, ViewStyle} from 'react-native'
+import React, {useCallback, useState} from 'react'
+import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
import RNPickerSelect from 'react-native-picker-select'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
import {MAX_ALT_TEXT} from '#/lib/constants'
import {useEnforceMaxGraphemeCount} from '#/lib/strings/helpers'
import {LANGUAGES} from '#/locale/languages'
-import {isWeb} from '#/platform/detection'
+import {isAndroid, isWeb} from '#/platform/detection'
import {useLanguagePrefs} from '#/state/preferences'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -21,9 +21,9 @@ import {Text} from '#/components/Typography'
import {SubtitleFilePicker} from './SubtitleFilePicker'
interface Props {
- altText: string
+ defaultAltText: string
captions: {lang: string; file: File}[]
- setAltText: (altText: string) => void
+ saveAltText: (altText: string) => void
setCaptions: React.Dispatch<
React.SetStateAction<{lang: string; file: File}[]>
>
@@ -34,7 +34,7 @@ export function SubtitleDialogBtn(props: Props) {
const {_} = useLingui()
return (
-
+
+ onPress={() => {
+ if (Keyboard.isVisible()) Keyboard.dismiss()
+ control.open()
+ }}>
{isWeb ? Captions & alt text : Alt text}
-
+
@@ -60,8 +65,8 @@ export function SubtitleDialogBtn(props: Props) {
}
function SubtitleDialogInner({
- altText,
- setAltText,
+ defaultAltText,
+ saveAltText,
captions,
setCaptions,
}: Props) {
@@ -71,6 +76,8 @@ function SubtitleDialogInner({
const enforceLen = useEnforceMaxGraphemeCount()
const {primaryLanguage} = useLanguagePrefs()
+ const [altText, setAltText] = useState(defaultAltText)
+
const handleSelectFile = useCallback(
(file: File) => {
setCaptions(subs => [
@@ -102,6 +109,7 @@ function SubtitleDialogInner({
onChangeText={evt => setAltText(enforceLen(evt, MAX_ALT_TEXT))}
maxLength={MAX_ALT_TEXT * 10}
multiline
+ style={{maxHeight: 300}}
numberOfLines={3}
onKeyPress={({nativeEvent}) => {
if (nativeEvent.key === 'Escape') {
@@ -144,22 +152,26 @@ function SubtitleDialogInner({
/>
))}
+ {subtitleMissingLanguage && (
+
+
+ Ensure you have selected a language for each subtitle file.
+
+
+ )}
>
)}
- {subtitleMissingLanguage && (
-
- Ensure you have selected a language for each subtitle file.
-
- )}
-
control.close()}
+ onPress={() => {
+ saveAltText(altText)
+ control.close()
+ }}
style={a.mt_lg}>
Done
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
index 199a1fff..60b467d6 100644
--- a/src/view/com/composer/videos/VideoPreview.tsx
+++ b/src/view/com/composer/videos/VideoPreview.tsx
@@ -6,8 +6,10 @@ import {useVideoPlayer, VideoView} from 'expo-video'
import {CompressedVideo} from '#/lib/media/video/types'
import {clamp} from '#/lib/numbers'
+import {useAutoplayDisabled} from '#/state/preferences'
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
import {atoms as a, useTheme} from '#/alf'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
export function VideoPreview({
asset,
@@ -20,10 +22,13 @@ export function VideoPreview({
clear: () => void
}) {
const t = useTheme()
+ const autoplayDisabled = useAutoplayDisabled()
const player = useVideoPlayer(video.uri, player => {
player.loop = true
player.muted = true
- player.play()
+ if (!autoplayDisabled) {
+ player.play()
+ }
})
let aspectRatio = asset.width / asset.height
@@ -53,6 +58,11 @@ export function VideoPreview({
contentFit="contain"
/>
+ {autoplayDisabled && (
+
+
+
+ )}
)
}
diff --git a/src/view/com/composer/videos/VideoPreview.web.tsx b/src/view/com/composer/videos/VideoPreview.web.tsx
index e802addd..88537956 100644
--- a/src/view/com/composer/videos/VideoPreview.web.tsx
+++ b/src/view/com/composer/videos/VideoPreview.web.tsx
@@ -1,11 +1,16 @@
import React, {useEffect, useRef} from 'react'
import {View} from 'react-native'
import {ImagePickerAsset} from 'expo-image-picker'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
import {CompressedVideo} from '#/lib/media/video/types'
import {clamp} from '#/lib/numbers'
+import {useAutoplayDisabled} from '#/state/preferences'
+import * as Toast from '#/view/com/util/Toast'
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
import {atoms as a} from '#/alf'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
export function VideoPreview({
asset,
@@ -19,6 +24,8 @@ export function VideoPreview({
clear: () => void
}) {
const ref = useRef(null)
+ const {_} = useLingui()
+ const autoplayDisabled = useAutoplayDisabled()
useEffect(() => {
if (!ref.current) return
@@ -32,11 +39,19 @@ export function VideoPreview({
},
{signal},
)
+ ref.current.addEventListener(
+ 'error',
+ () => {
+ Toast.show(_(msg`Could not process your video`), 'xmark')
+ clear()
+ },
+ {signal},
+ )
return () => {
abortController.abort()
}
- }, [setDimensions])
+ }, [setDimensions, _, clear])
let aspectRatio = asset.width / asset.height
@@ -54,17 +69,23 @@ export function VideoPreview({
{aspectRatio},
a.overflow_hidden,
{backgroundColor: 'black'},
+ a.relative,
]}>
+ {autoplayDisabled && (
+
+
+
+ )}
)
}
diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
index 3e26230f..f6f0f7cc 100644
--- a/src/view/com/composer/videos/VideoTranscodeProgress.tsx
+++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
@@ -35,7 +35,6 @@ export function VideoTranscodeProgress({
) : undefined}
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index 3e8f8d86..b1cf3b48 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -8,9 +8,6 @@ import {
} from 'react-native'
import {
AppBskyActorDefs,
- AppBskyEmbedExternal,
- AppBskyEmbedImages,
- AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphFollow,
@@ -25,7 +22,6 @@ import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
-import {parseTenorGif} from '#/lib/strings/embed-player'
import {logger} from '#/logger'
import {FeedNotification} from '#/state/queries/notifications/feed'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
@@ -52,11 +48,11 @@ import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/com
import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
import {StarterPack} from '#/components/icons/StarterPack'
import {Link as NewLink} from '#/components/Link'
+import * as MediaPreview from '#/components/MediaPreview'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
import {FeedSourceCard} from '../feeds/FeedSourceCard'
import {Post} from '../post/Post'
-import {ImageHorzList} from '../util/images/ImageHorzList'
import {Link, TextLink} from '../util/Link'
import {formatCount} from '../util/numeric/format'
import {Text} from '../util/text/Text'
@@ -593,49 +589,14 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
const pal = usePalette('default')
if (post && AppBskyFeedPost.isRecord(post?.record)) {
const text = post.record.text
- let images
- let isGif = false
-
- if (AppBskyEmbedImages.isView(post.embed)) {
- images = post.embed.images
- } else if (
- AppBskyEmbedRecordWithMedia.isView(post.embed) &&
- AppBskyEmbedImages.isView(post.embed.media)
- ) {
- images = post.embed.media.images
- } else if (
- AppBskyEmbedExternal.isView(post.embed) &&
- post.embed.external.thumb
- ) {
- let url: URL | undefined
- try {
- url = new URL(post.embed.external.uri)
- } catch {}
- if (url) {
- const {success} = parseTenorGif(url)
- if (success) {
- isGif = true
- images = [
- {
- thumb: post.embed.external.thumb,
- alt: post.embed.external.title,
- fullsize: post.embed.external.thumb,
- },
- ]
- }
- }
- }
return (
<>
{text?.length > 0 && {text}}
- {images && images.length > 0 && (
-
- )}
+
>
)
}
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 3b5ddb1d..8cd6e70b 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -43,7 +43,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
import {Link, TextLink} from '../util/Link'
import {formatCount} from '../util/numeric/format'
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
-import {PostEmbeds} from '../util/post-embeds'
+import {PostEmbeds, PostEmbedViewContext} from '../util/post-embeds'
import {PostMeta} from '../util/PostMeta'
import {Text} from '../util/text/Text'
import {PreviewableUserAvatar} from '../util/UserAvatar'
@@ -363,7 +363,11 @@ let PostThreadItemLoaded = ({
) : undefined}
{post.embed && (
-
+
)}
@@ -591,7 +595,11 @@ let PostThreadItemLoaded = ({
) : undefined}
{post.embed && (
-
+
)}
) : undefined}
{post.embed ? (
-
+
) : null}
-
) : null}
@@ -563,23 +561,6 @@ function ReplyToLabel({
)
}
-function VideoDebug() {
- const gate = useGate()
- const id = useId()
-
- if (!gate('video_debug')) return null
-
- return (
-
- )
-}
-
const styles = StyleSheet.create({
outer: {
paddingLeft: 10,
diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
deleted file mode 100644
index 356b3f09..00000000
--- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-import React from 'react'
-import {ScrollView, View} from 'react-native'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {logEvent} from '#/lib/statsig/statsig'
-import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
-import {isWeb} from 'platform/detection'
-import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
-import {Button, ButtonIcon} from '#/components/Button'
-import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
-import * as ProfileCard from '#/components/ProfileCard'
-import {Text} from '#/components/Typography'
-
-const OUTER_PADDING = a.p_md.padding
-const INNER_PADDING = a.p_lg.padding
-const TOTAL_HEIGHT = 232
-const MOBILE_CARD_WIDTH = 300
-
-function CardOuter({
- children,
- style,
-}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
- const t = useTheme()
- return (
-
- {children}
-
- )
-}
-
-export function SuggestedFollowPlaceholder() {
- const t = useTheme()
- return (
-
-
-
-
-
-
-
-
- )
-}
-
-export function ProfileHeaderSuggestedFollows({
- actorDid,
- requestDismiss,
-}: {
- actorDid: string
- requestDismiss: () => void
-}) {
- const t = useTheme()
- const {_} = useLingui()
- const {isLoading: isSuggestionsLoading, data} =
- useSuggestedFollowsByActorQuery({
- did: actorDid,
- })
- const moderationOpts = useModerationOpts()
- const isLoading = isSuggestionsLoading || !moderationOpts
-
- return (
-
-
-
-
- Similar accounts
-
-
-
-
-
-
-
-
-
- {isLoading ? (
- <>
-
-
-
-
-
- >
- ) : data ? (
- data.suggestions
- .filter(s => (s.associated?.labeler ? false : true))
- .map(profile => (
- {
- logEvent('profile:header:suggestedFollowsCard:press', {})
- }}
- style={[a.flex_1]}>
- {({hovered, pressed}) => (
-
-
-
-
-
-
-
-
-
-
- )}
-
- ))
- ) : (
-
- )}
-
-
-
-
- )
-}
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index c62ac5ed..79dd2f49 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -7,6 +7,7 @@ import {usePalette} from '#/lib/hooks/usePalette'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {useDedupe} from 'lib/hooks/useDedupe'
import {addStyle} from 'lib/styles'
+import {isIOS} from 'platform/detection'
import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
import {FlatList_INTERNAL} from './Views'
@@ -49,7 +50,7 @@ function ListImpl(
) {
const isScrolledDown = useSharedValue(false)
const pal = usePalette('default')
- const dedupe = useDedupe()
+ const dedupe = useDedupe(400)
function handleScrolledDownChange(didScrollDown: boolean) {
onScrolledDownChange?.(didScrollDown)
@@ -68,6 +69,7 @@ function ListImpl(
onBeginDragFromContext?.(e, ctx)
},
onEndDrag(e, ctx) {
+ runOnJS(updateActiveViewAsync)()
onEndDragFromContext?.(e, ctx)
},
onScroll(e, ctx) {
@@ -81,11 +83,14 @@ function ListImpl(
}
}
- runOnJS(dedupe)(updateActiveViewAsync)
+ if (isIOS) {
+ runOnJS(dedupe)(updateActiveViewAsync)
+ }
},
// Note: adding onMomentumBegin here makes simulator scroll
// lag on Android. So either don't add it, or figure out why.
onMomentumEnd(e, ctx) {
+ runOnJS(updateActiveViewAsync)()
onMomentumEndFromContext?.(e, ctx)
},
})
diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx
index 3163d854..c87ee209 100644
--- a/src/view/com/util/MainScrollProvider.tsx
+++ b/src/view/com/util/MainScrollProvider.tsx
@@ -9,7 +9,6 @@ import {
import EventEmitter from 'eventemitter3'
import {ScrollProvider} from '#/lib/ScrollContext'
-import {useGate} from '#/lib/statsig/statsig'
import {useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {isNative, isWeb} from 'platform/detection'
@@ -23,12 +22,10 @@ function clamp(num: number, min: number, max: number) {
export function MainScrollProvider({children}: {children: React.ReactNode}) {
const {headerHeight} = useShellLayout()
- const {headerMode, footerMode} = useMinimalShellMode()
+ const {headerMode} = useMinimalShellMode()
const startDragOffset = useSharedValue(null)
const startMode = useSharedValue(null)
const didJustRestoreScroll = useSharedValue(false)
- const gate = useGate()
- const isFixedBottomBar = gate('fixed_bottom_bar')
const setMode = React.useCallback(
(v: boolean) => {
@@ -37,14 +34,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
headerMode.value = withSpring(v ? 1 : 0, {
overshootClamping: true,
})
- if (!isFixedBottomBar) {
- cancelAnimation(footerMode)
- footerMode.value = withSpring(v ? 1 : 0, {
- overshootClamping: true,
- })
- }
},
- [headerMode, footerMode, isFixedBottomBar],
+ [headerMode],
)
useEffect(() => {
@@ -147,10 +138,6 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
// Cancel any any existing animation
cancelAnimation(headerMode)
headerMode.value = newValue
- if (!isFixedBottomBar) {
- cancelAnimation(footerMode)
- footerMode.value = newValue
- }
}
} else {
if (didJustRestoreScroll.value) {
@@ -173,12 +160,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
[
headerHeight,
headerMode,
- footerMode,
setMode,
startDragOffset,
startMode,
didJustRestoreScroll,
- isFixedBottomBar,
],
)
diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx
index f7c6bc2c..51e76bdc 100644
--- a/src/view/com/util/Toast.tsx
+++ b/src/view/com/util/Toast.tsx
@@ -59,7 +59,6 @@ function Toast({
a.flex_1,
t.atoms.bg,
a.shadow_lg,
- a.rounded_sm,
t.atoms.border_contrast_medium,
a.rounded_sm,
a.px_md,
diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx
index e6fb0ad4..ee8e1f47 100644
--- a/src/view/com/util/fab/FABInner.tsx
+++ b/src/view/com/util/fab/FABInner.tsx
@@ -79,6 +79,7 @@ const styles = StyleSheet.create({
// @ts-ignore web-only
position: isWeb ? 'fixed' : 'absolute',
zIndex: 1,
+ cursor: 'pointer',
},
inner: {
justifyContent: 'center',
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index 61cb6f69..f57ab4e3 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -1,106 +1,224 @@
import React from 'react'
-import {StyleProp, StyleSheet, Pressable, View, ViewStyle} from 'react-native'
+import {DimensionValue, Pressable, View} from 'react-native'
import {Image} from 'expo-image'
-import {clamp} from 'lib/numbers'
-import {Dimensions} from 'lib/media/types'
-import * as imageSizes from 'lib/media/image-sizes'
+import {AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-const MIN_ASPECT_RATIO = 0.33 // 1/3
-const MAX_ASPECT_RATIO = 10 // 10/1
+import * as imageSizes from '#/lib/media/image-sizes'
+import {Dimensions} from '#/lib/media/types'
+import {isNative} from '#/platform/detection'
+import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Crop_Stroke2_Corner0_Rounded as Crop} from '#/components/icons/Crop'
+import {Text} from '#/components/Typography'
-interface Props {
- alt?: string
- uri: string
- dimensionsHint?: Dimensions
- onPress?: () => void
- onLongPress?: () => void
- onPressIn?: () => void
- style?: StyleProp
- children?: React.ReactNode
+export function useImageAspectRatio({
+ src,
+ dimensions,
+}: {
+ src: string
+ dimensions: Dimensions | undefined
+}) {
+ const [raw, setAspectRatio] = React.useState(
+ dimensions ? calc(dimensions) : 1,
+ )
+ const {isCropped, constrained, max} = React.useMemo(() => {
+ const a34 = 0.75 // max of 3:4 ratio in feeds
+ const constrained = Math.max(raw, a34)
+ const max = Math.max(raw, 0.25) // max of 1:4 in thread
+ const isCropped = raw < constrained
+ return {
+ isCropped,
+ constrained,
+ max,
+ }
+ }, [raw])
+
+ React.useEffect(() => {
+ let aborted = false
+ if (dimensions) return
+ imageSizes.fetch(src).then(newDim => {
+ if (aborted) return
+ setAspectRatio(calc(newDim))
+ })
+ return () => {
+ aborted = true
+ }
+ }, [dimensions, setAspectRatio, src])
+
+ return {
+ dimensions,
+ raw,
+ constrained,
+ max,
+ isCropped,
+ }
+}
+
+export function ConstrainedImage({
+ aspectRatio,
+ fullBleed,
+ children,
+}: {
+ aspectRatio: number
+ fullBleed?: boolean
+ children: React.ReactNode
+}) {
+ const t = useTheme()
+ const {gtMobile} = useBreakpoints()
+ /**
+ * Computed as a % value to apply as `paddingTop`
+ */
+ const outerAspectRatio = React.useMemo(() => {
+ // capped to square or shorter
+ const ratio =
+ isNative || !gtMobile
+ ? Math.min(1 / aspectRatio, 1.5)
+ : Math.min(1 / aspectRatio, 1)
+ return `${ratio * 100}%`
+ }, [aspectRatio, gtMobile])
+
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
}
export function AutoSizedImage({
- alt,
- uri,
- dimensionsHint,
+ image,
+ crop = 'constrained',
+ hideBadge,
onPress,
onLongPress,
onPressIn,
- style,
- children = null,
-}: Props) {
+}: {
+ image: AppBskyEmbedImages.ViewImage
+ crop?: 'none' | 'square' | 'constrained'
+ hideBadge?: boolean
+ onPress?: () => void
+ onLongPress?: () => void
+ onPressIn?: () => void
+}) {
+ const t = useTheme()
const {_} = useLingui()
- const [dim, setDim] = React.useState(
- dimensionsHint || imageSizes.get(uri),
- )
- const [aspectRatio, setAspectRatio] = React.useState(
- dim ? calc(dim) : 1,
- )
- React.useEffect(() => {
- let aborted = false
- if (dim) {
- return
- }
- imageSizes.fetch(uri).then(newDim => {
- if (aborted) {
- return
- }
- setDim(newDim)
- setAspectRatio(calc(newDim))
- })
- }, [dim, setDim, setAspectRatio, uri])
+ const largeAlt = useLargeAltBadgeEnabled()
+ const {
+ constrained,
+ max,
+ isCropped: rawIsCropped,
+ } = useImageAspectRatio({
+ src: image.thumb,
+ dimensions: image.aspectRatio,
+ })
+ const cropDisabled = crop === 'none'
+ const isCropped = rawIsCropped && !cropDisabled
+ const hasAlt = !!image.alt
- if (onPress || onLongPress || onPressIn) {
+ const contents = (
+ <>
+
+
+ {(hasAlt || isCropped) && !hideBadge ? (
+
+ {isCropped && (
+
+ )}
+ {hasAlt && (
+
+ ALT
+
+ )}
+
+ ) : null}
+ >
+ )
+
+ if (cropDisabled) {
return (
- // disable a11y rule because in this case we want the tags on the image (#1640)
- // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors
-
- {children}
+ // alt here is what screen readers actually use
+ accessibilityLabel={image.alt}
+ accessibilityHint={_(msg`Tap to view full image`)}
+ style={[
+ a.w_full,
+ a.rounded_sm,
+ a.overflow_hidden,
+ t.atoms.bg_contrast_25,
+ {aspectRatio: max},
+ ]}>
+ {contents}
)
+ } else {
+ return (
+
+
+ {contents}
+
+
+ )
}
-
- return (
-
-
- {children}
-
- )
}
function calc(dim: Dimensions) {
if (dim.width === 0 || dim.height === 0) {
return 1
}
- return clamp(dim.width / dim.height, MIN_ASPECT_RATIO, MAX_ASPECT_RATIO)
+ return dim.width / dim.height
}
-
-const styles = StyleSheet.create({
- container: {
- overflow: 'hidden',
- },
- image: {
- width: '100%',
- },
-})
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 9bbb2ac1..839674c8 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -1,13 +1,14 @@
import React, {ComponentProps, FC} from 'react'
-import {Pressable, StyleSheet, Text, View} from 'react-native'
+import {Pressable, View} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {isWeb} from '#/platform/detection'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
-import {atoms as a} from '#/alf'
+import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
type EventFunction = (index: number) => void
@@ -17,7 +18,8 @@ interface GalleryItemProps {
onPress?: EventFunction
onLongPress?: EventFunction
onPressIn?: EventFunction
- imageStyle: ComponentProps['style']
+ imageStyle?: ComponentProps['style']
+ viewContext?: PostEmbedViewContext
}
export const GalleryItem: FC = ({
@@ -27,57 +29,69 @@ export const GalleryItem: FC = ({
onPress,
onPressIn,
onLongPress,
+ viewContext,
}) => {
+ const t = useTheme()
const {_} = useLingui()
const largeAltBadge = useLargeAltBadgeEnabled()
const image = images[index]
+ const hasAlt = !!image.alt
+ const hideBadges =
+ viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
return (
onPress(index) : undefined}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
- style={a.flex_1}
+ style={[
+ a.flex_1,
+ a.rounded_xs,
+ a.overflow_hidden,
+ t.atoms.bg_contrast_25,
+ imageStyle,
+ ]}
accessibilityRole="button"
accessibilityLabel={image.alt || _(msg`Image`)}
accessibilityHint="">
- {image.alt === '' ? null : (
-
+ {hasAlt && !hideBadges ? (
+
+ style={[a.font_heavy, largeAltBadge ? a.text_xs : {fontSize: 8}]}>
ALT
- )}
+ ) : null}
)
}
-
-const styles = StyleSheet.create({
- altContainer: {
- backgroundColor: 'rgba(0, 0, 0, 0.75)',
- borderRadius: 6,
- paddingHorizontal: 6,
- paddingVertical: 3,
- position: 'absolute',
- // Related to margin/gap hack. This keeps the alt label in the same position
- // on all platforms
- right: isWeb ? 8 : 5,
- bottom: isWeb ? 8 : 5,
- },
- alt: {
- color: 'white',
- fontSize: 7,
- fontWeight: 'bold',
- },
-})
diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx
deleted file mode 100644
index bade2a44..00000000
--- a/src/view/com/util/images/ImageHorzList.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {Image} from 'expo-image'
-import {AppBskyEmbedImages} from '@atproto/api'
-import {Trans} from '@lingui/macro'
-
-import {atoms as a} from '#/alf'
-import {Text} from '#/components/Typography'
-
-interface Props {
- images: AppBskyEmbedImages.ViewImage[]
- style?: StyleProp
- gif?: boolean
-}
-
-export function ImageHorzList({images, style, gif}: Props) {
- return (
-
- {images.map(({thumb, alt}) => (
-
-
- {gif && (
-
-
- GIF
-
-
- )}
-
- ))}
-
- )
-}
-
-const styles = StyleSheet.create({
- altContainer: {
- backgroundColor: 'rgba(0, 0, 0, 0.75)',
- borderRadius: 6,
- paddingHorizontal: 6,
- paddingVertical: 3,
- position: 'absolute',
- right: 5,
- bottom: 5,
- zIndex: 2,
- },
- alt: {
- color: 'white',
- fontSize: 7,
- fontWeight: 'bold',
- },
-})
diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx
index ba6c04f5..45da7f07 100644
--- a/src/view/com/util/images/ImageLayoutGrid.tsx
+++ b/src/view/com/util/images/ImageLayoutGrid.tsx
@@ -1,8 +1,10 @@
import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyEmbedImages} from '@atproto/api'
+
+import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
+import {atoms as a, useBreakpoints} from '#/alf'
import {GalleryItem} from './Gallery'
-import {isWeb} from 'platform/detection'
interface ImageLayoutGridProps {
images: AppBskyEmbedImages.ViewImage[]
@@ -10,13 +12,25 @@ interface ImageLayoutGridProps {
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
style?: StyleProp
+ viewContext?: PostEmbedViewContext
}
export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
+ const {gtMobile} = useBreakpoints()
+ const gap =
+ props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
+ ? gtMobile
+ ? a.gap_xs
+ : a.gap_2xs
+ : gtMobile
+ ? a.gap_sm
+ : a.gap_xs
+ const count = props.images.length
+ const aspectRatio = count === 2 ? 2 : count === 3 ? 1.5 : 1
return (
-
-
+
+
)
@@ -27,36 +41,39 @@ interface ImageLayoutGridInnerProps {
onPress?: (index: number) => void
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
+ viewContext?: PostEmbedViewContext
+ gap: {gap: number}
}
function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
+ const gap = props.gap
const count = props.images.length
switch (count) {
case 2:
return (
-
-
-
+
+
+
-
-
+
+
)
case 3:
return (
-
-
-
+
+
+
-
-
-
+
+
+
-
-
+
+
@@ -65,20 +82,20 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
case 4:
return (
<>
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
>
@@ -88,39 +105,3 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
return null
}
}
-
-// On web we use margin to calculate gap, as aspectRatio does not properly size
-// all images on web. On native though we cannot rely on margin, since the
-// negative margin interferes with the swipe controls on pagers.
-// https://github.com/facebook/yoga/issues/1418
-// https://github.com/bluesky-social/social-app/issues/2601
-const IMAGE_GAP = 5
-
-const styles = StyleSheet.create({
- container: isWeb
- ? {
- marginHorizontal: -IMAGE_GAP / 2,
- marginVertical: -IMAGE_GAP / 2,
- }
- : {
- gap: IMAGE_GAP,
- },
- flexRow: {
- flexDirection: 'row',
- gap: isWeb ? undefined : IMAGE_GAP,
- },
- smallItem: {flex: 1, aspectRatio: 1},
- image: isWeb
- ? {
- margin: IMAGE_GAP / 2,
- }
- : {},
- threeSingle: {
- flex: 2,
- aspectRatio: isWeb ? 1 : undefined,
- },
- threeDouble: {
- flex: 1,
- gap: isWeb ? undefined : IMAGE_GAP,
- },
-})
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index de73103c..1cad5e09 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -244,6 +244,7 @@ let PostCtrls = ({
a.flex_row,
a.justify_center,
a.align_center,
+ a.overflow_hidden,
{padding: 5},
(pressed || hovered) && t.atoms.bg_contrast_25,
],
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index d924adbe..8c4928df 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -53,6 +53,9 @@ let RepostButton = ({
onPress={() => {
requireAuth(() => dialogControl.open())
}}
+ onLongPress={() => {
+ requireAuth(() => onQuote())
+ }}
style={[
a.flex_row,
a.align_center,
diff --git a/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx b/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx
index 77616d78..95fa0bb0 100644
--- a/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx
+++ b/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx
@@ -1,11 +1,12 @@
import React from 'react'
import {useVideoPlayer, VideoPlayer} from 'expo-video'
-import {isNative} from '#/platform/detection'
+import {isAndroid, isNative} from '#/platform/detection'
const Context = React.createContext<{
- activeSource: string | null
- setActiveSource: (src: string) => void
+ activeSource: string
+ activeViewId: string | undefined
+ setActiveSource: (src: string | null, viewId: string | null) => void
player: VideoPlayer
} | null>(null)
@@ -15,15 +16,39 @@ export function Provider({children}: {children: React.ReactNode}) {
}
const [activeSource, setActiveSource] = React.useState('')
+ const [activeViewId, setActiveViewId] = React.useState()
const player = useVideoPlayer(activeSource, p => {
p.muted = true
p.loop = true
+ // We want to immediately call `play` so we get the loading state
p.play()
})
+ const setActiveSourceOuter = (src: string | null, viewId: string | null) => {
+ // HACK
+ // expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually
+ // unregister that player to let the new screen register it. This is only a problem on Android, so we only need to
+ // apply it there.
+ if (src === activeSource && isAndroid) {
+ setActiveSource('')
+ setTimeout(() => {
+ setActiveSource(src ? src : '')
+ }, 100)
+ } else {
+ setActiveSource(src ? src : '')
+ }
+ setActiveViewId(viewId ? viewId : '')
+ }
+
return (
-
+
{children}
)
diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
index 9fdede87..111867fc 100644
--- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
@@ -17,7 +17,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {WebView} from 'react-native-webview'
import {Image} from 'expo-image'
import {AppBskyEmbedExternal} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
@@ -29,6 +28,7 @@ import {useExternalEmbedsPrefs} from '#/state/preferences'
import {atoms as a} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {EventStopper} from '../EventStopper'
interface ShouldStartLoadRequest {
@@ -59,7 +59,7 @@ function PlaceholderOverlay({
onPress={onPress}
style={[styles.overlayContainer, styles.topRadius]}>
{!isPlayerActive ? (
-
+
) : (
)}
diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx
index 31c4419b..5b6e1c9d 100644
--- a/src/view/com/util/post-embeds/GifEmbed.tsx
+++ b/src/view/com/util/post-embeds/GifEmbed.tsx
@@ -8,7 +8,6 @@ import {
ViewStyle,
} from 'react-native'
import {AppBskyEmbedExternal} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -22,6 +21,7 @@ import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
@@ -69,24 +69,7 @@ function PlaybackControls({
) : !isPlaying ? (
-
-
-
+
) : undefined}
)
@@ -155,7 +138,6 @@ export function GifEmbed({
accessibilityHint={_(msg`Animated GIF`)}
accessibilityLabel={parsedAlt.alt}
/>
-
{!hideAlt && parsedAlt.isPreferred && }
@@ -183,7 +165,6 @@ function AltText({text}: {text: string}) {
ALT
-
Alt Text
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index c61cda68..87a6edeb 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -41,17 +41,20 @@ import {Link} from '../Link'
import {PostMeta} from '../PostMeta'
import {Text} from '../text/Text'
import {PostEmbeds} from '.'
+import {QuoteEmbedViewContext} from './types'
export function MaybeQuoteEmbed({
embed,
onOpen,
style,
allowNestedQuotes,
+ viewContext,
}: {
embed: AppBskyEmbedRecord.View
onOpen?: () => void
style?: StyleProp
allowNestedQuotes?: boolean
+ viewContext?: QuoteEmbedViewContext
}) {
const pal = usePalette('default')
const {currentAccount} = useSession()
@@ -67,6 +70,7 @@ export function MaybeQuoteEmbed({
onOpen={onOpen}
style={style}
allowNestedQuotes={allowNestedQuotes}
+ viewContext={viewContext}
/>
)
} else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
@@ -113,12 +117,14 @@ function QuoteEmbedModerated({
onOpen,
style,
allowNestedQuotes,
+ viewContext,
}: {
viewRecord: AppBskyEmbedRecord.ViewRecord
postRecord: AppBskyFeedPost.Record
onOpen?: () => void
style?: StyleProp
allowNestedQuotes?: boolean
+ viewContext?: QuoteEmbedViewContext
}) {
const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => {
@@ -144,6 +150,7 @@ function QuoteEmbedModerated({
onOpen={onOpen}
style={style}
allowNestedQuotes={allowNestedQuotes}
+ viewContext={viewContext}
/>
)
}
@@ -160,6 +167,7 @@ export function QuoteEmbed({
onOpen?: () => void
style?: StyleProp
allowNestedQuotes?: boolean
+ viewContext?: QuoteEmbedViewContext
}) {
const queryClient = useQueryClient()
const pal = usePalette('default')
diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx
index 55ac1882..3175266e 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.tsx
@@ -1,42 +1,38 @@
-import React, {useCallback, useState} from 'react'
+import React, {useCallback, useEffect, useId, useState} from 'react'
import {View} from 'react-native'
-import {Image} from 'expo-image'
+import {ImageBackground} from 'expo-image'
+import {PlayerError, VideoPlayerStatus} from 'expo-video'
import {AppBskyEmbedVideo} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {clamp} from '#/lib/numbers'
-import {useGate} from '#/lib/statsig/statsig'
+import {useAutoplayDisabled} from 'state/preferences'
import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a} from '#/alf'
import {Button} from '#/components/Button'
-import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
+import {Loader} from '#/components/Loader'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
import {ErrorBoundary} from '../ErrorBoundary'
import {useActiveVideoNative} from './ActiveVideoNativeContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
-export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
- const t = useTheme()
- const {activeSource, setActiveSource} = useActiveVideoNative()
- const isActive = embed.playlist === activeSource
- const {_} = useLingui()
+interface Props {
+ embed: AppBskyEmbedVideo.View
+}
+export function VideoEmbed({embed}: Props) {
const [key, setKey] = useState(0)
+
const renderError = useCallback(
(error: unknown) => (
setKey(key + 1)} />
),
[key],
)
- const gate = useGate()
-
- if (!gate('video_view_on_posts')) {
- return null
- }
let aspectRatio = 16 / 9
-
if (embed.aspectRatio) {
const {width, height} = embed.aspectRatio
aspectRatio = width / height
@@ -54,41 +50,162 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
a.my_xs,
]}>
- {
- if (isVisible) {
- setActiveSource(embed.playlist)
- }
- }}>
- {isActive ? (
-
- ) : (
- <>
-
- {
- setActiveSource(embed.playlist)
- }}
- label={_(msg`Play video`)}
- color="secondary">
-
-
- >
- )}
-
+
)
}
+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}
+
+ startPlaying(true)}
+ label={_(msg`Play video`)}
+ color="secondary">
+ {isLoading ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
+
function VideoError({retry}: {error: unknown; retry: () => void}) {
return (
diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx
index 0001a7af..a25f9464 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx
@@ -1,13 +1,15 @@
import React, {useCallback, useEffect, useRef, useState} from 'react'
import {View} from 'react-native'
import {AppBskyEmbedVideo} from '@atproto/api'
-import {Trans} from '@lingui/macro'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
import {clamp} from '#/lib/numbers'
import {useGate} from '#/lib/statsig/statsig'
import {
HLSUnsupportedError,
VideoEmbedInnerWeb,
+ VideoNotFoundError,
} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
import {atoms as a} from '#/alf'
import {ErrorBoundary} from '../ErrorBoundary'
@@ -152,23 +154,26 @@ function ViewportObserver({
}
function VideoError({error, retry}: {error: unknown; retry: () => void}) {
- const isHLS = error instanceof HLSUnsupportedError
+ const {_} = useLingui()
+
+ let showRetryButton = true
+ let text = null
+
+ if (error instanceof VideoNotFoundError) {
+ text = _(msg`Video not found.`)
+ } else if (error instanceof HLSUnsupportedError) {
+ showRetryButton = false
+ text = _(
+ msg`Your browser does not support the video format. Please try a different browser.`,
+ )
+ } else {
+ text = _(msg`An error occurred while loading the video. Please try again.`)
+ }
return (
-
- {isHLS ? (
-
- Your browser does not support the video format. Please try a
- different browser.
-
- ) : (
-
- An error occurred while loading the video. Please try again later.
-
- )}
-
- {!isHLS && }
+ {text}
+ {showRetryButton && }
)
}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
index b9c0a99a..de9a2c74 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect, useRef, useState} from 'react'
+import React, {useCallback, useRef} from 'react'
import {Pressable, View} from 'react-native'
import Animated, {FadeInDown} from 'react-native-reanimated'
import {VideoPlayer, VideoView} from 'expo-video'
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
import {HITSLOP_30} from '#/lib/constants'
import {clamp} from '#/lib/numbers'
+import {isAndroid} from 'platform/detection'
import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext'
import {atoms as a, useTheme} from '#/alf'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
@@ -20,13 +21,20 @@ import {TimeIndicator} from './TimeIndicator'
export function VideoEmbedInnerNative({
embed,
+ isFullscreen,
+ setIsFullscreen,
+ isMuted,
+ timeRemaining,
}: {
embed: AppBskyEmbedVideo.View
+ isFullscreen: boolean
+ setIsFullscreen: (isFullscreen: boolean) => void
+ timeRemaining: number
+ isMuted: boolean
}) {
const {_} = useLingui()
const {player} = useActiveVideoNative()
const ref = useRef(null)
- const [isFullscreen, setIsFullscreen] = useState(false)
const enterFullscreen = useCallback(() => {
ref.current?.enterFullscreen()
@@ -46,23 +54,23 @@ export function VideoEmbedInnerNative({
ref={ref}
player={player}
style={[a.flex_1, a.rounded_sm]}
- contentFit="contain"
+ contentFit="cover"
nativeControls={isFullscreen}
accessibilityIgnoresInvertColors
- onEnterFullscreen={() => {
+ onFullscreenEnter={() => {
PlatformInfo.setAudioCategory(AudioCategory.Playback)
PlatformInfo.setAudioActive(true)
player.muted = false
setIsFullscreen(true)
+ if (isAndroid) {
+ player.play()
+ }
}}
- onExitFullscreen={() => {
+ onFullscreenExit={() => {
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
PlatformInfo.setAudioActive(false)
player.muted = true
player.playbackRate = 1
- if (!player.playing) {
- player.play()
- }
setIsFullscreen(false)
}}
accessibilityLabel={
@@ -70,7 +78,12 @@ export function VideoEmbedInnerNative({
}
accessibilityHint=""
/>
-
+
)
}
@@ -78,31 +91,16 @@ export function VideoEmbedInnerNative({
function VideoControls({
player,
enterFullscreen,
+ timeRemaining,
+ isMuted,
}: {
player: VideoPlayer
enterFullscreen: () => void
+ timeRemaining: number
+ isMuted: boolean
}) {
const {_} = useLingui()
const t = useTheme()
- const [isMuted, setIsMuted] = useState(player.muted)
- const [timeRemaining, setTimeRemaining] = React.useState(0)
-
- useEffect(() => {
- // eslint-disable-next-line @typescript-eslint/no-shadow
- const volumeSub = player.addListener('volumeChange', ({isMuted}) => {
- setIsMuted(isMuted)
- })
- const timeSub = player.addListener(
- 'timeRemainingChange',
- secondsRemaining => {
- setTimeRemaining(secondsRemaining)
- },
- )
- return () => {
- volumeSub.remove()
- timeSub.remove()
- }
- }, [player])
const onPressFullscreen = useCallback(() => {
switch (player.status) {
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
index 77295c00..a30c0e1e 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
@@ -23,6 +23,12 @@ export function VideoEmbedInnerWeb({
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
const figId = useId()
+ // send error up to error boundary
+ const [error, setError] = useState(null)
+ if (error) {
+ throw error
+ }
+
const hlsRef = useRef(undefined)
useEffect(() => {
@@ -38,12 +44,25 @@ export function VideoEmbedInnerWeb({
// initial value, later on it's managed by Controls
hls.autoLevelCapping = 0
- hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => {
+ hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => {
if (data.subtitleTracks.length > 0) {
setHasSubtitleTrack(true)
}
})
+ hls.on(Hls.Events.ERROR, (_event, data) => {
+ if (data.fatal) {
+ if (
+ data.details === 'manifestLoadError' &&
+ data.response?.code === 404
+ ) {
+ setError(new VideoNotFoundError())
+ } else {
+ setError(data.error)
+ }
+ }
+ })
+
return () => {
hlsRef.current = undefined
hls.detachMedia()
@@ -104,3 +123,9 @@ export class HLSUnsupportedError extends Error {
super('HLS is not supported')
}
}
+
+export class VideoNotFoundError extends Error {
+ constructor() {
+ super('Video not found')
+ }
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
index d9b99ef3..82c0ab7a 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
@@ -111,9 +111,9 @@ export function Controls({
// autoplay/pause based on visibility
const autoplayDisabled = useAutoplayDisabled()
useEffect(() => {
- if (active && !autoplayDisabled) {
+ if (active) {
if (onScreen) {
- play()
+ if (!autoplayDisabled) play()
} else {
pause()
}
@@ -151,10 +151,11 @@ export function Controls({
const onPressEmptySpace = useCallback(() => {
if (!focused) {
drawFocus()
+ if (autoplayDisabled) play()
} else {
togglePlayPause()
}
- }, [togglePlayPause, drawFocus, focused])
+ }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play])
const onPressPlayPause = useCallback(() => {
drawFocus()
@@ -240,7 +241,8 @@ export function Controls({
}, [])
const showControls =
- (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
+ ((focused || autoplayDisabled) && !playing) ||
+ (interactingViaKeypress ? hasFocus : hovered)
return (
{!showControls && !focused && duration > 0 && (
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index d9e075e7..b4a6cf82 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -3,7 +3,6 @@ import {
InteractionManager,
StyleProp,
StyleSheet,
- Text,
View,
ViewStyle,
} from 'react-native'
@@ -22,7 +21,6 @@ import {
} from '@atproto/api'
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
-import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {usePalette} from 'lib/hooks/usePalette'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
@@ -34,8 +32,11 @@ import {AutoSizedImage} from '../images/AutoSizedImage'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed'
+import {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
import {VideoEmbed} from './VideoEmbed'
+export * from './types'
+
type Embed =
| AppBskyEmbedRecord.View
| AppBskyEmbedImages.View
@@ -50,15 +51,16 @@ export function PostEmbeds({
onOpen,
style,
allowNestedQuotes,
+ viewContext,
}: {
embed?: Embed
moderation?: ModerationDecision
onOpen?: () => void
style?: StyleProp
allowNestedQuotes?: boolean
+ viewContext?: PostEmbedViewContext
}) {
const {openLightbox} = useLightboxControls()
- const largeAltBadge = useLargeAltBadgeEnabled()
// quote post with media
// =
@@ -69,8 +71,17 @@ export function PostEmbeds({
embed={embed.media}
moderation={moderation}
onOpen={onOpen}
+ viewContext={viewContext}
+ />
+
-
)
}
@@ -124,27 +135,26 @@ export function PostEmbeds({
}
if (images.length === 1) {
- const {alt, thumb, aspectRatio} = images[0]
+ const image = images[0]
return (
_openLightbox(0)}
onPressIn={() => onPressIn(0)}
- style={a.rounded_sm}>
- {alt === '' ? null : (
-
-
- ALT
-
-
- )}
-
+ hideBadge={
+ viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
+ }
+ />
)
@@ -157,6 +167,7 @@ export function PostEmbeds({
images={embed.images}
onPress={_openLightbox}
onPressIn={onPressIn}
+ viewContext={viewContext}
/>
diff --git a/src/view/com/util/post-embeds/types.ts b/src/view/com/util/post-embeds/types.ts
new file mode 100644
index 00000000..08e90327
--- /dev/null
+++ b/src/view/com/util/post-embeds/types.ts
@@ -0,0 +1,9 @@
+export enum PostEmbedViewContext {
+ ThreadHighlighted = 'ThreadHighlighted',
+ Feed = 'Feed',
+ FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
+}
+
+export enum QuoteEmbedViewContext {
+ FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia,
+}
diff --git a/src/view/screens/AccessibilitySettings.tsx b/src/view/screens/AccessibilitySettings.tsx
index 5cb5c6a3..2992e5c7 100644
--- a/src/view/screens/AccessibilitySettings.tsx
+++ b/src/view/screens/AccessibilitySettings.tsx
@@ -108,7 +108,7 @@ export function AccessibilitySettingsScreen({}: Props) {
setAutoplayDisabled(!autoplayDisabled)}
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index fb487ad6..c790a815 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,24 +1,18 @@
import React from 'react'
-import {ActivityIndicator, AppState, StyleSheet, View} from 'react-native'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {logEvent, LogEvents} from '#/lib/statsig/statsig'
-import {useGate} from '#/lib/statsig/statsig'
import {emitSoftReset} from '#/state/events'
import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
import {FeedParams} from '#/state/queries/post-feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {useSession} from '#/state/session'
-import {
- useMinimalShellMode,
- useSetDrawerSwipeDisabled,
- useSetMinimalShellMode,
-} from '#/state/shell'
+import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
@@ -87,7 +81,6 @@ function HomeScreenReady({
const selectedIndex = Math.max(0, maybeFoundIndex)
const selectedFeed = allFeeds[selectedIndex]
const requestNotificationsPermission = useRequestNotificationsPermission()
- const gate = useGate()
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
useOTAUpdates()
@@ -134,29 +127,6 @@ function HomeScreenReady({
}),
)
- const {footerMode} = useMinimalShellMode()
- const {isMobile} = useWebMediaQueries()
- useFocusEffect(
- React.useCallback(() => {
- if (gate('fixed_bottom_bar')) {
- // Unnecessary because it's always there.
- return
- }
- const listener = AppState.addEventListener('change', nextAppState => {
- if (nextAppState === 'active') {
- if (isMobile && footerMode.value === 1) {
- // Reveal the bottom bar so you don't miss notifications or messages.
- // TODO: Experiment with only doing it when unread > 0.
- setMinimalShellMode(false)
- }
- }
- })
- return () => {
- listener.remove()
- }
- }, [setMinimalShellMode, footerMode, isMobile, gate]),
- )
-
const onPageSelected = React.useCallback(
(index: number) => {
setMinimalShellMode(false)
diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx
index 5d80dc42..42696139 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -61,7 +61,7 @@ export function Composer({}: {winHeight: number}) {
quoteCount={state?.quoteCount}
onPost={state.onPost}
mention={state.mention}
- openPicker={onOpenPicker}
+ openEmojiPicker={onOpenPicker}
text={state.text}
/>
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index d238f4da..bea0c602 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -160,7 +160,6 @@ export function BottomBar({navigation}: BottomTabBarProps) {
accessibilityHint=""
/>
) : (
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 7d080e57..aed92cbb 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -11,7 +11,7 @@ import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as NavigationBar from 'expo-navigation-bar'
import {StatusBar} from 'expo-status-bar'
-import {useNavigationState} from '@react-navigation/native'
+import {useNavigation, useNavigationState} from '@react-navigation/native'
import {useSession} from '#/state/session'
import {
@@ -20,6 +20,7 @@ import {
useSetDrawerOpen,
} from '#/state/shell'
import {useCloseAnyActiveElement} from '#/state/util'
+import {useDedupe} from 'lib/hooks/useDedupe'
import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler'
import {usePalette} from 'lib/hooks/usePalette'
import {useNotificationsRegistration} from 'lib/notifications/notifications'
@@ -33,6 +34,7 @@ import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin'
import {Outlet as PortalOutlet} from '#/components/Portal'
+import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
import {RoutesContainer, TabsNavigator} from '../../Navigation'
import {Composer} from './Composer'
import {DrawerContent} from './Drawer'
@@ -76,6 +78,27 @@ function ShellInner() {
}
}, [closeAnyActiveElement])
+ // HACK
+ // expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually
+ // unregister that player to let the new screen register it. This is only a problem on Android, so we only need to
+ // apply it there.
+ // The `state` event should only fire whenever we push or pop to a screen, and should not fire consecutively quickly.
+ // To be certain though, we will also dedupe these calls.
+ const navigation = useNavigation()
+ const dedupe = useDedupe(1000)
+ React.useEffect(() => {
+ if (!isAndroid) return
+ const onFocusOrBlur = () => {
+ setTimeout(() => {
+ dedupe(updateActiveViewAsync)
+ }, 500)
+ }
+ navigation.addListener('state', onFocusOrBlur)
+ return () => {
+ navigation.removeListener('state', onFocusOrBlur)
+ }
+ }, [dedupe, navigation])
+
return (
<>