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" "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.13.1", "@atproto/api": "0.13.6",
"@preact/preset-vite": "^2.8.2", "@preact/preset-vite": "^2.8.2",
"@vitejs/plugin-legacy": "^5.3.2", "@vitejs/plugin-legacy": "^5.3.2",
"preact": "^10.4.8", "preact": "^10.4.8",
+37 -1
View File
@@ -3,6 +3,7 @@ import {
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedRecord, AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
AppBskyEmbedVideo,
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
AppBskyGraphDefs, AppBskyGraphDefs,
@@ -14,6 +15,7 @@ import {ComponentChildren, h} from 'preact'
import {useMemo} from 'preact/hooks' import {useMemo} from 'preact/hooks'
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg' 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 starterPackIcon from '../../assets/starterPack.svg'
import {CONTENT_LABELS, labelsToInfo} from '../labels' import {CONTENT_LABELS, labelsToInfo} from '../labels'
import {getRkey} from '../utils' import {getRkey} from '../utils'
@@ -160,7 +162,12 @@ export function Embed({
return null return null
} }
// Case 4: Record with media // Case 4: Video
if (AppBskyEmbedVideo.isView(content)) {
return <VideoEmbed content={content} />
}
// Case 5: Record with media
if ( if (
AppBskyEmbedRecordWithMedia.isView(content) && AppBskyEmbedRecordWithMedia.isView(content) &&
AppBskyEmbedRecord.isViewRecord(content.record.record) 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({ function StarterPackEmbed({
content, content,
}: { }: {
@@ -410,3 +442,7 @@ function getStarterPackHref(
const handleOrDid = starterPack.creator.handle || starterPack.creator.did const handleOrDid = starterPack.creator.handle || starterPack.creator.did
return `/starter-pack/${handleOrDid}/${rkey}` 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/gen-mapping" "^0.3.5"
"@jridgewell/trace-mapping" "^0.3.24" "@jridgewell/trace-mapping" "^0.3.24"
"@atproto/api@0.13.1": "@atproto/api@0.13.6":
version "0.13.1" version "0.13.6"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.1.tgz#fbf4306e4465d5467aaf031308c1b47dcc8039d0" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.6.tgz#2500e9d7143e6718089632300c42ce50149f8cd5"
integrity sha512-DL3iBfavn8Nnl48FmnAreQB0k0cIkW531DJ5JAHUCQZo10Nq0ZLk2/WFxcs0KuBG5wuLnGUdo+Y6/GQPVq8dYw== integrity sha512-58emFFZhqY8nVWD3xFWK0yYqAmJ2un+NaTtZxBbRo00mGq1rz9VXTpVmfoHFcuXL1hoDQN3WyJfsub8r6xGOgg==
dependencies: dependencies:
"@atproto/common-web" "^0.3.0" "@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.1" "@atproto/lexicon" "^0.4.1"
"@atproto/syntax" "^0.3.0" "@atproto/syntax" "^0.3.0"
"@atproto/xrpc" "^0.6.0" "@atproto/xrpc" "^0.6.1"
await-lock "^2.2.2" await-lock "^2.2.2"
multiformats "^9.9.0" multiformats "^9.9.0"
tlds "^1.234.0" tlds "^1.234.0"
@@ -59,10 +59,10 @@
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3" resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA== integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
"@atproto/xrpc@^0.6.0": "@atproto/xrpc@^0.6.1":
version "0.6.0" version "0.6.1"
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.0.tgz#668c3262e67e2afa65951ea79a03bfe3720ddf5c" resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5"
integrity sha512-5BbhBTv5j6MC3iIQ4+vYxQE7nLy2dDGQ+LYJrH8PptOCUdq0Pwg6aRccQ3y52kUZlhE/mzOTZ8Ngiy9pSAyfVQ== integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A==
dependencies: dependencies:
"@atproto/lexicon" "^0.4.1" "@atproto/lexicon" "^0.4.1"
zod "^3.23.8" zod "^3.23.8"
+56 -5
View File
@@ -244,7 +244,7 @@ index a951d80..3932535 100644
} }
diff --git a/node_modules/expo-video/build/VideoPlayer.types.d.ts b/node_modules/expo-video/build/VideoPlayer.types.d.ts 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 --- a/node_modules/expo-video/build/VideoPlayer.types.d.ts
+++ b/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 = { @@ -128,6 +128,8 @@ export type VideoPlayerEvents = {
@@ -256,6 +256,15 @@ index a09fcfe..5eac9e5 100644
}; };
/** /**
* Describes the current status of the player. * 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 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 index cb9ca6d..ed8bb7e 100644
--- a/node_modules/expo-video/build/VideoView.types.d.ts --- a/node_modules/expo-video/build/VideoView.types.d.ts
@@ -270,8 +279,21 @@ index cb9ca6d..ed8bb7e 100644
} }
//# sourceMappingURL=VideoView.types.d.ts.map //# sourceMappingURL=VideoView.types.d.ts.map
\ No newline at end of file \ 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 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 --- a/node_modules/expo-video/ios/VideoManager.swift
+++ b/node_modules/expo-video/ios/VideoManager.swift +++ b/node_modules/expo-video/ios/VideoManager.swift
@@ -12,6 +12,7 @@ class VideoManager { @@ -12,6 +12,7 @@ class VideoManager {
@@ -427,7 +449,7 @@ index 3315b88..733ab1f 100644
if self.appContext != nil { if self.appContext != nil {
self.emit(event: event, arguments: repeat each arguments) 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 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 --- a/node_modules/expo-video/ios/VideoPlayerObserver.swift
+++ b/node_modules/expo-video/ios/VideoPlayerObserver.swift +++ b/node_modules/expo-video/ios/VideoPlayerObserver.swift
@@ -21,6 +21,7 @@ protocol VideoPlayerObserverDelegate: AnyObject { @@ -21,6 +21,7 @@ protocol VideoPlayerObserverDelegate: AnyObject {
@@ -464,7 +486,13 @@ index d289e26..ea4d96f 100644
} }
private func initializeCurrentPlayerItemObservers(player: AVPlayer, playerItem: AVPlayerItem) { 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) { if isPlaying != (player.timeControlStatus == .playing) {
isPlaying = player.timeControlStatus == .playing isPlaying = player.timeControlStatus == .playing
@@ -472,6 +500,20 @@ index d289e26..ea4d96f 100644
} }
} }
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 { @@ -310,4 +317,28 @@ class VideoPlayerObserver {
} }
} }
@@ -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 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 --- a/node_modules/expo-video/src/VideoPlayer.types.ts
+++ b/node_modules/expo-video/src/VideoPlayer.types.ts +++ b/node_modules/expo-video/src/VideoPlayer.types.ts
@@ -151,6 +151,8 @@ export type VideoPlayerEvents = { @@ -151,6 +151,8 @@ export type VideoPlayerEvents = {
@@ -543,6 +585,15 @@ index aaf4b63..f438196 100644
}; };
/** /**
@@ -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 diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts
index 29fe5db..e1fbf59 100644 index 29fe5db..e1fbf59 100644
--- a/node_modules/expo-video/src/VideoView.types.ts --- a/node_modules/expo-video/src/VideoView.types.ts
+13
View File
@@ -3,6 +3,7 @@
## `expo-video` Patch ## `expo-video` Patch
### `onEnterFullScreen`/`onExitFullScreen` ### `onEnterFullScreen`/`onExitFullScreen`
Adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on Adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on
the tin. 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 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 need (around PIP mode) that we can remove, and just pause any playing players on background and then resume them on
foreground. 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, md: 20,
lg: 24, lg: 24,
xl: 28, xl: 28,
'2xl': 32,
} }
export function useCommonSVGProps(props: Props) { export function useCommonSVGProps(props: Props) {
+5 -6
View File
@@ -14,19 +14,18 @@ import {
} from '#/components/moderation/LabelsOnMeDialog' } from '#/components/moderation/LabelsOnMeDialog'
export function LabelsOnMe({ export function LabelsOnMe({
details, type,
labels, labels,
size, size,
style, style,
}: { }: {
details: {did: string} | {uri: string; cid: string} type: 'account' | 'content'
labels: ComAtprotoLabelDefs.Label[] | undefined labels: ComAtprotoLabelDefs.Label[] | undefined
size?: ButtonSize size?: ButtonSize
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const isAccount = 'did' in details
const control = useLabelsOnMeDialogControl() const control = useLabelsOnMeDialogControl()
if (!labels || !currentAccount) { if (!labels || !currentAccount) {
@@ -39,7 +38,7 @@ export function LabelsOnMe({
return ( return (
<View style={[a.flex_row, style]}> <View style={[a.flex_row, style]}>
<LabelsOnMeDialog control={control} subject={details} labels={labels} /> <LabelsOnMeDialog control={control} labels={labels} type={type} />
<Button <Button
variant="solid" variant="solid"
@@ -51,7 +50,7 @@ export function LabelsOnMe({
}}> }}>
<ButtonIcon position="left" icon={CircleInfo} /> <ButtonIcon position="left" icon={CircleInfo} />
<ButtonText style={[a.leading_snug]}> <ButtonText style={[a.leading_snug]}>
{isAccount ? ( {type === 'account' ? (
<Plural <Plural
value={labels.length} value={labels.length}
one="# label has been placed on this account" one="# label has been placed on this account"
@@ -82,6 +81,6 @@ export function LabelsOnMyPost({
return null return null
} }
return ( 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 {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {useLabelSubject} from '#/lib/moderation'
import {useLabelInfo} from '#/lib/moderation/useLabelInfo' import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
@@ -18,21 +19,13 @@ import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {Divider} from '../Divider' import {Divider} from '../Divider'
import {Loader} from '../Loader' import {Loader} from '../Loader'
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
type Subject = export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
| {
uri: string
cid: string
}
| {
did: string
}
export interface LabelsOnMeDialogProps { export interface LabelsOnMeDialogProps {
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
subject: Subject
labels: ComAtprotoLabelDefs.Label[] labels: ComAtprotoLabelDefs.Label[]
type: 'account' | 'content'
} }
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) { export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
@@ -51,8 +44,8 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
const [appealingLabel, setAppealingLabel] = React.useState< const [appealingLabel, setAppealingLabel] = React.useState<
ComAtprotoLabelDefs.Label | undefined ComAtprotoLabelDefs.Label | undefined
>(undefined) >(undefined)
const {subject, labels} = props const {labels} = props
const isAccount = 'did' in subject const isAccount = props.type === 'account'
const containsSelfLabel = React.useMemo( const containsSelfLabel = React.useMemo(
() => labels.some(l => l.src === currentAccount?.did), () => labels.some(l => l.src === currentAccount?.did),
[currentAccount?.did, labels], [currentAccount?.did, labels],
@@ -68,7 +61,6 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
{appealingLabel ? ( {appealingLabel ? (
<AppealForm <AppealForm
label={appealingLabel} label={appealingLabel}
subject={subject}
control={props.control} control={props.control}
onPressBack={() => setAppealingLabel(undefined)} onPressBack={() => setAppealingLabel(undefined)}
/> />
@@ -188,12 +180,10 @@ function Label({
function AppealForm({ function AppealForm({
label, label,
subject,
control, control,
onPressBack, onPressBack,
}: { }: {
label: ComAtprotoLabelDefs.Label label: ComAtprotoLabelDefs.Label
subject: Subject
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
onPressBack: () => void onPressBack: () => void
}) { }) {
@@ -201,6 +191,7 @@ function AppealForm({
const {labeler, strings} = useLabelInfo(label) const {labeler, strings} = useLabelInfo(label)
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const [details, setDetails] = React.useState('') const [details, setDetails] = React.useState('')
const {subject} = useLabelSubject({label})
const isAccountReport = 'did' in subject const isAccountReport = 'did' in subject
const agent = useAgent() const agent = useAgent()
const sourceName = labeler 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>
)
}
+5
View File
@@ -271,10 +271,15 @@ export class FeedTuner {
} }
} else { } else {
if (!dryRun) { if (!dryRun) {
// 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) this.seenUris.add(item.post.uri)
} }
} }
} }
}
if (!dryRun) { if (!dryRun) {
this.seenKeys.add(slice._reactKey) this.seenKeys.add(slice._reactKey)
} }
+2 -2
View File
@@ -5,9 +5,9 @@
// - The count is going down and is 1 less than 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) { export function decideShouldRoll(isSet: boolean, count: number) {
let shouldRoll = false let shouldRoll = false
if (!isSet && count === 0) { if (!isSet && count === 1) {
shouldRoll = true shouldRoll = true
} else if (count > 0 && count < 1000) { } else if (count > 1 && count < 1000) {
shouldRoll = true shouldRoll = true
} else if (count > 0) { } else if (count > 0) {
const mod = count % 100 const mod = count % 100
+33
View File
@@ -1,6 +1,8 @@
import React from 'react'
import { import {
AppBskyLabelerDefs, AppBskyLabelerDefs,
BskyAgent, BskyAgent,
ComAtprotoLabelDefs,
InterpretedLabelValueDefinition, InterpretedLabelValueDefinition,
LABELS, LABELS,
ModerationCause, ModerationCause,
@@ -82,3 +84,34 @@ export function isLabelerSubscribed(
} }
return modOpts.prefs.labelers.find(l => l.did === labeler) 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, other,
], ],
post: [ post: [
{
reason: ComAtprotoModerationDefs.REASONMISLEADING,
title: _(msg`Misleading Post`),
description: _(msg`Impersonation, misinformation, or false claims`),
},
{ {
reason: ComAtprotoModerationDefs.REASONSPAM, reason: ComAtprotoModerationDefs.REASONSPAM,
title: _(msg`Spam`), title: _(msg`Spam`),
+1 -1
View File
@@ -226,12 +226,12 @@ AppState.addEventListener('change', (state: AppStateStatus) => {
let secondsActive = 0 let secondsActive = 0
if (lastActive != null) { if (lastActive != null) {
secondsActive = Math.round((performance.now() - lastActive) / 1e3) secondsActive = Math.round((performance.now() - lastActive) / 1e3)
}
lastActive = null lastActive = null
logEvent('state:background:sampled', { logEvent('state:background:sampled', {
secondsActive, secondsActive,
}) })
} }
}
}) })
export async function tryFetchGates( export async function tryFetchGates(
+1 -1
View File
@@ -48,7 +48,7 @@ msgstr "{0, plural, one {# repostagem} other {# repostagens}}"
#: src/components/KnownFollowers.tsx:179 #: src/components/KnownFollowers.tsx:179
#~ msgid "{0, plural, one {and # other} other {and # others}}" #~ 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/components/ProfileHoverCard/index.web.tsx:398
#: src/screens/Profile/Header/Metrics.tsx:23 #: src/screens/Profile/Header/Metrics.tsx:23
@@ -1,8 +1,6 @@
import React, {useCallback, useEffect, useMemo, useState} from 'react' import React, {useCallback, useEffect, useMemo, useState} from 'react'
import {LayoutAnimation, View} from 'react-native' import {LayoutAnimation, View} from 'react-native'
import { import {
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
AppBskyFeedPost, AppBskyFeedPost,
AppBskyRichtextFacet, AppBskyRichtextFacet,
AtUri, AtUri,
@@ -22,12 +20,12 @@ import {
} from '#/lib/strings/url-helpers' } from '#/lib/strings/url-helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {usePostQuery} from '#/state/queries/post' import {usePostQuery} from '#/state/queries/post'
import {ImageHorzList} from '#/view/com/util/images/ImageHorzList'
import {PostMeta} from '#/view/com/util/PostMeta' import {PostMeta} from '#/view/com/util/PostMeta'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as MediaPreview from '#/components/MediaPreview'
import {ContentHider} from '#/components/moderation/ContentHider' import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts' import {PostAlerts} from '#/components/moderation/PostAlerts'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
@@ -160,13 +158,6 @@ export function MessageInputEmbed({
return null 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 = ( content = (
<View <View
style={[ style={[
@@ -202,9 +193,7 @@ export function MessageInputEmbed({
/> />
</View> </View>
)} )}
{images && images?.length > 0 && ( <MediaPreview.Embed embed={post.embed} style={a.mt_sm} />
<ImageHorzList images={images} style={a.mt_xs} />
)}
</ContentHider> </ContentHider>
</View> </View>
) )
+1 -1
View File
@@ -86,7 +86,7 @@ let ProfileHeaderShell = ({
style={[a.px_lg, a.py_xs]} style={[a.px_lg, a.py_xs]}
pointerEvents={isIOS ? 'auto' : 'box-none'}> pointerEvents={isIOS ? 'auto' : 'box-none'}>
{isMe ? ( {isMe ? (
<LabelsOnMe details={{did: profile.did}} labels={profile.labels} /> <LabelsOnMe type="account" labels={profile.labels} />
) : ( ) : (
<ProfileHeaderAlerts moderation={moderation} /> <ProfileHeaderAlerts moderation={moderation} />
)} )}
+9 -3
View File
@@ -123,12 +123,16 @@ export function useUploadVideo({
blobRef, blobRef,
}) })
}, },
onError: useCallback(() => { onError: useCallback(
error => {
logger.error('Error processing video', {safeMessage: error})
dispatch({ dispatch({
type: 'SetError', type: 'SetError',
error: _(msg`Video failed to process`), error: _(msg`Video failed to process`),
}) })
}, [_]), },
[_],
),
}) })
const {mutate: onVideoCompressed} = useUploadVideoMutation({ const {mutate: onVideoCompressed} = useUploadVideoMutation({
@@ -140,6 +144,7 @@ export function useUploadVideo({
setJobId(response.jobId) setJobId(response.jobId)
}, },
onError: e => { onError: e => {
logger.error('Error uploading video', {safeMessage: e})
if (e instanceof ServerError) { if (e instanceof ServerError) {
dispatch({ dispatch({
type: 'SetError', type: 'SetError',
@@ -171,6 +176,7 @@ export function useUploadVideo({
onVideoCompressed(video) onVideoCompressed(video)
}, },
onError: e => { onError: e => {
logger.error('Error uploading video', {safeMessage: e})
if (e instanceof VideoTooLargeError) { if (e instanceof VideoTooLargeError) {
dispatch({ dispatch({
type: 'SetError', type: 'SetError',
@@ -250,7 +256,7 @@ const useUploadStatusQuery = ({
throw new Error('Job completed, but did not return a blob') throw new Error('Job completed, but did not return a blob')
onSuccess(status.blob) onSuccess(status.blob)
} else if (status.state === 'JOB_STATE_FAILED') { } 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) onStatusChange(status)
return status return status
+45 -12
View File
@@ -20,11 +20,14 @@ import {
// @ts-expect-error no type definition // @ts-expect-error no type definition
import ProgressCircle from 'react-native-progress/Circle' import ProgressCircle from 'react-native-progress/Circle'
import Animated, { import Animated, {
Easing,
FadeIn, FadeIn,
FadeOut, FadeOut,
interpolateColor, interpolateColor,
useAnimatedStyle, useAnimatedStyle,
useDerivedValue,
useSharedValue, useSharedValue,
withRepeat,
withTiming, withTiming,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
@@ -221,7 +224,12 @@ export const ComposePost = observer(function ComposePost({
) )
const onPressCancel = useCallback(() => { const onPressCancel = useCallback(() => {
if (graphemeLength > 0 || !gallery.isEmpty || extGif) { if (
graphemeLength > 0 ||
!gallery.isEmpty ||
extGif ||
videoUploadState.status !== 'idle'
) {
closeAllDialogs() closeAllDialogs()
Keyboard.dismiss() Keyboard.dismiss()
discardPromptControl.open() discardPromptControl.open()
@@ -235,6 +243,7 @@ export const ComposePost = observer(function ComposePost({
closeAllDialogs, closeAllDialogs,
discardPromptControl, discardPromptControl,
onClose, onClose,
videoUploadState.status,
]) ])
useImperativeHandle(cancelRef, () => ({onPressCancel})) useImperativeHandle(cancelRef, () => ({onPressCancel}))
@@ -329,7 +338,8 @@ export const ComposePost = observer(function ComposePost({
richtext.text.trim().length === 0 && richtext.text.trim().length === 0 &&
gallery.isEmpty && gallery.isEmpty &&
!extLink && !extLink &&
!quote !quote &&
videoUploadState.status === 'idle'
) { ) {
setError(_(msg`Did you want to say anything?`)) setError(_(msg`Did you want to say anything?`))
return return
@@ -595,7 +605,7 @@ export const ComposePost = observer(function ComposePost({
</View> </View>
</> </>
) : ( ) : (
<> <View style={[styles.postBtnWrapper]}>
<LabelsBtn <LabelsBtn
labels={labels} labels={labels}
onChange={setLabels} onChange={setLabels}
@@ -631,7 +641,7 @@ export const ComposePost = observer(function ComposePost({
</Text> </Text>
</View> </View>
)} )}
</> </View>
)} )}
</View> </View>
@@ -999,6 +1009,10 @@ const styles = StyleSheet.create({
paddingVertical: 6, paddingVertical: 6,
marginLeft: 12, marginLeft: 12,
}, },
postBtnWrapper: {
flexDirection: 'row',
gap: 14,
},
errorLine: { errorLine: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -1080,6 +1094,29 @@ function ToolbarWrapper({
function VideoUploadToolbar({state}: {state: VideoUploadState}) { function VideoUploadToolbar({state}: {state: VideoUploadState}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() 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 = '' let text = ''
@@ -1098,26 +1135,22 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
break 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) { if (state.error) {
text = _('Error') text = _('Error')
progress = 100 wheelProgress = 100
} }
return ( return (
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}> <ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
<Animated.View style={[animatedStyle]}>
<ProgressCircle <ProgressCircle
size={30} size={30}
borderWidth={1} borderWidth={1}
borderColor={t.atoms.border_contrast_low.borderColor} borderColor={t.atoms.border_contrast_low.borderColor}
color={state.error ? t.palette.negative_500 : t.palette.primary_500} color={state.error ? t.palette.negative_500 : t.palette.primary_500}
progress={progress} progress={wheelProgress}
/> />
</Animated.View>
<NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText> <NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText>
</ToolbarWrapper> </ToolbarWrapper>
) )
@@ -1,9 +1,12 @@
import React, {useEffect, useRef} from 'react' import React, {useEffect, useRef} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {ImagePickerAsset} from 'expo-image-picker' import {ImagePickerAsset} from 'expo-image-picker'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {CompressedVideo} from '#/lib/media/video/types' import {CompressedVideo} from '#/lib/media/video/types'
import {clamp} from '#/lib/numbers' import {clamp} from '#/lib/numbers'
import * as Toast from '#/view/com/util/Toast'
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn' import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
@@ -19,6 +22,7 @@ export function VideoPreview({
clear: () => void clear: () => void
}) { }) {
const ref = useRef<HTMLVideoElement>(null) const ref = useRef<HTMLVideoElement>(null)
const {_} = useLingui()
useEffect(() => { useEffect(() => {
if (!ref.current) return if (!ref.current) return
@@ -32,11 +36,19 @@ export function VideoPreview({
}, },
{signal}, {signal},
) )
ref.current.addEventListener(
'error',
() => {
Toast.show(_(msg`Could not process your video`))
clear()
},
{signal},
)
return () => { return () => {
abortController.abort() abortController.abort()
} }
}, [setDimensions]) }, [setDimensions, _, clear])
let aspectRatio = asset.width / asset.height let aspectRatio = asset.width / asset.height
+3 -42
View File
@@ -14,9 +14,6 @@ import {
} from 'react-native' } from 'react-native'
import { import {
AppBskyActorDefs, AppBskyActorDefs,
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
AppBskyGraphFollow, AppBskyGraphFollow,
@@ -31,7 +28,6 @@ import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {parseTenorGif} from '#/lib/strings/embed-player'
import {logger} from '#/logger' import {logger} from '#/logger'
import {FeedNotification} from '#/state/queries/notifications/feed' import {FeedNotification} from '#/state/queries/notifications/feed'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' 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 {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
import {StarterPack} from '#/components/icons/StarterPack' import {StarterPack} from '#/components/icons/StarterPack'
import {Link as NewLink} from '#/components/Link' import {Link as NewLink} from '#/components/Link'
import * as MediaPreview from '#/components/MediaPreview'
import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
import {FeedSourceCard} from '../feeds/FeedSourceCard' import {FeedSourceCard} from '../feeds/FeedSourceCard'
import {Post} from '../post/Post' import {Post} from '../post/Post'
import {ImageHorzList} from '../util/images/ImageHorzList'
import {Link, TextLink} from '../util/Link' import {Link, TextLink} from '../util/Link'
import {formatCount} from '../util/numeric/format' import {formatCount} from '../util/numeric/format'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
@@ -728,49 +724,14 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
const pal = usePalette('default') const pal = usePalette('default')
if (post && AppBskyFeedPost.isRecord(post?.record)) { if (post && AppBskyFeedPost.isRecord(post?.record)) {
const text = post.record.text 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 ( return (
<> <>
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>} {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
{images && images.length > 0 && ( <MediaPreview.Embed
<ImageHorzList embed={post.embed}
images={images}
style={styles.additionalPostImages} style={styles.additionalPostImages}
gif={isGif}
/> />
)}
</> </>
) )
} }
@@ -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.flex_row,
a.justify_center, a.justify_center,
a.align_center, a.align_center,
a.overflow_hidden,
{padding: 5}, {padding: 5},
(pressed || hovered) && t.atoms.bg_contrast_25, (pressed || hovered) && t.atoms.bg_contrast_25,
], ],
@@ -4,8 +4,9 @@ import {useVideoPlayer, VideoPlayer} from 'expo-video'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
const Context = React.createContext<{ const Context = React.createContext<{
activeSource: string | null activeSource: string
setActiveSource: (src: string) => void activeViewId: string | undefined
setActiveSource: (src: string, viewId: string) => void
player: VideoPlayer player: VideoPlayer
} | null>(null) } | null>(null)
@@ -15,6 +16,7 @@ export function Provider({children}: {children: React.ReactNode}) {
} }
const [activeSource, setActiveSource] = React.useState('') const [activeSource, setActiveSource] = React.useState('')
const [activeViewId, setActiveViewId] = React.useState<string>()
const player = useVideoPlayer(activeSource, p => { const player = useVideoPlayer(activeSource, p => {
p.muted = true p.muted = true
@@ -22,8 +24,19 @@ export function Provider({children}: {children: React.ReactNode}) {
p.play() p.play()
}) })
const setActiveSourceOuter = (src: string, viewId: string) => {
setActiveSource(src)
setActiveViewId(viewId)
}
return ( return (
<Context.Provider value={{activeSource, setActiveSource, player}}> <Context.Provider
value={{
activeSource,
setActiveSource: setActiveSourceOuter,
activeViewId,
player,
}}>
{children} {children}
</Context.Provider> </Context.Provider>
) )
@@ -17,7 +17,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {WebView} from 'react-native-webview' import {WebView} from 'react-native-webview'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {AppBskyEmbedExternal} from '@atproto/api' import {AppBskyEmbedExternal} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
@@ -29,6 +28,7 @@ import {useExternalEmbedsPrefs} from '#/state/preferences'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent' import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {EventStopper} from '../EventStopper' import {EventStopper} from '../EventStopper'
interface ShouldStartLoadRequest { interface ShouldStartLoadRequest {
@@ -59,7 +59,7 @@ function PlaceholderOverlay({
onPress={onPress} onPress={onPress}
style={[styles.overlayContainer, styles.topRadius]}> style={[styles.overlayContainer, styles.topRadius]}>
{!isPlayerActive ? ( {!isPlayerActive ? (
<FontAwesomeIcon icon="play" size={42} color="white" /> <PlayButtonIcon />
) : ( ) : (
<ActivityIndicator size="large" color="white" /> <ActivityIndicator size="large" color="white" />
)} )}
+2 -21
View File
@@ -8,7 +8,6 @@ import {
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
import {AppBskyEmbedExternal} from '@atproto/api' import {AppBskyEmbedExternal} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -22,6 +21,7 @@ import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {GifView} from '../../../../../modules/expo-bluesky-gif-view' import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types' import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
@@ -69,24 +69,7 @@ function PlaybackControls({
</View> </View>
</View> </View>
) : !isPlaying ? ( ) : !isPlaying ? (
<View <PlayButtonIcon />
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>
) : undefined} ) : undefined}
</Pressable> </Pressable>
) )
@@ -155,7 +138,6 @@ export function GifEmbed({
accessibilityHint={_(msg`Animated GIF`)} accessibilityHint={_(msg`Animated GIF`)}
accessibilityLabel={parsedAlt.alt} accessibilityLabel={parsedAlt.alt}
/> />
{!hideAlt && parsedAlt.isPreferred && <AltText text={parsedAlt.alt} />} {!hideAlt && parsedAlt.isPreferred && <AltText text={parsedAlt.alt} />}
</View> </View>
</View> </View>
@@ -183,7 +165,6 @@ function AltText({text}: {text: string}) {
<Trans>ALT</Trans> <Trans>ALT</Trans>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<Prompt.Outer control={control}> <Prompt.Outer control={control}>
<Prompt.TitleText> <Prompt.TitleText>
<Trans>Alt Text</Trans> <Trans>Alt Text</Trans>
+130 -31
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 {View} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {VideoPlayerStatus} from 'expo-video'
import {AppBskyEmbedVideo} from '@atproto/api' import {AppBskyEmbedVideo} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -8,41 +9,42 @@ import {useLingui} from '@lingui/react'
import {clamp} from '#/lib/numbers' import {clamp} from '#/lib/numbers'
import {useGate} from '#/lib/statsig/statsig' import {useGate} from '#/lib/statsig/statsig'
import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' 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 {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 {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
import {ErrorBoundary} from '../ErrorBoundary' import {ErrorBoundary} from '../ErrorBoundary'
import {useActiveVideoNative} from './ActiveVideoNativeContext' import {useActiveVideoNative} from './ActiveVideoNativeContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback' import * as VideoFallback from './VideoEmbedInner/VideoFallback'
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { interface Props {
const t = useTheme() embed: AppBskyEmbedVideo.View
const {activeSource, setActiveSource} = useActiveVideoNative() }
const isActive = embed.playlist === activeSource
const {_} = useLingui() export function VideoEmbed({embed}: Props) {
const gate = useGate()
const [key, setKey] = useState(0) const [key, setKey] = useState(0)
const renderError = useCallback( const renderError = useCallback(
(error: unknown) => ( (error: unknown) => (
<VideoError error={error} retry={() => setKey(key + 1)} /> <VideoError error={error} retry={() => setKey(key + 1)} />
), ),
[key], [key],
) )
const gate = useGate()
if (!gate('video_view_on_posts')) {
return null
}
let aspectRatio = 16 / 9 let aspectRatio = 16 / 9
if (embed.aspectRatio) { if (embed.aspectRatio) {
const {width, height} = embed.aspectRatio const {width, height} = embed.aspectRatio
aspectRatio = width / height aspectRatio = width / height
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1) aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
} }
if (!gate('video_view_on_posts')) {
return null
}
return ( return (
<View <View
style={[ style={[
@@ -54,38 +56,135 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
a.my_xs, a.my_xs,
]}> ]}>
<ErrorBoundary renderError={renderError} key={key}> <ErrorBoundary renderError={renderError} key={key}>
<VisibilityView <InnerWrapper embed={embed} />
enabled={true} </ErrorBoundary>
onChangeStatus={isVisible => { </View>
if (isVisible) { )
setActiveSource(embed.playlist)
} }
}}>
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 ? ( {isActive ? (
<VideoEmbedInnerNative embed={embed} /> <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 <Image
source={{uri: embed.thumbnail}} source={{uri: embed.thumbnail}}
alt={embed.alt} alt={embed.alt}
style={a.flex_1} style={a.flex_1}
contentFit="contain" contentFit="cover"
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
/> />
<Button <Button
style={[a.absolute, a.inset_0]} style={[a.absolute, a.inset_0]}
onPress={() => { onPress={() => {
setActiveSource(embed.playlist) setActiveSource(embed.playlist, viewId)
}} }}
label={_(msg`Play video`)} label={_(msg`Play video`)}
color="secondary"> color="secondary">
<PlayIcon width={48} fill={t.palette.white} /> {isLoading ? (
</Button> <View
</> style={[
)} a.rounded_full,
</VisibilityView> a.p_xs,
</ErrorBoundary> a.absolute,
{top: 'auto', left: 'auto'},
{backgroundColor: 'rgba(0,0,0,0.5)'},
]}>
<Loader size="2xl" style={{color: 'white'}} />
</View> </View>
) : (
<PlayButtonIcon />
)}
</Button>
</View>
) : null}
</VisibilityView>
) )
} }
@@ -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 {Pressable, View} from 'react-native'
import Animated, {FadeInDown} from 'react-native-reanimated' import Animated, {FadeInDown} from 'react-native-reanimated'
import {VideoPlayer, VideoView} from 'expo-video' import {VideoPlayer, VideoView} from 'expo-video'
@@ -20,13 +20,20 @@ import {TimeIndicator} from './TimeIndicator'
export function VideoEmbedInnerNative({ export function VideoEmbedInnerNative({
embed, embed,
isFullscreen,
setIsFullscreen,
isMuted,
timeRemaining,
}: { }: {
embed: AppBskyEmbedVideo.View embed: AppBskyEmbedVideo.View
isFullscreen: boolean
setIsFullscreen: (isFullscreen: boolean) => void
timeRemaining: number
isMuted: boolean
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const {player} = useActiveVideoNative() const {player} = useActiveVideoNative()
const ref = useRef<VideoView>(null) const ref = useRef<VideoView>(null)
const [isFullscreen, setIsFullscreen] = useState(false)
const enterFullscreen = useCallback(() => { const enterFullscreen = useCallback(() => {
ref.current?.enterFullscreen() ref.current?.enterFullscreen()
@@ -46,7 +53,7 @@ export function VideoEmbedInnerNative({
ref={ref} ref={ref}
player={player} player={player}
style={[a.flex_1, a.rounded_sm]} style={[a.flex_1, a.rounded_sm]}
contentFit="contain" contentFit="cover"
nativeControls={isFullscreen} nativeControls={isFullscreen}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
onEnterFullscreen={() => { onEnterFullscreen={() => {
@@ -70,7 +77,12 @@ export function VideoEmbedInnerNative({
} }
accessibilityHint="" accessibilityHint=""
/> />
<VideoControls player={player} enterFullscreen={enterFullscreen} /> <VideoControls
player={player}
enterFullscreen={enterFullscreen}
isMuted={isMuted}
timeRemaining={timeRemaining}
/>
</View> </View>
) )
} }
@@ -78,31 +90,16 @@ export function VideoEmbedInnerNative({
function VideoControls({ function VideoControls({
player, player,
enterFullscreen, enterFullscreen,
timeRemaining,
isMuted,
}: { }: {
player: VideoPlayer player: VideoPlayer
enterFullscreen: () => void enterFullscreen: () => void
timeRemaining: number
isMuted: boolean
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() 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(() => { const onPressFullscreen = useCallback(() => {
switch (player.status) { switch (player.status) {