Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15e51c09bc | |||
| a1969faf8e | |||
| 64b50ba69a | |||
| b90cd68359 | |||
| 55468595d0 | |||
| 18133483fe | |||
| 6eabedd037 | |||
| 7eb4cd89c5 | |||
| 8a66883df8 | |||
| 2265fedd2a | |||
| 117926357d | |||
| 91c3dbd812 | |||
| 824206b95f | |||
| 93c171b403 | |||
| 6d8ed5c3c8 | |||
| 4e6b6740f7 | |||
| 428607d9a3 | |||
| 60b74f7ab8 | |||
| d846f5bbf0 | |||
| 2556698427 | |||
| 76f493c279 | |||
| 4d97a2aa16 | |||
| 86de0dda02 | |||
| 0ef17a464d | |||
| 5dcb52015c | |||
| fcf27f0512 | |||
| e8eaf2f4a7 | |||
| c36c47d49a | |||
| 6382a91fb0 | |||
| 5f5c14d044 | |||
| 82ca0b16b6 | |||
| 12b4a250d2 | |||
| 45bb2477d8 | |||
| d94ff2695d | |||
| dee28f378a | |||
| 21e48bb2d8 | |||
| 515f87ed24 | |||
| 3eef62d995 | |||
| e2a244b998 | |||
| 8860890a85 | |||
| 39f74ced5c | |||
| 3ee5ef32d9 | |||
| 0bd0146efb | |||
| ea4d8bc1ab | |||
| ddda612c11 | |||
| 5c2e9f09bf | |||
| 7f26ad508e | |||
| 5d300bec90 | |||
| 0014d4363f | |||
| bd42f770b8 | |||
| dde72b48e1 | |||
| 05e61346b8 | |||
| 0e1de19903 | |||
| f9d736653c | |||
| 0469ca6cb4 | |||
| f3f7dfc3e6 | |||
| 4abcd65ccf | |||
| 05ac76fc89 | |||
| 1225e84485 | |||
| eb868a042a | |||
| 17d82a64a6 | |||
| bf15fad240 | |||
| 2df22d8674 | |||
| bff6aedecf | |||
| 3d992f6bb5 | |||
| ed232e69f7 | |||
| c41f372b3c | |||
| ab260c3599 | |||
| 8647c8e9f5 | |||
| c70ec1ce1a | |||
| e7954e590b | |||
| c60e8d0772 | |||
| dbbbba1d32 | |||
| 46b7193a2b | |||
| 2ef2ae1c6c | |||
| 05b73d4d1e | |||
| 8cddce5f30 | |||
| 8651f31ebb | |||
| d5a7618374 | |||
| 497aacf113 | |||
| 69e896c221 | |||
| 91fe41670f |
+1
-1
@@ -191,7 +191,7 @@ module.exports = function (config) {
|
||||
'expo-build-properties',
|
||||
{
|
||||
ios: {
|
||||
deploymentTarget: '14.0',
|
||||
deploymentTarget: '15.1',
|
||||
newArchEnabled: false,
|
||||
},
|
||||
android: {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 289 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#fff" d="M9.576 2.534C7.578 1.299 5 2.737 5 5.086v13.828c0 2.35 2.578 3.787 4.576 2.552l11.194-6.914c1.899-1.172 1.899-3.932 0-5.104L9.576 2.534Z"/></svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -9,7 +9,7 @@
|
||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.13.1",
|
||||
"@atproto/api": "0.13.6",
|
||||
"@preact/preset-vite": "^2.8.2",
|
||||
"@vitejs/plugin-legacy": "^5.3.2",
|
||||
"preact": "^10.4.8",
|
||||
@@ -22,7 +22,7 @@
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^4.0.5",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.2.8",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedVideo,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyGraphDefs,
|
||||
@@ -14,6 +15,7 @@ import {ComponentChildren, h} from 'preact'
|
||||
import {useMemo} from 'preact/hooks'
|
||||
|
||||
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
|
||||
import playIcon from '../../assets/play_filled_corner2_rounded.svg'
|
||||
import starterPackIcon from '../../assets/starterPack.svg'
|
||||
import {CONTENT_LABELS, labelsToInfo} from '../labels'
|
||||
import {getRkey} from '../utils'
|
||||
@@ -160,7 +162,12 @@ export function Embed({
|
||||
return null
|
||||
}
|
||||
|
||||
// Case 4: Record with media
|
||||
// Case 4: Video
|
||||
if (AppBskyEmbedVideo.isView(content)) {
|
||||
return <VideoEmbed content={content} />
|
||||
}
|
||||
|
||||
// Case 5: Record with media
|
||||
if (
|
||||
AppBskyEmbedRecordWithMedia.isView(content) &&
|
||||
AppBskyEmbedRecord.isViewRecord(content.record.record)
|
||||
@@ -354,6 +361,31 @@ function GenericWithImageEmbed({
|
||||
)
|
||||
}
|
||||
|
||||
// just the thumbnail and a play button
|
||||
function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
|
||||
let aspectRatio = 1
|
||||
|
||||
if (content.aspectRatio) {
|
||||
const {width, height} = content.aspectRatio
|
||||
aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-lg aspect-square"
|
||||
style={{aspectRatio: `${aspectRatio} / 1`}}>
|
||||
<img
|
||||
src={content.thumbnail}
|
||||
alt={content.alt}
|
||||
className="object-cover size-full"
|
||||
/>
|
||||
<div className="size-24 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-black/50 flex items-center justify-center">
|
||||
<img src={playIcon} className="object-cover size-3/5" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StarterPackEmbed({
|
||||
content,
|
||||
}: {
|
||||
@@ -410,3 +442,7 @@ function getStarterPackHref(
|
||||
const handleOrDid = starterPack.creator.handle || starterPack.creator.did
|
||||
return `/starter-pack/${handleOrDid}/${rkey}`
|
||||
}
|
||||
|
||||
function clamp(num: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(num, max))
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
|
||||
import logo from '../../assets/logo.svg'
|
||||
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
|
||||
import {CONTENT_LABELS} from '../labels'
|
||||
import {getRkey, niceDate} from '../utils'
|
||||
import {getRkey, niceDate, prettyNumber} from '../utils'
|
||||
import {Container} from './container'
|
||||
import {Embed} from './embed'
|
||||
import {Link} from './link'
|
||||
@@ -78,7 +78,7 @@ export function Post({thread}: Props) {
|
||||
<div className="flex items-center gap-2 cursor-pointer">
|
||||
<img src={likeIcon} className="w-5 h-5" />
|
||||
<p className="font-bold text-neutral-500 mb-px">
|
||||
{post.likeCount}
|
||||
{prettyNumber(post.likeCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -86,7 +86,7 @@ export function Post({thread}: Props) {
|
||||
<div className="flex items-center gap-2 cursor-pointer">
|
||||
<img src={repostIcon} className="w-5 h-5" />
|
||||
<p className="font-bold text-neutral-500 mb-px">
|
||||
{post.repostCount}
|
||||
{prettyNumber(post.repostCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -97,7 +97,7 @@ export function Post({thread}: Props) {
|
||||
<div className="flex-1" />
|
||||
<p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
|
||||
{post.replyCount
|
||||
? `Read ${post.replyCount} ${
|
||||
? `Read ${prettyNumber(post.replyCount)} ${
|
||||
post.replyCount > 1 ? 'replies' : 'reply'
|
||||
} on Bluesky`
|
||||
: `View on Bluesky`}
|
||||
|
||||
@@ -16,3 +16,13 @@ export function getRkey({uri}: {uri: string}): string {
|
||||
const at = new AtUri(uri)
|
||||
return at.rkey
|
||||
}
|
||||
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
roundingMode: 'trunc',
|
||||
})
|
||||
|
||||
export function prettyNumber(number: number) {
|
||||
return formatter.format(number)
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"jsxFragmentFactory": "Fragment",
|
||||
"downlevelIteration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"strict": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["snippet"],
|
||||
"include": ["snippet"]
|
||||
}
|
||||
|
||||
+13
-13
@@ -20,15 +20,15 @@
|
||||
"@jridgewell/gen-mapping" "^0.3.5"
|
||||
"@jridgewell/trace-mapping" "^0.3.24"
|
||||
|
||||
"@atproto/api@0.13.1":
|
||||
version "0.13.1"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.1.tgz#fbf4306e4465d5467aaf031308c1b47dcc8039d0"
|
||||
integrity sha512-DL3iBfavn8Nnl48FmnAreQB0k0cIkW531DJ5JAHUCQZo10Nq0ZLk2/WFxcs0KuBG5wuLnGUdo+Y6/GQPVq8dYw==
|
||||
"@atproto/api@0.13.6":
|
||||
version "0.13.6"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.6.tgz#2500e9d7143e6718089632300c42ce50149f8cd5"
|
||||
integrity sha512-58emFFZhqY8nVWD3xFWK0yYqAmJ2un+NaTtZxBbRo00mGq1rz9VXTpVmfoHFcuXL1hoDQN3WyJfsub8r6xGOgg==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.3.0"
|
||||
"@atproto/lexicon" "^0.4.1"
|
||||
"@atproto/syntax" "^0.3.0"
|
||||
"@atproto/xrpc" "^0.6.0"
|
||||
"@atproto/xrpc" "^0.6.1"
|
||||
await-lock "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
tlds "^1.234.0"
|
||||
@@ -59,10 +59,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
|
||||
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
|
||||
|
||||
"@atproto/xrpc@^0.6.0":
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.0.tgz#668c3262e67e2afa65951ea79a03bfe3720ddf5c"
|
||||
integrity sha512-5BbhBTv5j6MC3iIQ4+vYxQE7nLy2dDGQ+LYJrH8PptOCUdq0Pwg6aRccQ3y52kUZlhE/mzOTZ8Ngiy9pSAyfVQ==
|
||||
"@atproto/xrpc@^0.6.1":
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5"
|
||||
integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A==
|
||||
dependencies:
|
||||
"@atproto/lexicon" "^0.4.1"
|
||||
zod "^3.23.8"
|
||||
@@ -4024,10 +4024,10 @@ typed-array-length@^1.0.6:
|
||||
is-typed-array "^1.1.13"
|
||||
possible-typed-array-names "^1.0.0"
|
||||
|
||||
typescript@^4.0.5:
|
||||
version "4.9.5"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
|
||||
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
|
||||
typescript@^5.5.4:
|
||||
version "5.5.4"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba"
|
||||
integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
|
||||
|
||||
uint8arrays@3.0.0:
|
||||
version "3.0.0"
|
||||
|
||||
+2
-1
@@ -139,7 +139,7 @@
|
||||
"expo-system-ui": "~3.0.4",
|
||||
"expo-task-manager": "~11.8.1",
|
||||
"expo-updates": "~0.25.14",
|
||||
"expo-video": "^1.2.4",
|
||||
"expo-video": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-1.tgz",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"history": "^5.3.0",
|
||||
@@ -180,6 +180,7 @@
|
||||
"react-native-image-crop-picker": "0.40.3",
|
||||
"react-native-ios-context-menu": "^1.15.3",
|
||||
"react-native-keyboard-controller": "^1.12.1",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-pager-view": "6.2.3",
|
||||
"react-native-picker-select": "^9.1.3",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
|
||||
index 473f964..f37aff9 100644
|
||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
|
||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
|
||||
@@ -41,6 +41,11 @@ sealed class PlayerEvent {
|
||||
override val name = "playToEnd"
|
||||
}
|
||||
|
||||
+ data class PlayerTimeRemainingChanged(val timeRemaining: Double): PlayerEvent() {
|
||||
+ override val name = "timeRemainingChange"
|
||||
+ override val arguments = arrayOf(timeRemaining)
|
||||
+ }
|
||||
+
|
||||
fun emit(player: VideoPlayer, listeners: List<VideoPlayerListener>) {
|
||||
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<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
|
||||
+ val fullscreenButton =
|
||||
+ findViewById<android.widget.ImageButton>(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/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<Unit>()
|
||||
val onPictureInPictureStop by EventDispatcher<Unit>()
|
||||
+ 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..65fe29a 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/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..f482390 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<AVPlayer>, Hashable, VideoPlayerObse
|
||||
safeEmit(event: "sourceChange", arguments: newVideoPlayerItem?.videoSource, oldVideoPlayerItem?.videoSource)
|
||||
}
|
||||
|
||||
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {
|
||||
+ safeEmit(event: "timeRemainingChange", arguments: timeRemaining)
|
||||
+ }
|
||||
+
|
||||
func safeEmit<each A: AnyArgument>(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..d0fdd30 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
|
||||
+ addOrRemovePeriodicTimeObserver()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,4 +317,30 @@ class VideoPlayerObserver {
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+ private func onPlayerTimeRemainingChanged(_ player: AVPlayer, _ timeRemaining: Double) {
|
||||
+ delegates.forEach { delegate in
|
||||
+ delegate.value?.onPlayerTimeRemainingChanged(player: player, timeRemaining: timeRemaining)
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ private func addOrRemovePeriodicTimeObserver() {
|
||||
+ guard 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)
|
||||
+ }
|
||||
+ } else if let playerPeriodicTimeObserver = self.playerPeriodicTimeObserver {
|
||||
+ player.removeTimeObserver(playerPeriodicTimeObserver)
|
||||
+ }
|
||||
+ }
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
## uwu woad beawing, do not wemove
|
||||
|
||||
## `expo-video` Patch
|
||||
|
||||
This patch adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on
|
||||
the tin.
|
||||
@@ -57,7 +57,7 @@ const withXcodeTarget = (config, {targetName}) => {
|
||||
buildSettingsObj.SWIFT_VERSION = '5.0'
|
||||
buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"`
|
||||
buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
|
||||
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0'
|
||||
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '15.1'
|
||||
buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon'
|
||||
}
|
||||
}
|
||||
|
||||
+10
-11
@@ -2,7 +2,6 @@ const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const projectRoot = path.join(__dirname, '..')
|
||||
const webBuildJs = path.join(projectRoot, 'web-build', 'static', 'js')
|
||||
const templateFile = path.join(
|
||||
projectRoot,
|
||||
'bskyweb',
|
||||
@@ -10,18 +9,18 @@ const templateFile = path.join(
|
||||
'scripts.html',
|
||||
)
|
||||
|
||||
const jsFiles = fs.readdirSync(webBuildJs).filter(name => name.endsWith('.js'))
|
||||
jsFiles.sort((a, b) => {
|
||||
// make sure main is written last
|
||||
if (a.startsWith('main')) return 1
|
||||
if (b.startsWith('main')) return -1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
const {entrypoints} = require(path.join(
|
||||
projectRoot,
|
||||
'web-build/asset-manifest.json',
|
||||
))
|
||||
|
||||
console.log(`Found ${jsFiles.length} js files in web-build`)
|
||||
console.log(`Found ${entrypoints.length} entrypoints`)
|
||||
console.log(`Writing ${templateFile}`)
|
||||
|
||||
const outputFile = jsFiles
|
||||
.map(name => `<script defer="defer" src="/static/js/${name}"></script>`)
|
||||
const outputFile = entrypoints
|
||||
.map(name => {
|
||||
const file = path.basename(name)
|
||||
return `<script defer="defer" src="/static/js/${file}"></script>`
|
||||
})
|
||||
.join('\n')
|
||||
fs.writeFileSync(templateFile, outputFile)
|
||||
|
||||
+14
-14
@@ -175,25 +175,25 @@ function App() {
|
||||
<A11yProvider>
|
||||
<KeyboardProvider enabled={false} statusBarTranslucent={true}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<StarterPackProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</KeyboardProvider>
|
||||
</A11yProvider>
|
||||
|
||||
+14
-14
@@ -153,25 +153,25 @@ function App() {
|
||||
return (
|
||||
<A11yProvider>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<StarterPackProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</A11yProvider>
|
||||
)
|
||||
|
||||
@@ -490,6 +490,7 @@ function MyProfileTabNavigator() {
|
||||
getComponent={() => ProfileScreen}
|
||||
initialParams={{
|
||||
name: 'me',
|
||||
hideBackButton: true,
|
||||
}}
|
||||
/>
|
||||
{commonScreens(MyProfileTab as typeof HomeTab)}
|
||||
|
||||
+28
-1
@@ -1,9 +1,14 @@
|
||||
import {Platform, StyleSheet} from 'react-native'
|
||||
import {Platform, StyleSheet, ViewStyle} from 'react-native'
|
||||
|
||||
import * as tokens from '#/alf/tokens'
|
||||
import {native, web} from '#/alf/util/platform'
|
||||
|
||||
export const atoms = {
|
||||
debug: {
|
||||
borderColor: 'red',
|
||||
borderWidth: 1,
|
||||
},
|
||||
|
||||
/*
|
||||
* Positioning
|
||||
*/
|
||||
@@ -55,6 +60,19 @@ export const atoms = {
|
||||
height: '100vh',
|
||||
}),
|
||||
|
||||
/**
|
||||
* Used for the outermost components on screens, to ensure that they can fill
|
||||
* the screen and extend beyond.
|
||||
*/
|
||||
util_screen_outer: [
|
||||
web({
|
||||
minHeight: '100vh',
|
||||
}),
|
||||
native({
|
||||
height: '100%',
|
||||
}),
|
||||
] as ViewStyle,
|
||||
|
||||
/*
|
||||
* Theme-independent bg colors
|
||||
*/
|
||||
@@ -853,6 +871,7 @@ export const atoms = {
|
||||
mr_auto: {
|
||||
marginRight: 'auto',
|
||||
},
|
||||
|
||||
/*
|
||||
* Pointer events & user select
|
||||
*/
|
||||
@@ -871,6 +890,7 @@ export const atoms = {
|
||||
user_select_all: {
|
||||
userSelect: 'all',
|
||||
},
|
||||
|
||||
/*
|
||||
* Text decoration
|
||||
*/
|
||||
@@ -880,4 +900,11 @@ export const atoms = {
|
||||
strike_through: {
|
||||
textDecorationLine: 'line-through',
|
||||
},
|
||||
|
||||
/*
|
||||
* Display
|
||||
*/
|
||||
hidden: {
|
||||
display: 'none',
|
||||
},
|
||||
} as const
|
||||
|
||||
+10
-53
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {Dimensions} from 'react-native'
|
||||
import {useMediaQuery} from 'react-responsive'
|
||||
|
||||
import {createThemes, defaultTheme} from '#/alf/themes'
|
||||
import {Theme, ThemeName} from '#/alf/types'
|
||||
@@ -12,52 +12,15 @@ export * from '#/alf/util/flatten'
|
||||
export * from '#/alf/util/platform'
|
||||
export * from '#/alf/util/themeSelector'
|
||||
|
||||
type BreakpointName = keyof typeof breakpoints
|
||||
|
||||
/*
|
||||
* Breakpoints
|
||||
*/
|
||||
const breakpoints: {
|
||||
[key: string]: number
|
||||
} = {
|
||||
gtPhone: 500,
|
||||
gtMobile: 800,
|
||||
gtTablet: 1300,
|
||||
}
|
||||
function getActiveBreakpoints({width}: {width: number}) {
|
||||
const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter(
|
||||
breakpoint => width >= breakpoints[breakpoint],
|
||||
)
|
||||
|
||||
return {
|
||||
active: active[active.length - 1],
|
||||
gtPhone: active.includes('gtPhone'),
|
||||
gtMobile: active.includes('gtMobile'),
|
||||
gtTablet: active.includes('gtTablet'),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Context
|
||||
*/
|
||||
export const Context = React.createContext<{
|
||||
themeName: ThemeName
|
||||
theme: Theme
|
||||
breakpoints: {
|
||||
active: BreakpointName | undefined
|
||||
gtPhone: boolean
|
||||
gtMobile: boolean
|
||||
gtTablet: boolean
|
||||
}
|
||||
}>({
|
||||
themeName: 'light',
|
||||
theme: defaultTheme,
|
||||
breakpoints: {
|
||||
active: undefined,
|
||||
gtPhone: false,
|
||||
gtMobile: false,
|
||||
gtTablet: false,
|
||||
},
|
||||
})
|
||||
|
||||
export function ThemeProvider({
|
||||
@@ -74,18 +37,6 @@ export function ThemeProvider({
|
||||
})
|
||||
}, [])
|
||||
const theme = themes[themeName]
|
||||
const [breakpoints, setBreakpoints] = React.useState(() =>
|
||||
getActiveBreakpoints({width: Dimensions.get('window').width}),
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const listener = Dimensions.addEventListener('change', ({window}) => {
|
||||
const bp = getActiveBreakpoints({width: window.width})
|
||||
if (bp.active !== breakpoints.active) setBreakpoints(bp)
|
||||
})
|
||||
|
||||
return listener.remove
|
||||
}, [breakpoints, setBreakpoints])
|
||||
|
||||
return (
|
||||
<Context.Provider
|
||||
@@ -93,9 +44,8 @@ export function ThemeProvider({
|
||||
() => ({
|
||||
themeName: themeName,
|
||||
theme: theme,
|
||||
breakpoints,
|
||||
}),
|
||||
[theme, themeName, breakpoints],
|
||||
[theme, themeName],
|
||||
)}>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
@@ -107,5 +57,12 @@ export function useTheme() {
|
||||
}
|
||||
|
||||
export function useBreakpoints() {
|
||||
return React.useContext(Context).breakpoints
|
||||
const gtPhone = useMediaQuery({minWidth: 500})
|
||||
const gtMobile = useMediaQuery({minWidth: 800})
|
||||
const gtTablet = useMediaQuery({minWidth: 1300})
|
||||
return {
|
||||
gtPhone,
|
||||
gtMobile,
|
||||
gtTablet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ScrollView} from 'react-native-gesture-handler'
|
||||
import {AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
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'
|
||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {useProfilesQuery} from '#/state/queries/profile'
|
||||
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useProgressGuide} from '#/state/shell/progress-guide'
|
||||
import * as userActionHistory from '#/state/userActionHistory'
|
||||
@@ -173,14 +176,63 @@ function useExperimentalSuggestedUsersQuery() {
|
||||
}
|
||||
}
|
||||
|
||||
export function SuggestedFollows() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
||||
const gate = useGate()
|
||||
const [feedType, feedUri] = feed.split('|')
|
||||
if (feedType === 'author') {
|
||||
if (gate('show_follow_suggestions_in_profile')) {
|
||||
return <SuggestedFollowsProfile did={feedUri} />
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
return <SuggestedFollowsHome />
|
||||
}
|
||||
}
|
||||
|
||||
export function SuggestedFollowsProfile({did}: {did: string}) {
|
||||
const {
|
||||
isLoading: isSuggestionsLoading,
|
||||
data,
|
||||
error,
|
||||
} = useSuggestedFollowsByActorQuery({
|
||||
did,
|
||||
})
|
||||
return (
|
||||
<ProfileGrid
|
||||
isSuggestionsLoading={isSuggestionsLoading}
|
||||
profiles={data?.suggestions ?? []}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SuggestedFollowsHome() {
|
||||
const {
|
||||
isLoading: isSuggestionsLoading,
|
||||
profiles,
|
||||
error,
|
||||
} = useExperimentalSuggestedUsersQuery()
|
||||
return (
|
||||
<ProfileGrid
|
||||
isSuggestionsLoading={isSuggestionsLoading}
|
||||
profiles={profiles}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfileGrid({
|
||||
isSuggestionsLoading,
|
||||
error,
|
||||
profiles,
|
||||
}: {
|
||||
isSuggestionsLoading: boolean
|
||||
profiles: AppBskyActorDefs.ProfileViewDetailed[]
|
||||
error: Error | null
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
@@ -178,7 +178,7 @@ let ListMaybePlaceholder = ({
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.h_full_vh,
|
||||
a.align_center,
|
||||
!gtMobile ? a.justify_between : a.gap_5xl,
|
||||
t.atoms.border_contrast_low,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedVideo,
|
||||
} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {parseTenorGif} from '#/lib/strings/embed-player'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
|
||||
/**
|
||||
* Streamlined MediaPreview component which just handles images, gifs, and videos
|
||||
*/
|
||||
export function Embed({
|
||||
embed,
|
||||
style,
|
||||
}: {
|
||||
embed?:
|
||||
| AppBskyEmbedImages.View
|
||||
| AppBskyEmbedRecordWithMedia.View
|
||||
| AppBskyEmbedExternal.View
|
||||
| AppBskyEmbedVideo.View
|
||||
| {[k: string]: unknown}
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
let media = AppBskyEmbedRecordWithMedia.isView(embed) ? embed.media : embed
|
||||
|
||||
if (AppBskyEmbedImages.isView(media)) {
|
||||
return (
|
||||
<Outer style={style}>
|
||||
{media.images.map(image => (
|
||||
<ImageItem
|
||||
key={image.thumb}
|
||||
thumbnail={image.thumb}
|
||||
alt={image.alt}
|
||||
/>
|
||||
))}
|
||||
</Outer>
|
||||
)
|
||||
} 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 (
|
||||
<Outer style={style}>
|
||||
<GifItem
|
||||
thumbnail={embed.external.thumb}
|
||||
alt={embed.external.title}
|
||||
/>
|
||||
</Outer>
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (AppBskyEmbedVideo.isView(embed)) {
|
||||
return (
|
||||
<Outer style={style}>
|
||||
<VideoItem thumbnail={embed.thumbnail} alt={embed.alt} />
|
||||
</Outer>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function Outer({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
return <View style={[a.flex_row, a.gap_xs, style]}>{children}</View>
|
||||
}
|
||||
|
||||
export function ImageItem({
|
||||
thumbnail,
|
||||
alt,
|
||||
children,
|
||||
}: {
|
||||
thumbnail: string
|
||||
alt?: string
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<View style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}>
|
||||
<Image
|
||||
key={thumbnail}
|
||||
source={{uri: thumbnail}}
|
||||
style={[a.flex_1, a.rounded_xs]}
|
||||
contentFit="cover"
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
accessibilityHint={alt}
|
||||
accessibilityLabel=""
|
||||
/>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function GifItem({thumbnail, alt}: {thumbnail: string; alt?: string}) {
|
||||
return (
|
||||
<ImageItem thumbnail={thumbnail} alt={alt}>
|
||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
<PlayButtonIcon size={24} />
|
||||
</View>
|
||||
<View style={styles.altContainer}>
|
||||
<Text style={styles.alt}>
|
||||
<Trans>GIF</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</ImageItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function VideoItem({
|
||||
thumbnail,
|
||||
alt,
|
||||
}: {
|
||||
thumbnail?: string
|
||||
alt?: string
|
||||
}) {
|
||||
if (!thumbnail) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{backgroundColor: 'black'},
|
||||
a.flex_1,
|
||||
{aspectRatio: 1, maxWidth: 100},
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
]}>
|
||||
<PlayButtonIcon size={24} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ImageItem thumbnail={thumbnail} alt={alt}>
|
||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
<PlayButtonIcon size={24} />
|
||||
</View>
|
||||
</ImageItem>
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
@@ -377,7 +377,7 @@ function Inner({
|
||||
hide: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {_, i18n} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const moderation = React.useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
@@ -393,8 +393,8 @@ function Inner({
|
||||
profile.viewer?.blocking ||
|
||||
profile.viewer?.blockedBy ||
|
||||
profile.viewer?.blockingByList
|
||||
const following = formatCount(profile.followsCount || 0)
|
||||
const followers = formatCount(profile.followersCount || 0)
|
||||
const following = formatCount(i18n, profile.followsCount || 0)
|
||||
const followers = formatCount(i18n, profile.followersCount || 0)
|
||||
const pluralizedFollowers = plural(profile.followersCount || 0, {
|
||||
one: 'follower',
|
||||
other: 'followers',
|
||||
|
||||
@@ -8,7 +8,10 @@ import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export {useDialogControl as usePromptControl} from '#/components/Dialog'
|
||||
export {
|
||||
type DialogControlProps as PromptControlProps,
|
||||
useDialogControl as usePromptControl,
|
||||
} from '#/components/Dialog'
|
||||
|
||||
const Context = React.createContext<{
|
||||
titleId: string
|
||||
@@ -23,7 +26,7 @@ export function Outer({
|
||||
control,
|
||||
testID,
|
||||
}: React.PropsWithChildren<{
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
control: Dialog.DialogControlProps
|
||||
testID?: string
|
||||
}>) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
@@ -59,20 +59,24 @@ export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode(
|
||||
<QrCodeInner link={link} />
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.align_center, {gap: 5}]}>
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
a.text_center,
|
||||
{color: 'white', fontSize: 18},
|
||||
]}>
|
||||
<Trans>on</Trans>
|
||||
</Text>
|
||||
<Logo width={26} fill="white" />
|
||||
<View style={[{marginTop: 5, marginLeft: 2.5}]}>
|
||||
<Logotype width={68} fill="white" />
|
||||
</View>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
a.flex,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.font_bold,
|
||||
{color: 'white', fontSize: 18, gap: 6},
|
||||
]}>
|
||||
<Trans>
|
||||
on
|
||||
<View style={[a.flex_row, a.align_center, {gap: 6}]}>
|
||||
<Logo width={25} fill="white" />
|
||||
<View style={[{marginTop: 3.5}]}>
|
||||
<Logotype width={72} fill="white" />
|
||||
</View>
|
||||
</View>
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</LinearGradientBackground>
|
||||
</ViewShot>
|
||||
|
||||
@@ -43,7 +43,7 @@ function EmbedDialogInner({
|
||||
timestamp,
|
||||
}: Omit<EmbedDialogProps, 'control'>) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {_, i18n} = useLingui()
|
||||
const ref = useRef<TextInput>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
@@ -86,9 +86,9 @@ function EmbedDialogInner({
|
||||
)} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
|
||||
postAuthor.handle,
|
||||
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
|
||||
niceDate(timestamp),
|
||||
niceDate(i18n, timestamp),
|
||||
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
|
||||
}, [postUri, postCid, record, timestamp, postAuthor])
|
||||
}, [i18n, postUri, postCid, record, timestamp, postAuthor])
|
||||
|
||||
return (
|
||||
<Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ChatBskyConvoDefs,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -153,14 +154,14 @@ let MessageItemMetadata = ({
|
||||
)
|
||||
|
||||
const relativeTimestamp = useCallback(
|
||||
(timestamp: string) => {
|
||||
(i18n: I18n, timestamp: string) => {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
|
||||
const time = new Intl.DateTimeFormat(undefined, {
|
||||
const time = i18n.date(date, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
}).format(date)
|
||||
})
|
||||
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
@@ -182,13 +183,13 @@ let MessageItemMetadata = ({
|
||||
return _(msg`Yesterday, ${time}`)
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
return i18n.date(date, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
})
|
||||
},
|
||||
[_],
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyEmbedRecord} from '@atproto/api'
|
||||
|
||||
import {PostEmbeds} from '#/view/com/util/post-embeds'
|
||||
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
|
||||
let MessageItemEmbed = ({
|
||||
@@ -14,7 +14,11 @@ let MessageItemEmbed = ({
|
||||
|
||||
return (
|
||||
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
|
||||
<PostEmbeds embed={embed} allowNestedQuotes />
|
||||
<PostEmbeds
|
||||
embed={embed}
|
||||
allowNestedQuotes
|
||||
viewContext={PostEmbedViewContext.Feed}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {android, atoms as a, useTheme, web} from '#/alf'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {localizeDate} from './utils'
|
||||
|
||||
// looks like a TextField.Input, but is just a button. It'll do something different on each platform on press
|
||||
// iOS: open a dialog with an inline date picker
|
||||
@@ -25,6 +25,7 @@ export function DateFieldButton({
|
||||
isInvalid?: boolean
|
||||
accessibilityHint?: string
|
||||
}) {
|
||||
const {i18n} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const {
|
||||
@@ -91,7 +92,7 @@ export function DateFieldButton({
|
||||
t.atoms.text,
|
||||
{lineHeight: a.text_md.fontSize * 1.1875},
|
||||
]}>
|
||||
{localizeDate(value)}
|
||||
{i18n.date(value, {timeZone: 'UTC'})}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import {getLocales} from 'expo-localization'
|
||||
|
||||
const LOCALE = getLocales()[0]
|
||||
|
||||
// we need the date in the form yyyy-MM-dd to pass to the input
|
||||
export function toSimpleDateString(date: Date | string): string {
|
||||
const _date = typeof date === 'string' ? new Date(date) : date
|
||||
return _date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
export function localizeDate(date: Date | string): string {
|
||||
const _date = typeof date === 'string' ? new Date(date) : date
|
||||
return new Intl.DateTimeFormat(LOCALE.languageTag, {
|
||||
timeZone: 'UTC',
|
||||
}).format(_date)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Crop_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M6 2a1 1 0 0 1 1 1v2h11a1 1 0 0 1 1 1v11h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H6a1 1 0 0 1-1-1V7H3a1 1 0 0 1 0-2h2V3a1 1 0 0 1 1-1Zm1 5v10h10V7H7Z',
|
||||
})
|
||||
@@ -19,6 +19,7 @@ export const sizes = {
|
||||
md: 20,
|
||||
lg: 24,
|
||||
xl: 28,
|
||||
'2xl': 32,
|
||||
}
|
||||
|
||||
export function useCommonSVGProps(props: Props) {
|
||||
|
||||
@@ -14,19 +14,18 @@ import {
|
||||
} from '#/components/moderation/LabelsOnMeDialog'
|
||||
|
||||
export function LabelsOnMe({
|
||||
details,
|
||||
type,
|
||||
labels,
|
||||
size,
|
||||
style,
|
||||
}: {
|
||||
details: {did: string} | {uri: string; cid: string}
|
||||
type: 'account' | 'content'
|
||||
labels: ComAtprotoLabelDefs.Label[] | undefined
|
||||
size?: ButtonSize
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const isAccount = 'did' in details
|
||||
const control = useLabelsOnMeDialogControl()
|
||||
|
||||
if (!labels || !currentAccount) {
|
||||
@@ -39,7 +38,7 @@ export function LabelsOnMe({
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, style]}>
|
||||
<LabelsOnMeDialog control={control} subject={details} labels={labels} />
|
||||
<LabelsOnMeDialog control={control} labels={labels} type={type} />
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
@@ -51,7 +50,7 @@ export function LabelsOnMe({
|
||||
}}>
|
||||
<ButtonIcon position="left" icon={CircleInfo} />
|
||||
<ButtonText style={[a.leading_snug]}>
|
||||
{isAccount ? (
|
||||
{type === 'account' ? (
|
||||
<Plural
|
||||
value={labels.length}
|
||||
one="# label has been placed on this account"
|
||||
@@ -82,6 +81,6 @@ export function LabelsOnMyPost({
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<LabelsOnMe details={post} labels={post.labels} size="tiny" style={style} />
|
||||
<LabelsOnMe type="content" labels={post.labels} size="tiny" style={style} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {useLabelSubject} from '#/lib/moderation'
|
||||
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
@@ -18,21 +19,13 @@ import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Divider} from '../Divider'
|
||||
import {Loader} from '../Loader'
|
||||
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
|
||||
|
||||
type Subject =
|
||||
| {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
| {
|
||||
did: string
|
||||
}
|
||||
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
|
||||
|
||||
export interface LabelsOnMeDialogProps {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
subject: Subject
|
||||
labels: ComAtprotoLabelDefs.Label[]
|
||||
type: 'account' | 'content'
|
||||
}
|
||||
|
||||
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
|
||||
@@ -51,8 +44,8 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
|
||||
const [appealingLabel, setAppealingLabel] = React.useState<
|
||||
ComAtprotoLabelDefs.Label | undefined
|
||||
>(undefined)
|
||||
const {subject, labels} = props
|
||||
const isAccount = 'did' in subject
|
||||
const {labels} = props
|
||||
const isAccount = props.type === 'account'
|
||||
const containsSelfLabel = React.useMemo(
|
||||
() => labels.some(l => l.src === currentAccount?.did),
|
||||
[currentAccount?.did, labels],
|
||||
@@ -68,7 +61,6 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
|
||||
{appealingLabel ? (
|
||||
<AppealForm
|
||||
label={appealingLabel}
|
||||
subject={subject}
|
||||
control={props.control}
|
||||
onPressBack={() => setAppealingLabel(undefined)}
|
||||
/>
|
||||
@@ -188,12 +180,10 @@ function Label({
|
||||
|
||||
function AppealForm({
|
||||
label,
|
||||
subject,
|
||||
control,
|
||||
onPressBack,
|
||||
}: {
|
||||
label: ComAtprotoLabelDefs.Label
|
||||
subject: Subject
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
onPressBack: () => void
|
||||
}) {
|
||||
@@ -201,6 +191,7 @@ function AppealForm({
|
||||
const {labeler, strings} = useLabelInfo(label)
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [details, setDetails] = React.useState('')
|
||||
const {subject} = useLabelSubject({label})
|
||||
const isAccountReport = 'did' in subject
|
||||
const agent = useAgent()
|
||||
const sourceName = labeler
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
|
||||
export function PlayButtonIcon({size = 44}: {size?: number}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
backgroundColor: t.palette.primary_500,
|
||||
width: size + 16,
|
||||
height: size + 16,
|
||||
},
|
||||
]}>
|
||||
<PlayIcon height={size} width={size} style={{color: 'white'}} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -81,7 +81,15 @@ export class FeedViewPostsSlice {
|
||||
isParentBlocked,
|
||||
isParentNotFound,
|
||||
})
|
||||
if (!reply || reason) {
|
||||
if (!reply) {
|
||||
if (post.record.reply) {
|
||||
// This reply wasn't properly hydrated by the AppView.
|
||||
this.isOrphan = true
|
||||
this.items[0].isParentNotFound = true
|
||||
}
|
||||
return
|
||||
}
|
||||
if (reason) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
@@ -263,7 +271,12 @@ export class FeedTuner {
|
||||
}
|
||||
} else {
|
||||
if (!dryRun) {
|
||||
this.seenUris.add(item.post.uri)
|
||||
// Reposting a reply elevates it to top-level, so its parent/root won't be displayed.
|
||||
// Disable in-thread dedupe for this case since we don't want to miss them later.
|
||||
const disableDedupe = slice.isReply && slice.isRepost
|
||||
if (!disableDedupe) {
|
||||
this.seenUris.add(item.post.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,11 +379,7 @@ export class FeedTuner {
|
||||
): FeedViewPostsSlice[] => {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
const slice = slices[i]
|
||||
if (
|
||||
slice.isReply &&
|
||||
!slice.isRepost &&
|
||||
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
|
||||
) {
|
||||
if (slice.isReply && !shouldDisplayReplyInFollowing(slice, userDid)) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
@@ -434,9 +443,13 @@ function areSameAuthor(authors: AuthorContext): boolean {
|
||||
}
|
||||
|
||||
function shouldDisplayReplyInFollowing(
|
||||
authors: AuthorContext,
|
||||
slice: FeedViewPostsSlice,
|
||||
userDid: string,
|
||||
): boolean {
|
||||
if (slice.isRepost) {
|
||||
return true
|
||||
}
|
||||
const authors = slice.getAuthors()
|
||||
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
||||
if (!isSelfOrFollowing(author, userDid)) {
|
||||
// Only show replies from self or people you follow.
|
||||
@@ -450,6 +463,21 @@ function shouldDisplayReplyInFollowing(
|
||||
// Always show self-threads.
|
||||
return true
|
||||
}
|
||||
if (
|
||||
parentAuthor &&
|
||||
parentAuthor.did !== author.did &&
|
||||
rootAuthor &&
|
||||
rootAuthor.did === author.did &&
|
||||
slice.items.length > 2
|
||||
) {
|
||||
// If you follow A, show A -> someone[>0 likes] -> A chains too.
|
||||
// This is different from cases below because you only know one person.
|
||||
const parentPost = slice.items[1].post
|
||||
const parentLikeCount = parentPost.likeCount ?? 0
|
||||
if (parentLikeCount > 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// From this point on we need at least one more reason to show it.
|
||||
if (
|
||||
parentAuthor &&
|
||||
|
||||
+25
-3
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AppBskyEmbedDefs,
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
@@ -45,7 +46,12 @@ interface PostOpts {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
video?: BlobRef
|
||||
video?: {
|
||||
blobRef: BlobRef
|
||||
altText: string
|
||||
captions: {lang: string; file: File}[]
|
||||
aspectRatio?: AppBskyEmbedDefs.AspectRatio
|
||||
}
|
||||
extLink?: ExternalEmbedDraft
|
||||
images?: ImageModel[]
|
||||
labels?: string[]
|
||||
@@ -128,19 +134,35 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
|
||||
// add video embed if present
|
||||
if (opts.video) {
|
||||
const captions = await Promise.all(
|
||||
opts.video.captions
|
||||
.filter(caption => caption.lang !== '')
|
||||
.map(async caption => {
|
||||
const {data} = await agent.uploadBlob(caption.file, {
|
||||
encoding: 'text/vtt',
|
||||
})
|
||||
return {lang: caption.lang, file: data.blob}
|
||||
}),
|
||||
)
|
||||
if (opts.quote) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.recordWithMedia',
|
||||
record: embed,
|
||||
media: {
|
||||
$type: 'app.bsky.embed.video',
|
||||
video: opts.video,
|
||||
video: opts.video.blobRef,
|
||||
alt: opts.video.altText || undefined,
|
||||
captions: captions.length === 0 ? undefined : captions,
|
||||
aspectRatio: opts.video.aspectRatio,
|
||||
} as AppBskyEmbedVideo.Main,
|
||||
} as AppBskyEmbedRecordWithMedia.Main
|
||||
} else {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.video',
|
||||
video: opts.video,
|
||||
video: opts.video.blobRef,
|
||||
alt: opts.video.altText || undefined,
|
||||
captions: captions.length === 0 ? undefined : captions,
|
||||
aspectRatio: opts.video.aspectRatio,
|
||||
} as AppBskyEmbedVideo.Main
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,3 +136,12 @@ export const GIF_FEATURED = (params: string) =>
|
||||
`${GIF_SERVICE}/tenor/v2/featured?${params}`
|
||||
|
||||
export const MAX_LABELERS = 20
|
||||
|
||||
export const SUPPORTED_MIME_TYPES = [
|
||||
'video/mp4',
|
||||
'video/mpeg',
|
||||
'video/webm',
|
||||
'video/quicktime',
|
||||
] as const
|
||||
|
||||
export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
LayoutAnimationConfig,
|
||||
useReducedMotion,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {i18n} from '@lingui/core'
|
||||
|
||||
import {decideShouldRoll} from 'lib/custom-animations/util'
|
||||
import {s} from 'lib/styles'
|
||||
import {formatCount} from 'view/com/util/numeric/format'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
const animationConfig = {
|
||||
duration: 400,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
}
|
||||
|
||||
function EnteringUp() {
|
||||
'worklet'
|
||||
const animations = {
|
||||
opacity: withTiming(1, animationConfig),
|
||||
transform: [{translateY: withTiming(0, animationConfig)}],
|
||||
}
|
||||
const initialValues = {
|
||||
opacity: 0,
|
||||
transform: [{translateY: 18}],
|
||||
}
|
||||
return {
|
||||
animations,
|
||||
initialValues,
|
||||
}
|
||||
}
|
||||
|
||||
function EnteringDown() {
|
||||
'worklet'
|
||||
const animations = {
|
||||
opacity: withTiming(1, animationConfig),
|
||||
transform: [{translateY: withTiming(0, animationConfig)}],
|
||||
}
|
||||
const initialValues = {
|
||||
opacity: 0,
|
||||
transform: [{translateY: -18}],
|
||||
}
|
||||
return {
|
||||
animations,
|
||||
initialValues,
|
||||
}
|
||||
}
|
||||
|
||||
function ExitingUp() {
|
||||
'worklet'
|
||||
const animations = {
|
||||
opacity: withTiming(0, animationConfig),
|
||||
transform: [
|
||||
{
|
||||
translateY: withTiming(-18, animationConfig),
|
||||
},
|
||||
],
|
||||
}
|
||||
const initialValues = {
|
||||
opacity: 1,
|
||||
transform: [{translateY: 0}],
|
||||
}
|
||||
return {
|
||||
animations,
|
||||
initialValues,
|
||||
}
|
||||
}
|
||||
|
||||
function ExitingDown() {
|
||||
'worklet'
|
||||
const animations = {
|
||||
opacity: withTiming(0, animationConfig),
|
||||
transform: [{translateY: withTiming(18, animationConfig)}],
|
||||
}
|
||||
const initialValues = {
|
||||
opacity: 1,
|
||||
transform: [{translateY: 0}],
|
||||
}
|
||||
return {
|
||||
animations,
|
||||
initialValues,
|
||||
}
|
||||
}
|
||||
|
||||
export function CountWheel({
|
||||
likeCount,
|
||||
big,
|
||||
isLiked,
|
||||
isToggle,
|
||||
}: {
|
||||
likeCount: number
|
||||
big?: boolean
|
||||
isLiked: boolean
|
||||
isToggle: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const shouldAnimate = !useReducedMotion() && isToggle
|
||||
const shouldRoll = decideShouldRoll(isLiked, likeCount)
|
||||
|
||||
// Incrementing the key will cause the `Animated.View` to re-render, with the newly selected entering/exiting
|
||||
// animation
|
||||
// The initial entering/exiting animations will get skipped, since these will happen on screen mounts and would
|
||||
// be unnecessary
|
||||
const [key, setKey] = React.useState(0)
|
||||
const [prevCount, setPrevCount] = React.useState(likeCount)
|
||||
const prevIsLiked = React.useRef(isLiked)
|
||||
const formattedCount = formatCount(i18n, likeCount)
|
||||
const formattedPrevCount = formatCount(i18n, prevCount)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLiked === prevIsLiked.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
|
||||
setKey(prev => prev + 1)
|
||||
setPrevCount(newPrevCount)
|
||||
prevIsLiked.current = isLiked
|
||||
}, [isLiked, likeCount])
|
||||
|
||||
const enteringAnimation =
|
||||
shouldAnimate && shouldRoll
|
||||
? isLiked
|
||||
? EnteringUp
|
||||
: EnteringDown
|
||||
: undefined
|
||||
const exitingAnimation =
|
||||
shouldAnimate && shouldRoll
|
||||
? isLiked
|
||||
? ExitingUp
|
||||
: ExitingDown
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<LayoutAnimationConfig skipEntering skipExiting>
|
||||
{likeCount > 0 ? (
|
||||
<View style={[a.justify_center]}>
|
||||
<Animated.View entering={enteringAnimation} key={key}>
|
||||
<Text
|
||||
testID="likeCount"
|
||||
style={[
|
||||
big ? a.text_md : {fontSize: 15},
|
||||
a.user_select_none,
|
||||
isLiked
|
||||
? [a.font_bold, s.likeColor]
|
||||
: {color: t.palette.contrast_500},
|
||||
]}>
|
||||
{formattedCount}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
{shouldAnimate && (likeCount > 1 || !isLiked) ? (
|
||||
<Animated.View
|
||||
entering={exitingAnimation}
|
||||
// Add 2 to the key so there are never duplicates
|
||||
key={key + 2}
|
||||
style={[a.absolute, {width: 50, opacity: 0}]}
|
||||
aria-disabled={true}>
|
||||
<Text
|
||||
style={[
|
||||
big ? a.text_md : {fontSize: 15},
|
||||
a.user_select_none,
|
||||
isLiked
|
||||
? [a.font_bold, s.likeColor]
|
||||
: {color: t.palette.contrast_500},
|
||||
]}>
|
||||
{formattedPrevCount}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</LayoutAnimationConfig>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useReducedMotion} from 'react-native-reanimated'
|
||||
import {i18n} from '@lingui/core'
|
||||
|
||||
import {decideShouldRoll} from 'lib/custom-animations/util'
|
||||
import {s} from 'lib/styles'
|
||||
import {formatCount} from 'view/com/util/numeric/format'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
const animationConfig = {
|
||||
duration: 400,
|
||||
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
fill: 'forwards' as FillMode,
|
||||
}
|
||||
|
||||
const enteringUpKeyframe = [
|
||||
{opacity: 0, transform: 'translateY(18px)'},
|
||||
{opacity: 1, transform: 'translateY(0)'},
|
||||
]
|
||||
|
||||
const enteringDownKeyframe = [
|
||||
{opacity: 0, transform: 'translateY(-18px)'},
|
||||
{opacity: 1, transform: 'translateY(0)'},
|
||||
]
|
||||
|
||||
const exitingUpKeyframe = [
|
||||
{opacity: 1, transform: 'translateY(0)'},
|
||||
{opacity: 0, transform: 'translateY(-18px)'},
|
||||
]
|
||||
|
||||
const exitingDownKeyframe = [
|
||||
{opacity: 1, transform: 'translateY(0)'},
|
||||
{opacity: 0, transform: 'translateY(18px)'},
|
||||
]
|
||||
|
||||
export function CountWheel({
|
||||
likeCount,
|
||||
big,
|
||||
isLiked,
|
||||
isToggle,
|
||||
}: {
|
||||
likeCount: number
|
||||
big?: boolean
|
||||
isLiked: boolean
|
||||
isToggle: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const shouldAnimate = !useReducedMotion() && isToggle
|
||||
const shouldRoll = decideShouldRoll(isLiked, likeCount)
|
||||
|
||||
const countView = React.useRef<HTMLDivElement>(null)
|
||||
const prevCountView = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const [prevCount, setPrevCount] = React.useState(likeCount)
|
||||
const prevIsLiked = React.useRef(isLiked)
|
||||
const formattedCount = formatCount(i18n, likeCount)
|
||||
const formattedPrevCount = formatCount(i18n, prevCount)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLiked === prevIsLiked.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
|
||||
if (shouldAnimate && shouldRoll) {
|
||||
countView.current?.animate?.(
|
||||
isLiked ? enteringUpKeyframe : enteringDownKeyframe,
|
||||
animationConfig,
|
||||
)
|
||||
prevCountView.current?.animate?.(
|
||||
isLiked ? exitingUpKeyframe : exitingDownKeyframe,
|
||||
animationConfig,
|
||||
)
|
||||
setPrevCount(newPrevCount)
|
||||
}
|
||||
prevIsLiked.current = isLiked
|
||||
}, [isLiked, likeCount, shouldAnimate, shouldRoll])
|
||||
|
||||
if (likeCount < 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
// @ts-expect-error is div
|
||||
ref={countView}>
|
||||
<Text
|
||||
testID="likeCount"
|
||||
style={[
|
||||
big ? a.text_md : {fontSize: 15},
|
||||
a.user_select_none,
|
||||
isLiked
|
||||
? [a.font_bold, s.likeColor]
|
||||
: {color: t.palette.contrast_500},
|
||||
]}>
|
||||
{formattedCount}
|
||||
</Text>
|
||||
</View>
|
||||
{shouldAnimate && (likeCount > 1 || !isLiked) ? (
|
||||
<View
|
||||
style={{position: 'absolute', opacity: 0}}
|
||||
aria-disabled={true}
|
||||
// @ts-expect-error is div
|
||||
ref={prevCountView}>
|
||||
<Text
|
||||
style={[
|
||||
big ? a.text_md : {fontSize: 15},
|
||||
a.user_select_none,
|
||||
isLiked
|
||||
? [a.font_bold, s.likeColor]
|
||||
: {color: t.palette.contrast_500},
|
||||
]}>
|
||||
{formattedPrevCount}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {
|
||||
Keyframe,
|
||||
LayoutAnimationConfig,
|
||||
useReducedMotion,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
import {s} from 'lib/styles'
|
||||
import {useTheme} from '#/alf'
|
||||
import {
|
||||
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
|
||||
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
|
||||
} from '#/components/icons/Heart2'
|
||||
|
||||
const keyframe = new Keyframe({
|
||||
0: {
|
||||
transform: [{scale: 1}],
|
||||
},
|
||||
10: {
|
||||
transform: [{scale: 0.7}],
|
||||
},
|
||||
40: {
|
||||
transform: [{scale: 1.2}],
|
||||
},
|
||||
100: {
|
||||
transform: [{scale: 1}],
|
||||
},
|
||||
})
|
||||
|
||||
const circle1Keyframe = new Keyframe({
|
||||
0: {
|
||||
opacity: 0,
|
||||
transform: [{scale: 0}],
|
||||
},
|
||||
10: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
40: {
|
||||
transform: [{scale: 1.5}],
|
||||
},
|
||||
95: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
100: {
|
||||
opacity: 0,
|
||||
transform: [{scale: 1.5}],
|
||||
},
|
||||
})
|
||||
|
||||
const circle2Keyframe = new Keyframe({
|
||||
0: {
|
||||
opacity: 0,
|
||||
transform: [{scale: 0}],
|
||||
},
|
||||
10: {
|
||||
opacity: 1,
|
||||
},
|
||||
40: {
|
||||
transform: [{scale: 0}],
|
||||
},
|
||||
95: {
|
||||
opacity: 1,
|
||||
},
|
||||
100: {
|
||||
opacity: 0,
|
||||
transform: [{scale: 1.5}],
|
||||
},
|
||||
})
|
||||
|
||||
export function AnimatedLikeIcon({
|
||||
isLiked,
|
||||
big,
|
||||
isToggle,
|
||||
}: {
|
||||
isLiked: boolean
|
||||
big?: boolean
|
||||
isToggle: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const size = big ? 22 : 18
|
||||
const shouldAnimate = !useReducedMotion() && isToggle
|
||||
|
||||
return (
|
||||
<View>
|
||||
<LayoutAnimationConfig skipEntering>
|
||||
{isLiked ? (
|
||||
<Animated.View
|
||||
entering={shouldAnimate ? keyframe.duration(300) : undefined}>
|
||||
<HeartIconFilled style={s.likeColor} width={size} />
|
||||
</Animated.View>
|
||||
) : (
|
||||
<HeartIconOutline
|
||||
style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]}
|
||||
width={size}
|
||||
/>
|
||||
)}
|
||||
{isLiked ? (
|
||||
<>
|
||||
<Animated.View
|
||||
entering={
|
||||
shouldAnimate ? circle1Keyframe.duration(300) : undefined
|
||||
}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
backgroundColor: s.likeColor.color,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: size / 2,
|
||||
}}
|
||||
/>
|
||||
<Animated.View
|
||||
entering={
|
||||
shouldAnimate ? circle2Keyframe.duration(300) : undefined
|
||||
}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
backgroundColor: t.atoms.bg.backgroundColor,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: size / 2,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</LayoutAnimationConfig>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useReducedMotion} from 'react-native-reanimated'
|
||||
|
||||
import {s} from 'lib/styles'
|
||||
import {useTheme} from '#/alf'
|
||||
import {
|
||||
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
|
||||
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
|
||||
} from '#/components/icons/Heart2'
|
||||
|
||||
const animationConfig = {
|
||||
duration: 400,
|
||||
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
fill: 'forwards' as FillMode,
|
||||
}
|
||||
|
||||
const keyframe = [
|
||||
{transform: 'scale(1)'},
|
||||
{transform: 'scale(0.7)'},
|
||||
{transform: 'scale(1.2)'},
|
||||
{transform: 'scale(1)'},
|
||||
]
|
||||
|
||||
const circle1Keyframe = [
|
||||
{opacity: 0, transform: 'scale(0)'},
|
||||
{opacity: 0.4},
|
||||
{transform: 'scale(1.5)'},
|
||||
{opacity: 0.4},
|
||||
{opacity: 0, transform: 'scale(1.5)'},
|
||||
]
|
||||
|
||||
const circle2Keyframe = [
|
||||
{opacity: 0, transform: 'scale(0)'},
|
||||
{opacity: 1},
|
||||
{transform: 'scale(0)'},
|
||||
{opacity: 1},
|
||||
{opacity: 0, transform: 'scale(1.5)'},
|
||||
]
|
||||
|
||||
export function AnimatedLikeIcon({
|
||||
isLiked,
|
||||
big,
|
||||
isToggle,
|
||||
}: {
|
||||
isLiked: boolean
|
||||
big?: boolean
|
||||
isToggle: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const size = big ? 22 : 18
|
||||
const shouldAnimate = !useReducedMotion() && isToggle
|
||||
const prevIsLiked = React.useRef(isLiked)
|
||||
|
||||
const likeIconRef = React.useRef<HTMLDivElement>(null)
|
||||
const circle1Ref = React.useRef<HTMLDivElement>(null)
|
||||
const circle2Ref = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (prevIsLiked.current === isLiked) {
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldAnimate && isLiked) {
|
||||
likeIconRef.current?.animate?.(keyframe, animationConfig)
|
||||
circle1Ref.current?.animate?.(circle1Keyframe, animationConfig)
|
||||
circle2Ref.current?.animate?.(circle2Keyframe, animationConfig)
|
||||
}
|
||||
prevIsLiked.current = isLiked
|
||||
}, [shouldAnimate, isLiked])
|
||||
|
||||
return (
|
||||
<View>
|
||||
{isLiked ? (
|
||||
// @ts-expect-error is div
|
||||
<View ref={likeIconRef}>
|
||||
<HeartIconFilled style={s.likeColor} width={size} />
|
||||
</View>
|
||||
) : (
|
||||
<HeartIconOutline
|
||||
style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]}
|
||||
width={size}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
// @ts-expect-error is div
|
||||
ref={circle1Ref}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
backgroundColor: s.likeColor.color,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: size / 2,
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
// @ts-expect-error is div
|
||||
ref={circle2Ref}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
backgroundColor: t.atoms.bg.backgroundColor,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: size / 2,
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// It should roll when:
|
||||
// - We're going from 1 to 0 (roll backwards)
|
||||
// - The count is anywhere between 1 and 999
|
||||
// - The count is going up and is a multiple of 100
|
||||
// - The count is going down and is 1 less than a multiple of 100
|
||||
export function decideShouldRoll(isSet: boolean, count: number) {
|
||||
let shouldRoll = false
|
||||
if (!isSet && count === 1) {
|
||||
shouldRoll = true
|
||||
} else if (count > 1 && count < 1000) {
|
||||
shouldRoll = true
|
||||
} else if (count > 0) {
|
||||
const mod = count % 100
|
||||
if (isSet && mod === 0) {
|
||||
shouldRoll = true
|
||||
} else if (!isSet && mod === 99) {
|
||||
shouldRoll = true
|
||||
}
|
||||
}
|
||||
return shouldRoll
|
||||
}
|
||||
@@ -65,7 +65,6 @@ export function useGenerateStarterPackMutation({
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const agent = useAgent()
|
||||
const starterPackString = _(msg`Starter Pack`)
|
||||
|
||||
return useMutation<{uri: string; cid: string}, Error, void>({
|
||||
mutationFn: async () => {
|
||||
@@ -106,7 +105,7 @@ export function useGenerateStarterPackMutation({
|
||||
25,
|
||||
true,
|
||||
)
|
||||
const starterPackName = `${displayName}'s ${starterPackString}`
|
||||
const starterPackName = _(msg`${displayName}'s Starter Pack`)
|
||||
|
||||
const list = await createStarterPackList({
|
||||
name: starterPackName,
|
||||
|
||||
@@ -1,102 +1,213 @@
|
||||
import {describe, expect, it} from '@jest/globals'
|
||||
import {MessageDescriptor} from '@lingui/core'
|
||||
import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns'
|
||||
|
||||
import {dateDiff} from '../useTimeAgo'
|
||||
|
||||
const lingui: any = (obj: MessageDescriptor) => obj.message
|
||||
|
||||
const base = new Date('2024-06-17T00:00:00Z')
|
||||
|
||||
describe('dateDiff', () => {
|
||||
it(`works with numbers`, () => {
|
||||
expect(dateDiff(subDays(base, 3), Number(base), {lingui})).toEqual('3d')
|
||||
const earlier = subDays(base, 3)
|
||||
expect(dateDiff(earlier, Number(base))).toEqual({
|
||||
value: 3,
|
||||
unit: 'day',
|
||||
earlier,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`works with strings`, () => {
|
||||
expect(dateDiff(subDays(base, 3), base.toString(), {lingui})).toEqual('3d')
|
||||
const earlier = subDays(base, 3)
|
||||
expect(dateDiff(earlier, base.toString())).toEqual({
|
||||
value: 3,
|
||||
unit: 'day',
|
||||
earlier,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`works with dates`, () => {
|
||||
expect(dateDiff(subDays(base, 3), base, {lingui})).toEqual('3d')
|
||||
const earlier = subDays(base, 3)
|
||||
expect(dateDiff(earlier, base)).toEqual({
|
||||
value: 3,
|
||||
unit: 'day',
|
||||
earlier,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
|
||||
it(`equal values return now`, () => {
|
||||
expect(dateDiff(base, base, {lingui})).toEqual('now')
|
||||
expect(dateDiff(base, base)).toEqual({
|
||||
value: 0,
|
||||
unit: 'now',
|
||||
earlier: base,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`future dates return now`, () => {
|
||||
expect(dateDiff(addDays(base, 3), base, {lingui})).toEqual('now')
|
||||
const earlier = addDays(base, 3)
|
||||
expect(dateDiff(earlier, base)).toEqual({
|
||||
value: 0,
|
||||
unit: 'now',
|
||||
earlier,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
|
||||
it(`values < 5 seconds ago return now`, () => {
|
||||
const then = subSeconds(base, 4)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('now')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 0,
|
||||
unit: 'now',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`values >= 5 seconds ago return seconds`, () => {
|
||||
const then = subSeconds(base, 5)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('5s')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 5,
|
||||
unit: 'second',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
|
||||
it(`values < 1 min return seconds`, () => {
|
||||
const then = subSeconds(base, 59)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('59s')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 59,
|
||||
unit: 'second',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`values >= 1 min return minutes`, () => {
|
||||
const then = subSeconds(base, 60)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1m')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 1,
|
||||
unit: 'minute',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`minutes round down`, () => {
|
||||
const then = subSeconds(base, 119)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1m')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 1,
|
||||
unit: 'minute',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
|
||||
it(`values < 1 hour return minutes`, () => {
|
||||
const then = subMinutes(base, 59)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('59m')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 59,
|
||||
unit: 'minute',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`values >= 1 hour return hours`, () => {
|
||||
const then = subMinutes(base, 60)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1h')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 1,
|
||||
unit: 'hour',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`hours round down`, () => {
|
||||
const then = subMinutes(base, 119)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1h')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 1,
|
||||
unit: 'hour',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
|
||||
it(`values < 1 day return hours`, () => {
|
||||
const then = subHours(base, 23)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('23h')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 23,
|
||||
unit: 'hour',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`values >= 1 day return days`, () => {
|
||||
const then = subHours(base, 24)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1d')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 1,
|
||||
unit: 'day',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`days round down`, () => {
|
||||
const then = subHours(base, 47)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1d')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 1,
|
||||
unit: 'day',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
|
||||
it(`values < 30 days return days`, () => {
|
||||
const then = subDays(base, 29)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('29d')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 29,
|
||||
unit: 'day',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`values >= 30 days return months`, () => {
|
||||
const then = subDays(base, 30)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1mo')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 1,
|
||||
unit: 'month',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`months round down`, () => {
|
||||
const then = subDays(base, 59)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1mo')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 1,
|
||||
unit: 'month',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`values are rounded by increments of 30`, () => {
|
||||
const then = subDays(base, 61)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('2mo')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 2,
|
||||
unit: 'month',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
|
||||
it(`values < 360 days return months`, () => {
|
||||
const then = subDays(base, 359)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('11mo')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 11,
|
||||
unit: 'month',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`values >= 360 days return the earlier value`, () => {
|
||||
const then = subDays(base, 360)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual(then.toLocaleDateString())
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 12,
|
||||
unit: 'month',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,5 +15,10 @@ export function useInitialNumToRender({
|
||||
|
||||
const finalHeight =
|
||||
screenHeight - screenHeightOffset - topInset - bottomBarHeight
|
||||
return Math.floor(finalHeight / minItemHeight) + 1
|
||||
|
||||
const minItems = Math.floor(finalHeight / minItemHeight)
|
||||
if (minItems < 1) {
|
||||
return 1
|
||||
}
|
||||
return minItems
|
||||
}
|
||||
|
||||
+153
-61
@@ -1,25 +1,16 @@
|
||||
import {useCallback} from 'react'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {I18nContext, useLingui} from '@lingui/react'
|
||||
import {I18n} from '@lingui/core'
|
||||
import {defineMessage, msg, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {differenceInSeconds} from 'date-fns'
|
||||
|
||||
export type TimeAgoOptions = {
|
||||
lingui: I18nContext['_']
|
||||
format?: 'long' | 'short'
|
||||
}
|
||||
export type DateDiffFormat = 'long' | 'short'
|
||||
|
||||
export function useGetTimeAgo() {
|
||||
const {_} = useLingui()
|
||||
return useCallback(
|
||||
(
|
||||
earlier: number | string | Date,
|
||||
later: number | string | Date,
|
||||
options?: Omit<TimeAgoOptions, 'lingui'>,
|
||||
) => {
|
||||
return dateDiff(earlier, later, {lingui: _, format: options?.format})
|
||||
},
|
||||
[_],
|
||||
)
|
||||
type DateDiff = {
|
||||
value: number
|
||||
unit: 'now' | 'second' | 'minute' | 'hour' | 'day' | 'month'
|
||||
earlier: Date
|
||||
later: Date
|
||||
}
|
||||
|
||||
const NOW = 5
|
||||
@@ -28,59 +19,160 @@ const HOUR = MINUTE * 60
|
||||
const DAY = HOUR * 24
|
||||
const MONTH_30 = DAY * 30
|
||||
|
||||
export function useGetTimeAgo() {
|
||||
const {i18n} = useLingui()
|
||||
return useCallback(
|
||||
(
|
||||
earlier: number | string | Date,
|
||||
later: number | string | Date,
|
||||
options?: {format: DateDiffFormat},
|
||||
) => {
|
||||
const diff = dateDiff(earlier, later)
|
||||
return formatDateDiff({diff, i18n, format: options?.format})
|
||||
},
|
||||
[i18n],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the difference between `earlier` and `later` dates, formatted as a
|
||||
* natural language string.
|
||||
* Returns the difference between `earlier` and `later` dates, based on
|
||||
* opinionated rules.
|
||||
*
|
||||
* - All month are considered exactly 30 days.
|
||||
* - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
|
||||
* - All values round down
|
||||
*/
|
||||
export function dateDiff(
|
||||
earlier: number | string | Date,
|
||||
later: number | string | Date,
|
||||
): DateDiff {
|
||||
let diff = {
|
||||
value: 0,
|
||||
unit: 'now' as DateDiff['unit'],
|
||||
}
|
||||
const e = new Date(earlier)
|
||||
const l = new Date(later)
|
||||
const diffSeconds = differenceInSeconds(l, e)
|
||||
|
||||
if (diffSeconds < NOW) {
|
||||
diff = {
|
||||
value: 0,
|
||||
unit: 'now' as DateDiff['unit'],
|
||||
}
|
||||
} else if (diffSeconds < MINUTE) {
|
||||
diff = {
|
||||
value: diffSeconds,
|
||||
unit: 'second' as DateDiff['unit'],
|
||||
}
|
||||
} else if (diffSeconds < HOUR) {
|
||||
const value = Math.floor(diffSeconds / MINUTE)
|
||||
diff = {
|
||||
value,
|
||||
unit: 'minute' as DateDiff['unit'],
|
||||
}
|
||||
} else if (diffSeconds < DAY) {
|
||||
const value = Math.floor(diffSeconds / HOUR)
|
||||
diff = {
|
||||
value,
|
||||
unit: 'hour' as DateDiff['unit'],
|
||||
}
|
||||
} else if (diffSeconds < MONTH_30) {
|
||||
const value = Math.floor(diffSeconds / DAY)
|
||||
diff = {
|
||||
value,
|
||||
unit: 'day' as DateDiff['unit'],
|
||||
}
|
||||
} else {
|
||||
const value = Math.floor(diffSeconds / MONTH_30)
|
||||
diff = {
|
||||
value,
|
||||
unit: 'month' as DateDiff['unit'],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...diff,
|
||||
earlier: e,
|
||||
later: l,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a `DateDiff` and teturns the difference between `earlier` and
|
||||
* `later` dates, formatted as a natural language string.
|
||||
*
|
||||
* - All month are considered exactly 30 days.
|
||||
* - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
|
||||
* - Differences >= 360 days are returned as the "M/D/YYYY" string
|
||||
* - All values round down
|
||||
*/
|
||||
export function dateDiff(
|
||||
earlier: number | string | Date,
|
||||
later: number | string | Date,
|
||||
options: TimeAgoOptions,
|
||||
): string {
|
||||
const _ = options.lingui
|
||||
const format = options?.format || 'short'
|
||||
export function formatDateDiff({
|
||||
diff,
|
||||
format = 'short',
|
||||
i18n,
|
||||
}: {
|
||||
diff: DateDiff
|
||||
format?: DateDiffFormat
|
||||
i18n: I18n
|
||||
}): string {
|
||||
const long = format === 'long'
|
||||
const diffSeconds = differenceInSeconds(new Date(later), new Date(earlier))
|
||||
|
||||
if (diffSeconds < NOW) {
|
||||
return _(msg`now`)
|
||||
} else if (diffSeconds < MINUTE) {
|
||||
return `${diffSeconds}${
|
||||
long ? ` ${plural(diffSeconds, {one: 'second', other: 'seconds'})}` : 's'
|
||||
}`
|
||||
} else if (diffSeconds < HOUR) {
|
||||
const diff = Math.floor(diffSeconds / MINUTE)
|
||||
return `${diff}${
|
||||
long ? ` ${plural(diff, {one: 'minute', other: 'minutes'})}` : 'm'
|
||||
}`
|
||||
} else if (diffSeconds < DAY) {
|
||||
const diff = Math.floor(diffSeconds / HOUR)
|
||||
return `${diff}${
|
||||
long ? ` ${plural(diff, {one: 'hour', other: 'hours'})}` : 'h'
|
||||
}`
|
||||
} else if (diffSeconds < MONTH_30) {
|
||||
const diff = Math.floor(diffSeconds / DAY)
|
||||
return `${diff}${
|
||||
long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd'
|
||||
}`
|
||||
} else {
|
||||
const diff = Math.floor(diffSeconds / MONTH_30)
|
||||
if (diff < 12) {
|
||||
return `${diff}${
|
||||
long ? ` ${plural(diff, {one: 'month', other: 'months'})}` : 'mo'
|
||||
}`
|
||||
} else {
|
||||
const str = new Date(earlier).toLocaleDateString()
|
||||
|
||||
if (long) {
|
||||
return _(msg`on ${str}`)
|
||||
switch (diff.unit) {
|
||||
case 'now': {
|
||||
return i18n._(msg`now`)
|
||||
}
|
||||
case 'second': {
|
||||
return long
|
||||
? i18n._(plural(diff.value, {one: '# second', other: '# seconds'}))
|
||||
: i18n._(
|
||||
defineMessage({
|
||||
message: `${diff.value}s`,
|
||||
comment: `How many seconds have passed, displayed in a narrow form`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
case 'minute': {
|
||||
return long
|
||||
? i18n._(plural(diff.value, {one: '# minute', other: '# minutes'}))
|
||||
: i18n._(
|
||||
defineMessage({
|
||||
message: `${diff.value}m`,
|
||||
comment: `How many minutes have passed, displayed in a narrow form`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
case 'hour': {
|
||||
return long
|
||||
? i18n._(plural(diff.value, {one: '# hour', other: '# hours'}))
|
||||
: i18n._(
|
||||
defineMessage({
|
||||
message: `${diff.value}h`,
|
||||
comment: `How many hours have passed, displayed in a narrow form`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
case 'day': {
|
||||
return long
|
||||
? i18n._(plural(diff.value, {one: '# day', other: '# days'}))
|
||||
: i18n._(
|
||||
defineMessage({
|
||||
message: `${diff.value}d`,
|
||||
comment: `How many days have passed, displayed in a narrow form`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
case 'month': {
|
||||
if (diff.value < 12) {
|
||||
return long
|
||||
? i18n._(plural(diff.value, {one: '# month', other: '# months'}))
|
||||
: i18n._(
|
||||
defineMessage({
|
||||
message: `${diff.value}mo`,
|
||||
comment: `How many months have passed, displayed in a narrow form`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return str
|
||||
return i18n.date(new Date(diff.earlier))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import {getVideoMetaData, Video} from 'react-native-compressor'
|
||||
|
||||
export type CompressedVideo = {
|
||||
uri: string
|
||||
size: number
|
||||
}
|
||||
import {CompressedVideo} from './types'
|
||||
|
||||
export async function compressVideo(
|
||||
file: string,
|
||||
@@ -32,5 +29,6 @@ export async function compressVideo(
|
||||
)
|
||||
|
||||
const info = await getVideoMetaData(compressed)
|
||||
return {uri: compressed, size: info.size}
|
||||
|
||||
return {uri: compressed, size: info.size, mimeType: `video/mp4`}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import {VideoTooLargeError} from 'lib/media/video/errors'
|
||||
import {CompressedVideo} from './types'
|
||||
|
||||
const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB
|
||||
|
||||
export type CompressedVideo = {
|
||||
uri: string
|
||||
size: number
|
||||
}
|
||||
|
||||
// doesn't actually compress, but throws if >100MB
|
||||
export async function compressVideo(
|
||||
file: string,
|
||||
@@ -15,8 +11,9 @@ export async function compressVideo(
|
||||
onProgress?: (progress: number) => void
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const blob = await fetch(file).then(res => res.blob())
|
||||
const video = URL.createObjectURL(blob)
|
||||
const {mimeType, base64} = parseDataUrl(file)
|
||||
const blob = base64ToBlob(base64, mimeType)
|
||||
const uri = URL.createObjectURL(blob)
|
||||
|
||||
if (blob.size > MAX_VIDEO_SIZE) {
|
||||
throw new VideoTooLargeError()
|
||||
@@ -24,6 +21,35 @@ export async function compressVideo(
|
||||
|
||||
return {
|
||||
size: blob.size,
|
||||
uri: video,
|
||||
uri,
|
||||
bytes: await blob.arrayBuffer(),
|
||||
mimeType,
|
||||
}
|
||||
}
|
||||
|
||||
function parseDataUrl(dataUrl: string) {
|
||||
const [mimeType, base64] = dataUrl.slice('data:'.length).split(';base64,')
|
||||
if (!mimeType || !base64) {
|
||||
throw new Error('Invalid data URL')
|
||||
}
|
||||
return {mimeType, base64}
|
||||
}
|
||||
|
||||
function base64ToBlob(base64: string, mimeType: string) {
|
||||
const byteCharacters = atob(base64)
|
||||
const byteArrays = []
|
||||
|
||||
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
|
||||
const slice = byteCharacters.slice(offset, offset + 512)
|
||||
const byteNumbers = new Array(slice.length)
|
||||
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
byteNumbers[i] = slice.charCodeAt(i)
|
||||
}
|
||||
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
byteArrays.push(byteArray)
|
||||
}
|
||||
|
||||
return new Blob(byteArrays, {type: mimeType})
|
||||
}
|
||||
|
||||
@@ -4,3 +4,10 @@ export class VideoTooLargeError extends Error {
|
||||
this.name = 'VideoTooLargeError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ServerError'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export type CompressedVideo = {
|
||||
uri: string
|
||||
mimeType: string
|
||||
size: number
|
||||
// web only, can fall back to uri if missing
|
||||
bytes?: ArrayBuffer
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
AppBskyLabelerDefs,
|
||||
BskyAgent,
|
||||
ComAtprotoLabelDefs,
|
||||
InterpretedLabelValueDefinition,
|
||||
LABELS,
|
||||
ModerationCause,
|
||||
@@ -82,3 +84,34 @@ export function isLabelerSubscribed(
|
||||
}
|
||||
return modOpts.prefs.labelers.find(l => l.did === labeler)
|
||||
}
|
||||
|
||||
export type Subject =
|
||||
| {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
| {
|
||||
did: string
|
||||
}
|
||||
|
||||
export function useLabelSubject({label}: {label: ComAtprotoLabelDefs.Label}): {
|
||||
subject: Subject
|
||||
} {
|
||||
return React.useMemo(() => {
|
||||
const {cid, uri} = label
|
||||
if (cid) {
|
||||
return {
|
||||
subject: {
|
||||
uri,
|
||||
cid,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
subject: {
|
||||
did: uri,
|
||||
},
|
||||
}
|
||||
}
|
||||
}, [label])
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
ComAtprotoLabelDefs,
|
||||
AppBskyLabelerDefs,
|
||||
LABELS,
|
||||
interpretLabelValueDefinition,
|
||||
ComAtprotoLabelDefs,
|
||||
InterpretedLabelValueDefinition,
|
||||
interpretLabelValueDefinition,
|
||||
LABELS,
|
||||
} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import * as bcp47Match from 'bcp-47-match'
|
||||
|
||||
@@ -62,6 +62,11 @@ export function useReportOptions(): ReportOptions {
|
||||
other,
|
||||
],
|
||||
post: [
|
||||
{
|
||||
reason: ComAtprotoModerationDefs.REASONMISLEADING,
|
||||
title: _(msg`Misleading Post`),
|
||||
description: _(msg`Impersonation, misinformation, or false claims`),
|
||||
},
|
||||
{
|
||||
reason: ComAtprotoModerationDefs.REASONSPAM,
|
||||
title: _(msg`Spam`),
|
||||
|
||||
@@ -4,5 +4,7 @@ export type Gate =
|
||||
| 'fixed_bottom_bar'
|
||||
| 'onboarding_minimum_interests'
|
||||
| 'suggested_feeds_interstitial'
|
||||
| 'video_debug'
|
||||
| 'videos'
|
||||
| 'show_follow_suggestions_in_profile'
|
||||
| 'video_debug' // not recommended
|
||||
| 'video_upload' // upload videos
|
||||
| 'video_view_on_posts' // see posted videos
|
||||
|
||||
@@ -226,11 +226,11 @@ AppState.addEventListener('change', (state: AppStateStatus) => {
|
||||
let secondsActive = 0
|
||||
if (lastActive != null) {
|
||||
secondsActive = Math.round((performance.now() - lastActive) / 1e3)
|
||||
lastActive = null
|
||||
logEvent('state:background:sampled', {
|
||||
secondsActive,
|
||||
})
|
||||
}
|
||||
lastActive = null
|
||||
logEvent('state:background:sampled', {
|
||||
secondsActive,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import Graphemer from 'graphemer'
|
||||
|
||||
export function enforceLen(
|
||||
str: string,
|
||||
len: number,
|
||||
@@ -23,6 +26,21 @@ export function enforceLen(
|
||||
return str
|
||||
}
|
||||
|
||||
export function useEnforceMaxGraphemeCount() {
|
||||
const splitter = useMemo(() => new Graphemer(), [])
|
||||
|
||||
return useCallback(
|
||||
(text: string, maxCount: number) => {
|
||||
if (splitter.countGraphemes(text) > maxCount) {
|
||||
return splitter.splitGraphemes(text).slice(0, maxCount).join('')
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
},
|
||||
[splitter],
|
||||
)
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/52171480
|
||||
export function toHashCode(str: string, seed = 0): number {
|
||||
let h1 = 0xdeadbeef ^ seed,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
export function niceDate(date: number | string | Date) {
|
||||
import {I18n} from '@lingui/core'
|
||||
|
||||
export function niceDate(i18n: I18n, date: number | string | Date) {
|
||||
const d = new Date(date)
|
||||
return `${d.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})} at ${d.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
|
||||
return i18n.date(d, {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
export function getAge(birthDate: Date): number {
|
||||
|
||||
@@ -339,3 +339,21 @@ export function shortLinkToHref(url: string): string {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
export function getHostnameFromUrl(url: string | URL): string | null {
|
||||
let urlp
|
||||
try {
|
||||
urlp = new URL(url)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
return urlp.hostname
|
||||
}
|
||||
|
||||
export function getServiceAuthAudFromUrl(url: string | URL): string | null {
|
||||
const hostname = getHostnameFromUrl(url)
|
||||
if (!hostname) {
|
||||
return null
|
||||
}
|
||||
return `did:web:${hostname}`
|
||||
}
|
||||
|
||||
+64
-16
@@ -37,82 +37,130 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
switch (locale) {
|
||||
case AppLanguage.ca: {
|
||||
i18n.loadAndActivate({locale, messages: messagesCa})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/ca')
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/ca'),
|
||||
import('@formatjs/intl-numberformat/locale-data/ca'),
|
||||
])
|
||||
break
|
||||
}
|
||||
case AppLanguage.de: {
|
||||
i18n.loadAndActivate({locale, messages: messagesDe})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/de')
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/de'),
|
||||
import('@formatjs/intl-numberformat/locale-data/de'),
|
||||
])
|
||||
break
|
||||
}
|
||||
case AppLanguage.es: {
|
||||
i18n.loadAndActivate({locale, messages: messagesEs})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/es')
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/es'),
|
||||
import('@formatjs/intl-numberformat/locale-data/es'),
|
||||
])
|
||||
break
|
||||
}
|
||||
case AppLanguage.fi: {
|
||||
i18n.loadAndActivate({locale, messages: messagesFi})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/fi')
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/fi'),
|
||||
import('@formatjs/intl-numberformat/locale-data/fi'),
|
||||
])
|
||||
break
|
||||
}
|
||||
case AppLanguage.fr: {
|
||||
i18n.loadAndActivate({locale, messages: messagesFr})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/fr')
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/fr'),
|
||||
import('@formatjs/intl-numberformat/locale-data/fr'),
|
||||
])
|
||||
break
|
||||
}
|
||||
case AppLanguage.ga: {
|
||||
i18n.loadAndActivate({locale, messages: messagesGa})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/ga')
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/ga'),
|
||||
import('@formatjs/intl-numberformat/locale-data/ga'),
|
||||
])
|
||||
break
|
||||
}
|
||||
case AppLanguage.hi: {
|
||||
i18n.loadAndActivate({locale, messages: messagesHi})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/hi')
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/hi'),
|
||||
import('@formatjs/intl-numberformat/locale-data/hi'),
|
||||
])
|
||||
break
|
||||
}
|
||||
case AppLanguage.id: {
|
||||
i18n.loadAndActivate({locale, messages: messagesId})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/id')
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/id'),
|
||||
import('@formatjs/intl-numberformat/locale-data/id'),
|
||||
])
|
||||
break
|
||||
}
|
||||
case AppLanguage.it: {
|
||||
i18n.loadAndActivate({locale, messages: messagesIt})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/it')
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/it'),
|
||||
import('@formatjs/intl-numberformat/locale-data/it'),
|
||||
])
|
||||
break
|
||||
}
|
||||
case AppLanguage.ja: {
|
||||
i18n.loadAndActivate({locale, messages: messagesJa})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/ja')
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/ja'),
|
||||
import('@formatjs/intl-numberformat/locale-data/ja'),
|
||||
])
|
||||
break
|
||||
}
|
||||
case AppLanguage.ko: {
|
||||
i18n.loadAndActivate({locale, messages: messagesKo})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/ko')
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/ko'),
|
||||
import('@formatjs/intl-numberformat/locale-data/ko'),
|
||||
])
|
||||
break
|
||||
}
|
||||
case AppLanguage.pt_BR: {
|
||||
i18n.loadAndActivate({locale, messages: messagesPt_BR})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/pt')
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/pt'),
|
||||
import('@formatjs/intl-numberformat/locale-data/pt'),
|
||||
])
|
||||
break
|
||||
}
|
||||
case AppLanguage.tr: {
|
||||
i18n.loadAndActivate({locale, messages: messagesTr})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/tr')
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/tr'),
|
||||
import('@formatjs/intl-numberformat/locale-data/tr'),
|
||||
])
|
||||
break
|
||||
}
|
||||
case AppLanguage.uk: {
|
||||
i18n.loadAndActivate({locale, messages: messagesUk})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/uk')
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/uk'),
|
||||
import('@formatjs/intl-numberformat/locale-data/uk'),
|
||||
])
|
||||
break
|
||||
}
|
||||
case AppLanguage.zh_CN: {
|
||||
i18n.loadAndActivate({locale, messages: messagesZh_CN})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/zh')
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/zh'),
|
||||
import('@formatjs/intl-numberformat/locale-data/zh'),
|
||||
])
|
||||
break
|
||||
}
|
||||
case AppLanguage.zh_TW: {
|
||||
i18n.loadAndActivate({locale, messages: messagesZh_TW})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/zh')
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/zh'),
|
||||
import('@formatjs/intl-numberformat/locale-data/zh'),
|
||||
])
|
||||
break
|
||||
}
|
||||
default: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+13
-15
@@ -1,12 +1,11 @@
|
||||
import React from 'react'
|
||||
import {ListRenderItemInfo, Pressable, StyleSheet, View} from 'react-native'
|
||||
import {ListRenderItemInfo, Pressable, View} from 'react-native'
|
||||
import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {CommonNavigatorParams} from 'lib/routes/types'
|
||||
@@ -39,7 +38,6 @@ export default function HashtagScreen({
|
||||
}: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) {
|
||||
const {tag, author} = route.params
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
|
||||
const fullTag = React.useMemo(() => {
|
||||
return `#${decodeURIComponent(tag)}`
|
||||
@@ -111,7 +109,7 @@ export default function HashtagScreen({
|
||||
|
||||
return (
|
||||
<>
|
||||
<CenteredView sideBorders style={[pal.border, pal.view]}>
|
||||
<CenteredView sideBorders={true}>
|
||||
<ViewHeader
|
||||
showOnDesktop
|
||||
title={headerTitle}
|
||||
@@ -138,8 +136,17 @@ export default function HashtagScreen({
|
||||
onPageSelected={onPageSelected}
|
||||
renderTabBar={props => (
|
||||
<CenteredView
|
||||
sideBorders
|
||||
style={[pal.border, pal.view, styles.tabBarContainer]}>
|
||||
sideBorders={true}
|
||||
// @ts-ignore web only
|
||||
style={
|
||||
isWeb
|
||||
? {
|
||||
position: isWeb ? 'sticky' : '',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
}
|
||||
: undefined
|
||||
}>
|
||||
<TabBar items={sections.map(section => section.title)} {...props} />
|
||||
</CenteredView>
|
||||
)}
|
||||
@@ -234,12 +241,3 @@ function HashtagScreenTab({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabBarContainer: {
|
||||
// @ts-ignore web only
|
||||
position: isWeb ? 'sticky' : '',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import {LayoutAnimation, View} from 'react-native'
|
||||
import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedPost,
|
||||
AppBskyRichtextFacet,
|
||||
AtUri,
|
||||
@@ -22,12 +20,12 @@ import {
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {usePostQuery} from '#/state/queries/post'
|
||||
import {ImageHorzList} from '#/view/com/util/images/ImageHorzList'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as MediaPreview from '#/components/MediaPreview'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {RichText} from '#/components/RichText'
|
||||
@@ -160,13 +158,6 @@ export function MessageInputEmbed({
|
||||
return null
|
||||
}
|
||||
|
||||
const images = AppBskyEmbedImages.isView(post.embed)
|
||||
? post.embed.images
|
||||
: AppBskyEmbedRecordWithMedia.isView(post.embed) &&
|
||||
AppBskyEmbedImages.isView(post.embed.media)
|
||||
? post.embed.media.images
|
||||
: undefined
|
||||
|
||||
content = (
|
||||
<View
|
||||
style={[
|
||||
@@ -202,9 +193,7 @@ export function MessageInputEmbed({
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{images && images?.length > 0 && (
|
||||
<ImageHorzList images={images} style={a.mt_xs} />
|
||||
)}
|
||||
<MediaPreview.Embed embed={post.embed} style={a.mt_sm} />
|
||||
</ContentHider>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
@@ -7,9 +6,12 @@ import {useFocusEffect} from '@react-navigation/native'
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {PostLikedBy as PostLikedByComponent} from '#/view/com/post-thread/PostLikedBy'
|
||||
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {ListHeaderDesktop} from '#/components/Lists'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'>
|
||||
export const PostLikedByScreen = ({route}: Props) => {
|
||||
@@ -25,9 +27,10 @@ export const PostLikedByScreen = ({route}: Props) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={a.flex_1}>
|
||||
<ViewHeader title={_(msg`Liked By`)} />
|
||||
<CenteredView style={a.util_screen_outer} sideBorders={true}>
|
||||
<ListHeaderDesktop title={_(msg`Liked By`)} />
|
||||
<ViewHeader title={_(msg`Liked By`)} showBorder={!isWeb} />
|
||||
<PostLikedByComponent uri={uri} />
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
@@ -7,9 +6,12 @@ import {useFocusEffect} from '@react-navigation/native'
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {PostQuotes as PostQuotesComponent} from '#/view/com/post-thread/PostQuotes'
|
||||
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {ListHeaderDesktop} from '#/components/Lists'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostQuotes'>
|
||||
export const PostQuotesScreen = ({route}: Props) => {
|
||||
@@ -25,9 +27,10 @@ export const PostQuotesScreen = ({route}: Props) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={a.flex_1}>
|
||||
<ViewHeader title={_(msg`Quotes`)} />
|
||||
<CenteredView style={a.util_screen_outer} sideBorders={true}>
|
||||
<ListHeaderDesktop title={_(msg`Quotes`)} />
|
||||
<ViewHeader title={_(msg`Quotes`)} showBorder={!isWeb} />
|
||||
<PostQuotesComponent uri={uri} />
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
@@ -7,9 +6,12 @@ import {useFocusEffect} from '@react-navigation/native'
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {PostRepostedBy as PostRepostedByComponent} from '#/view/com/post-thread/PostRepostedBy'
|
||||
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {ListHeaderDesktop} from '#/components/Lists'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'>
|
||||
export const PostRepostedByScreen = ({route}: Props) => {
|
||||
@@ -25,9 +27,10 @@ export const PostRepostedByScreen = ({route}: Props) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={a.flex_1}>
|
||||
<ViewHeader title={_(msg`Reposted By`)} />
|
||||
<CenteredView style={a.util_screen_outer} sideBorders={true}>
|
||||
<ListHeaderDesktop title={_(msg`Reposted By`)} />
|
||||
<ViewHeader title={_(msg`Reposted By`)} showBorder={!isWeb} />
|
||||
<PostRepostedByComponent uri={uri} />
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ export function ProfileHeaderMetrics({
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const following = formatCount(profile.followsCount || 0)
|
||||
const followers = formatCount(profile.followersCount || 0)
|
||||
const {_, i18n} = useLingui()
|
||||
const following = formatCount(i18n, profile.followsCount || 0)
|
||||
const followers = formatCount(i18n, profile.followersCount || 0)
|
||||
const pluralizedFollowers = plural(profile.followersCount || 0, {
|
||||
one: 'follower',
|
||||
other: 'followers',
|
||||
@@ -54,7 +54,7 @@ export function ProfileHeaderMetrics({
|
||||
</Text>
|
||||
</InlineLinkText>
|
||||
<Text style={[a.font_bold, t.atoms.text, a.text_md]}>
|
||||
{formatCount(profile.postsCount || 0)}{' '}
|
||||
{formatCount(i18n, profile.postsCount || 0)}{' '}
|
||||
<Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}>
|
||||
{plural(profile.postsCount || 0, {one: 'post', other: 'posts'})}
|
||||
</Text>
|
||||
|
||||
@@ -10,6 +10,7 @@ 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'
|
||||
@@ -59,6 +60,7 @@ let ProfileHeaderStandard = ({
|
||||
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
|
||||
useProfileShadow(profileUnshadowed)
|
||||
const t = useTheme()
|
||||
const gate = useGate()
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
const {_} = useLingui()
|
||||
const {openModal} = useModalControls()
|
||||
@@ -203,27 +205,29 @@ let ProfileHeaderStandard = ({
|
||||
{hasSession && (
|
||||
<>
|
||||
<MessageProfileButton profile={profile} />
|
||||
<Button
|
||||
testID="suggestedFollowsBtn"
|
||||
size="small"
|
||||
color={showSuggestedFollows ? 'primary' : 'secondary'}
|
||||
variant="solid"
|
||||
shape="round"
|
||||
onPress={() =>
|
||||
setShowSuggestedFollows(!showSuggestedFollows)
|
||||
}
|
||||
label={_(msg`Show follows similar to ${profile.handle}`)}
|
||||
style={{width: 36, height: 36}}>
|
||||
<FontAwesomeIcon
|
||||
icon="user-plus"
|
||||
style={
|
||||
showSuggestedFollows
|
||||
? {color: t.palette.white}
|
||||
: t.atoms.text
|
||||
{!gate('show_follow_suggestions_in_profile') && (
|
||||
<Button
|
||||
testID="suggestedFollowsBtn"
|
||||
size="small"
|
||||
color={showSuggestedFollows ? 'primary' : 'secondary'}
|
||||
variant="solid"
|
||||
shape="round"
|
||||
onPress={() =>
|
||||
setShowSuggestedFollows(!showSuggestedFollows)
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
</Button>
|
||||
label={_(msg`Show follows similar to ${profile.handle}`)}
|
||||
style={{width: 36, height: 36}}>
|
||||
<FontAwesomeIcon
|
||||
icon="user-plus"
|
||||
style={
|
||||
showSuggestedFollows
|
||||
? {color: t.palette.white}
|
||||
: t.atoms.text
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ let ProfileHeaderShell = ({
|
||||
style={[a.px_lg, a.py_xs]}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
{isMe ? (
|
||||
<LabelsOnMe details={{did: profile.did}} labels={profile.labels} />
|
||||
<LabelsOnMe type="account" labels={profile.labels} />
|
||||
) : (
|
||||
<ProfileHeaderAlerts moderation={moderation} />
|
||||
)}
|
||||
|
||||
@@ -113,7 +113,7 @@ function LandingScreenLoaded({
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const {creator, listItemsSample, feeds} = starterPack
|
||||
const {_} = useLingui()
|
||||
const {_, i18n} = useLingui()
|
||||
const t = useTheme()
|
||||
const activeStarterPack = useActiveStarterPack()
|
||||
const setActiveStarterPack = useSetActiveStarterPack()
|
||||
@@ -225,7 +225,9 @@ function LandingScreenLoaded({
|
||||
t.atoms.text_contrast_medium,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
<Trans>{formatCount(JOINED_THIS_WEEK)} joined this week</Trans>
|
||||
<Trans>
|
||||
{formatCount(i18n, JOINED_THIS_WEEK)} joined this week
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -2,7 +2,8 @@ import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {cancelable} from '#/lib/async/cancelable'
|
||||
import {CompressedVideo, compressVideo} from 'lib/media/video/compress'
|
||||
import {CompressedVideo} from '#/lib/media/video/types'
|
||||
import {compressVideo} from 'lib/media/video/compress'
|
||||
|
||||
export function useCompressVideoMutation({
|
||||
onProgress,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {useMemo} from 'react'
|
||||
import {AtpAgent} from '@atproto/api'
|
||||
|
||||
import {SupportedMimeTypes} from '#/lib/constants'
|
||||
|
||||
const UPLOAD_ENDPOINT = 'https://video.bsky.app/'
|
||||
|
||||
export const createVideoEndpointUrl = (
|
||||
@@ -24,3 +26,18 @@ export function useVideoAgent() {
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
|
||||
switch (mimeType) {
|
||||
case 'video/mp4':
|
||||
return 'mp4'
|
||||
case 'video/webm':
|
||||
return 'webm'
|
||||
case 'video/mpeg':
|
||||
return 'mpeg'
|
||||
case 'video/quicktime':
|
||||
return 'mov'
|
||||
default:
|
||||
throw new Error(`Unsupported mime type: ${mimeType}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
|
||||
import {AppBskyVideoDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {cancelable} from '#/lib/async/cancelable'
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
||||
import {ServerError} from '#/lib/media/video/errors'
|
||||
import {CompressedVideo} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
onSuccess,
|
||||
@@ -21,24 +25,27 @@ export const useUploadVideoMutation = ({
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
const {_} = useLingui()
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['video', 'upload'],
|
||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||
did: currentAccount!.did,
|
||||
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
|
||||
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||
})
|
||||
|
||||
// a logged-in agent should have this set, but we'll check just in case
|
||||
if (!agent.pdsUrl) {
|
||||
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: `did:web:${agent.pdsUrl.hostname}`,
|
||||
aud: serviceAuthAud,
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||
},
|
||||
)
|
||||
|
||||
@@ -47,7 +54,7 @@ export const useUploadVideoMutation = ({
|
||||
video.uri,
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'video/mp4',
|
||||
'content-type': video.mimeType,
|
||||
Authorization: `Bearer ${serviceAuth.token}`,
|
||||
},
|
||||
httpMethod: 'POST',
|
||||
@@ -62,6 +69,13 @@ export const useUploadVideoMutation = ({
|
||||
}
|
||||
|
||||
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
|
||||
|
||||
if (!responseBody.jobId) {
|
||||
throw new ServerError(
|
||||
responseBody.error || _(msg`Failed to upload video`),
|
||||
)
|
||||
}
|
||||
|
||||
return responseBody
|
||||
}, signal),
|
||||
onError,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import {AppBskyVideoDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {cancelable} from '#/lib/async/cancelable'
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
||||
import {ServerError} from '#/lib/media/video/errors'
|
||||
import {CompressedVideo} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
onSuccess,
|
||||
@@ -20,28 +24,35 @@ export const useUploadVideoMutation = ({
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
const {_} = useLingui()
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['video', 'upload'],
|
||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||
did: currentAccount!.did,
|
||||
name: `${nanoid(12)}.mp4`, // @TODO: make sure it's always mp4'
|
||||
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||
})
|
||||
|
||||
// a logged-in agent should have this set, but we'll check just in case
|
||||
if (!agent.pdsUrl) {
|
||||
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: `did:web:${agent.pdsUrl.hostname}`,
|
||||
aud: serviceAuthAud,
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||
},
|
||||
)
|
||||
|
||||
const bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
||||
let bytes = video.bytes
|
||||
|
||||
if (!bytes) {
|
||||
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
|
||||
@@ -56,23 +67,24 @@ export const useUploadVideoMutation = ({
|
||||
xhr.responseText,
|
||||
) as AppBskyVideoDefs.JobStatus
|
||||
resolve(uploadRes)
|
||||
onSuccess(uploadRes)
|
||||
} else {
|
||||
reject()
|
||||
onError(new Error('Failed to upload video'))
|
||||
reject(new ServerError(_(msg`Failed to upload video`)))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
reject()
|
||||
onError(new Error('Failed to upload video'))
|
||||
reject(new ServerError(_(msg`Failed to upload video`)))
|
||||
}
|
||||
xhr.open('POST', uri)
|
||||
xhr.setRequestHeader('Content-Type', 'video/mp4')
|
||||
xhr.setRequestHeader('Content-Type', video.mimeType)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
|
||||
xhr.send(bytes)
|
||||
},
|
||||
)
|
||||
|
||||
if (!res.jobId) {
|
||||
throw new ServerError(res.error || _(msg`Failed to upload video`))
|
||||
}
|
||||
|
||||
return res
|
||||
}, signal),
|
||||
onError,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import React from 'react'
|
||||
import React, {useCallback, useEffect} from 'react'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {AppBskyVideoDefs, BlobRef} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {AbortError} from '#/lib/async/cancelable'
|
||||
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {CompressedVideo} from 'lib/media/video/compress'
|
||||
import {VideoTooLargeError} from 'lib/media/video/errors'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
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'
|
||||
import {useUploadVideoMutation} from 'state/queries/video/video-upload'
|
||||
@@ -20,9 +23,10 @@ type Action =
|
||||
| {type: 'SetError'; error: string | undefined}
|
||||
| {type: 'Reset'}
|
||||
| {type: 'SetAsset'; asset: ImagePickerAsset}
|
||||
| {type: 'SetDimensions'; width: number; height: number}
|
||||
| {type: 'SetVideo'; video: CompressedVideo}
|
||||
| {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus}
|
||||
| {type: 'SetBlobRef'; blobRef: BlobRef}
|
||||
| {type: 'SetComplete'; blobRef: BlobRef}
|
||||
|
||||
export interface State {
|
||||
status: Status
|
||||
@@ -33,8 +37,11 @@ export interface State {
|
||||
blobRef?: BlobRef
|
||||
error?: string
|
||||
abortController: AbortController
|
||||
pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean}
|
||||
}
|
||||
|
||||
export type VideoUploadDispatch = (action: Action) => void
|
||||
|
||||
function reducer(queryClient: QueryClient) {
|
||||
return (state: State, action: Action): State => {
|
||||
let updatedState = state
|
||||
@@ -57,13 +64,32 @@ function reducer(queryClient: QueryClient) {
|
||||
abortController: new AbortController(),
|
||||
}
|
||||
} else if (action.type === 'SetAsset') {
|
||||
updatedState = {...state, asset: action.asset}
|
||||
updatedState = {
|
||||
...state,
|
||||
asset: action.asset,
|
||||
status: 'compressing',
|
||||
error: undefined,
|
||||
}
|
||||
} else if (action.type === 'SetDimensions') {
|
||||
updatedState = {
|
||||
...state,
|
||||
asset: state.asset
|
||||
? {...state.asset, width: action.width, height: action.height}
|
||||
: undefined,
|
||||
}
|
||||
} else if (action.type === 'SetVideo') {
|
||||
updatedState = {...state, video: action.video}
|
||||
updatedState = {...state, video: action.video, status: 'uploading'}
|
||||
} else if (action.type === 'SetJobStatus') {
|
||||
updatedState = {...state, jobStatus: action.jobStatus}
|
||||
} else if (action.type === 'SetBlobRef') {
|
||||
updatedState = {...state, blobRef: action.blobRef}
|
||||
} else if (action.type === 'SetComplete') {
|
||||
updatedState = {
|
||||
...state,
|
||||
pendingPublish: {
|
||||
blobRef: action.blobRef,
|
||||
mutableProcessed: false,
|
||||
},
|
||||
status: 'done',
|
||||
}
|
||||
}
|
||||
return updatedState
|
||||
}
|
||||
@@ -71,7 +97,6 @@ function reducer(queryClient: QueryClient) {
|
||||
|
||||
export function useUploadVideo({
|
||||
setStatus,
|
||||
onSuccess,
|
||||
}: {
|
||||
setStatus: (status: string) => void
|
||||
onSuccess: () => void
|
||||
@@ -97,15 +122,20 @@ export function useUploadVideo({
|
||||
},
|
||||
onSuccess: blobRef => {
|
||||
dispatch({
|
||||
type: 'SetBlobRef',
|
||||
type: 'SetComplete',
|
||||
blobRef,
|
||||
})
|
||||
dispatch({
|
||||
type: 'SetStatus',
|
||||
status: 'idle',
|
||||
})
|
||||
onSuccess()
|
||||
},
|
||||
onError: useCallback(
|
||||
error => {
|
||||
logger.error('Error processing video', {safeMessage: error})
|
||||
dispatch({
|
||||
type: 'SetError',
|
||||
error: _(msg`Video failed to process`),
|
||||
})
|
||||
},
|
||||
[_],
|
||||
),
|
||||
})
|
||||
|
||||
const {mutate: onVideoCompressed} = useUploadVideoMutation({
|
||||
@@ -117,10 +147,19 @@ export function useUploadVideo({
|
||||
setJobId(response.jobId)
|
||||
},
|
||||
onError: e => {
|
||||
dispatch({
|
||||
type: 'SetError',
|
||||
error: _(msg`An error occurred while uploading the video.`),
|
||||
})
|
||||
if (e instanceof AbortError) {
|
||||
return
|
||||
} else if (e instanceof ServerError) {
|
||||
dispatch({
|
||||
type: 'SetError',
|
||||
error: e.message,
|
||||
})
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'SetError',
|
||||
error: _(msg`An error occurred while uploading the video.`),
|
||||
})
|
||||
}
|
||||
logger.error('Error uploading video', {safeMessage: e})
|
||||
},
|
||||
setProgress: p => {
|
||||
@@ -133,8 +172,17 @@ export function useUploadVideo({
|
||||
onProgress: p => {
|
||||
dispatch({type: 'SetProgress', progress: p})
|
||||
},
|
||||
onSuccess: (video: CompressedVideo) => {
|
||||
dispatch({
|
||||
type: 'SetVideo',
|
||||
video,
|
||||
})
|
||||
onVideoCompressed(video)
|
||||
},
|
||||
onError: e => {
|
||||
if (e instanceof VideoTooLargeError) {
|
||||
if (e instanceof AbortError) {
|
||||
return
|
||||
} else if (e instanceof VideoTooLargeError) {
|
||||
dispatch({
|
||||
type: 'SetError',
|
||||
error: _(msg`The selected video is larger than 100MB.`),
|
||||
@@ -142,35 +190,27 @@ export function useUploadVideo({
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'SetError',
|
||||
// @TODO better error message from server, left untranslated on purpose
|
||||
error: 'An error occurred while compressing the video.',
|
||||
error: _(msg`An error occurred while compressing the video.`),
|
||||
})
|
||||
logger.error('Error compressing video', {safeMessage: e})
|
||||
}
|
||||
},
|
||||
onSuccess: (video: CompressedVideo) => {
|
||||
dispatch({
|
||||
type: 'SetVideo',
|
||||
video,
|
||||
})
|
||||
dispatch({
|
||||
type: 'SetStatus',
|
||||
status: 'uploading',
|
||||
})
|
||||
onVideoCompressed(video)
|
||||
},
|
||||
signal: state.abortController.signal,
|
||||
})
|
||||
|
||||
const selectVideo = (asset: ImagePickerAsset) => {
|
||||
// compression step on native converts to mp4, so no need to check there
|
||||
if (isWeb) {
|
||||
const mimeType = getMimeType(asset)
|
||||
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
|
||||
throw new Error(_(msg`Unsupported video type: ${mimeType}`))
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'SetAsset',
|
||||
asset,
|
||||
})
|
||||
dispatch({
|
||||
type: 'SetStatus',
|
||||
status: 'compressing',
|
||||
})
|
||||
onSelectVideo(asset)
|
||||
}
|
||||
|
||||
@@ -178,26 +218,37 @@ export function useUploadVideo({
|
||||
dispatch({type: 'Reset'})
|
||||
}
|
||||
|
||||
const updateVideoDimensions = useCallback((width: number, height: number) => {
|
||||
dispatch({
|
||||
type: 'SetDimensions',
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
state,
|
||||
dispatch,
|
||||
selectVideo,
|
||||
clearVideo,
|
||||
updateVideoDimensions,
|
||||
}
|
||||
}
|
||||
|
||||
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<string>()
|
||||
|
||||
const {isLoading, isError} = useQuery({
|
||||
const {error} = useQuery({
|
||||
queryKey: ['video', 'upload status', jobId],
|
||||
queryFn: async () => {
|
||||
if (!jobId) return // this won't happen, can ignore
|
||||
@@ -210,7 +261,7 @@ const useUploadStatusQuery = ({
|
||||
throw new Error('Job completed, but did not return a blob')
|
||||
onSuccess(status.blob)
|
||||
} else if (status.state === 'JOB_STATE_FAILED') {
|
||||
throw new Error('Job failed to process')
|
||||
throw new Error(status.error ?? 'Job failed to process')
|
||||
}
|
||||
onStatusChange(status)
|
||||
return status
|
||||
@@ -219,11 +270,31 @@ const useUploadStatusQuery = ({
|
||||
refetchInterval: 1500,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
onError(error)
|
||||
setEnabled(false)
|
||||
}
|
||||
}, [error, onError])
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
setJobId: (_jobId: string) => {
|
||||
setJobId(_jobId)
|
||||
setEnabled(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getMimeType(asset: ImagePickerAsset) {
|
||||
if (isWeb) {
|
||||
const [mimeType] = asset.uri.slice('data:'.length).split(';base64,')
|
||||
if (!mimeType) {
|
||||
throw new Error('Could not determine mime type')
|
||||
}
|
||||
return mimeType
|
||||
}
|
||||
if (!asset.mimeType) {
|
||||
throw new Error('Could not determine mime type')
|
||||
}
|
||||
return asset.mimeType
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
AppBskyRichtextFacet,
|
||||
ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
|
||||
export interface ComposerOptsPostRef {
|
||||
uri: string
|
||||
@@ -22,12 +25,7 @@ export interface ComposerOptsQuote {
|
||||
text: string
|
||||
facets?: AppBskyRichtextFacet.Main[]
|
||||
indexedAt: string
|
||||
author: {
|
||||
did: string
|
||||
handle: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
}
|
||||
author: AppBskyActorDefs.ProfileViewBasic
|
||||
embeds?: AppBskyEmbedRecord.ViewRecord['embeds']
|
||||
}
|
||||
export interface ComposerOpts {
|
||||
@@ -56,10 +54,25 @@ const controlsContext = React.createContext<ControlsContext>({
|
||||
})
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const {_} = useLingui()
|
||||
const [state, setState] = React.useState<StateContext>()
|
||||
|
||||
const openComposer = useNonReactiveCallback((opts: ComposerOpts) => {
|
||||
setState(opts)
|
||||
const author = opts.replyTo?.author || opts.quote?.author
|
||||
const isBlocked = Boolean(
|
||||
author &&
|
||||
(author.viewer?.blocking ||
|
||||
author.viewer?.blockedBy ||
|
||||
author.viewer?.blockingByList),
|
||||
)
|
||||
if (isBlocked) {
|
||||
Toast.show(
|
||||
_(msg`Cannot interact with a blocked user`),
|
||||
'exclamation-circle',
|
||||
)
|
||||
} else {
|
||||
setState(opts)
|
||||
}
|
||||
})
|
||||
|
||||
const closeComposer = useNonReactiveCallback(() => {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# `#/storage`
|
||||
|
||||
## Usage
|
||||
|
||||
Import the correctly scoped store from `#/storage`. Each instance of `Storage`
|
||||
(the base class, not to be used directly), has the following interface:
|
||||
|
||||
- `set([...scope, key], value)`
|
||||
- `get([...scope, key])`
|
||||
- `remove([...scope, key])`
|
||||
- `removeMany([...scope], [...keys])`
|
||||
|
||||
For example, using our `device` store looks like this, since it's scoped to the
|
||||
device (the most base level scope):
|
||||
|
||||
```typescript
|
||||
import { device } from '#/storage';
|
||||
|
||||
device.set(['foobar'], true);
|
||||
device.get(['foobar']);
|
||||
device.remove(['foobar']);
|
||||
device.removeMany([], ['foobar']);
|
||||
```
|
||||
|
||||
## TypeScript
|
||||
|
||||
Stores are strongly typed, and when setting a given value, it will need to
|
||||
conform to the schemas defined in `#/storage/schema`. When getting a value, it
|
||||
will be returned to you as the type defined in its schema.
|
||||
|
||||
## Scoped Stores
|
||||
|
||||
Some stores are (or might be) scoped to an account or other identifier. In this
|
||||
case, storage instances are created with type-guards, like this:
|
||||
|
||||
```typescript
|
||||
type AccountSchema = {
|
||||
language: `${string}-${string}`;
|
||||
};
|
||||
|
||||
type DID = `did:${string}`;
|
||||
|
||||
const account = new Storage<
|
||||
[DID],
|
||||
AccountSchema
|
||||
>({
|
||||
id: 'account',
|
||||
});
|
||||
|
||||
account.set(
|
||||
['did:plc:abc', 'language'],
|
||||
'en-US',
|
||||
);
|
||||
|
||||
const language = account.get([
|
||||
'did:plc:abc',
|
||||
'language',
|
||||
]);
|
||||
```
|
||||
|
||||
Here, if `['did:plc:abc']` is not supplied along with the key of
|
||||
`language`, the `get` will return undefined (and TS will yell at you).
|
||||
@@ -0,0 +1,81 @@
|
||||
import {beforeEach, expect, jest, test} from '@jest/globals'
|
||||
|
||||
import {Storage} from '#/storage'
|
||||
|
||||
jest.mock('react-native-mmkv', () => ({
|
||||
MMKV: class MMKVMock {
|
||||
_store = new Map()
|
||||
|
||||
set(key: string, value: unknown) {
|
||||
this._store.set(key, value)
|
||||
}
|
||||
|
||||
getString(key: string) {
|
||||
return this._store.get(key)
|
||||
}
|
||||
|
||||
delete(key: string) {
|
||||
return this._store.delete(key)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
type Schema = {
|
||||
boo: boolean
|
||||
str: string | null
|
||||
num: number
|
||||
obj: Record<string, unknown>
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import {MMKV} from 'react-native-mmkv'
|
||||
|
||||
import {Device} from '#/storage/schema'
|
||||
|
||||
/**
|
||||
* Generic storage class. DO NOT use this directly. Instead, use the exported
|
||||
* storage instances below.
|
||||
*/
|
||||
export class Storage<Scopes extends unknown[], Schema> {
|
||||
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<Key extends keyof Schema>(
|
||||
scopes: [...Scopes, Key],
|
||||
data: Schema[Key],
|
||||
): void {
|
||||
// stored as `{ data: <value> }` 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<Key extends keyof Schema>(
|
||||
scopes: [...Scopes, Key],
|
||||
): Schema[Key] | undefined {
|
||||
const res = this.store.getString(scopes.join(this.sep))
|
||||
if (!res) return undefined
|
||||
// parsed from storage structure `{ data: <value> }`
|
||||
return JSON.parse(res).data
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a value from storage based on scopes and/or keys
|
||||
*
|
||||
* `remove([key])`
|
||||
* `remove([scope, key])`
|
||||
*/
|
||||
remove<Key extends keyof Schema>(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<Key extends keyof Schema>(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'})
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Device data that's specific to the device and does not vary based account
|
||||
*/
|
||||
export type Device = {}
|
||||
+417
-227
@@ -20,12 +20,19 @@ import {
|
||||
// @ts-expect-error no type definition
|
||||
import ProgressCircle from 'react-native-progress/Circle'
|
||||
import Animated, {
|
||||
Easing,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
interpolateColor,
|
||||
LayoutAnimationConfig,
|
||||
LinearTransition,
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {
|
||||
@@ -39,18 +46,31 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import * as apilib from '#/lib/api/index'
|
||||
import {until} from '#/lib/async/until'
|
||||
import {MAX_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||
import {
|
||||
createGIFDescription,
|
||||
parseAltFromGIFDescription,
|
||||
} from '#/lib/gif-alt-text'
|
||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {LikelyType} from '#/lib/link-meta/link-meta'
|
||||
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {insertMentionAt} from '#/lib/strings/mention-manip'
|
||||
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
||||
import {colors, s} from '#/lib/styles'
|
||||
import {logger} from '#/logger'
|
||||
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {emitPostCreated} from '#/state/events'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useModals} from '#/state/modals'
|
||||
import {GalleryModel} from '#/state/models/media/gallery'
|
||||
import {useRequireAltTextEnabled} from '#/state/preferences'
|
||||
import {
|
||||
toPostLanguages,
|
||||
@@ -62,54 +82,45 @@ import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {Gif} from '#/state/queries/tenor'
|
||||
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
|
||||
import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util'
|
||||
import {useUploadVideo} from '#/state/queries/video/video'
|
||||
import {
|
||||
State as VideoUploadState,
|
||||
useUploadVideo,
|
||||
VideoUploadDispatch,
|
||||
} from '#/state/queries/video/video'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
|
||||
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {insertMentionAt} from 'lib/strings/mention-manip'
|
||||
import {shortenLinks} from 'lib/strings/rich-text-manip'
|
||||
import {colors, s} from 'lib/styles'
|
||||
import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
|
||||
import {useDialogStateControlContext} from 'state/dialogs'
|
||||
import {GalleryModel} from 'state/models/media/gallery'
|
||||
import {State as VideoUploadState} from 'state/queries/video/video'
|
||||
import {ComposerOpts} from 'state/shell/composer'
|
||||
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ComposerOpts} from '#/state/shell/composer'
|
||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
|
||||
import {ExternalEmbed} from '#/view/com/composer/ExternalEmbed'
|
||||
import {GifAltText} from '#/view/com/composer/GifAltText'
|
||||
import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
|
||||
import {Gallery} from '#/view/com/composer/photos/Gallery'
|
||||
import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
|
||||
import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
|
||||
import {SelectPhotoBtn} from '#/view/com/composer/photos/SelectPhotoBtn'
|
||||
import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn'
|
||||
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
|
||||
// TODO: Prevent naming components that coincide with RN primitives
|
||||
// due to linting false positives
|
||||
import {TextInput, TextInputRef} from '#/view/com/composer/text-input/TextInput'
|
||||
import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
|
||||
import {useExternalLinkFetch} from '#/view/com/composer/useExternalLinkFetch'
|
||||
import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn'
|
||||
import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
|
||||
import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
|
||||
import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
|
||||
import {QuoteEmbed, QuoteX} from '#/view/com/util/post-embeds/QuoteEmbed'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text as NewText} from '#/components/Typography'
|
||||
import {QuoteEmbed, QuoteX} from '../util/post-embeds/QuoteEmbed'
|
||||
import {Text} from '../util/text/Text'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {CharProgress} from './char-progress/CharProgress'
|
||||
import {ExternalEmbed} from './ExternalEmbed'
|
||||
import {GifAltText} from './GifAltText'
|
||||
import {LabelsBtn} from './labels/LabelsBtn'
|
||||
import {Gallery} from './photos/Gallery'
|
||||
import {OpenCameraBtn} from './photos/OpenCameraBtn'
|
||||
import {SelectGifBtn} from './photos/SelectGifBtn'
|
||||
import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
|
||||
import {SelectLangBtn} from './select-language/SelectLangBtn'
|
||||
import {SuggestedLanguage} from './select-language/SuggestedLanguage'
|
||||
// TODO: Prevent naming components that coincide with RN primitives
|
||||
// due to linting false positives
|
||||
import {TextInput, TextInputRef} from './text-input/TextInput'
|
||||
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
||||
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
||||
import {SelectVideoBtn} from './videos/SelectVideoBtn'
|
||||
import {VideoPreview} from './videos/VideoPreview'
|
||||
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
|
||||
|
||||
type CancelRef = {
|
||||
onPressCancel: () => void
|
||||
@@ -172,10 +183,15 @@ export const ComposePost = observer(function ComposePost({
|
||||
initQuote,
|
||||
)
|
||||
|
||||
const [videoAltText, setVideoAltText] = useState('')
|
||||
const [captions, setCaptions] = useState<{lang: string; file: File}[]>([])
|
||||
|
||||
const {
|
||||
selectVideo,
|
||||
clearVideo,
|
||||
state: videoUploadState,
|
||||
updateVideoDimensions,
|
||||
dispatch: videoUploadDispatch,
|
||||
} = useUploadVideo({
|
||||
setStatus: setProcessingState,
|
||||
onSuccess: () => {
|
||||
@@ -184,6 +200,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
||||
|
||||
const {extLink, setExtLink} = useExternalLinkFetch({setQuote, setError})
|
||||
@@ -214,7 +231,12 @@ export const ComposePost = observer(function ComposePost({
|
||||
)
|
||||
|
||||
const onPressCancel = useCallback(() => {
|
||||
if (graphemeLength > 0 || !gallery.isEmpty || extGif) {
|
||||
if (
|
||||
graphemeLength > 0 ||
|
||||
!gallery.isEmpty ||
|
||||
extGif ||
|
||||
videoUploadState.status !== 'idle'
|
||||
) {
|
||||
closeAllDialogs()
|
||||
Keyboard.dismiss()
|
||||
discardPromptControl.open()
|
||||
@@ -228,6 +250,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
closeAllDialogs,
|
||||
discardPromptControl,
|
||||
onClose,
|
||||
videoUploadState.status,
|
||||
])
|
||||
|
||||
useImperativeHandle(cancelRef, () => ({onPressCancel}))
|
||||
@@ -297,135 +320,188 @@ export const ComposePost = observer(function ComposePost({
|
||||
return false
|
||||
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
|
||||
|
||||
const onPressPublish = async (finishedUploading?: boolean) => {
|
||||
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
||||
return
|
||||
}
|
||||
const onPressPublish = React.useCallback(
|
||||
async (finishedUploading?: boolean) => {
|
||||
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isAltTextRequiredAndMissing) {
|
||||
return
|
||||
}
|
||||
if (isAltTextRequiredAndMissing) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!finishedUploading &&
|
||||
videoUploadState.status !== 'idle' &&
|
||||
videoUploadState.asset
|
||||
) {
|
||||
setPublishOnUpload(true)
|
||||
return
|
||||
}
|
||||
if (
|
||||
!finishedUploading &&
|
||||
videoUploadState.asset &&
|
||||
videoUploadState.status !== 'done'
|
||||
) {
|
||||
setPublishOnUpload(true)
|
||||
return
|
||||
}
|
||||
|
||||
setError('')
|
||||
setError('')
|
||||
|
||||
if (
|
||||
richtext.text.trim().length === 0 &&
|
||||
gallery.isEmpty &&
|
||||
!extLink &&
|
||||
!quote
|
||||
) {
|
||||
setError(_(msg`Did you want to say anything?`))
|
||||
return
|
||||
}
|
||||
if (extLink?.isLoading) {
|
||||
setError(_(msg`Please wait for your link card to finish loading`))
|
||||
return
|
||||
}
|
||||
if (
|
||||
richtext.text.trim().length === 0 &&
|
||||
gallery.isEmpty &&
|
||||
!extLink &&
|
||||
!quote &&
|
||||
videoUploadState.status === 'idle'
|
||||
) {
|
||||
setError(_(msg`Did you want to say anything?`))
|
||||
return
|
||||
}
|
||||
if (extLink?.isLoading) {
|
||||
setError(_(msg`Please wait for your link card to finish loading`))
|
||||
return
|
||||
}
|
||||
|
||||
setIsProcessing(true)
|
||||
setIsProcessing(true)
|
||||
|
||||
let postUri
|
||||
try {
|
||||
postUri = (
|
||||
await apilib.post(agent, {
|
||||
rawText: richtext.text,
|
||||
replyTo: replyTo?.uri,
|
||||
images: gallery.images,
|
||||
quote,
|
||||
extLink,
|
||||
labels,
|
||||
threadgate: threadgateAllowUISettings,
|
||||
postgate,
|
||||
onStateChange: setProcessingState,
|
||||
langs: toPostLanguages(langPrefs.postLanguage),
|
||||
video: videoUploadState.blobRef,
|
||||
})
|
||||
).uri
|
||||
let postUri
|
||||
try {
|
||||
await whenAppViewReady(agent, postUri, res => {
|
||||
const thread = res.data.thread
|
||||
return AppBskyFeedDefs.isThreadViewPost(thread)
|
||||
})
|
||||
} catch (waitErr: any) {
|
||||
logger.error(waitErr, {
|
||||
message: `Waiting for app view failed`,
|
||||
})
|
||||
// Keep going because the post *was* published.
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error(e, {
|
||||
message: `Composer: create post failed`,
|
||||
hasImages: gallery.size > 0,
|
||||
})
|
||||
|
||||
if (extLink) {
|
||||
setExtLink({
|
||||
...extLink,
|
||||
isLoading: true,
|
||||
localThumb: undefined,
|
||||
} as apilib.ExternalEmbedDraft)
|
||||
}
|
||||
let err = cleanError(e.message)
|
||||
if (err.includes('not locate record')) {
|
||||
err = _(
|
||||
msg`We're sorry! The post you are replying to has been deleted.`,
|
||||
)
|
||||
}
|
||||
setError(err)
|
||||
setIsProcessing(false)
|
||||
return
|
||||
} finally {
|
||||
if (postUri) {
|
||||
logEvent('post:create', {
|
||||
imageCount: gallery.size,
|
||||
isReply: replyTo != null,
|
||||
hasLink: extLink != null,
|
||||
hasQuote: quote != null,
|
||||
langs: langPrefs.postLanguage,
|
||||
logContext: 'Composer',
|
||||
})
|
||||
}
|
||||
track('Create Post', {
|
||||
imageCount: gallery.size,
|
||||
})
|
||||
if (replyTo && replyTo.uri) track('Post:Reply')
|
||||
}
|
||||
if (postUri && !replyTo) {
|
||||
emitPostCreated()
|
||||
}
|
||||
setLangPrefs.savePostLanguageToHistory()
|
||||
if (quote) {
|
||||
// We want to wait for the quote count to update before we call `onPost`, which will refetch data
|
||||
whenAppViewReady(agent, quote.uri, res => {
|
||||
const thread = res.data.thread
|
||||
if (
|
||||
AppBskyFeedDefs.isThreadViewPost(thread) &&
|
||||
thread.post.quoteCount !== quoteCount
|
||||
) {
|
||||
onPost?.(postUri)
|
||||
return true
|
||||
postUri = (
|
||||
await apilib.post(agent, {
|
||||
rawText: richtext.text,
|
||||
replyTo: replyTo?.uri,
|
||||
images: gallery.images,
|
||||
quote,
|
||||
extLink,
|
||||
labels,
|
||||
threadgate: threadgateAllowUISettings,
|
||||
postgate,
|
||||
onStateChange: setProcessingState,
|
||||
langs: toPostLanguages(langPrefs.postLanguage),
|
||||
video: videoUploadState.pendingPublish?.blobRef
|
||||
? {
|
||||
blobRef: videoUploadState.pendingPublish.blobRef,
|
||||
altText: videoAltText,
|
||||
captions: captions,
|
||||
aspectRatio: videoUploadState.asset
|
||||
? {
|
||||
width: videoUploadState.asset?.width,
|
||||
height: videoUploadState.asset?.height,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
).uri
|
||||
try {
|
||||
await whenAppViewReady(agent, postUri, res => {
|
||||
const thread = res.data.thread
|
||||
return AppBskyFeedDefs.isThreadViewPost(thread)
|
||||
})
|
||||
} catch (waitErr: any) {
|
||||
logger.error(waitErr, {
|
||||
message: `Waiting for app view failed`,
|
||||
})
|
||||
// Keep going because the post *was* published.
|
||||
}
|
||||
return false
|
||||
})
|
||||
} else {
|
||||
onPost?.(postUri)
|
||||
} catch (e: any) {
|
||||
logger.error(e, {
|
||||
message: `Composer: create post failed`,
|
||||
hasImages: gallery.size > 0,
|
||||
})
|
||||
|
||||
if (extLink) {
|
||||
setExtLink({
|
||||
...extLink,
|
||||
isLoading: true,
|
||||
localThumb: undefined,
|
||||
} as apilib.ExternalEmbedDraft)
|
||||
}
|
||||
let err = cleanError(e.message)
|
||||
if (err.includes('not locate record')) {
|
||||
err = _(
|
||||
msg`We're sorry! The post you are replying to has been deleted.`,
|
||||
)
|
||||
}
|
||||
setError(err)
|
||||
setIsProcessing(false)
|
||||
return
|
||||
} finally {
|
||||
if (postUri) {
|
||||
logEvent('post:create', {
|
||||
imageCount: gallery.size,
|
||||
isReply: replyTo != null,
|
||||
hasLink: extLink != null,
|
||||
hasQuote: quote != null,
|
||||
langs: langPrefs.postLanguage,
|
||||
logContext: 'Composer',
|
||||
})
|
||||
}
|
||||
track('Create Post', {
|
||||
imageCount: gallery.size,
|
||||
})
|
||||
if (replyTo && replyTo.uri) track('Post:Reply')
|
||||
}
|
||||
if (postUri && !replyTo) {
|
||||
emitPostCreated()
|
||||
}
|
||||
setLangPrefs.savePostLanguageToHistory()
|
||||
if (quote) {
|
||||
// We want to wait for the quote count to update before we call `onPost`, which will refetch data
|
||||
whenAppViewReady(agent, quote.uri, res => {
|
||||
const thread = res.data.thread
|
||||
if (
|
||||
AppBskyFeedDefs.isThreadViewPost(thread) &&
|
||||
thread.post.quoteCount !== quoteCount
|
||||
) {
|
||||
onPost?.(postUri)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
} else {
|
||||
onPost?.(postUri)
|
||||
}
|
||||
onClose()
|
||||
Toast.show(
|
||||
replyTo
|
||||
? _(msg`Your reply has been published`)
|
||||
: _(msg`Your post has been published`),
|
||||
)
|
||||
},
|
||||
[
|
||||
_,
|
||||
agent,
|
||||
captions,
|
||||
extLink,
|
||||
gallery.images,
|
||||
gallery.isEmpty,
|
||||
gallery.size,
|
||||
graphemeLength,
|
||||
isAltTextRequiredAndMissing,
|
||||
isProcessing,
|
||||
labels,
|
||||
langPrefs.postLanguage,
|
||||
onClose,
|
||||
onPost,
|
||||
postgate,
|
||||
quote,
|
||||
quoteCount,
|
||||
replyTo,
|
||||
richtext.text,
|
||||
setExtLink,
|
||||
setLangPrefs,
|
||||
threadgateAllowUISettings,
|
||||
track,
|
||||
videoAltText,
|
||||
videoUploadState.asset,
|
||||
videoUploadState.pendingPublish,
|
||||
videoUploadState.status,
|
||||
],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (videoUploadState.pendingPublish && publishOnUpload) {
|
||||
if (!videoUploadState.pendingPublish.mutableProcessed) {
|
||||
videoUploadState.pendingPublish.mutableProcessed = true
|
||||
onPressPublish(true)
|
||||
}
|
||||
}
|
||||
onClose()
|
||||
Toast.show(
|
||||
replyTo
|
||||
? _(msg`Your reply has been published`)
|
||||
: _(msg`Your post has been published`),
|
||||
)
|
||||
}
|
||||
}, [onPressPublish, publishOnUpload, videoUploadState.pendingPublish])
|
||||
|
||||
const canPost = useMemo(
|
||||
() => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing,
|
||||
@@ -506,7 +582,9 @@ export const ComposePost = observer(function ComposePost({
|
||||
keyboardVerticalOffset={keyboardVerticalOffset}
|
||||
style={a.flex_1}>
|
||||
<View style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal>
|
||||
<Animated.View style={topBarAnimatedStyle}>
|
||||
<Animated.View
|
||||
style={topBarAnimatedStyle}
|
||||
layout={native(LinearTransition)}>
|
||||
<View style={styles.topbarInner}>
|
||||
<Button
|
||||
label={_(msg`Cancel`)}
|
||||
@@ -536,7 +614,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View style={[styles.postBtnWrapper]}>
|
||||
<LabelsBtn
|
||||
labels={labels}
|
||||
onChange={setLabels}
|
||||
@@ -572,7 +650,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -590,45 +668,15 @@ export const ComposePost = observer(function ComposePost({
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{error !== '' && (
|
||||
<View style={[a.px_lg, a.pb_sm]}>
|
||||
<View
|
||||
style={[
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
a.rounded_sm,
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
paddingRight: 48,
|
||||
},
|
||||
]}>
|
||||
<CircleInfo fill={t.palette.negative_400} />
|
||||
<NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
|
||||
{error}
|
||||
</NewText>
|
||||
<Button
|
||||
label={_(msg`Dismiss error`)}
|
||||
size="tiny"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
top: a.py_sm.paddingTop,
|
||||
right: a.px_md.paddingRight,
|
||||
},
|
||||
]}
|
||||
onPress={() => setError('')}>
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
videoUploadState={videoUploadState}
|
||||
clearError={() => setError('')}
|
||||
videoUploadDispatch={videoUploadDispatch}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.ScrollView
|
||||
layout={native(LinearTransition)}
|
||||
onScroll={scrollHandler}
|
||||
style={styles.scrollView}
|
||||
keyboardShouldPersistTaps="always"
|
||||
@@ -694,16 +742,36 @@ export const ComposePost = observer(function ComposePost({
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{videoUploadState.status === 'compressing' &&
|
||||
videoUploadState.asset ? (
|
||||
<VideoTranscodeProgress
|
||||
asset={videoUploadState.asset}
|
||||
progress={videoUploadState.progress}
|
||||
clear={clearVideo}
|
||||
/>
|
||||
) : videoUploadState.video ? (
|
||||
<VideoPreview video={videoUploadState.video} clear={clearVideo} />
|
||||
) : null}
|
||||
<LayoutAnimationConfig skipExiting>
|
||||
{(videoUploadState.asset || videoUploadState.video) && (
|
||||
<Animated.View
|
||||
style={[a.w_full, a.mt_xs]}
|
||||
entering={native(ZoomIn)}
|
||||
exiting={native(ZoomOut)}>
|
||||
{videoUploadState.asset &&
|
||||
(videoUploadState.status === 'compressing' ? (
|
||||
<VideoTranscodeProgress
|
||||
asset={videoUploadState.asset}
|
||||
progress={videoUploadState.progress}
|
||||
clear={clearVideo}
|
||||
/>
|
||||
) : videoUploadState.video ? (
|
||||
<VideoPreview
|
||||
asset={videoUploadState.asset}
|
||||
video={videoUploadState.video}
|
||||
setDimensions={updateVideoDimensions}
|
||||
clear={clearVideo}
|
||||
/>
|
||||
) : null)}
|
||||
<SubtitleDialogBtn
|
||||
defaultAltText={videoAltText}
|
||||
saveAltText={setVideoAltText}
|
||||
captions={captions}
|
||||
setCaptions={setCaptions}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</LayoutAnimationConfig>
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
<SuggestedLanguage text={richtext.text} />
|
||||
@@ -725,15 +793,17 @@ export const ComposePost = observer(function ComposePost({
|
||||
t.atoms.border_contrast_medium,
|
||||
styles.bottomBar,
|
||||
]}>
|
||||
{videoUploadState.status !== 'idle' ? (
|
||||
{videoUploadState.status !== 'idle' &&
|
||||
videoUploadState.status !== 'done' ? (
|
||||
<VideoUploadToolbar state={videoUploadState} />
|
||||
) : (
|
||||
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
{gate('videos') && (
|
||||
{gate('video_upload') && (
|
||||
<SelectVideoBtn
|
||||
onSelectVideo={selectVideo}
|
||||
disabled={!canSelectImages}
|
||||
setError={setError}
|
||||
/>
|
||||
)}
|
||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
@@ -922,6 +992,10 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 6,
|
||||
marginLeft: 12,
|
||||
},
|
||||
postBtnWrapper: {
|
||||
flexDirection: 'row',
|
||||
gap: 14,
|
||||
},
|
||||
errorLine: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -982,6 +1056,80 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
})
|
||||
|
||||
function ErrorBanner({
|
||||
error: standardError,
|
||||
videoUploadState,
|
||||
clearError,
|
||||
videoUploadDispatch,
|
||||
}: {
|
||||
error: string
|
||||
videoUploadState: VideoUploadState
|
||||
clearError: () => void
|
||||
videoUploadDispatch: VideoUploadDispatch
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const videoError =
|
||||
videoUploadState.status !== 'idle' ? videoUploadState.error : undefined
|
||||
const error = standardError || videoError
|
||||
|
||||
const onClearError = () => {
|
||||
if (standardError) {
|
||||
clearError()
|
||||
} else {
|
||||
videoUploadDispatch({type: 'Reset'})
|
||||
}
|
||||
}
|
||||
|
||||
if (!error) return null
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[a.px_lg, a.pb_sm]}
|
||||
entering={FadeIn}
|
||||
exiting={FadeOut}>
|
||||
<View
|
||||
style={[
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
a.gap_xs,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={[a.relative, a.flex_row, a.gap_sm, {paddingRight: 48}]}>
|
||||
<CircleInfo fill={t.palette.negative_400} />
|
||||
<NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
|
||||
{error}
|
||||
</NewText>
|
||||
<Button
|
||||
label={_(msg`Dismiss error`)}
|
||||
size="tiny"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
style={[a.absolute, {top: 0, right: 0}]}
|
||||
onPress={onClearError}>
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
</View>
|
||||
{videoError && videoUploadState.jobStatus?.jobId && (
|
||||
<NewText
|
||||
style={[
|
||||
{paddingLeft: 28},
|
||||
a.text_xs,
|
||||
a.font_bold,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_low,
|
||||
]}>
|
||||
<Trans>Job ID: {videoUploadState.jobStatus.jobId}</Trans>
|
||||
</NewText>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarWrapper({
|
||||
style,
|
||||
children,
|
||||
@@ -1002,23 +1150,65 @@ function ToolbarWrapper({
|
||||
|
||||
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const progress = state.jobStatus?.progress
|
||||
? state.jobStatus.progress / 100
|
||||
: state.progress
|
||||
let wheelProgress = progress === 0 || progress === 1 ? 0.33 : progress
|
||||
|
||||
const progress =
|
||||
state.status === 'compressing' || state.status === 'uploading'
|
||||
? state.progress
|
||||
: state.jobStatus?.progress ?? 100
|
||||
const rotate = useDerivedValue(() => {
|
||||
if (progress === 0 || progress >= 0.99) {
|
||||
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 = ''
|
||||
|
||||
switch (state.status) {
|
||||
case 'compressing':
|
||||
text = _('Compressing video...')
|
||||
break
|
||||
case 'uploading':
|
||||
text = _('Uploading video...')
|
||||
break
|
||||
case 'processing':
|
||||
text = _('Processing video...')
|
||||
break
|
||||
case 'done':
|
||||
text = _('Video uploaded')
|
||||
break
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
text = _('Error')
|
||||
wheelProgress = 100
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolbarWrapper
|
||||
style={[a.gap_sm, a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
||||
<ProgressCircle
|
||||
size={30}
|
||||
borderWidth={1}
|
||||
borderColor={t.atoms.border_contrast_low.borderColor}
|
||||
color={t.palette.primary_500}
|
||||
progress={progress}
|
||||
/>
|
||||
<Text>{state.status}</Text>
|
||||
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
||||
<Animated.View style={[animatedStyle]}>
|
||||
<ProgressCircle
|
||||
size={30}
|
||||
borderWidth={1}
|
||||
borderColor={t.atoms.border_contrast_low.borderColor}
|
||||
color={state.error ? t.palette.negative_500 : t.palette.primary_500}
|
||||
progress={wheelProgress}
|
||||
/>
|
||||
</Animated.View>
|
||||
<NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText>
|
||||
</ToolbarWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {Keyboard} from 'react-native'
|
||||
import {
|
||||
ImagePickerAsset,
|
||||
launchImageLibraryAsync,
|
||||
@@ -10,40 +11,66 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
|
||||
const VIDEO_MAX_DURATION = 90
|
||||
const VIDEO_MAX_DURATION = 60
|
||||
|
||||
type Props = {
|
||||
onSelectVideo: (video: ImagePickerAsset) => void
|
||||
disabled?: boolean
|
||||
setError: (error: string) => void
|
||||
}
|
||||
|
||||
export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
|
||||
export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
|
||||
const control = Prompt.usePromptControl()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const onPressSelectVideo = useCallback(async () => {
|
||||
if (isNative && !(await requestVideoAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = await launchImageLibraryAsync({
|
||||
exif: false,
|
||||
mediaTypes: MediaTypeOptions.Videos,
|
||||
videoMaxDuration: VIDEO_MAX_DURATION,
|
||||
quality: 1,
|
||||
legacy: true,
|
||||
preferredAssetRepresentationMode:
|
||||
UIImagePickerPreferredAssetRepresentationMode.Current,
|
||||
})
|
||||
if (response.assets && response.assets.length > 0) {
|
||||
onSelectVideo(response.assets[0])
|
||||
if (!currentAccount?.emailConfirmed) {
|
||||
Keyboard.dismiss()
|
||||
control.open()
|
||||
} else {
|
||||
const response = await launchImageLibraryAsync({
|
||||
exif: false,
|
||||
mediaTypes: MediaTypeOptions.Videos,
|
||||
videoMaxDuration: VIDEO_MAX_DURATION,
|
||||
quality: 1,
|
||||
legacy: true,
|
||||
preferredAssetRepresentationMode:
|
||||
UIImagePickerPreferredAssetRepresentationMode.Current,
|
||||
})
|
||||
if (response.assets && response.assets.length > 0) {
|
||||
try {
|
||||
onSelectVideo(response.assets[0])
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(_(msg`An error occurred while selecting the video`))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [onSelectVideo, requestVideoAccessIfNeeded])
|
||||
}, [
|
||||
onSelectVideo,
|
||||
requestVideoAccessIfNeeded,
|
||||
setError,
|
||||
_,
|
||||
control,
|
||||
currentAccount?.emailConfirmed,
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -62,6 +89,32 @@ export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
|
||||
style={disabled && t.atoms.text_contrast_low}
|
||||
/>
|
||||
</Button>
|
||||
<VerifyEmailPrompt control={control} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
|
||||
const {_} = useLingui()
|
||||
const {openModal} = useModalControls()
|
||||
|
||||
return (
|
||||
<Prompt.Basic
|
||||
control={control}
|
||||
title={_(msg`Verified email required`)}
|
||||
description={_(
|
||||
msg`To upload videos to Bluesky, you must first verify your email.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Verify now`)}
|
||||
confirmButtonColor="primary"
|
||||
onConfirm={() => {
|
||||
control.close(() => {
|
||||
openModal({
|
||||
name: 'verify-email',
|
||||
showReminder: false,
|
||||
})
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import React, {useCallback, useState} from 'react'
|
||||
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import RNPickerSelect from 'react-native-picker-select'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
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 {useLanguagePrefs} from '#/state/preferences'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {CC_Stroke2_Corner0_Rounded as CCIcon} from '#/components/icons/CC'
|
||||
import {PageText_Stroke2_Corner0_Rounded as PageTextIcon} from '#/components/icons/PageText'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {SubtitleFilePicker} from './SubtitleFilePicker'
|
||||
|
||||
interface Props {
|
||||
defaultAltText: string
|
||||
captions: {lang: string; file: File}[]
|
||||
saveAltText: (altText: string) => void
|
||||
setCaptions: React.Dispatch<
|
||||
React.SetStateAction<{lang: string; file: File}[]>
|
||||
>
|
||||
}
|
||||
|
||||
export function SubtitleDialogBtn(props: Props) {
|
||||
const control = Dialog.useDialogControl()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.my_xs]}>
|
||||
<Button
|
||||
label={isWeb ? _('Captions & alt text') : _('Alt text')}
|
||||
accessibilityHint={
|
||||
isWeb
|
||||
? _('Opens captions and alt text dialog')
|
||||
: _('Opens alt text dialog')
|
||||
}
|
||||
size="xsmall"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
if (Keyboard.isVisible()) Keyboard.dismiss()
|
||||
control.open()
|
||||
}}>
|
||||
<ButtonIcon icon={CCIcon} />
|
||||
<ButtonText>
|
||||
{isWeb ? <Trans>Captions & alt text</Trans> : <Trans>Alt text</Trans>}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
nativeOptions={isAndroid ? {sheet: {snapPoints: ['60%']}} : {}}>
|
||||
<Dialog.Handle />
|
||||
<SubtitleDialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function SubtitleDialogInner({
|
||||
defaultAltText,
|
||||
saveAltText,
|
||||
captions,
|
||||
setCaptions,
|
||||
}: Props) {
|
||||
const control = Dialog.useDialogContext()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const enforceLen = useEnforceMaxGraphemeCount()
|
||||
const {primaryLanguage} = useLanguagePrefs()
|
||||
|
||||
const [altText, setAltText] = useState(defaultAltText)
|
||||
|
||||
const handleSelectFile = useCallback(
|
||||
(file: File) => {
|
||||
setCaptions(subs => [
|
||||
...subs,
|
||||
{
|
||||
lang: subs.some(s => s.lang === primaryLanguage)
|
||||
? ''
|
||||
: primaryLanguage,
|
||||
file,
|
||||
},
|
||||
])
|
||||
},
|
||||
[setCaptions, primaryLanguage],
|
||||
)
|
||||
|
||||
const subtitleMissingLanguage = captions.some(sub => sub.lang === '')
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label={_(msg`Video settings`)}>
|
||||
<View style={a.gap_md}>
|
||||
<Text style={[a.text_xl, a.font_bold, a.leading_tight]}>
|
||||
<Trans>Alt text</Trans>
|
||||
</Text>
|
||||
<TextField.Root>
|
||||
<Dialog.Input
|
||||
label={_(msg`Alt text`)}
|
||||
placeholder={_(msg`Add alt text (optional)`)}
|
||||
value={altText}
|
||||
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') {
|
||||
control.close()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TextField.Root>
|
||||
|
||||
{isWeb && (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.border_t,
|
||||
a.w_full,
|
||||
t.atoms.border_contrast_medium,
|
||||
a.my_md,
|
||||
]}
|
||||
/>
|
||||
<Text style={[a.text_xl, a.font_bold, a.leading_tight]}>
|
||||
<Trans>Captions (.vtt)</Trans>
|
||||
</Text>
|
||||
<SubtitleFilePicker
|
||||
onSelectFile={handleSelectFile}
|
||||
disabled={subtitleMissingLanguage || captions.length >= 4}
|
||||
/>
|
||||
<View>
|
||||
{captions.map((subtitle, i) => (
|
||||
<SubtitleFileRow
|
||||
key={subtitle.lang}
|
||||
language={subtitle.lang}
|
||||
file={subtitle.file}
|
||||
setCaptions={setCaptions}
|
||||
otherLanguages={LANGUAGES.filter(
|
||||
lang =>
|
||||
langCode(lang) === subtitle.lang ||
|
||||
!captions.some(s => s.lang === langCode(lang)),
|
||||
)}
|
||||
style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{subtitleMissingLanguage && (
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Ensure you have selected a language for each subtitle file.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={web([a.flex_row, a.justify_end])}>
|
||||
<Button
|
||||
label={_(msg`Done`)}
|
||||
size={isWeb ? 'small' : 'medium'}
|
||||
color="primary"
|
||||
variant="solid"
|
||||
onPress={() => {
|
||||
saveAltText(altText)
|
||||
control.close()
|
||||
}}
|
||||
style={a.mt_lg}>
|
||||
<ButtonText>
|
||||
<Trans>Done</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
function SubtitleFileRow({
|
||||
language,
|
||||
file,
|
||||
otherLanguages,
|
||||
setCaptions,
|
||||
style,
|
||||
}: {
|
||||
language: string
|
||||
file: File
|
||||
otherLanguages: {code2: string; code3: string; name: string}[]
|
||||
setCaptions: React.Dispatch<
|
||||
React.SetStateAction<{lang: string; file: File}[]>
|
||||
>
|
||||
style: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(lang: string) => {
|
||||
if (lang) {
|
||||
setCaptions(subs =>
|
||||
subs.map(s => (s.lang === language ? {lang, file: s.file} : s)),
|
||||
)
|
||||
}
|
||||
},
|
||||
[setCaptions, language],
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.rounded_md,
|
||||
a.gap_md,
|
||||
style,
|
||||
]}>
|
||||
<View style={[a.flex_1, a.gap_xs, a.justify_center]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
{language === '' ? (
|
||||
<WarningIcon
|
||||
style={a.flex_shrink_0}
|
||||
fill={t.palette.negative_500}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<PageTextIcon style={[t.atoms.text, a.flex_shrink_0]} size="sm" />
|
||||
)}
|
||||
<Text
|
||||
style={[a.flex_1, a.leading_snug, a.font_bold, a.mb_2xs]}
|
||||
numberOfLines={1}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<RNPickerSelect
|
||||
placeholder={{
|
||||
label: _(msg`Select language...`),
|
||||
value: '',
|
||||
}}
|
||||
value={language}
|
||||
onValueChange={handleValueChange}
|
||||
items={otherLanguages.map(lang => ({
|
||||
label: `${lang.name} (${langCode(lang)})`,
|
||||
value: langCode(lang),
|
||||
}))}
|
||||
style={{viewContainer: {maxWidth: 200, flex: 1}}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
label={_(msg`Remove subtitle file`)}
|
||||
size="tiny"
|
||||
shape="round"
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
onPress={() =>
|
||||
setCaptions(subs => subs.filter(s => s.lang !== language))
|
||||
}
|
||||
style={[a.ml_sm]}>
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function langCode(lang: {code2: string; code3: string}) {
|
||||
return lang.code2 || lang.code3
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function SubtitleFilePicker() {
|
||||
throw new Error('SubtitleFilePicker is a web-only component')
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, {useRef} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {CC_Stroke2_Corner0_Rounded as CCIcon} from '#/components/icons/CC'
|
||||
|
||||
export function SubtitleFilePicker({
|
||||
onSelectFile,
|
||||
disabled,
|
||||
}: {
|
||||
onSelectFile: (file: File) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleClick = () => {
|
||||
ref.current?.click()
|
||||
}
|
||||
|
||||
const handlePick = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = evt.target.files?.[0]
|
||||
if (selectedFile) {
|
||||
if (selectedFile.type === 'text/vtt') {
|
||||
onSelectFile(selectedFile)
|
||||
} else {
|
||||
Toast.show(_(msg`Only WebVTT (.vtt) files are supported`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={a.gap_lg}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".vtt"
|
||||
ref={ref}
|
||||
style={a.hidden}
|
||||
onChange={handlePick}
|
||||
disabled={disabled}
|
||||
aria-disabled={disabled}
|
||||
/>
|
||||
<View style={a.flex_row}>
|
||||
<Button
|
||||
onPress={handleClick}
|
||||
label={_('Select subtitle file (.vtt)')}
|
||||
size="medium"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
disabled={disabled}>
|
||||
<ButtonIcon icon={CCIcon} />
|
||||
<ButtonText>
|
||||
<Trans>Select subtitle file (.vtt)</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +1,68 @@
|
||||
/* eslint-disable @typescript-eslint/no-shadow */
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {useVideoPlayer, VideoView} from 'expo-video'
|
||||
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
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} from '#/alf'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
|
||||
export function VideoPreview({
|
||||
asset,
|
||||
video,
|
||||
clear,
|
||||
}: {
|
||||
asset: ImagePickerAsset
|
||||
video: CompressedVideo
|
||||
setDimensions: (width: number, height: number) => void
|
||||
clear: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
const player = useVideoPlayer(video.uri, player => {
|
||||
player.loop = true
|
||||
player.muted = true
|
||||
player.play()
|
||||
if (!autoplayDisabled) {
|
||||
player.play()
|
||||
}
|
||||
})
|
||||
|
||||
let aspectRatio = asset.width / asset.height
|
||||
|
||||
if (isNaN(aspectRatio)) {
|
||||
aspectRatio = 16 / 9
|
||||
}
|
||||
|
||||
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 16 / 9},
|
||||
{aspectRatio},
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
{backgroundColor: 'black'},
|
||||
]}>
|
||||
<VideoView
|
||||
player={player}
|
||||
style={a.flex_1}
|
||||
allowsPictureInPicture={false}
|
||||
nativeControls={false}
|
||||
contentFit="contain"
|
||||
/>
|
||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||
{autoplayDisabled && (
|
||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
<PlayButtonIcon size={48} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,91 @@
|
||||
import React from 'react'
|
||||
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/compress'
|
||||
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,
|
||||
video,
|
||||
setDimensions,
|
||||
clear,
|
||||
}: {
|
||||
asset: ImagePickerAsset
|
||||
video: CompressedVideo
|
||||
setDimensions: (width: number, height: number) => void
|
||||
clear: () => void
|
||||
}) {
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
const {_} = useLingui()
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
const abortController = new AbortController()
|
||||
const {signal} = abortController
|
||||
ref.current.addEventListener(
|
||||
'loadedmetadata',
|
||||
function () {
|
||||
setDimensions(this.videoWidth, this.videoHeight)
|
||||
},
|
||||
{signal},
|
||||
)
|
||||
ref.current.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
Toast.show(_(msg`Could not process your video`))
|
||||
clear()
|
||||
},
|
||||
{signal},
|
||||
)
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [setDimensions, _, clear])
|
||||
|
||||
let aspectRatio = asset.width / asset.height
|
||||
|
||||
if (isNaN(aspectRatio)) {
|
||||
aspectRatio = 16 / 9
|
||||
}
|
||||
|
||||
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 16 / 9},
|
||||
{aspectRatio},
|
||||
a.overflow_hidden,
|
||||
{backgroundColor: 'black'},
|
||||
a.relative,
|
||||
]}>
|
||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||
<video src={video.uri} style={a.flex_1} autoPlay loop muted playsInline />
|
||||
<video
|
||||
ref={ref}
|
||||
src={video.uri}
|
||||
style={{width: '100%', height: '100%', objectFit: 'cover'}}
|
||||
autoPlay={!autoplayDisabled}
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
{autoplayDisabled && (
|
||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
<PlayButtonIcon size={48} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ export function VideoTranscodeBackdrop({uri}: {uri: string}) {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Animated.View style={a.flex_1} entering={FadeIn}>
|
||||
{thumbnail && (
|
||||
thumbnail && (
|
||||
<Animated.View style={a.flex_1} entering={FadeIn}>
|
||||
<Image
|
||||
style={a.flex_1}
|
||||
source={thumbnail.path}
|
||||
@@ -31,7 +31,7 @@ export function VideoTranscodeBackdrop({uri}: {uri: string}) {
|
||||
blurRadius={15}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import React from 'react'
|
||||
|
||||
export function VideoTranscodeBackdrop({uri}: {uri: string}) {
|
||||
return (
|
||||
<video src={uri} style={{flex: 1, filter: 'blur(10px)'}} muted autoPlay />
|
||||
)
|
||||
export function VideoTranscodeBackdrop() {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {View} from 'react-native'
|
||||
import ProgressPie from 'react-native-progress/Pie'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn'
|
||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||
@@ -19,17 +21,24 @@ export function VideoTranscodeProgress({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
const aspectRatio = asset.width / asset.height
|
||||
if (isWeb) return null
|
||||
|
||||
let aspectRatio = asset.width / asset.height
|
||||
|
||||
if (isNaN(aspectRatio)) {
|
||||
aspectRatio = 16 / 9
|
||||
}
|
||||
|
||||
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.mt_md,
|
||||
t.atoms.bg_contrast_50,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
{aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio},
|
||||
{aspectRatio},
|
||||
]}>
|
||||
<VideoTranscodeBackdrop uri={asset.uri} />
|
||||
<View
|
||||
|
||||
@@ -173,7 +173,7 @@ export function Component({
|
||||
accessibilityLabel={_(msg`Confirmation code`)}
|
||||
accessibilityHint=""
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoComplete="one-time-code"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
@@ -8,9 +8,6 @@ import {
|
||||
} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyGraphFollow,
|
||||
@@ -25,7 +22,6 @@ import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {parseTenorGif} from '#/lib/strings/embed-player'
|
||||
import {logger} from '#/logger'
|
||||
import {FeedNotification} from '#/state/queries/notifications/feed'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
@@ -52,11 +48,11 @@ import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/com
|
||||
import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
|
||||
import {StarterPack} from '#/components/icons/StarterPack'
|
||||
import {Link as NewLink} from '#/components/Link'
|
||||
import * as MediaPreview from '#/components/MediaPreview'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||
import {FeedSourceCard} from '../feeds/FeedSourceCard'
|
||||
import {Post} from '../post/Post'
|
||||
import {ImageHorzList} from '../util/images/ImageHorzList'
|
||||
import {Link, TextLink} from '../util/Link'
|
||||
import {formatCount} from '../util/numeric/format'
|
||||
import {Text} from '../util/text/Text'
|
||||
@@ -84,7 +80,7 @@ let FeedItem = ({
|
||||
}): React.ReactNode => {
|
||||
const queryClient = useQueryClient()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {_, i18n} = useLingui()
|
||||
const t = useTheme()
|
||||
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
|
||||
const itemHref = useMemo(() => {
|
||||
@@ -225,11 +221,11 @@ let FeedItem = ({
|
||||
}
|
||||
|
||||
const formattedCount =
|
||||
authors.length > 1 ? formatCount(authors.length - 1) : ''
|
||||
authors.length > 1 ? formatCount(i18n, authors.length - 1) : ''
|
||||
const firstAuthorName = sanitizeDisplayName(
|
||||
authors[0].profile.displayName || authors[0].profile.handle,
|
||||
)
|
||||
const niceTimestamp = niceDate(item.notification.indexedAt)
|
||||
const niceTimestamp = niceDate(i18n, item.notification.indexedAt)
|
||||
const a11yLabelUsers =
|
||||
authors.length > 1
|
||||
? _(msg` and `) +
|
||||
@@ -593,49 +589,14 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
|
||||
const pal = usePalette('default')
|
||||
if (post && AppBskyFeedPost.isRecord(post?.record)) {
|
||||
const text = post.record.text
|
||||
let images
|
||||
let isGif = false
|
||||
|
||||
if (AppBskyEmbedImages.isView(post.embed)) {
|
||||
images = post.embed.images
|
||||
} else if (
|
||||
AppBskyEmbedRecordWithMedia.isView(post.embed) &&
|
||||
AppBskyEmbedImages.isView(post.embed.media)
|
||||
) {
|
||||
images = post.embed.media.images
|
||||
} else if (
|
||||
AppBskyEmbedExternal.isView(post.embed) &&
|
||||
post.embed.external.thumb
|
||||
) {
|
||||
let url: URL | undefined
|
||||
try {
|
||||
url = new URL(post.embed.external.uri)
|
||||
} catch {}
|
||||
if (url) {
|
||||
const {success} = parseTenorGif(url)
|
||||
if (success) {
|
||||
isGif = true
|
||||
images = [
|
||||
{
|
||||
thumb: post.embed.external.thumb,
|
||||
alt: post.embed.external.title,
|
||||
fullsize: post.embed.external.thumb,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
|
||||
{images && images.length > 0 && (
|
||||
<ImageHorzList
|
||||
images={images}
|
||||
style={styles.additionalPostImages}
|
||||
gif={isGif}
|
||||
/>
|
||||
)}
|
||||
<MediaPreview.Embed
|
||||
embed={post.embed}
|
||||
style={styles.additionalPostImages}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import React, {useCallback, useMemo, useState} from 'react'
|
||||
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {useLikedByQuery} from '#/state/queries/post-liked-by'
|
||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {
|
||||
ListFooter,
|
||||
ListHeaderDesktop,
|
||||
ListMaybePlaceholder,
|
||||
} from '#/components/Lists'
|
||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||
|
||||
function renderItem({item}: {item: GetLikes.Like}) {
|
||||
return <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
|
||||
function renderItem({item, index}: {item: GetLikes.Like; index: number}) {
|
||||
return (
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.actor.did}
|
||||
profile={item.actor}
|
||||
noBorder={index === 0 && !isWeb}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function keyExtractor(item: GetLikes.Like) {
|
||||
@@ -25,7 +26,6 @@ function keyExtractor(item: GetLikes.Like) {
|
||||
}
|
||||
|
||||
export function PostLikedBy({uri}: {uri: string}) {
|
||||
const {_} = useLingui()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
|
||||
const [isPTRing, setIsPTRing] = useState(false)
|
||||
@@ -78,6 +78,7 @@ export function PostLikedBy({uri}: {uri: string}) {
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoadingUri || isLoadingLikes}
|
||||
isError={isError}
|
||||
sideBorders={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -91,7 +92,6 @@ export function PostLikedBy({uri}: {uri: string}) {
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Liked By`)} />}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
@@ -103,6 +103,7 @@ export function PostLikedBy({uri}: {uri: string}) {
|
||||
desktopFixedHeight
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
sideBorders={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,24 +14,23 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {usePostQuotesQuery} from '#/state/queries/post-quotes'
|
||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {Post} from 'view/com/post/Post'
|
||||
import {
|
||||
ListFooter,
|
||||
ListHeaderDesktop,
|
||||
ListMaybePlaceholder,
|
||||
} from '#/components/Lists'
|
||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||
import {List} from '../util/List'
|
||||
|
||||
function renderItem({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
moderation: ModerationDecision
|
||||
record: AppBskyFeedPost.Record
|
||||
}
|
||||
index: number
|
||||
}) {
|
||||
return <Post post={item.post} />
|
||||
return <Post post={item.post} hideTopBorder={index === 0 && !isWeb} />
|
||||
}
|
||||
|
||||
function keyExtractor(item: {
|
||||
@@ -45,7 +44,6 @@ function keyExtractor(item: {
|
||||
export function PostQuotes({uri}: {uri: string}) {
|
||||
const {_} = useLingui()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
|
||||
const [isPTRing, setIsPTRing] = useState(false)
|
||||
|
||||
const {
|
||||
@@ -104,6 +102,7 @@ export function PostQuotes({uri}: {uri: string}) {
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoadingUri || isLoadingQuotes}
|
||||
isError={isError}
|
||||
sideBorders={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -119,7 +118,6 @@ export function PostQuotes({uri}: {uri: string}) {
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Quotes`)} />}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
@@ -133,6 +131,7 @@ export function PostQuotes({uri}: {uri: string}) {
|
||||
desktopFixedHeight
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
sideBorders={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, {useCallback, useMemo, useState} from 'react'
|
||||
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
@@ -10,11 +8,7 @@ import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {
|
||||
ListFooter,
|
||||
ListHeaderDesktop,
|
||||
ListMaybePlaceholder,
|
||||
} from '#/components/Lists'
|
||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||
|
||||
function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) {
|
||||
return <ProfileCardWithFollowBtn key={item.did} profile={item} />
|
||||
@@ -25,7 +19,6 @@ function keyExtractor(item: ActorDefs.ProfileViewBasic) {
|
||||
}
|
||||
|
||||
export function PostRepostedBy({uri}: {uri: string}) {
|
||||
const {_} = useLingui()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
|
||||
const [isPTRing, setIsPTRing] = useState(false)
|
||||
@@ -78,6 +71,7 @@ export function PostRepostedBy({uri}: {uri: string}) {
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoadingUri || isLoadingRepostedBy}
|
||||
isError={isError}
|
||||
sideBorders={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -93,7 +87,6 @@ export function PostRepostedBy({uri}: {uri: string}) {
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Reposted By`)} />}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
@@ -105,6 +98,7 @@ export function PostRepostedBy({uri}: {uri: string}) {
|
||||
desktopFixedHeight
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
sideBorders={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {Link, TextLink} from '../util/Link'
|
||||
import {formatCount} from '../util/numeric/format'
|
||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
import {PostEmbeds, PostEmbedViewContext} from '../util/post-embeds'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||
@@ -181,7 +181,7 @@ let PostThreadItemLoaded = ({
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {_, i18n} = useLingui()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const {openComposer} = useComposerControls()
|
||||
const [limitLines, setLimitLines] = React.useState(
|
||||
@@ -363,7 +363,11 @@ let PostThreadItemLoaded = ({
|
||||
) : undefined}
|
||||
{post.embed && (
|
||||
<View style={[a.pb_sm]}>
|
||||
<PostEmbeds embed={post.embed} moderation={moderation} />
|
||||
<PostEmbeds
|
||||
embed={post.embed}
|
||||
moderation={moderation}
|
||||
viewContext={PostEmbedViewContext.ThreadHighlighted}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ContentHider>
|
||||
@@ -388,7 +392,7 @@ let PostThreadItemLoaded = ({
|
||||
type="lg"
|
||||
style={pal.textLight}>
|
||||
<Text type="xl-bold" style={pal.text}>
|
||||
{formatCount(post.repostCount)}
|
||||
{formatCount(i18n, post.repostCount)}
|
||||
</Text>{' '}
|
||||
<Plural
|
||||
value={post.repostCount}
|
||||
@@ -410,7 +414,7 @@ let PostThreadItemLoaded = ({
|
||||
type="lg"
|
||||
style={pal.textLight}>
|
||||
<Text type="xl-bold" style={pal.text}>
|
||||
{formatCount(post.quoteCount)}
|
||||
{formatCount(i18n, post.quoteCount)}
|
||||
</Text>{' '}
|
||||
<Plural
|
||||
value={post.quoteCount}
|
||||
@@ -430,7 +434,7 @@ let PostThreadItemLoaded = ({
|
||||
type="lg"
|
||||
style={pal.textLight}>
|
||||
<Text type="xl-bold" style={pal.text}>
|
||||
{formatCount(post.likeCount)}
|
||||
{formatCount(i18n, post.likeCount)}
|
||||
</Text>{' '}
|
||||
<Plural value={post.likeCount} one="like" other="likes" />
|
||||
</Text>
|
||||
@@ -591,7 +595,11 @@ let PostThreadItemLoaded = ({
|
||||
) : undefined}
|
||||
{post.embed && (
|
||||
<View style={[a.pb_xs]}>
|
||||
<PostEmbeds embed={post.embed} moderation={moderation} />
|
||||
<PostEmbeds
|
||||
embed={post.embed}
|
||||
moderation={moderation}
|
||||
viewContext={PostEmbedViewContext.Feed}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<PostCtrls
|
||||
@@ -705,7 +713,7 @@ function ExpandedPostDetails({
|
||||
translatorUrl: string
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {_, i18n} = useLingui()
|
||||
const openLink = useOpenLink()
|
||||
const isRootPost = !('reply' in post.record)
|
||||
|
||||
@@ -723,7 +731,9 @@ function ExpandedPostDetails({
|
||||
s.mt2,
|
||||
s.mb10,
|
||||
]}>
|
||||
<Text style={[a.text_sm, pal.textLight]}>{niceDate(post.indexedAt)}</Text>
|
||||
<Text style={[a.text_sm, pal.textLight]}>
|
||||
{niceDate(i18n, post.indexedAt)}
|
||||
</Text>
|
||||
{isRootPost && (
|
||||
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
|
||||
)}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
|
||||
import {PostAlerts} from '../../../components/moderation/PostAlerts'
|
||||
import {Link, TextLink} from '../util/Link'
|
||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
import {PostEmbeds, PostEmbedViewContext} from '../util/post-embeds'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||
@@ -238,7 +238,11 @@ function PostInner({
|
||||
/>
|
||||
) : undefined}
|
||||
{post.embed ? (
|
||||
<PostEmbeds embed={post.embed} moderation={moderation} />
|
||||
<PostEmbeds
|
||||
embed={post.embed}
|
||||
moderation={moderation}
|
||||
viewContext={PostEmbedViewContext.Feed}
|
||||
/>
|
||||
) : null}
|
||||
</ContentHider>
|
||||
<PostCtrls
|
||||
|
||||
+32
-15
@@ -101,7 +101,7 @@ const feedInterstitialType = 'interstitialFeeds'
|
||||
const followInterstitialType = 'interstitialFollows'
|
||||
const progressGuideInterstitialType = 'interstitialProgressGuide'
|
||||
const interstials: Record<
|
||||
'following' | 'discover',
|
||||
'following' | 'discover' | 'profile',
|
||||
(FeedItem & {
|
||||
type:
|
||||
| 'interstitialFeeds'
|
||||
@@ -128,6 +128,16 @@ const interstials: Record<
|
||||
slot: 20,
|
||||
},
|
||||
],
|
||||
profile: [
|
||||
{
|
||||
type: followInterstitialType,
|
||||
params: {
|
||||
variant: 'default',
|
||||
},
|
||||
key: followInterstitialType,
|
||||
slot: 5,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export function getFeedPostSlice(feedItem: FeedItem): FeedPostSlice | null {
|
||||
@@ -193,9 +203,7 @@ let Feed = ({
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const checkForNewRef = React.useRef<(() => void) | null>(null)
|
||||
const lastFetchRef = React.useRef<number>(Date.now())
|
||||
const [feedType, feedUri] = feed.split('|')
|
||||
const feedIsDiscover = feedUri === DISCOVER_FEED_URI
|
||||
const feedIsFollowing = feedType === 'following'
|
||||
const [feedType, feedUri, feedTab] = feed.split('|')
|
||||
const gate = useGate()
|
||||
|
||||
const opts = React.useMemo(
|
||||
@@ -339,14 +347,21 @@ let Feed = ({
|
||||
}
|
||||
|
||||
if (hasSession) {
|
||||
const feedType = feedIsFollowing
|
||||
? 'following'
|
||||
: feedIsDiscover
|
||||
? 'discover'
|
||||
: undefined
|
||||
let feedKind: 'following' | 'discover' | 'profile' | undefined
|
||||
if (feedType === 'following') {
|
||||
feedKind = 'following'
|
||||
} else if (feedUri === DISCOVER_FEED_URI) {
|
||||
feedKind = 'discover'
|
||||
} else if (
|
||||
feedType === 'author' &&
|
||||
(feedTab === 'posts_and_author_threads' ||
|
||||
feedTab === 'posts_with_replies')
|
||||
) {
|
||||
feedKind = 'profile'
|
||||
}
|
||||
|
||||
if (feedType) {
|
||||
for (const interstitial of interstials[feedType]) {
|
||||
if (feedKind) {
|
||||
for (const interstitial of interstials[feedKind]) {
|
||||
const shouldShow =
|
||||
(interstitial.type === feedInterstitialType &&
|
||||
gate('suggested_feeds_interstitial')) ||
|
||||
@@ -377,9 +392,9 @@ let Feed = ({
|
||||
isEmpty,
|
||||
lastFetchedAt,
|
||||
data,
|
||||
feedType,
|
||||
feedUri,
|
||||
feedIsDiscover,
|
||||
feedIsFollowing,
|
||||
feedTab,
|
||||
gate,
|
||||
hasSession,
|
||||
])
|
||||
@@ -470,7 +485,7 @@ let Feed = ({
|
||||
} else if (item.type === feedInterstitialType) {
|
||||
return <SuggestedFeeds />
|
||||
} else if (item.type === followInterstitialType) {
|
||||
return <SuggestedFollows />
|
||||
return <SuggestedFollows feed={feed} />
|
||||
} else if (item.type === progressGuideInterstitialType) {
|
||||
return <ProgressGuide />
|
||||
} else if (item.type === 'slice') {
|
||||
@@ -547,7 +562,9 @@ let Feed = ({
|
||||
desktopFixedHeightOffset ? desktopFixedHeightOffset : true
|
||||
}
|
||||
initialNumToRender={initialNumToRenderOverride ?? initialNumToRender}
|
||||
windowSize={11}
|
||||
windowSize={9}
|
||||
maxToRenderPerBatch={5}
|
||||
updateCellsBatchingPeriod={40}
|
||||
onItemSeen={feedFeedback.onItemSeen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -34,7 +34,7 @@ import {useComposerControls} from '#/state/shell/composer'
|
||||
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||
import {FeedNameText} from '#/view/com/util/FeedInfoText'
|
||||
import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls'
|
||||
import {PostEmbeds} from '#/view/com/util/post-embeds'
|
||||
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
@@ -488,6 +488,7 @@ let PostContent = ({
|
||||
embed={postEmbed}
|
||||
moderation={moderation}
|
||||
onOpen={onOpenEmbed}
|
||||
viewContext={PostEmbedViewContext.Feed}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user