Merge remote-tracking branch 'upstream/main' into Improve-notification-localization

This commit is contained in:
Minseo Lee
2024-09-05 09:33:34 +09:00
30 changed files with 671 additions and 308 deletions
@@ -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

+1 -1
View File
@@ -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",
+37 -1
View File
@@ -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))
}
+9 -9
View File
@@ -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"
+81 -30
View File
@@ -5,7 +5,7 @@ index 473f964..f37aff9 100644
@@ -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)
@@ -32,10 +32,10 @@ index 9905e13..47342ff 100644
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)
@@ -144,7 +144,7 @@ index ec3da2a..5a1397a 100644
+ "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
@@ -152,7 +152,7 @@ index 58f00af..5ad8237 100644
+++ 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
@@ -162,18 +162,18 @@ index 58f00af..5ad8237 100644
.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
@@ -184,7 +184,7 @@ index 58f00af..5ad8237 100644
@@ -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
@@ -224,7 +224,7 @@ index a951d80..3932535 100644
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
@@ -234,7 +234,7 @@ index a951d80..3932535 100644
+ 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)
@@ -242,9 +242,9 @@ index a951d80..3932535 100644
+ this.onExitFullscreen(mapOf())
isInFullscreen = false
}
diff --git a/node_modules/expo-video/build/VideoPlayer.types.d.ts b/node_modules/expo-video/build/VideoPlayer.types.d.ts
index a09fcfe..5eac9e5 100644
index a09fcfe..46cbae7 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 = {
@@ -256,6 +256,15 @@ index a09fcfe..5eac9e5 100644
};
/**
* Describes the current status of the player.
@@ -136,7 +138,7 @@ export type VideoPlayerEvents = {
* - `readyToPlay`: The player has loaded enough data to start playing or to continue playback.
* - `error`: The player has encountered an error while loading or playing the video.
*/
-export type VideoPlayerStatus = 'idle' | 'loading' | 'readyToPlay' | 'error';
+export type VideoPlayerStatus = 'idle' | 'loading' | 'readyToPlay' | 'error' | 'waitingToPlayAtSpecifiedRate';
export type VideoSource = string | {
/**
* The URI of the video.
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
@@ -270,8 +279,21 @@ index cb9ca6d..ed8bb7e 100644
}
//# sourceMappingURL=VideoView.types.d.ts.map
\ No newline at end of file
diff --git a/node_modules/expo-video/ios/Enums/PlayerStatus.swift b/node_modules/expo-video/ios/Enums/PlayerStatus.swift
index 6af69ca..189fbbe 100644
--- a/node_modules/expo-video/ios/Enums/PlayerStatus.swift
+++ b/node_modules/expo-video/ios/Enums/PlayerStatus.swift
@@ -6,5 +6,8 @@ internal enum PlayerStatus: String, Enumerable {
case idle
case loading
case readyToPlay
+ case waitingToPlayAtSpecifiedRate
+ case unlikeToKeepUp
+ case playbackBufferEmpty
case error
}
diff --git a/node_modules/expo-video/ios/VideoManager.swift b/node_modules/expo-video/ios/VideoManager.swift
index 094a8b0..3f00525 100644
index 094a8b0..16e7081 100644
--- a/node_modules/expo-video/ios/VideoManager.swift
+++ b/node_modules/expo-video/ios/VideoManager.swift
@@ -12,6 +12,7 @@ class VideoManager {
@@ -409,7 +431,7 @@ index c537a12..e4a918f 100644
+ "onEnterFullscreen",
+ "onExitFullscreen"
)
Prop("player") { (view, player: VideoPlayer?) in
diff --git a/node_modules/expo-video/ios/VideoPlayer.swift b/node_modules/expo-video/ios/VideoPlayer.swift
index 3315b88..733ab1f 100644
@@ -418,7 +440,7 @@ index 3315b88..733ab1f 100644
@@ -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)
+ }
@@ -427,7 +449,7 @@ index 3315b88..733ab1f 100644
if self.appContext != nil {
self.emit(event: event, arguments: repeat each arguments)
diff --git a/node_modules/expo-video/ios/VideoPlayerObserver.swift b/node_modules/expo-video/ios/VideoPlayerObserver.swift
index d289e26..ea4d96f 100644
index d289e26..7de8cbf 100644
--- a/node_modules/expo-video/ios/VideoPlayerObserver.swift
+++ b/node_modules/expo-video/ios/VideoPlayerObserver.swift
@@ -21,6 +21,7 @@ protocol VideoPlayerObserverDelegate: AnyObject {
@@ -436,7 +458,7 @@ index d289e26..ea4d96f 100644
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?) {}
@@ -444,14 +466,14 @@ index d289e26..ea4d96f 100644
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 {
@@ -462,16 +484,36 @@ index d289e26..ea4d96f 100644
+ player?.removeTimeObserver(playerPeriodicTimeObserver)
+ }
}
private func initializeCurrentPlayerItemObservers(player: AVPlayer, playerItem: AVPlayerItem) {
@@ -270,6 +276,7 @@ class VideoPlayerObserver {
@@ -265,23 +271,24 @@ class VideoPlayerObserver {
if player.timeControlStatus != .waitingToPlayAtSpecifiedRate && player.status == .readyToPlay && currentItem?.isPlaybackBufferEmpty != true {
status = .readyToPlay
} else if player.timeControlStatus == .waitingToPlayAtSpecifiedRate {
- status = .loading
+ status = .waitingToPlayAtSpecifiedRate
}
if isPlaying != (player.timeControlStatus == .playing) {
isPlaying = player.timeControlStatus == .playing
+ addPeriodicTimeObserverIfNeeded()
}
}
private func onIsBufferEmptyChanged(_ playerItem: AVPlayerItem, _ change: NSKeyValueObservedChange<Bool>) {
if playerItem.isPlaybackBufferEmpty {
- status = .loading
+ status = .playbackBufferEmpty
}
}
private func onPlayerLikelyToKeepUpChanged(_ playerItem: AVPlayerItem, _ change: NSKeyValueObservedChange<Bool>) {
if !playerItem.isPlaybackLikelyToKeepUp && playerItem.isPlaybackBufferEmpty {
- status = .loading
+ status = .unlikeToKeepUp
} else if playerItem.isPlaybackLikelyToKeepUp {
status = .readyToPlay
}
@@ -310,4 +317,28 @@ class VideoPlayerObserver {
}
}
@@ -506,12 +548,12 @@ 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 {
@@ -521,7 +563,7 @@ index f4579e4..10c5908 100644
+ onEnterFullscreen()
isFullscreen = true
}
@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
if wasPlaying {
self.player?.pointer.play()
@@ -531,7 +573,7 @@ index f4579e4..10c5908 100644
}
}
diff --git a/node_modules/expo-video/src/VideoPlayer.types.ts b/node_modules/expo-video/src/VideoPlayer.types.ts
index aaf4b63..f438196 100644
index aaf4b63..5ff6b7a 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 = {
@@ -541,8 +583,17 @@ index aaf4b63..f438196 100644
+
+ timeRemainingChange(timeRemaining: number): void;
};
/**
@@ -160,7 +162,7 @@ export type VideoPlayerEvents = {
* - `readyToPlay`: The player has loaded enough data to start playing or to continue playback.
* - `error`: The player has encountered an error while loading or playing the video.
*/
-export type VideoPlayerStatus = 'idle' | 'loading' | 'readyToPlay' | 'error';
+export type VideoPlayerStatus = 'idle' | 'loading' | 'readyToPlay' | 'error' | 'waitingToPlayAtSpecifiedRate';
export type VideoSource =
| string
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
+13
View File
@@ -3,6 +3,7 @@
## `expo-video` Patch
### `onEnterFullScreen`/`onExitFullScreen`
Adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on
the tin.
@@ -16,3 +17,15 @@ ourselves.
Instead of handling the pausing/playing of videos in React, we'll handle them here. There's some logic that we do not
need (around PIP mode) that we can remove, and just pause any playing players on background and then resume them on
foreground.
### Additional `statusChange` Events
`expo-video` uses the `loading` status for a variety of cases where the video is not actually "loading". We're making
those status events more specific here, so that we can determine if a video is truly loading or not. These statuses are:
- `waitingToPlayAtSpecifiedRate`
- `unlikelyToKeepUp`
- `playbackBufferEmpty`
It's unlikely we will ever need to pay attention to these statuses, so they are not being include in the TypeScript
types.
+172
View File
@@ -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',
},
})
+1
View File
@@ -19,6 +19,7 @@ export const sizes = {
md: 20,
lg: 24,
xl: 28,
'2xl': 32,
}
export function useCommonSVGProps(props: Props) {
+5 -6
View File
@@ -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} />
)
}
+6 -15
View File
@@ -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
+25
View File
@@ -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>
)
}
+6 -1
View File
@@ -271,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)
}
}
}
}
+2 -2
View File
@@ -5,9 +5,9 @@
// - 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 === 0) {
if (!isSet && count === 1) {
shouldRoll = true
} else if (count > 0 && count < 1000) {
} else if (count > 1 && count < 1000) {
shouldRoll = true
} else if (count > 0) {
const mod = count % 100
+33
View File
@@ -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])
}
+5
View File
@@ -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 -4
View File
@@ -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 -1
View File
@@ -48,7 +48,7 @@ msgstr "{0, plural, one {# repostagem} other {# repostagens}}"
#: src/components/KnownFollowers.tsx:179
#~ msgid "{0, plural, one {and # other} other {and # others}}"
#~ msgstr "{0, plural, one {e # outro} other {e # outros}"
#~ msgstr "{0, plural, one {e # outro} other {e # outros}}"
#: src/components/ProfileHoverCard/index.web.tsx:398
#: src/screens/Profile/Header/Metrics.tsx:23
@@ -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 -1
View File
@@ -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} />
)}
+13 -7
View File
@@ -123,12 +123,16 @@ export function useUploadVideo({
blobRef,
})
},
onError: useCallback(() => {
dispatch({
type: 'SetError',
error: _(msg`Video failed to process`),
})
}, [_]),
onError: useCallback(
error => {
logger.error('Error processing video', {safeMessage: error})
dispatch({
type: 'SetError',
error: _(msg`Video failed to process`),
})
},
[_],
),
})
const {mutate: onVideoCompressed} = useUploadVideoMutation({
@@ -140,6 +144,7 @@ export function useUploadVideo({
setJobId(response.jobId)
},
onError: e => {
logger.error('Error uploading video', {safeMessage: e})
if (e instanceof ServerError) {
dispatch({
type: 'SetError',
@@ -171,6 +176,7 @@ export function useUploadVideo({
onVideoCompressed(video)
},
onError: e => {
logger.error('Error uploading video', {safeMessage: e})
if (e instanceof VideoTooLargeError) {
dispatch({
type: 'SetError',
@@ -250,7 +256,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
+51 -18
View File
@@ -20,11 +20,14 @@ import {
// @ts-expect-error no type definition
import ProgressCircle from 'react-native-progress/Circle'
import Animated, {
Easing,
FadeIn,
FadeOut,
interpolateColor,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withRepeat,
withTiming,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
@@ -221,7 +224,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()
@@ -235,6 +243,7 @@ export const ComposePost = observer(function ComposePost({
closeAllDialogs,
discardPromptControl,
onClose,
videoUploadState.status,
])
useImperativeHandle(cancelRef, () => ({onPressCancel}))
@@ -329,7 +338,8 @@ export const ComposePost = observer(function ComposePost({
richtext.text.trim().length === 0 &&
gallery.isEmpty &&
!extLink &&
!quote
!quote &&
videoUploadState.status === 'idle'
) {
setError(_(msg`Did you want to say anything?`))
return
@@ -595,7 +605,7 @@ export const ComposePost = observer(function ComposePost({
</View>
</>
) : (
<>
<View style={[styles.postBtnWrapper]}>
<LabelsBtn
labels={labels}
onChange={setLabels}
@@ -631,7 +641,7 @@ export const ComposePost = observer(function ComposePost({
</Text>
</View>
)}
</>
</View>
)}
</View>
@@ -999,6 +1009,10 @@ const styles = StyleSheet.create({
paddingVertical: 6,
marginLeft: 12,
},
postBtnWrapper: {
flexDirection: 'row',
gap: 14,
},
errorLine: {
flexDirection: 'row',
alignItems: 'center',
@@ -1080,6 +1094,29 @@ 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 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 = ''
@@ -1098,26 +1135,22 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
break
}
// we could use state.jobStatus?.progress but 99% of the time it jumps from 0 to 100
let progress =
state.status === 'compressing' || state.status === 'uploading'
? state.progress
: 100
if (state.error) {
text = _('Error')
progress = 100
wheelProgress = 100
}
return (
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
<ProgressCircle
size={30}
borderWidth={1}
borderColor={t.atoms.border_contrast_low.borderColor}
color={state.error ? t.palette.negative_500 : t.palette.primary_500}
progress={progress}
/>
<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,9 +1,12 @@
import React, {useEffect, useRef} from 'react'
import {View} from 'react-native'
import {ImagePickerAsset} from 'expo-image-picker'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {CompressedVideo} from '#/lib/media/video/types'
import {clamp} from '#/lib/numbers'
import * as Toast from '#/view/com/util/Toast'
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
import {atoms as a} from '#/alf'
@@ -19,6 +22,7 @@ export function VideoPreview({
clear: () => void
}) {
const ref = useRef<HTMLVideoElement>(null)
const {_} = useLingui()
useEffect(() => {
if (!ref.current) return
@@ -32,11 +36,19 @@ export function VideoPreview({
},
{signal},
)
ref.current.addEventListener(
'error',
() => {
Toast.show(_(msg`Could not process your video`))
clear()
},
{signal},
)
return () => {
abortController.abort()
}
}, [setDimensions])
}, [setDimensions, _, clear])
let aspectRatio = asset.width / asset.height
+5 -44
View File
@@ -14,9 +14,6 @@ import {
} from 'react-native'
import {
AppBskyActorDefs,
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphFollow,
@@ -31,7 +28,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'
@@ -58,11 +54,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'
@@ -728,49 +724,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,61 +0,0 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
interface Props {
images: AppBskyEmbedImages.ViewImage[]
style?: StyleProp<ViewStyle>
gif?: boolean
}
export function ImageHorzList({images, style, gif}: Props) {
return (
<View style={[a.flex_row, a.gap_xs, style]}>
{images.map(({thumb, alt}) => (
<View
key={thumb}
style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}>
<Image
key={thumb}
source={{uri: thumb}}
style={[a.flex_1, a.rounded_xs]}
accessible={true}
accessibilityIgnoresInvertColors
accessibilityHint={alt}
accessibilityLabel=""
/>
{gif && (
<View style={styles.altContainer}>
<Text style={styles.alt}>
<Trans>GIF</Trans>
</Text>
</View>
)}
</View>
))}
</View>
)
}
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',
},
})
@@ -244,6 +244,7 @@ let PostCtrls = ({
a.flex_row,
a.justify_center,
a.align_center,
a.overflow_hidden,
{padding: 5},
(pressed || hovered) && t.atoms.bg_contrast_25,
],
@@ -4,8 +4,9 @@ import {useVideoPlayer, VideoPlayer} from 'expo-video'
import {isNative} from '#/platform/detection'
const Context = React.createContext<{
activeSource: string | null
setActiveSource: (src: string) => void
activeSource: string
activeViewId: string | undefined
setActiveSource: (src: string, viewId: string) => void
player: VideoPlayer
} | null>(null)
@@ -15,6 +16,7 @@ export function Provider({children}: {children: React.ReactNode}) {
}
const [activeSource, setActiveSource] = React.useState('')
const [activeViewId, setActiveViewId] = React.useState<string>()
const player = useVideoPlayer(activeSource, p => {
p.muted = true
@@ -22,8 +24,19 @@ export function Provider({children}: {children: React.ReactNode}) {
p.play()
})
const setActiveSourceOuter = (src: string, viewId: string) => {
setActiveSource(src)
setActiveViewId(viewId)
}
return (
<Context.Provider value={{activeSource, setActiveSource, player}}>
<Context.Provider
value={{
activeSource,
setActiveSource: setActiveSourceOuter,
activeViewId,
player,
}}>
{children}
</Context.Provider>
)
@@ -17,7 +17,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {WebView} from 'react-native-webview'
import {Image} from 'expo-image'
import {AppBskyEmbedExternal} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
@@ -29,6 +28,7 @@ import {useExternalEmbedsPrefs} from '#/state/preferences'
import {atoms as a} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {EventStopper} from '../EventStopper'
interface ShouldStartLoadRequest {
@@ -59,7 +59,7 @@ function PlaceholderOverlay({
onPress={onPress}
style={[styles.overlayContainer, styles.topRadius]}>
{!isPlayerActive ? (
<FontAwesomeIcon icon="play" size={42} color="white" />
<PlayButtonIcon />
) : (
<ActivityIndicator size="large" color="white" />
)}
+2 -21
View File
@@ -8,7 +8,6 @@ import {
ViewStyle,
} from 'react-native'
import {AppBskyEmbedExternal} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -22,6 +21,7 @@ import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
@@ -69,24 +69,7 @@ function PlaybackControls({
</View>
</View>
) : !isPlaying ? (
<View
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{
backgroundColor: t.palette.primary_500,
width: 60,
height: 60,
},
]}>
<FontAwesomeIcon
icon="play"
size={42}
color="white"
style={{marginLeft: 8}}
/>
</View>
<PlayButtonIcon />
) : undefined}
</Pressable>
)
@@ -155,7 +138,6 @@ export function GifEmbed({
accessibilityHint={_(msg`Animated GIF`)}
accessibilityLabel={parsedAlt.alt}
/>
{!hideAlt && parsedAlt.isPreferred && <AltText text={parsedAlt.alt} />}
</View>
</View>
@@ -183,7 +165,6 @@ function AltText({text}: {text: string}) {
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<Prompt.Outer control={control}>
<Prompt.TitleText>
<Trans>Alt Text</Trans>
+143 -44
View File
@@ -1,6 +1,7 @@
import React, {useCallback, useState} from 'react'
import React, {useCallback, useEffect, useId, useState} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {VideoPlayerStatus} from 'expo-video'
import {AppBskyEmbedVideo} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -8,41 +9,42 @@ import {useLingui} from '@lingui/react'
import {clamp} from '#/lib/numbers'
import {useGate} from '#/lib/statsig/statsig'
import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {Button} from '#/components/Button'
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
import {Loader} from '#/components/Loader'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
import {ErrorBoundary} from '../ErrorBoundary'
import {useActiveVideoNative} from './ActiveVideoNativeContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const t = useTheme()
const {activeSource, setActiveSource} = useActiveVideoNative()
const isActive = embed.playlist === activeSource
const {_} = useLingui()
interface Props {
embed: AppBskyEmbedVideo.View
}
export function VideoEmbed({embed}: Props) {
const gate = useGate()
const [key, setKey] = useState(0)
const renderError = useCallback(
(error: unknown) => (
<VideoError error={error} retry={() => setKey(key + 1)} />
),
[key],
)
const gate = useGate()
if (!gate('video_view_on_posts')) {
return null
}
let aspectRatio = 16 / 9
if (embed.aspectRatio) {
const {width, height} = embed.aspectRatio
aspectRatio = width / height
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
}
if (!gate('video_view_on_posts')) {
return null
}
return (
<View
style={[
@@ -54,41 +56,138 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
a.my_xs,
]}>
<ErrorBoundary renderError={renderError} key={key}>
<VisibilityView
enabled={true}
onChangeStatus={isVisible => {
if (isVisible) {
setActiveSource(embed.playlist)
}
}}>
{isActive ? (
<VideoEmbedInnerNative embed={embed} />
) : (
<>
<Image
source={{uri: embed.thumbnail}}
alt={embed.alt}
style={a.flex_1}
contentFit="contain"
accessibilityIgnoresInvertColors
/>
<Button
style={[a.absolute, a.inset_0]}
onPress={() => {
setActiveSource(embed.playlist)
}}
label={_(msg`Play video`)}
color="secondary">
<PlayIcon width={48} fill={t.palette.white} />
</Button>
</>
)}
</VisibilityView>
<InnerWrapper embed={embed} />
</ErrorBoundary>
</View>
)
}
function InnerWrapper({embed}: Props) {
const {_} = useLingui()
const {activeSource, activeViewId, setActiveSource, player} =
useActiveVideoNative()
const viewId = useId()
const [playerStatus, setPlayerStatus] = useState<VideoPlayerStatus>('loading')
const [isMuted, setIsMuted] = useState(player.muted)
const [isFullscreen, setIsFullscreen] = React.useState(false)
const [timeRemaining, setTimeRemaining] = React.useState(0)
const isActive = embed.playlist === activeSource && activeViewId === viewId
const isLoading =
isActive &&
(playerStatus === 'waitingToPlayAtSpecifiedRate' ||
playerStatus === 'loading')
useEffect(() => {
if (isActive) {
// eslint-disable-next-line @typescript-eslint/no-shadow
const volumeSub = player.addListener('volumeChange', ({isMuted}) => {
setIsMuted(isMuted)
})
const timeSub = player.addListener(
'timeRemainingChange',
secondsRemaining => {
setTimeRemaining(secondsRemaining)
},
)
const statusSub = player.addListener(
'statusChange',
(status, _oldStatus, error) => {
setPlayerStatus(status)
if (status === 'error') {
throw error
}
},
)
return () => {
volumeSub.remove()
timeSub.remove()
statusSub.remove()
}
}
}, [player, isActive])
useEffect(() => {
if (!isActive && playerStatus !== 'loading') {
setPlayerStatus('loading')
}
}, [isActive, playerStatus])
const onChangeStatus = (isVisible: boolean) => {
if (isFullscreen) {
return
}
if (isVisible) {
setActiveSource(embed.playlist, viewId)
if (!player.playing) {
player.play()
}
} else {
player.muted = true
if (player.playing) {
player.pause()
}
}
}
return (
<VisibilityView enabled={true} onChangeStatus={onChangeStatus}>
{isActive ? (
<VideoEmbedInnerNative
embed={embed}
timeRemaining={timeRemaining}
isMuted={isMuted}
isFullscreen={isFullscreen}
setIsFullscreen={setIsFullscreen}
/>
) : null}
{!isActive || isLoading ? (
<View
style={[
{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
},
]}>
<Image
source={{uri: embed.thumbnail}}
alt={embed.alt}
style={a.flex_1}
contentFit="cover"
accessibilityIgnoresInvertColors
/>
<Button
style={[a.absolute, a.inset_0]}
onPress={() => {
setActiveSource(embed.playlist, viewId)
}}
label={_(msg`Play video`)}
color="secondary">
{isLoading ? (
<View
style={[
a.rounded_full,
a.p_xs,
a.absolute,
{top: 'auto', left: 'auto'},
{backgroundColor: 'rgba(0,0,0,0.5)'},
]}>
<Loader size="2xl" style={{color: 'white'}} />
</View>
) : (
<PlayButtonIcon />
)}
</Button>
</View>
) : null}
</VisibilityView>
)
}
function VideoError({retry}: {error: unknown; retry: () => void}) {
return (
<VideoFallback.Container>
@@ -1,4 +1,4 @@
import React, {useCallback, useEffect, useRef, useState} from 'react'
import React, {useCallback, useRef} from 'react'
import {Pressable, View} from 'react-native'
import Animated, {FadeInDown} from 'react-native-reanimated'
import {VideoPlayer, VideoView} from 'expo-video'
@@ -20,13 +20,20 @@ import {TimeIndicator} from './TimeIndicator'
export function VideoEmbedInnerNative({
embed,
isFullscreen,
setIsFullscreen,
isMuted,
timeRemaining,
}: {
embed: AppBskyEmbedVideo.View
isFullscreen: boolean
setIsFullscreen: (isFullscreen: boolean) => void
timeRemaining: number
isMuted: boolean
}) {
const {_} = useLingui()
const {player} = useActiveVideoNative()
const ref = useRef<VideoView>(null)
const [isFullscreen, setIsFullscreen] = useState(false)
const enterFullscreen = useCallback(() => {
ref.current?.enterFullscreen()
@@ -46,7 +53,7 @@ export function VideoEmbedInnerNative({
ref={ref}
player={player}
style={[a.flex_1, a.rounded_sm]}
contentFit="contain"
contentFit="cover"
nativeControls={isFullscreen}
accessibilityIgnoresInvertColors
onEnterFullscreen={() => {
@@ -70,7 +77,12 @@ export function VideoEmbedInnerNative({
}
accessibilityHint=""
/>
<VideoControls player={player} enterFullscreen={enterFullscreen} />
<VideoControls
player={player}
enterFullscreen={enterFullscreen}
isMuted={isMuted}
timeRemaining={timeRemaining}
/>
</View>
)
}
@@ -78,31 +90,16 @@ export function VideoEmbedInnerNative({
function VideoControls({
player,
enterFullscreen,
timeRemaining,
isMuted,
}: {
player: VideoPlayer
enterFullscreen: () => void
timeRemaining: number
isMuted: boolean
}) {
const {_} = useLingui()
const t = useTheme()
const [isMuted, setIsMuted] = useState(player.muted)
const [timeRemaining, setTimeRemaining] = React.useState(0)
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-shadow
const volumeSub = player.addListener('volumeChange', ({isMuted}) => {
setIsMuted(isMuted)
})
const timeSub = player.addListener(
'timeRemainingChange',
secondsRemaining => {
setTimeRemaining(secondsRemaining)
},
)
return () => {
volumeSub.remove()
timeSub.remove()
}
}, [player])
const onPressFullscreen = useCallback(() => {
switch (player.status) {