diff --git a/__e2e__/flows/feed-reorder.yml b/__e2e__/flows/feed-reorder.yml
index 2b892b53..449df065 100644
--- a/__e2e__/flows/feed-reorder.yml
+++ b/__e2e__/flows/feed-reorder.yml
@@ -10,13 +10,8 @@ appId: xyz.blueskyweb.app
id: "e2eSignInAlice"
# Pin alice's feed
-- extendedWaitUntil:
- visible:
- id: "viewHeaderDrawerBtn"
- tapOn:
- id: "viewHeaderDrawerBtn"
-- tapOn:
- id: "profileCardButton"
+ id: "bottomBarProfileBtn"
- swipe:
from:
id: "profilePager-selector"
diff --git a/__e2e__/flows/home-screen.yml b/__e2e__/flows/home-screen.yml
index 799a2021..c8d83fb1 100644
--- a/__e2e__/flows/home-screen.yml
+++ b/__e2e__/flows/home-screen.yml
@@ -9,9 +9,6 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "e2eSignInAlice"
-- extendedWaitUntil:
- visible:
- text: "Feeds ✨"
- tapOn:
label: "Can go to feeds page using feeds button in tab bar"
text: "Feeds ✨"
@@ -37,16 +34,26 @@ appId: xyz.blueskyweb.app
- tapOn:
label: "Can like posts"
id: "likeBtn"
+- assertVisible:
+ id: "likeCount"
+ text: "1"
- tapOn:
id: "likeBtn"
+- assertNotVisible:
+ id: "likeCount"
- tapOn:
label: "Can repost posts"
id: "repostBtn"
- tapOn: "Repost"
+- assertVisible:
+ id: "repostCount"
+ text: "1"
- tapOn:
id: "repostBtn"
- tapOn: "Remove repost"
+- assertNotVisible:
+ id: "repostCount"
- tapOn:
label: "Can delete posts"
diff --git a/__e2e__/flows/profile-screen-edit.yml b/__e2e__/flows/profile-screen-edit.yml
index 251eca35..288a5d4f 100644
--- a/__e2e__/flows/profile-screen-edit.yml
+++ b/__e2e__/flows/profile-screen-edit.yml
@@ -11,9 +11,6 @@ appId: xyz.blueskyweb.app
# Navigate to my profile
-- extendedWaitUntil:
- visible:
- id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarProfileBtn"
diff --git a/__e2e__/flows/profile-screen.yml b/__e2e__/flows/profile-screen.yml
index b9f95aca..7d2d43de 100644
--- a/__e2e__/flows/profile-screen.yml
+++ b/__e2e__/flows/profile-screen.yml
@@ -10,9 +10,6 @@ appId: xyz.blueskyweb.app
id: "e2eSignInAlice"
# Navigate to another user profile
-- extendedWaitUntil:
- visible:
- id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarSearchBtn"
- tapOn:
diff --git a/__e2e__/flows/thread-screen.yml b/__e2e__/flows/thread-screen.yml
index 9120f4f6..fdc73259 100644
--- a/__e2e__/flows/thread-screen.yml
+++ b/__e2e__/flows/thread-screen.yml
@@ -11,8 +11,6 @@ appId: xyz.blueskyweb.app
# Navigate to thread
-- extendedWaitUntil:
- visible: "Thread root"
- tapOn: "Thread root"
- assertVisible: "Thread reply"
@@ -35,10 +33,18 @@ appId: xyz.blueskyweb.app
id: "likeBtn"
childOf:
id: "postThreadItem-by-carla.test"
+- assertVisible:
+ id: "likeCount"
+ childOf:
+ id: "postThreadItem-by-carla.test"
- tapOn:
id: "likeBtn"
childOf:
id: "postThreadItem-by-carla.test"
+- assertNotVisible:
+ id: "likeCount"
+ childOf:
+ id: "postThreadItem-by-carla.test"
# Can repost the root post
- tapOn:
diff --git a/app.config.js b/app.config.js
index 25014ee8..cd8a4b03 100644
--- a/app.config.js
+++ b/app.config.js
@@ -191,7 +191,7 @@ module.exports = function (config) {
'expo-build-properties',
{
ios: {
- deploymentTarget: '15.1',
+ deploymentTarget: '14.0',
newArchEnabled: false,
},
android: {
diff --git a/assets/icons/crop_stroke2_corner0_rounded.svg b/assets/icons/crop_stroke2_corner0_rounded.svg
deleted file mode 100644
index 118d148f..00000000
--- a/assets/icons/crop_stroke2_corner0_rounded.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/bskyembed/assets/play_filled_corner2_rounded.svg b/bskyembed/assets/play_filled_corner2_rounded.svg
deleted file mode 100644
index 48da4add..00000000
--- a/bskyembed/assets/play_filled_corner2_rounded.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/bskyembed/package.json b/bskyembed/package.json
index e269b1c9..72d2b6df 100644
--- a/bskyembed/package.json
+++ b/bskyembed/package.json
@@ -9,7 +9,7 @@
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
},
"dependencies": {
- "@atproto/api": "0.13.6",
+ "@atproto/api": "0.13.1",
"@preact/preset-vite": "^2.8.2",
"@vitejs/plugin-legacy": "^5.3.2",
"preact": "^10.4.8",
diff --git a/bskyembed/src/components/embed.tsx b/bskyembed/src/components/embed.tsx
index 3b4f5e77..600c7c2c 100644
--- a/bskyembed/src/components/embed.tsx
+++ b/bskyembed/src/components/embed.tsx
@@ -3,7 +3,6 @@ import {
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
- AppBskyEmbedVideo,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphDefs,
@@ -15,7 +14,6 @@ import {ComponentChildren, h} from 'preact'
import {useMemo} from 'preact/hooks'
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
-import playIcon from '../../assets/play_filled_corner2_rounded.svg'
import starterPackIcon from '../../assets/starterPack.svg'
import {CONTENT_LABELS, labelsToInfo} from '../labels'
import {getRkey} from '../utils'
@@ -162,12 +160,7 @@ export function Embed({
return null
}
- // Case 4: Video
- if (AppBskyEmbedVideo.isView(content)) {
- return
- }
-
- // Case 5: Record with media
+ // Case 4: Record with media
if (
AppBskyEmbedRecordWithMedia.isView(content) &&
AppBskyEmbedRecord.isViewRecord(content.record.record)
@@ -361,31 +354,6 @@ function GenericWithImageEmbed({
)
}
-// just the thumbnail and a play button
-function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
- let aspectRatio = 1
-
- if (content.aspectRatio) {
- const {width, height} = content.aspectRatio
- aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
- }
-
- return (
-
-

-
-

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