Port post embeds to new arch (#7408)

* Direct port of embeds to new arch

(cherry picked from commit cc3fa1f6cea396dd9222486c633a508bfee1ecd6)

* Re-org

* Split out ListEmbed and FeedEmbed

* Split out ImageEmbed

* DRY up a bit

* Port over ExternalLinkEmbed

* Port over Player and Gif embeds

* Migrate ComposerReplyTo

* Replace other usages of old post-embeds

* Migrate view contexts

* Copy pasta VideoEmbed

* Copy pasta GifEmbed

* Swap in new file location

* Clean up

* Fix up native

* Add back in correct moderation on List and Feed embeds

* Format

* Prettier

* delete old video utils

* move bandwidth-estimate.ts

* Remove log

* Add LazyQuoteEmbed for composer use

* Clean up unused things

* Remove remaining items

* Prettier

* Fix imports

* Handle nested quotes same as prod

* Add back silenced error handling

* Fix lint

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
Eric Bailey
2025-06-13 12:05:41 -05:00
committed by GitHub
parent ba0f5a9bde
commit 45f0f7eefe
75 changed files with 812 additions and 799 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ import {Pressable} from 'react-native'
import Animated, {
Extrapolation,
interpolate,
SharedValue,
type SharedValue,
useAnimatedStyle,
} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
+12 -6
View File
@@ -1,24 +1,30 @@
import React from 'react'
import {View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {AppBskyFeedDefs, AtUri} from '@atproto/api'
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {type NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {useProfilesQuery} from '#/state/queries/profile'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {SeenPost} from '#/state/userActionHistory'
import {type SeenPost} from '#/state/userActionHistory'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
import {
atoms as a,
useBreakpoints,
useTheme,
type ViewStyleProp,
web,
} from '#/alf'
import {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
@@ -27,7 +33,7 @@ import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/P
import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import type * as bsky from '#/types/bsky'
import {ProgressGuideList} from './ProgressGuide/List'
const MOBILE_CARD_WIDTH = 300
+4 -4
View File
@@ -1,24 +1,24 @@
import {createContext, useCallback, useContext} from 'react'
import {GestureResponderEvent, Keyboard, View} from 'react-native'
import {type GestureResponderEvent, Keyboard, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {HITSLOP_30} from '#/lib/constants'
import {NavigationProp} from '#/lib/routes/types'
import {type NavigationProp} from '#/lib/routes/types'
import {isIOS} from '#/platform/detection'
import {useSetDrawerOpen} from '#/state/shell'
import {
atoms as a,
platform,
TextStyleProp,
type TextStyleProp,
useBreakpoints,
useGutters,
useLayoutBreakpoints,
useTheme,
web,
} from '#/alf'
import {Button, ButtonIcon, ButtonProps} from '#/components/Button'
import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
import {
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react'
import type {ContextType, ItemContextType} from '#/components/Menu/types'
import {type ContextType, type ItemContextType} from '#/components/Menu/types'
export const Context = React.createContext<ContextType | null>(null)
@@ -0,0 +1,147 @@
import React from 'react'
import {ActivityIndicator, GestureResponderEvent, Pressable} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyEmbedExternal} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {EmbedPlayerParams} from '#/lib/strings/embed-player'
import {isIOS, isNative, isWeb} from '#/platform/detection'
import {useExternalEmbedsPrefs} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
import {Fill} from '#/components/Fill'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
export function ExternalGif({
link,
params,
}: {
link: AppBskyEmbedExternal.ViewExternal
params: EmbedPlayerParams
}) {
const t = useTheme()
const externalEmbedsPrefs = useExternalEmbedsPrefs()
const {_} = useLingui()
const consentDialogControl = useDialogControl()
// Tracking if the placer has been activated
const [isPlayerActive, setIsPlayerActive] = React.useState(false)
// Tracking whether the gif has been loaded yet
const [isPrefetched, setIsPrefetched] = React.useState(false)
// Tracking whether the image is animating
const [isAnimating, setIsAnimating] = React.useState(true)
// Used for controlling animation
const imageRef = React.useRef<Image>(null)
const load = React.useCallback(() => {
setIsPlayerActive(true)
Image.prefetch(params.playerUri).then(() => {
// Replace the image once it's fetched
setIsPrefetched(true)
})
}, [params.playerUri])
const onPlayPress = React.useCallback(
(event: GestureResponderEvent) => {
// Don't propagate on web
event.preventDefault()
// Show consent if this is the first load
if (externalEmbedsPrefs?.[params.source] === undefined) {
consentDialogControl.open()
return
}
// If the player isn't active, we want to activate it and prefetch the gif
if (!isPlayerActive) {
load()
return
}
// Control animation on native
setIsAnimating(prev => {
if (prev) {
if (isNative) {
imageRef.current?.stopAnimating()
}
return false
} else {
if (isNative) {
imageRef.current?.startAnimating()
}
return true
}
})
},
[
consentDialogControl,
externalEmbedsPrefs,
isPlayerActive,
load,
params.source,
],
)
return (
<>
<EmbedConsentDialog
control={consentDialogControl}
source={params.source}
onAccept={load}
/>
<Pressable
style={[
{height: 300},
a.w_full,
a.overflow_hidden,
{
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
]}
onPress={onPlayPress}
accessibilityRole="button"
accessibilityHint={_(msg`Plays the GIF`)}
accessibilityLabel={_(msg`Play ${link.title}`)}>
<Image
source={{
uri:
!isPrefetched || (isWeb && !isAnimating)
? link.thumb
: params.playerUri,
}} // Web uses the thumb to control playback
style={{flex: 1}}
ref={imageRef}
autoplay={isAnimating}
contentFit="contain"
accessibilityIgnoresInvertColors
accessibilityLabel={link.title}
accessibilityHint={link.title}
cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios
/>
{(!isPrefetched || !isAnimating) && (
<Fill style={[a.align_center, a.justify_center]}>
<Fill
style={[
t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
{
opacity: 0.3,
},
]}
/>
{!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active
<PlayButtonIcon />
) : (
// Activity indicator while gif loads
<ActivityIndicator size="large" color="white" />
)}
</Fill>
)}
</Pressable>
</>
)
}
@@ -0,0 +1,281 @@
import React from 'react'
import {
ActivityIndicator,
GestureResponderEvent,
Pressable,
StyleSheet,
useWindowDimensions,
View,
} from 'react-native'
import Animated, {
measure,
runOnJS,
useAnimatedRef,
useFrameCallback,
} from 'react-native-reanimated'
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 {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {EmbedPlayerParams, getPlayerAspect} from '#/lib/strings/embed-player'
import {isNative} from '#/platform/detection'
import {useExternalEmbedsPrefs} from '#/state/preferences'
import {EventStopper} from '#/view/com/util/EventStopper'
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
import {Fill} from '#/components/Fill'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
interface ShouldStartLoadRequest {
url: string
}
// This renders the overlay when the player is either inactive or loading as a separate layer
function PlaceholderOverlay({
isLoading,
isPlayerActive,
onPress,
}: {
isLoading: boolean
isPlayerActive: boolean
onPress: (event: GestureResponderEvent) => void
}) {
const {_} = useLingui()
// If the player is active and not loading, we don't want to show the overlay.
if (isPlayerActive && !isLoading) return null
return (
<View style={[a.absolute, a.inset_0, styles.overlayLayer]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Play Video`)}
accessibilityHint={_(msg`Plays the video`)}
onPress={onPress}
style={[styles.overlayContainer]}>
{!isPlayerActive ? (
<PlayButtonIcon />
) : (
<ActivityIndicator size="large" color="white" />
)}
</Pressable>
</View>
)
}
// This renders the webview/youtube player as a separate layer
function Player({
params,
onLoad,
isPlayerActive,
}: {
isPlayerActive: boolean
params: EmbedPlayerParams
onLoad: () => void
}) {
// ensures we only load what's requested
// when it's a youtube video, we need to allow both bsky.app and youtube.com
const onShouldStartLoadWithRequest = React.useCallback(
(event: ShouldStartLoadRequest) =>
event.url === params.playerUri ||
(params.source.startsWith('youtube') &&
event.url.includes('www.youtube.com')),
[params.playerUri, params.source],
)
// Don't show the player until it is active
if (!isPlayerActive) return null
return (
<EventStopper style={[a.absolute, a.inset_0, styles.playerLayer]}>
<WebView
javaScriptEnabled={true}
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
mediaPlaybackRequiresUserAction={false}
allowsInlineMediaPlayback
bounces={false}
allowsFullscreenVideo
nestedScrollEnabled
source={{uri: params.playerUri}}
onLoad={onLoad}
style={styles.webview}
setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
/>
</EventStopper>
)
}
// This renders the player area and handles the logic for when to show the player and when to show the overlay
export function ExternalPlayer({
link,
params,
}: {
link: AppBskyEmbedExternal.ViewExternal
params: EmbedPlayerParams
}) {
const t = useTheme()
const navigation = useNavigation<NavigationProp>()
const insets = useSafeAreaInsets()
const windowDims = useWindowDimensions()
const externalEmbedsPrefs = useExternalEmbedsPrefs()
const consentDialogControl = useDialogControl()
const [isPlayerActive, setPlayerActive] = React.useState(false)
const [isLoading, setIsLoading] = React.useState(true)
const aspect = React.useMemo(() => {
return getPlayerAspect({
type: params.type,
width: windowDims.width,
hasThumb: !!link.thumb,
})
}, [params.type, windowDims.width, link.thumb])
const viewRef = useAnimatedRef()
const frameCallback = useFrameCallback(() => {
const measurement = measure(viewRef)
if (!measurement) return
const {height: winHeight, width: winWidth} = windowDims
// Get the proper screen height depending on what is going on
const realWinHeight = isNative // If it is native, we always want the larger number
? winHeight > winWidth
? winHeight
: winWidth
: winHeight // On web, we always want the actual screen height
const top = measurement.pageY
const bot = measurement.pageY + measurement.height
// We can use the same logic on all platforms against the screenHeight that we get above
const isVisible = top <= realWinHeight - insets.bottom && bot >= insets.top
if (!isVisible) {
runOnJS(setPlayerActive)(false)
}
}, false) // False here disables autostarting the callback
// watch for leaving the viewport due to scrolling
React.useEffect(() => {
// We don't want to do anything if the player isn't active
if (!isPlayerActive) return
// Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will
// continue playing. We need to watch for the blur event
const unsubscribe = navigation.addListener('blur', () => {
setPlayerActive(false)
})
// Start watching for changes
frameCallback.setActive(true)
return () => {
unsubscribe()
frameCallback.setActive(false)
}
}, [navigation, isPlayerActive, frameCallback])
const onLoad = React.useCallback(() => {
setIsLoading(false)
}, [])
const onPlayPress = React.useCallback(
(event: GestureResponderEvent) => {
// Prevent this from propagating upward on web
event.preventDefault()
if (externalEmbedsPrefs?.[params.source] === undefined) {
consentDialogControl.open()
return
}
setPlayerActive(true)
},
[externalEmbedsPrefs, consentDialogControl, params.source],
)
const onAcceptConsent = React.useCallback(() => {
setPlayerActive(true)
}, [])
return (
<>
<EmbedConsentDialog
control={consentDialogControl}
source={params.source}
onAccept={onAcceptConsent}
/>
<Animated.View
ref={viewRef}
collapsable={false}
style={[aspect, a.overflow_hidden]}>
{link.thumb && (!isPlayerActive || isLoading) ? (
<>
<Image
style={[a.flex_1]}
source={{uri: link.thumb}}
accessibilityIgnoresInvertColors
/>
<Fill
style={[
t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
{
opacity: 0.3,
},
]}
/>
</>
) : (
<Fill
style={[
{
backgroundColor:
t.name === 'light' ? t.palette.contrast_975 : 'black',
opacity: 0.3,
},
]}
/>
)}
<PlaceholderOverlay
isLoading={isLoading}
isPlayerActive={isPlayerActive}
onPress={onPlayPress}
/>
<Player
isPlayerActive={isPlayerActive}
params={params}
onLoad={onLoad}
/>
</Animated.View>
</>
)
}
const styles = StyleSheet.create({
overlayContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
overlayLayer: {
zIndex: 2,
},
playerLayer: {
zIndex: 3,
},
webview: {
backgroundColor: 'transparent',
},
gifContainer: {
width: '100%',
overflow: 'hidden',
},
})
@@ -0,0 +1,224 @@
import React from 'react'
import {
Pressable,
StyleProp,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_20} from '#/lib/constants'
import {EmbedPlayerParams} from '#/lib/strings/embed-player'
import {isWeb} from '#/platform/detection'
import {useAutoplayDisabled} from '#/state/preferences'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
import {Fill} from '#/components/Fill'
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'
function PlaybackControls({
onPress,
isPlaying,
isLoaded,
}: {
onPress: () => void
isPlaying: boolean
isLoaded: boolean
}) {
const {_} = useLingui()
const t = useTheme()
return (
<Pressable
accessibilityRole="button"
accessibilityHint={_(msg`Plays or pauses the GIF`)}
accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
style={[
a.absolute,
a.align_center,
a.justify_center,
!isLoaded && a.border,
t.atoms.border_contrast_medium,
a.inset_0,
a.w_full,
a.h_full,
{
zIndex: 2,
backgroundColor: !isLoaded
? t.atoms.bg_contrast_25.backgroundColor
: undefined,
},
]}
onPress={onPress}>
{!isLoaded ? (
<View>
<View style={[a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
</View>
) : !isPlaying ? (
<PlayButtonIcon />
) : undefined}
</Pressable>
)
}
export function GifEmbed({
params,
thumb,
altText,
isPreferredAltText,
hideAlt,
style = {width: '100%'},
}: {
params: EmbedPlayerParams
thumb: string | undefined
altText: string
isPreferredAltText: boolean
hideAlt?: boolean
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
const {_} = useLingui()
const autoplayDisabled = useAutoplayDisabled()
const playerRef = React.useRef<GifView>(null)
const [playerState, setPlayerState] = React.useState<{
isPlaying: boolean
isLoaded: boolean
}>({
isPlaying: !autoplayDisabled,
isLoaded: false,
})
const onPlayerStateChange = React.useCallback(
(e: GifViewStateChangeEvent) => {
setPlayerState(e.nativeEvent)
},
[],
)
const onPress = React.useCallback(() => {
playerRef.current?.toggleAsync()
}, [])
return (
<View
style={[
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
{aspectRatio: params.dimensions!.width / params.dimensions!.height},
style,
]}>
<View
style={[
a.absolute,
/*
* Aspect ratio was being clipped weirdly on web -esb
*/
{
top: -2,
bottom: -2,
left: -2,
right: -2,
},
]}>
<PlaybackControls
onPress={onPress}
isPlaying={playerState.isPlaying}
isLoaded={playerState.isLoaded}
/>
<GifView
source={params.playerUri}
placeholderSource={thumb}
style={[a.flex_1]}
autoplay={!autoplayDisabled}
onPlayerStateChange={onPlayerStateChange}
ref={playerRef}
accessibilityHint={_(msg`Animated GIF`)}
accessibilityLabel={altText}
/>
{!playerState.isPlaying && (
<Fill
style={[
t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
{
opacity: 0.3,
},
]}
/>
)}
{!hideAlt && isPreferredAltText && <AltText text={altText} />}
</View>
</View>
)
}
function AltText({text}: {text: string}) {
const control = Prompt.usePromptControl()
const largeAltBadge = useLargeAltBadgeEnabled()
const {_} = useLingui()
return (
<>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Show alt text`)}
accessibilityHint=""
hitSlop={HITSLOP_20}
onPress={control.open}
style={styles.altContainer}>
<Text
style={[styles.alt, largeAltBadge && a.text_xs]}
accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<Prompt.Outer control={control}>
<Prompt.TitleText>
<Trans>Alt Text</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action
onPress={() => control.close()}
cta={_(msg`Close`)}
color="secondary"
/>
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
const styles = StyleSheet.create({
altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: isWeb ? 8 : 6,
paddingVertical: isWeb ? 6 : 3,
position: 'absolute',
// Related to margin/gap hack. This keeps the alt label in the same position
// on all platforms
right: isWeb ? 8 : 5,
bottom: isWeb ? 8 : 5,
zIndex: 2,
},
alt: {
color: 'white',
fontSize: isWeb ? 10 : 7,
fontWeight: '600',
},
})
@@ -0,0 +1,182 @@
import React, {useCallback} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {type AppBskyEmbedExternal} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {parseAltFromGIFDescription} from '#/lib/gif-alt-text'
import {useHaptics} from '#/lib/haptics'
import {shareUrl} from '#/lib/sharing'
import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {useExternalEmbedsPrefs} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {Divider} from '#/components/Divider'
import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
import {ExternalGif} from './ExternalGif'
import {ExternalPlayer} from './ExternalPlayer'
import {GifEmbed} from './Gif'
export const ExternalEmbed = ({
link,
onOpen,
style,
hideAlt,
}: {
link: AppBskyEmbedExternal.ViewExternal
onOpen?: () => void
style?: StyleProp<ViewStyle>
hideAlt?: boolean
}) => {
const {_} = useLingui()
const t = useTheme()
const playHaptic = useHaptics()
const externalEmbedPrefs = useExternalEmbedsPrefs()
const niceUrl = toNiceDomain(link.uri)
const imageUri = link.thumb
const embedPlayerParams = React.useMemo(() => {
const params = parseEmbedPlayerFromUrl(link.uri)
if (params && externalEmbedPrefs?.[params.source] !== 'hide') {
return params
}
}, [link.uri, externalEmbedPrefs])
const hasMedia = Boolean(imageUri || embedPlayerParams)
const onPress = useCallback(() => {
playHaptic('Light')
onOpen?.()
}, [playHaptic, onOpen])
const onShareExternal = useCallback(() => {
if (link.uri && isNative) {
playHaptic('Heavy')
shareUrl(link.uri)
}
}, [link.uri, playHaptic])
if (embedPlayerParams?.source === 'tenor') {
const parsedAlt = parseAltFromGIFDescription(link.description)
return (
<View style={style}>
<GifEmbed
params={embedPlayerParams}
thumb={link.thumb}
altText={parsedAlt.alt}
isPreferredAltText={parsedAlt.isPreferred}
hideAlt={hideAlt}
/>
</View>
)
}
return (
<Link
label={link.title || _(msg`Open link to ${niceUrl}`)}
to={link.uri}
shouldProxy={true}
onPress={onPress}
onLongPress={onShareExternal}>
{({hovered}) => (
<View
style={[
a.transition_color,
a.flex_col,
a.rounded_md,
a.overflow_hidden,
a.w_full,
a.border,
style,
hovered
? t.atoms.border_contrast_high
: t.atoms.border_contrast_low,
]}>
{imageUri && !embedPlayerParams ? (
<Image
style={{
aspectRatio: 1.91,
}}
source={{uri: imageUri}}
accessibilityIgnoresInvertColors
/>
) : undefined}
{embedPlayerParams?.isGif ? (
<ExternalGif link={link} params={embedPlayerParams} />
) : embedPlayerParams ? (
<ExternalPlayer link={link} params={embedPlayerParams} />
) : undefined}
<View
style={[
a.flex_1,
a.pt_sm,
{gap: 3},
hasMedia && a.border_t,
hovered
? t.atoms.border_contrast_high
: t.atoms.border_contrast_low,
]}>
<View style={[{gap: 3}, a.pb_xs, a.px_md]}>
{!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && (
<Text
emoji
numberOfLines={3}
style={[a.text_md, a.font_bold, a.leading_snug]}>
{link.title || link.uri}
</Text>
)}
{link.description ? (
<Text
emoji
numberOfLines={link.thumb ? 2 : 4}
style={[a.text_sm, a.leading_snug]}>
{link.description}
</Text>
) : undefined}
</View>
<View style={[a.px_md]}>
<Divider />
<View
style={[
a.flex_row,
a.align_center,
a.gap_2xs,
a.pb_sm,
{
paddingTop: 6, // off menu
},
]}>
<Globe
size="xs"
style={[
a.transition_color,
hovered
? t.atoms.text_contrast_medium
: t.atoms.text_contrast_low,
]}
/>
<Text
numberOfLines={1}
style={[
a.transition_color,
a.text_xs,
a.leading_snug,
hovered
? t.atoms.text_contrast_high
: t.atoms.text_contrast_medium,
]}>
{toNiceDomain(link.uri)}
</Text>
</View>
</View>
</View>
</View>
)}
</Link>
)
}
+52
View File
@@ -0,0 +1,52 @@
import React from 'react'
import {StyleSheet} from 'react-native'
import {moderateFeedGenerator} from '@atproto/api'
import {usePalette} from '#/lib/hooks/usePalette'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard'
import {ContentHider} from '#/components/moderation/ContentHider'
import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types'
export function FeedEmbed({
embed,
}: CommonProps & {
embed: EmbedType<'feed'>
}) {
const pal = usePalette('default')
return (
<FeedSourceCard
feedUri={embed.view.uri}
style={[pal.view, pal.border, styles.customFeedOuter]}
showLikes
/>
)
}
export function ModeratedFeedEmbed({
embed,
}: CommonProps & {
embed: EmbedType<'feed'>
}) {
const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => {
return moderationOpts
? moderateFeedGenerator(embed.view, moderationOpts)
: undefined
}, [embed.view, moderationOpts])
return (
<ContentHider modui={moderation?.ui('contentList')}>
<FeedEmbed embed={embed} />
</ContentHider>
)
}
const styles = StyleSheet.create({
customFeedOuter: {
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
},
})
+106
View File
@@ -0,0 +1,106 @@
import {InteractionManager, View} from 'react-native'
import {
type AnimatedRef,
measure,
type MeasuredDimensions,
runOnJS,
runOnUI,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {useLightboxControls} from '#/state/lightbox'
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
import {AutoSizedImage} from '#/view/com/util/images/AutoSizedImage'
import {ImageLayoutGrid} from '#/view/com/util/images/ImageLayoutGrid'
import {atoms as a} from '#/alf'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types'
export function ImageEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'images'>
}) {
const {openLightbox} = useLightboxControls()
const {images} = embed.view
if (images.length > 0) {
const items = images.map(img => ({
uri: img.fullsize,
thumbUri: img.thumb,
alt: img.alt,
dimensions: img.aspectRatio ?? null,
}))
const _openLightbox = (
index: number,
thumbRects: (MeasuredDimensions | null)[],
fetchedDims: (Dimensions | null)[],
) => {
openLightbox({
images: items.map((item, i) => ({
...item,
thumbRect: thumbRects[i] ?? null,
thumbDimensions: fetchedDims[i] ?? null,
type: 'image',
})),
index,
})
}
const onPress = (
index: number,
refs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => {
runOnUI(() => {
'worklet'
const rects: (MeasuredDimensions | null)[] = []
for (const r of refs) {
rects.push(measure(r))
}
runOnJS(_openLightbox)(index, rects, fetchedDims)
})()
}
const onPressIn = (_: number) => {
InteractionManager.runAfterInteractions(() => {
Image.prefetch(items.map(i => i.uri))
})
}
if (images.length === 1) {
const image = images[0]
return (
<View style={[a.mt_sm, rest.style]}>
<AutoSizedImage
crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
onPressIn={() => onPressIn(0)}
hideBadge={
rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/>
</View>
)
}
return (
<View style={[a.mt_sm, rest.style]}>
<ImageLayoutGrid
images={images}
onPress={onPress}
onPressIn={onPressIn}
viewContext={rest.viewContext}
/>
</View>
)
}
}
@@ -0,0 +1,37 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util'
import {useResolveLinkQuery} from '#/state/queries/resolve-link'
import {atoms as a, useTheme} from '#/alf'
import {QuoteEmbed} from '#/components/Post/Embed'
export function LazyQuoteEmbed({uri}: {uri: string}) {
const t = useTheme()
const {data} = useResolveLinkQuery(uri)
const view = useMemo(() => {
if (!data || data.type !== 'record' || data.kind !== 'post') return
return createEmbedViewRecordFromPost(data.view)
}, [data])
return view ? (
<QuoteEmbed
embed={{
type: 'post',
view,
}}
/>
) : (
<View
style={[
a.w_full,
a.rounded_md,
t.atoms.bg_contrast_25,
{
height: 68,
},
]}
/>
)
}
+42
View File
@@ -0,0 +1,42 @@
import React from 'react'
import {View} from 'react-native'
import {moderateUserList} from '@atproto/api'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {atoms as a, useTheme} from '#/alf'
import * as ListCard from '#/components/ListCard'
import {ContentHider} from '#/components/moderation/ContentHider'
import {EmbedType} from '#/types/bsky/post'
import {CommonProps} from './types'
export function ListEmbed({
embed,
}: CommonProps & {
embed: EmbedType<'list'>
}) {
const t = useTheme()
return (
<View
style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}>
<ListCard.Default view={embed.view} />
</View>
)
}
export function ModeratedListEmbed({
embed,
}: CommonProps & {
embed: EmbedType<'list'>
}) {
const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => {
return moderationOpts
? moderateUserList(embed.view, moderationOpts)
: undefined
}, [embed.view, moderationOpts])
return (
<ContentHider modui={moderation?.ui('contentList')}>
<ListEmbed embed={embed} />
</ContentHider>
)
}
@@ -0,0 +1,33 @@
import {StyleSheet, View} from 'react-native'
import {usePalette} from '#/lib/hooks/usePalette'
import {InfoCircleIcon} from '#/lib/icons'
import {Text} from '#/view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
export function PostPlaceholder({children}: {children: React.ReactNode}) {
const t = useTheme()
const pal = usePalette('default')
return (
<View
style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}>
<InfoCircleIcon size={18} style={pal.text} />
<Text type="lg" style={pal.text}>
{children}
</Text>
</View>
)
}
const styles = StyleSheet.create({
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
borderRadius: 8,
marginTop: 8,
paddingVertical: 14,
paddingHorizontal: 14,
borderWidth: StyleSheet.hairlineWidth,
},
})
@@ -0,0 +1,114 @@
import React, {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
import {useWindowDimensions} from 'react-native'
import {isNative, isWeb} from '#/platform/detection'
const Context = React.createContext<{
activeViewId: string | null
setActiveView: (viewId: string) => void
sendViewPosition: (viewId: string, y: number) => void
} | null>(null)
export function Provider({children}: {children: React.ReactNode}) {
if (!isWeb) {
throw new Error('ActiveVideoWebContext may only be used on web.')
}
const [activeViewId, setActiveViewId] = useState<string | null>(null)
const activeViewLocationRef = useRef(Infinity)
const {height: windowHeight} = useWindowDimensions()
// minimising re-renders by using refs
const manuallySetRef = useRef(false)
const activeViewIdRef = useRef(activeViewId)
useEffect(() => {
activeViewIdRef.current = activeViewId
}, [activeViewId])
const setActiveView = useCallback(
(viewId: string) => {
setActiveViewId(viewId)
manuallySetRef.current = true
// we don't know the exact position, but it's definitely on screen
// so just guess that it's in the middle. Any value is fine
// so long as it's not offscreen
activeViewLocationRef.current = windowHeight / 2
},
[windowHeight],
)
const sendViewPosition = useCallback(
(viewId: string, y: number) => {
if (isNative) return
if (viewId === activeViewIdRef.current) {
activeViewLocationRef.current = y
} else {
if (
distanceToIdealPosition(y) <
distanceToIdealPosition(activeViewLocationRef.current)
) {
// if the old view was manually set, only usurp if the old view is offscreen
if (
manuallySetRef.current &&
withinViewport(activeViewLocationRef.current)
) {
return
}
setActiveViewId(viewId)
activeViewLocationRef.current = y
manuallySetRef.current = false
}
}
function distanceToIdealPosition(yPos: number) {
return Math.abs(yPos - windowHeight / 2.5)
}
function withinViewport(yPos: number) {
return yPos > 0 && yPos < windowHeight
}
},
[windowHeight],
)
const value = useMemo(
() => ({
activeViewId,
setActiveView,
sendViewPosition,
}),
[activeViewId, setActiveView, sendViewPosition],
)
return <Context.Provider value={value}>{children}</Context.Provider>
}
export function useActiveVideoWeb() {
const context = React.useContext(Context)
if (!context) {
throw new Error(
'useActiveVideoWeb must be used within a ActiveVideoWebProvider',
)
}
const {activeViewId, setActiveView, sendViewPosition} = context
const id = useId()
return {
active: activeViewId === id,
setActive: () => {
setActiveView(id)
},
currentActiveView: activeViewId,
sendPosition: (y: number) => sendViewPosition(id, y),
}
}
@@ -0,0 +1,64 @@
import {StyleProp, ViewStyle} from 'react-native'
import {View} from 'react-native'
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
/**
* Absolutely positioned time indicator showing how many seconds are remaining
* Time is in seconds
*/
export function TimeIndicator({
time,
style,
}: {
time: number
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
const {_} = useLingui()
if (isNaN(time)) {
return null
}
const minutes = Math.floor(time / 60)
const seconds = String(time % 60).padStart(2, '0')
return (
<View
pointerEvents="none"
accessibilityLabel={_(
msg`Time remaining: ${plural(Number(time) || 0, {
one: '# second',
other: '# seconds',
})}`,
)}
accessibilityHint=""
style={[
{
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
left: 6,
bottom: 6,
minHeight: 21,
},
a.absolute,
a.justify_center,
style,
]}>
<Text
style={[
{color: t.palette.white, fontSize: 12, fontVariant: ['tabular-nums']},
a.font_bold,
{lineHeight: 1.25},
]}>
{`${minutes}:${seconds}`}
</Text>
</View>
)
}
@@ -0,0 +1,210 @@
import React, {useRef} from 'react'
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyEmbedVideo} from '@atproto/api'
import {BlueskyVideoView} from '@haileyok/bluesky-video'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_30} from '#/lib/constants'
import {useAutoplayDisabled} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {useIsWithinMessage} from '#/components/dms/MessageContext'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {TimeIndicator} from './TimeIndicator'
export const VideoEmbedInnerNative = React.forwardRef(
function VideoEmbedInnerNative(
{
embed,
setStatus,
setIsLoading,
setIsActive,
}: {
embed: AppBskyEmbedVideo.View
setStatus: (status: 'playing' | 'paused') => void
setIsLoading: (isLoading: boolean) => void
setIsActive: (isActive: boolean) => void
},
ref: React.Ref<{togglePlayback: () => void}>,
) {
const {_} = useLingui()
const videoRef = useRef<BlueskyVideoView>(null)
const autoplayDisabled = useAutoplayDisabled()
const isWithinMessage = useIsWithinMessage()
const [muted, setMuted] = useVideoMuteState()
const [isPlaying, setIsPlaying] = React.useState(false)
const [timeRemaining, setTimeRemaining] = React.useState(0)
const [error, setError] = React.useState<string>()
React.useImperativeHandle(ref, () => ({
togglePlayback: () => {
videoRef.current?.togglePlayback()
},
}))
if (error) {
throw new Error(error)
}
return (
<View style={[a.flex_1, a.relative]}>
<BlueskyVideoView
url={embed.playlist}
autoplay={!autoplayDisabled && !isWithinMessage}
beginMuted={autoplayDisabled ? false : muted}
style={[a.rounded_sm]}
onActiveChange={e => {
setIsActive(e.nativeEvent.isActive)
}}
onLoadingChange={e => {
setIsLoading(e.nativeEvent.isLoading)
}}
onMutedChange={e => {
setMuted(e.nativeEvent.isMuted)
}}
onStatusChange={e => {
setStatus(e.nativeEvent.status)
setIsPlaying(e.nativeEvent.status === 'playing')
}}
onTimeRemainingChange={e => {
setTimeRemaining(e.nativeEvent.timeRemaining)
}}
onError={e => {
setError(e.nativeEvent.error)
}}
ref={videoRef}
accessibilityLabel={
embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`)
}
accessibilityHint=""
/>
<VideoControls
enterFullscreen={() => {
videoRef.current?.enterFullscreen(true)
}}
toggleMuted={() => {
videoRef.current?.toggleMuted()
}}
togglePlayback={() => {
videoRef.current?.togglePlayback()
}}
isPlaying={isPlaying}
timeRemaining={timeRemaining}
/>
<MediaInsetBorder />
</View>
)
},
)
function VideoControls({
enterFullscreen,
toggleMuted,
togglePlayback,
timeRemaining,
isPlaying,
}: {
enterFullscreen: () => void
toggleMuted: () => void
togglePlayback: () => void
timeRemaining: number
isPlaying: boolean
}) {
const {_} = useLingui()
const t = useTheme()
const [muted] = useVideoMuteState()
// show countdown when:
// 1. timeRemaining is a number - was seeing NaNs
// 2. duration is greater than 0 - means metadata has loaded
// 3. we're less than 5 second into the video
const showTime = !isNaN(timeRemaining)
return (
<View style={[a.absolute, a.inset_0]}>
<Pressable
onPress={enterFullscreen}
style={a.flex_1}
accessibilityLabel={_(msg`Video`)}
accessibilityHint={_(msg`Enters full screen`)}
accessibilityRole="button"
/>
<ControlButton
onPress={togglePlayback}
label={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
accessibilityHint={_(msg`Plays or pauses the video`)}
style={{left: 6}}>
{isPlaying ? (
<PauseIcon width={13} fill={t.palette.white} />
) : (
<PlayIcon width={13} fill={t.palette.white} />
)}
</ControlButton>
{showTime && <TimeIndicator time={timeRemaining} style={{left: 33}} />}
<ControlButton
onPress={toggleMuted}
label={
muted
? _(msg({message: `Unmute`, context: 'video'}))
: _(msg({message: `Mute`, context: 'video'}))
}
accessibilityHint={_(msg`Toggles the sound`)}
style={{right: 6}}>
{muted ? (
<MuteIcon width={13} fill={t.palette.white} />
) : (
<UnmuteIcon width={13} fill={t.palette.white} />
)}
</ControlButton>
</View>
)
}
function ControlButton({
onPress,
children,
label,
accessibilityHint,
style,
}: {
onPress: () => void
children: React.ReactNode
label: string
accessibilityHint: string
style?: StyleProp<ViewStyle>
}) {
return (
<View
style={[
a.absolute,
a.rounded_full,
a.justify_center,
{
backgroundColor: 'rgba(0, 0, 0, 0.5)',
paddingHorizontal: 4,
paddingVertical: 4,
bottom: 6,
minHeight: 21,
minWidth: 21,
},
style,
]}>
<Pressable
onPress={onPress}
style={a.flex_1}
accessibilityLabel={label}
accessibilityHint={accessibilityHint}
accessibilityRole="button"
hitSlop={HITSLOP_30}>
{children}
</Pressable>
</View>
)
}
@@ -0,0 +1,3 @@
export function VideoEmbedInnerNative() {
throw new Error('VideoEmbedInnerNative may not be used on web.')
}
@@ -0,0 +1,3 @@
export function VideoEmbedInnerWeb() {
throw new Error('VideoEmbedInnerWeb may not be used on native.')
}
@@ -0,0 +1,307 @@
import {useEffect, useId, useRef, useState} from 'react'
import {View} from 'react-native'
import {type AppBskyEmbedVideo} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import type * as HlsTypes from 'hls.js'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {atoms as a} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import * as BandwidthEstimate from './bandwidth-estimate'
import {Controls} from './web-controls/VideoControls'
export function VideoEmbedInnerWeb({
embed,
active,
setActive,
onScreen,
lastKnownTime,
}: {
embed: AppBskyEmbedVideo.View
active: boolean
setActive: () => void
onScreen: boolean
lastKnownTime: React.MutableRefObject<number | undefined>
}) {
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const [focused, setFocused] = useState(false)
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
const [hlsLoading, setHlsLoading] = useState(false)
const figId = useId()
const {_} = useLingui()
// send error up to error boundary
const [error, setError] = useState<Error | null>(null)
if (error) {
throw error
}
const hlsRef = useHLS({
playlist: embed.playlist,
setHasSubtitleTrack,
setError,
videoRef,
setHlsLoading,
})
useEffect(() => {
if (lastKnownTime.current && videoRef.current) {
videoRef.current.currentTime = lastKnownTime.current
}
}, [lastKnownTime])
return (
<View
style={[a.flex_1, a.rounded_md, a.overflow_hidden]}
accessibilityLabel={_(msg`Embedded video player`)}
accessibilityHint="">
<div ref={containerRef} style={{height: '100%', width: '100%'}}>
<figure style={{margin: 0, position: 'absolute', inset: 0}}>
<video
ref={videoRef}
poster={embed.thumbnail}
style={{width: '100%', height: '100%', objectFit: 'contain'}}
playsInline
preload="none"
muted={!focused}
aria-labelledby={embed.alt ? figId : undefined}
onTimeUpdate={e => {
lastKnownTime.current = e.currentTarget.currentTime
}}
/>
{embed.alt && (
<figcaption
id={figId}
style={{
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
borderWidth: 0,
}}>
{embed.alt}
</figcaption>
)}
</figure>
<Controls
videoRef={videoRef}
hlsRef={hlsRef}
active={active}
setActive={setActive}
focused={focused}
setFocused={setFocused}
hlsLoading={hlsLoading}
onScreen={onScreen}
fullscreenRef={containerRef}
hasSubtitleTrack={hasSubtitleTrack}
/>
</div>
<MediaInsetBorder />
</View>
)
}
export class HLSUnsupportedError extends Error {
constructor() {
super('HLS is not supported')
}
}
export class VideoNotFoundError extends Error {
constructor() {
super('Video not found')
}
}
type CachedPromise<T> = Promise<T> & {value: undefined | T}
const promiseForHls = import(
// @ts-ignore
'hls.js/dist/hls.min'
).then(mod => mod.default) as CachedPromise<typeof HlsTypes.default>
promiseForHls.value = undefined
promiseForHls.then(Hls => {
promiseForHls.value = Hls
})
function useHLS({
playlist,
setHasSubtitleTrack,
setError,
videoRef,
setHlsLoading,
}: {
playlist: string
setHasSubtitleTrack: (v: boolean) => void
setError: (v: Error | null) => void
videoRef: React.RefObject<HTMLVideoElement>
setHlsLoading: (v: boolean) => void
}) {
const [Hls, setHls] = useState<typeof HlsTypes.default | undefined>(
() => promiseForHls.value,
)
useEffect(() => {
if (!Hls) {
setHlsLoading(true)
promiseForHls.then(loadedHls => {
setHls(() => loadedHls)
setHlsLoading(false)
})
}
}, [Hls, setHlsLoading])
const hlsRef = useRef<HlsTypes.default | undefined>(undefined)
const [lowQualityFragments, setLowQualityFragments] = useState<
HlsTypes.Fragment[]
>([])
// purge low quality segments from buffer on next frag change
const handleFragChange = useNonReactiveCallback(
(
_event: HlsTypes.Events.FRAG_CHANGED,
{frag}: HlsTypes.FragChangedData,
) => {
if (!Hls) return
if (!hlsRef.current) return
const hls = hlsRef.current
// if the current quality level goes above 0, flush the low quality segments
if (hls.nextAutoLevel > 0) {
const flushed: HlsTypes.Fragment[] = []
for (const lowQualFrag of lowQualityFragments) {
// avoid if close to the current fragment
if (Math.abs(frag.start - lowQualFrag.start) < 0.1) {
continue
}
hls.trigger(Hls.Events.BUFFER_FLUSHING, {
startOffset: lowQualFrag.start,
endOffset: lowQualFrag.end,
type: 'video',
})
flushed.push(lowQualFrag)
}
setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f)))
}
},
)
const flushOnLoop = useNonReactiveCallback(() => {
if (!Hls) return
if (!hlsRef.current) return
const hls = hlsRef.current
// the above callback will catch most stale frags, but there's a corner case -
// if there's only one segment in the video, it won't get flushed because it avoids
// flushing the currently active segment. Therefore, we have to catch it when we loop
if (
hls.nextAutoLevel > 0 &&
lowQualityFragments.length === 1 &&
lowQualityFragments[0].start === 0
) {
const lowQualFrag = lowQualityFragments[0]
hls.trigger(Hls.Events.BUFFER_FLUSHING, {
startOffset: lowQualFrag.start,
endOffset: lowQualFrag.end,
type: 'video',
})
setLowQualityFragments([])
}
})
useEffect(() => {
if (!videoRef.current) return
if (!Hls) return
if (!Hls.isSupported()) {
throw new HLSUnsupportedError()
}
const hls = new Hls({
maxMaxBufferLength: 10, // only load 10s ahead
// note: the amount buffered is affected by both maxBufferLength and maxBufferSize
// it will buffer until it is greater than *both* of those values
// so we use maxMaxBufferLength to set the actual maximum amount of buffering instead
})
hlsRef.current = hls
const latestEstimate = BandwidthEstimate.get()
if (latestEstimate !== undefined) {
hls.bandwidthEstimate = latestEstimate
}
hls.attachMedia(videoRef.current)
hls.loadSource(playlist)
// manually loop, so if we've flushed the first buffer it doesn't get confused
const abortController = new AbortController()
const {signal} = abortController
const videoNode = videoRef.current
videoNode.addEventListener(
'ended',
() => {
flushOnLoop()
videoNode.currentTime = 0
videoNode.play()
},
{signal},
)
hls.on(Hls.Events.FRAG_LOADED, () => {
BandwidthEstimate.set(hls.bandwidthEstimate)
})
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => {
if (data.subtitleTracks.length > 0) {
setHasSubtitleTrack(true)
}
})
hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => {
if (frag.level === 0) {
setLowQualityFragments(prev => [...prev, frag])
}
})
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
if (
data.details === 'manifestLoadError' &&
data.response?.code === 404
) {
setError(new VideoNotFoundError())
} else {
setError(data.error)
}
} else {
console.error(data.error)
}
})
hls.on(Hls.Events.FRAG_CHANGED, handleFragChange)
return () => {
hlsRef.current = undefined
hls.detachMedia()
hls.destroy()
abortController.abort()
}
}, [
playlist,
setError,
setHasSubtitleTrack,
videoRef,
handleFragChange,
flushOnLoop,
Hls,
])
return hlsRef
}
@@ -0,0 +1,61 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Text as TypoText} from '#/components/Typography'
export function Container({children}: {children: React.ReactNode}) {
const t = useTheme()
return (
<View
style={[
a.flex_1,
t.atoms.bg_contrast_25,
a.justify_center,
a.align_center,
a.px_lg,
a.border,
t.atoms.border_contrast_low,
a.rounded_sm,
a.gap_lg,
]}>
{children}
</View>
)
}
export function Text({children}: {children: React.ReactNode}) {
const t = useTheme()
return (
<TypoText
style={[
a.text_center,
t.atoms.text_contrast_high,
a.text_md,
a.leading_snug,
{maxWidth: 300},
]}>
{children}
</TypoText>
)
}
export function RetryButton({onPress}: {onPress: () => void}) {
const {_} = useLingui()
return (
<Button
onPress={onPress}
size="small"
color="secondary_inverted"
variant="solid"
label={_(msg`Retry`)}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
</Button>
)
}
@@ -0,0 +1,11 @@
let latestBandwidthEstimate: number | undefined
export function get() {
return latestBandwidthEstimate
}
export function set(estimate: number) {
if (!isNaN(estimate)) {
latestBandwidthEstimate = estimate
}
}
@@ -0,0 +1,42 @@
import React from 'react'
import {SvgProps} from 'react-native-svg'
import {PressableWithHover} from '#/view/com/util/PressableWithHover'
import {atoms as a, useTheme, web} from '#/alf'
export function ControlButton({
active,
activeLabel,
inactiveLabel,
activeIcon: ActiveIcon,
inactiveIcon: InactiveIcon,
onPress,
}: {
active: boolean
activeLabel: string
inactiveLabel: string
activeIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>>
inactiveIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>>
onPress: () => void
}) {
const t = useTheme()
return (
<PressableWithHover
accessibilityRole="button"
accessibilityLabel={active ? activeLabel : inactiveLabel}
accessibilityHint=""
onPress={onPress}
style={[
a.p_xs,
a.rounded_full,
web({transition: 'background-color 0.1s'}),
]}
hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.2)'}}>
{active ? (
<ActiveIcon fill={t.palette.white} width={20} aria-hidden />
) : (
<InactiveIcon fill={t.palette.white} width={20} aria-hidden />
)}
</PressableWithHover>
)
}
@@ -0,0 +1,238 @@
import React, {useCallback, useEffect, useRef, useState} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isFirefox, isTouchDevice} from '#/lib/browser'
import {clamp} from '#/lib/numbers'
import {atoms as a, useTheme, web} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {formatTime} from './utils'
export function Scrubber({
duration,
currentTime,
onSeek,
onSeekEnd,
onSeekStart,
seekLeft,
seekRight,
togglePlayPause,
drawFocus,
}: {
duration: number
currentTime: number
onSeek: (time: number) => void
onSeekEnd: () => void
onSeekStart: () => void
seekLeft: () => void
seekRight: () => void
togglePlayPause: () => void
drawFocus: () => void
}) {
const {_} = useLingui()
const t = useTheme()
const [scrubberActive, setScrubberActive] = useState(false)
const {
state: hovered,
onIn: onStartHover,
onOut: onEndHover,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const [seekPosition, setSeekPosition] = useState(0)
const isSeekingRef = useRef(false)
const barRef = useRef<HTMLDivElement>(null)
const circleRef = useRef<HTMLDivElement>(null)
const seek = useCallback(
(evt: React.PointerEvent<HTMLDivElement>) => {
if (!barRef.current) return
const {left, width} = barRef.current.getBoundingClientRect()
const x = evt.clientX
const percent = clamp((x - left) / width, 0, 1) * duration
onSeek(percent)
setSeekPosition(percent)
},
[duration, onSeek],
)
const onPointerDown = useCallback(
(evt: React.PointerEvent<HTMLDivElement>) => {
const target = evt.target
if (target instanceof Element) {
evt.preventDefault()
target.setPointerCapture(evt.pointerId)
isSeekingRef.current = true
seek(evt)
setScrubberActive(true)
onSeekStart()
}
},
[seek, onSeekStart],
)
const onPointerMove = useCallback(
(evt: React.PointerEvent<HTMLDivElement>) => {
if (isSeekingRef.current) {
evt.preventDefault()
seek(evt)
}
},
[seek],
)
const onPointerUp = useCallback(
(evt: React.PointerEvent<HTMLDivElement>) => {
const target = evt.target
if (isSeekingRef.current && target instanceof Element) {
evt.preventDefault()
target.releasePointerCapture(evt.pointerId)
isSeekingRef.current = false
onSeekEnd()
setScrubberActive(false)
}
},
[onSeekEnd],
)
useEffect(() => {
// HACK: there's divergent browser behaviour about what to do when
// a pointerUp event is fired outside the element that captured the
// pointer. Firefox clicks on the element the mouse is over, so we have
// to make everything unclickable while seeking -sfn
if (isFirefox && scrubberActive) {
document.body.classList.add('force-no-clicks')
return () => {
document.body.classList.remove('force-no-clicks')
}
}
}, [scrubberActive, onSeekEnd])
useEffect(() => {
if (!circleRef.current) return
if (focused) {
const abortController = new AbortController()
const {signal} = abortController
circleRef.current.addEventListener(
'keydown',
evt => {
// space: play/pause
// arrow left: seek backward
// arrow right: seek forward
if (evt.key === ' ') {
evt.preventDefault()
drawFocus()
togglePlayPause()
} else if (evt.key === 'ArrowLeft') {
evt.preventDefault()
drawFocus()
seekLeft()
} else if (evt.key === 'ArrowRight') {
evt.preventDefault()
drawFocus()
seekRight()
}
},
{signal},
)
return () => abortController.abort()
}
}, [focused, seekLeft, seekRight, togglePlayPause, drawFocus])
const progress = scrubberActive ? seekPosition : currentTime
const progressPercent = (progress / duration) * 100
return (
<View
testID="scrubber"
style={[
{height: isTouchDevice ? 32 : 18, width: '100%'},
a.flex_shrink_0,
a.px_xs,
]}
onPointerEnter={onStartHover}
onPointerLeave={onEndHover}>
<div
ref={barRef}
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
position: 'relative',
cursor: scrubberActive ? 'grabbing' : 'grab',
padding: '4px 0',
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}>
<View
style={[
a.w_full,
a.rounded_full,
a.overflow_hidden,
{backgroundColor: 'rgba(255, 255, 255, 0.4)'},
{height: hovered || scrubberActive ? 6 : 3},
web({transition: 'height 0.1s ease'}),
]}>
{duration > 0 && (
<View
style={[
a.h_full,
{backgroundColor: t.palette.white},
{width: `${progressPercent}%`},
]}
/>
)}
</View>
<div
ref={circleRef}
aria-label={_(
msg`Seek slider. Use the arrow keys to seek forwards and backwards, and space to play/pause`,
)}
role="slider"
aria-valuemax={duration}
aria-valuemin={0}
aria-valuenow={currentTime}
aria-valuetext={_(
msg`${formatTime(currentTime)} of ${formatTime(duration)}`,
)}
tabIndex={0}
onFocus={onFocus}
onBlur={onBlur}
style={{
position: 'absolute',
height: 16,
width: 16,
left: `calc(${progressPercent}% - 8px)`,
borderRadius: 8,
pointerEvents: 'none',
}}>
<View
style={[
a.w_full,
a.h_full,
a.rounded_full,
{backgroundColor: t.palette.white},
{
transform: [
{
scale:
hovered || scrubberActive || focused
? scrubberActive
? 1
: 0.6
: 0,
},
],
},
]}
/>
</div>
</div>
</View>
)
}
@@ -0,0 +1,3 @@
export function Controls() {
throw new Error('VideoWebControls may not be used on native.')
}
@@ -0,0 +1,427 @@
import {useCallback, useEffect, useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import type Hls from 'hls.js'
import {isTouchDevice} from '#/lib/browser'
import {clamp} from '#/lib/numbers'
import {isIPhoneWeb} from '#/platform/detection'
import {
useAutoplayDisabled,
useSetSubtitlesEnabled,
useSubtitlesEnabled,
} from '#/state/preferences'
import {atoms as a, useTheme, web} from '#/alf'
import {useIsWithinMessage} from '#/components/dms/MessageContext'
import {useFullscreen} from '#/components/hooks/useFullscreen'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {
ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon,
} from '#/components/icons/ArrowsDiagonal'
import {
CC_Filled_Corner0_Rounded as CCActiveIcon,
CC_Stroke2_Corner0_Rounded as CCInactiveIcon,
} from '#/components/icons/CC'
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {TimeIndicator} from '../TimeIndicator'
import {ControlButton} from './ControlButton'
import {Scrubber} from './Scrubber'
import {formatTime, useVideoElement} from './utils'
import {VolumeControl} from './VolumeControl'
export function Controls({
videoRef,
hlsRef,
active,
setActive,
focused,
setFocused,
onScreen,
fullscreenRef,
hlsLoading,
hasSubtitleTrack,
}: {
videoRef: React.RefObject<HTMLVideoElement>
hlsRef: React.RefObject<Hls | undefined>
active: boolean
setActive: () => void
focused: boolean
setFocused: (focused: boolean) => void
onScreen: boolean
fullscreenRef: React.RefObject<HTMLDivElement>
hlsLoading: boolean
hasSubtitleTrack: boolean
}) {
const {
play,
pause,
playing,
muted,
changeMuted,
togglePlayPause,
currentTime,
duration,
buffering,
error,
canPlay,
} = useVideoElement(videoRef)
const t = useTheme()
const {_} = useLingui()
const subtitlesEnabled = useSubtitlesEnabled()
const setSubtitlesEnabled = useSetSubtitlesEnabled()
const {
state: hovered,
onIn: onHover,
onOut: onEndHover,
} = useInteractionState()
const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
const showSpinner = hlsLoading || buffering
const {
state: volumeHovered,
onIn: onVolumeHover,
onOut: onVolumeEndHover,
} = useInteractionState()
const onKeyDown = useCallback(() => {
setInteractingViaKeypress(true)
}, [])
useEffect(() => {
if (interactingViaKeypress) {
document.addEventListener('click', () => setInteractingViaKeypress(false))
return () => {
document.removeEventListener('click', () =>
setInteractingViaKeypress(false),
)
}
}
}, [interactingViaKeypress])
useEffect(() => {
if (isFullscreen) {
document.documentElement.style.scrollbarGutter = 'unset'
return () => {
document.documentElement.style.removeProperty('scrollbar-gutter')
}
}
}, [isFullscreen])
// pause + unfocus when another video is active
useEffect(() => {
if (!active) {
pause()
setFocused(false)
}
}, [active, pause, setFocused])
// autoplay/pause based on visibility
const isWithinMessage = useIsWithinMessage()
const autoplayDisabled = useAutoplayDisabled() || isWithinMessage
useEffect(() => {
if (active) {
if (onScreen) {
if (!autoplayDisabled) play()
} else {
pause()
}
}
}, [onScreen, pause, active, play, autoplayDisabled])
// use minimal quality when not focused
useEffect(() => {
if (!hlsRef.current) return
if (focused) {
// allow 30s of buffering
hlsRef.current.config.maxMaxBufferLength = 30
} else {
// back to what we initially set
hlsRef.current.config.maxMaxBufferLength = 10
}
}, [hlsRef, focused])
useEffect(() => {
if (!hlsRef.current) return
if (hasSubtitleTrack && subtitlesEnabled && canPlay) {
hlsRef.current.subtitleTrack = 0
} else {
hlsRef.current.subtitleTrack = -1
}
}, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay])
// clicking on any button should focus the player, if it's not already focused
const drawFocus = useCallback(() => {
if (!active) {
setActive()
}
setFocused(true)
}, [active, setActive, setFocused])
const onPressEmptySpace = useCallback(() => {
if (!focused) {
drawFocus()
if (autoplayDisabled) play()
} else {
togglePlayPause()
}
}, [togglePlayPause, drawFocus, focused, autoplayDisabled, play])
const onPressPlayPause = useCallback(() => {
drawFocus()
togglePlayPause()
}, [drawFocus, togglePlayPause])
const onPressSubtitles = useCallback(() => {
drawFocus()
setSubtitlesEnabled(!subtitlesEnabled)
}, [drawFocus, setSubtitlesEnabled, subtitlesEnabled])
const onPressFullscreen = useCallback(() => {
drawFocus()
toggleFullscreen()
}, [drawFocus, toggleFullscreen])
const onSeek = useCallback(
(time: number) => {
if (!videoRef.current) return
if (videoRef.current.fastSeek) {
videoRef.current.fastSeek(time)
} else {
videoRef.current.currentTime = time
}
},
[videoRef],
)
const playStateBeforeSeekRef = useRef(false)
const onSeekStart = useCallback(() => {
drawFocus()
playStateBeforeSeekRef.current = playing
pause()
}, [playing, pause, drawFocus])
const onSeekEnd = useCallback(() => {
if (playStateBeforeSeekRef.current) {
play()
}
}, [play])
const seekLeft = useCallback(() => {
if (!videoRef.current) return
// eslint-disable-next-line @typescript-eslint/no-shadow
const currentTime = videoRef.current.currentTime
// eslint-disable-next-line @typescript-eslint/no-shadow
const duration = videoRef.current.duration || 0
onSeek(clamp(currentTime - 5, 0, duration))
}, [onSeek, videoRef])
const seekRight = useCallback(() => {
if (!videoRef.current) return
// eslint-disable-next-line @typescript-eslint/no-shadow
const currentTime = videoRef.current.currentTime
// eslint-disable-next-line @typescript-eslint/no-shadow
const duration = videoRef.current.duration || 0
onSeek(clamp(currentTime + 5, 0, duration))
}, [onSeek, videoRef])
const [showCursor, setShowCursor] = useState(true)
const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
const onPointerMoveEmptySpace = useCallback(() => {
setShowCursor(true)
if (cursorTimeoutRef.current) {
clearTimeout(cursorTimeoutRef.current)
}
cursorTimeoutRef.current = setTimeout(() => {
setShowCursor(false)
onEndHover()
}, 2000)
}, [onEndHover])
const onPointerLeaveEmptySpace = useCallback(() => {
setShowCursor(false)
if (cursorTimeoutRef.current) {
clearTimeout(cursorTimeoutRef.current)
}
}, [])
// these are used to trigger the hover state. on mobile, the hover state
// should stick around for a bit after they tap, and if the controls aren't
// present this initial tab should *only* show the controls and not activate anything
const onPointerDown = useCallback(
(evt: React.PointerEvent<HTMLDivElement>) => {
if (evt.pointerType !== 'mouse' && !hovered) {
evt.preventDefault()
}
clearTimeout(timeoutRef.current)
},
[hovered],
)
const timeoutRef = useRef<ReturnType<typeof setTimeout>>()
const onHoverWithTimeout = useCallback(() => {
onHover()
clearTimeout(timeoutRef.current)
}, [onHover])
const onEndHoverWithTimeout = useCallback(
(evt: React.PointerEvent<HTMLDivElement>) => {
// if touch, end after 3s
// if mouse, end immediately
if (evt.pointerType !== 'mouse') {
setTimeout(onEndHover, 3000)
} else {
onEndHover()
}
},
[onEndHover],
)
const showControls =
((focused || autoplayDisabled) && !playing) ||
(interactingViaKeypress ? hasFocus : hovered)
return (
<div
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
onClick={evt => {
evt.stopPropagation()
setInteractingViaKeypress(false)
}}
onPointerEnter={onHoverWithTimeout}
onPointerMove={onHoverWithTimeout}
onPointerLeave={onEndHoverWithTimeout}
onPointerDown={onPointerDown}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}>
<Pressable
accessibilityRole="button"
onPointerEnter={onPointerMoveEmptySpace}
onPointerMove={onPointerMoveEmptySpace}
onPointerLeave={onPointerLeaveEmptySpace}
accessibilityLabel={_(
!focused
? msg`Unmute video`
: playing
? msg`Pause video`
: msg`Play video`,
)}
accessibilityHint=""
style={[
a.flex_1,
web({cursor: showCursor || !playing ? 'pointer' : 'none'}),
]}
onPress={onPressEmptySpace}
/>
{!showControls && !focused && duration > 0 && (
<TimeIndicator time={Math.floor(duration - currentTime)} />
)}
<View
style={[
a.flex_shrink_0,
a.w_full,
a.px_xs,
web({
background:
'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))',
}),
{opacity: showControls ? 1 : 0},
{transition: 'opacity 0.2s ease-in-out'},
]}>
{(!volumeHovered || isTouchDevice) && (
<Scrubber
duration={duration}
currentTime={currentTime}
onSeek={onSeek}
onSeekStart={onSeekStart}
onSeekEnd={onSeekEnd}
seekLeft={seekLeft}
seekRight={seekRight}
togglePlayPause={togglePlayPause}
drawFocus={drawFocus}
/>
)}
<View
style={[
a.flex_1,
a.px_xs,
a.pb_sm,
a.gap_sm,
a.flex_row,
a.align_center,
]}>
<ControlButton
active={playing}
activeLabel={_(msg`Pause`)}
inactiveLabel={_(msg`Play`)}
activeIcon={PauseIcon}
inactiveIcon={PlayIcon}
onPress={onPressPlayPause}
/>
<View style={a.flex_1} />
<Text
style={[
a.px_xs,
{color: t.palette.white, fontVariant: ['tabular-nums']},
]}>
{formatTime(currentTime)} / {formatTime(duration)}
</Text>
{hasSubtitleTrack && (
<ControlButton
active={subtitlesEnabled}
activeLabel={_(msg`Disable subtitles`)}
inactiveLabel={_(msg`Enable subtitles`)}
activeIcon={CCActiveIcon}
inactiveIcon={CCInactiveIcon}
onPress={onPressSubtitles}
/>
)}
<VolumeControl
muted={muted}
changeMuted={changeMuted}
hovered={volumeHovered}
onHover={onVolumeHover}
onEndHover={onVolumeEndHover}
drawFocus={drawFocus}
/>
{!isIPhoneWeb && (
<ControlButton
active={isFullscreen}
activeLabel={_(msg`Exit fullscreen`)}
inactiveLabel={_(msg`Enter fullscreen`)}
activeIcon={ArrowsInIcon}
inactiveIcon={ArrowsOutIcon}
onPress={onPressFullscreen}
/>
)}
</View>
</View>
{(showSpinner || error) && (
<View
pointerEvents="none"
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
{showSpinner && <Loader fill={t.palette.white} size="lg" />}
{error && (
<Text style={{color: t.palette.white}}>
<Trans>An error occurred</Trans>
</Text>
)}
</View>
)}
</div>
)
}
@@ -0,0 +1,110 @@
import React, {useCallback} from 'react'
import {View} from 'react-native'
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isSafari, isTouchDevice} from '#/lib/browser'
import {atoms as a} from '#/alf'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {ControlButton} from './ControlButton'
export function VolumeControl({
muted,
changeMuted,
hovered,
onHover,
onEndHover,
drawFocus,
}: {
muted: boolean
changeMuted: (muted: boolean | ((prev: boolean) => boolean)) => void
hovered: boolean
onHover: () => void
onEndHover: () => void
drawFocus: () => void
}) {
const {_} = useLingui()
const [volume, setVolume] = useVideoVolumeState()
const onVolumeChange = useCallback(
(evt: React.ChangeEvent<HTMLInputElement>) => {
drawFocus()
const vol = sliderVolumeToVideoVolume(Number(evt.target.value))
setVolume(vol)
changeMuted(vol === 0)
},
[setVolume, drawFocus, changeMuted],
)
const sliderVolume = muted ? 0 : videoVolumeToSliderVolume(volume)
const isZeroVolume = volume === 0
const onPressMute = useCallback(() => {
drawFocus()
if (isZeroVolume) {
setVolume(1)
changeMuted(false)
} else {
changeMuted(prevMuted => !prevMuted)
}
}, [drawFocus, setVolume, isZeroVolume, changeMuted])
return (
<View
onPointerEnter={onHover}
onPointerLeave={onEndHover}
style={[a.relative]}>
{hovered && !isTouchDevice && (
<Animated.View
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
style={[a.absolute, a.w_full, {height: 100, bottom: '100%'}]}>
<View
style={[
a.flex_1,
a.mb_xs,
a.px_2xs,
a.py_xs,
{backgroundColor: 'rgba(0, 0, 0, 0.6)'},
a.rounded_xs,
a.align_center,
]}>
<input
type="range"
min={0}
max={100}
value={sliderVolume}
aria-label={_(msg`Volume`)}
style={
// Ridiculous safari hack for old version of safari. Fixed in sonoma beta -h
isSafari ? {height: 92, minHeight: '100%'} : {height: '100%'}
}
onChange={onVolumeChange}
// @ts-expect-error for old versions of firefox, and then re-using it for targeting the CSS -sfn
orient="vertical"
/>
</View>
</Animated.View>
)}
<ControlButton
active={muted || volume === 0}
activeLabel={_(msg({message: `Unmute`, context: 'video'}))}
inactiveLabel={_(msg({message: `Mute`, context: 'video'}))}
activeIcon={MuteIcon}
inactiveIcon={UnmuteIcon}
onPress={onPressMute}
/>
</View>
)
}
function sliderVolumeToVideoVolume(value: number) {
return Math.pow(value / 100, 4)
}
function videoVolumeToSliderVolume(value: number) {
return Math.round(Math.pow(value, 1 / 4) * 100)
}
@@ -0,0 +1,240 @@
import {type RefObject, useCallback, useEffect, useRef, useState} from 'react'
import {isSafari} from '#/lib/browser'
import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
export function useVideoElement(ref: RefObject<HTMLVideoElement>) {
const [playing, setPlaying] = useState(false)
const [muted, setMuted] = useState(true)
const [currentTime, setCurrentTime] = useState(0)
const [volume, setVolume] = useVideoVolumeState()
const [duration, setDuration] = useState(0)
const [buffering, setBuffering] = useState(false)
const [error, setError] = useState(false)
const [canPlay, setCanPlay] = useState(false)
const playWhenReadyRef = useRef(false)
useEffect(() => {
if (!ref.current) return
ref.current.volume = volume
}, [ref, volume])
useEffect(() => {
if (!ref.current) return
let bufferingTimeout: ReturnType<typeof setTimeout> | undefined
function round(num: number) {
return Math.round(num * 100) / 100
}
// Initial values
setCurrentTime(round(ref.current.currentTime) || 0)
setDuration(round(ref.current.duration) || 0)
setMuted(ref.current.muted)
setPlaying(!ref.current.paused)
setVolume(ref.current.volume)
const handleTimeUpdate = () => {
if (!ref.current) return
setCurrentTime(round(ref.current.currentTime) || 0)
// HACK: Safari randomly fires `stalled` events when changing between segments
// let's just clear the buffering state if the video is still progressing -sfn
if (isSafari) {
if (bufferingTimeout) clearTimeout(bufferingTimeout)
setBuffering(false)
}
}
const handleDurationChange = () => {
if (!ref.current) return
setDuration(round(ref.current.duration) || 0)
}
const handlePlay = () => {
setPlaying(true)
}
const handlePause = () => {
setPlaying(false)
}
const handleVolumeChange = () => {
if (!ref.current) return
setMuted(ref.current.muted)
}
const handleError = () => {
setError(true)
}
const handleCanPlay = async () => {
if (bufferingTimeout) clearTimeout(bufferingTimeout)
setBuffering(false)
setCanPlay(true)
if (!ref.current) return
if (playWhenReadyRef.current) {
try {
await ref.current.play()
} catch (e: any) {
if (
!e.message?.includes(`The request is not allowed by the user agent`)
) {
throw e
}
}
playWhenReadyRef.current = false
}
}
const handleCanPlayThrough = () => {
if (bufferingTimeout) clearTimeout(bufferingTimeout)
setBuffering(false)
}
const handleWaiting = () => {
if (bufferingTimeout) clearTimeout(bufferingTimeout)
bufferingTimeout = setTimeout(() => {
setBuffering(true)
}, 500) // Delay to avoid frequent buffering state changes
}
const handlePlaying = () => {
if (bufferingTimeout) clearTimeout(bufferingTimeout)
setBuffering(false)
setError(false)
}
const handleStalled = () => {
if (bufferingTimeout) clearTimeout(bufferingTimeout)
bufferingTimeout = setTimeout(() => {
setBuffering(true)
}, 500) // Delay to avoid frequent buffering state changes
}
const handleEnded = () => {
setPlaying(false)
setBuffering(false)
setError(false)
}
const abortController = new AbortController()
ref.current.addEventListener('timeupdate', handleTimeUpdate, {
signal: abortController.signal,
})
ref.current.addEventListener('durationchange', handleDurationChange, {
signal: abortController.signal,
})
ref.current.addEventListener('play', handlePlay, {
signal: abortController.signal,
})
ref.current.addEventListener('pause', handlePause, {
signal: abortController.signal,
})
ref.current.addEventListener('volumechange', handleVolumeChange, {
signal: abortController.signal,
})
ref.current.addEventListener('error', handleError, {
signal: abortController.signal,
})
ref.current.addEventListener('canplay', handleCanPlay, {
signal: abortController.signal,
})
ref.current.addEventListener('canplaythrough', handleCanPlayThrough, {
signal: abortController.signal,
})
ref.current.addEventListener('waiting', handleWaiting, {
signal: abortController.signal,
})
ref.current.addEventListener('playing', handlePlaying, {
signal: abortController.signal,
})
ref.current.addEventListener('stalled', handleStalled, {
signal: abortController.signal,
})
ref.current.addEventListener('ended', handleEnded, {
signal: abortController.signal,
})
return () => {
abortController.abort()
clearTimeout(bufferingTimeout)
}
}, [ref, setVolume])
const play = useCallback(() => {
if (!ref.current) return
if (ref.current.ended) {
ref.current.currentTime = 0
}
if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
playWhenReadyRef.current = true
} else {
const promise = ref.current.play()
if (promise !== undefined) {
promise.catch(err => {
console.error('Error playing video:', err)
})
}
}
}, [ref])
const pause = useCallback(() => {
if (!ref.current) return
ref.current.pause()
playWhenReadyRef.current = false
}, [ref])
const togglePlayPause = useCallback(() => {
if (!ref.current) return
if (ref.current.paused) {
play()
} else {
pause()
}
}, [ref, play, pause])
const changeMuted = useCallback(
(newMuted: boolean | ((prev: boolean) => boolean)) => {
if (!ref.current) return
const value =
typeof newMuted === 'function' ? newMuted(ref.current.muted) : newMuted
ref.current.muted = value
},
[ref],
)
return {
play,
pause,
togglePlayPause,
duration,
currentTime,
playing,
muted,
changeMuted,
buffering,
error,
canPlay,
}
}
export function formatTime(time: number) {
if (isNaN(time)) {
return '--'
}
time = Math.round(time)
const minutes = Math.floor(time / 60)
const seconds = String(time % 60).padStart(2, '0')
return `${minutes}:${seconds}`
}
@@ -0,0 +1,47 @@
import React from 'react'
const Context = React.createContext<{
// native
muted: boolean
setMuted: React.Dispatch<React.SetStateAction<boolean>>
// web
volume: number
setVolume: React.Dispatch<React.SetStateAction<number>>
} | null>(null)
export function Provider({children}: {children: React.ReactNode}) {
const [muted, setMuted] = React.useState(true)
const [volume, setVolume] = React.useState(1)
const value = React.useMemo(
() => ({
muted,
setMuted,
volume,
setVolume,
}),
[muted, setMuted, volume, setVolume],
)
return <Context.Provider value={value}>{children}</Context.Provider>
}
export function useVideoVolumeState() {
const context = React.useContext(Context)
if (!context) {
throw new Error(
'useVideoVolumeState must be used within a VideoVolumeProvider',
)
}
return [context.volume, context.setVolume] as const
}
export function useVideoMuteState() {
const context = React.useContext(Context)
if (!context) {
throw new Error(
'useVideoMuteState must be used within a VideoVolumeProvider',
)
}
return [context.muted, context.setMuted] as const
}
@@ -0,0 +1,167 @@
import React, {useCallback, useState} from 'react'
import {ActivityIndicator, View} from 'react-native'
import {ImageBackground} from 'expo-image'
import {AppBskyEmbedVideo} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
interface Props {
embed: AppBskyEmbedVideo.View
crop?: 'none' | 'square' | 'constrained'
}
export function VideoEmbed({embed, crop}: Props) {
const t = useTheme()
const [key, setKey] = useState(0)
const renderError = useCallback(
(error: unknown) => (
<VideoError error={error} retry={() => setKey(key + 1)} />
),
[key],
)
let aspectRatio: number | undefined
const dims = embed.aspectRatio
if (dims) {
aspectRatio = dims.width / dims.height
if (Number.isNaN(aspectRatio)) {
aspectRatio = undefined
}
}
let constrained: number | undefined
let max: number | undefined
if (aspectRatio !== undefined) {
const ratio = 1 / 2 // max of 1:2 ratio in feeds
constrained = Math.max(aspectRatio, ratio)
max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread
}
const cropDisabled = crop === 'none'
const contents = (
<ErrorBoundary renderError={renderError} key={key}>
<InnerWrapper embed={embed} />
</ErrorBoundary>
)
return (
<View style={[a.pt_xs]}>
{cropDisabled ? (
<View
style={[
a.w_full,
a.overflow_hidden,
{aspectRatio: max ?? 1},
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
]}>
{contents}
</View>
) : (
<ConstrainedImage
fullBleed={crop === 'square'}
aspectRatio={constrained || 1}>
{contents}
</ConstrainedImage>
)}
</View>
)
}
function InnerWrapper({embed}: Props) {
const {_} = useLingui()
const ref = React.useRef<{togglePlayback: () => void}>(null)
const [status, setStatus] = React.useState<'playing' | 'paused' | 'pending'>(
'pending',
)
const [isLoading, setIsLoading] = React.useState(false)
const [isActive, setIsActive] = React.useState(false)
const showSpinner = useThrottledValue(isActive && isLoading, 100)
const showOverlay =
!isActive ||
isLoading ||
(status === 'paused' && !isActive) ||
status === 'pending'
React.useEffect(() => {
if (!isActive && status !== 'pending') {
setStatus('pending')
}
}, [isActive, status])
return (
<>
<VideoEmbedInnerNative
embed={embed}
setStatus={setStatus}
setIsLoading={setIsLoading}
setIsActive={setIsActive}
ref={ref}
/>
<ImageBackground
source={{uri: embed.thumbnail}}
accessibilityIgnoresInvertColors
style={[
a.absolute,
a.inset_0,
{
backgroundColor: 'transparent', // If you don't add `backgroundColor` to the styles here,
// the play button won't show up on the first render on android 🥴😮‍💨
display: showOverlay ? 'flex' : 'none',
},
]}
cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android
>
{showOverlay && (
<Button
style={[a.flex_1, a.align_center, a.justify_center]}
onPress={() => {
ref.current?.togglePlayback()
}}
label={_(msg`Play video`)}
color="secondary">
{showSpinner ? (
<View
style={[
a.rounded_full,
a.p_xs,
a.align_center,
a.justify_center,
]}>
<ActivityIndicator size="large" color="white" />
</View>
) : (
<PlayButtonIcon />
)}
</Button>
)}
</ImageBackground>
</>
)
}
function VideoError({retry}: {error: unknown; retry: () => void}) {
return (
<VideoFallback.Container>
<VideoFallback.Text>
<Trans>
An error occurred while loading the video. Please try again later.
</Trans>
</VideoFallback.Text>
<VideoFallback.RetryButton onPress={retry} />
</VideoFallback.Container>
)
}
@@ -0,0 +1,207 @@
import React, {useCallback, useEffect, useRef, useState} from 'react'
import {View} from 'react-native'
import {AppBskyEmbedVideo} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isFirefox} from '#/lib/browser'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
import {atoms as a} from '#/alf'
import {useIsWithinMessage} from '#/components/dms/MessageContext'
import {useFullscreen} from '#/components/hooks/useFullscreen'
import {
HLSUnsupportedError,
VideoEmbedInnerWeb,
VideoNotFoundError,
} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb'
import {useActiveVideoWeb} from './ActiveVideoWebContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
export function VideoEmbed({
embed,
crop,
}: {
embed: AppBskyEmbedVideo.View
crop?: 'none' | 'square' | 'constrained'
}) {
const ref = useRef<HTMLDivElement>(null)
const {active, setActive, sendPosition, currentActiveView} =
useActiveVideoWeb()
const [onScreen, setOnScreen] = useState(false)
const [isFullscreen] = useFullscreen()
const lastKnownTime = useRef<number | undefined>()
useEffect(() => {
if (!ref.current) return
if (isFullscreen && !isFirefox) return
const observer = new IntersectionObserver(
entries => {
const entry = entries[0]
if (!entry) return
setOnScreen(entry.isIntersecting)
sendPosition(
entry.boundingClientRect.y + entry.boundingClientRect.height / 2,
)
},
{threshold: 0.5},
)
observer.observe(ref.current)
return () => observer.disconnect()
}, [sendPosition, isFullscreen])
const [key, setKey] = useState(0)
const renderError = useCallback(
(error: unknown) => (
<VideoError error={error} retry={() => setKey(key + 1)} />
),
[key],
)
let aspectRatio: number | undefined
const dims = embed.aspectRatio
if (dims) {
aspectRatio = dims.width / dims.height
if (Number.isNaN(aspectRatio)) {
aspectRatio = undefined
}
}
let constrained: number | undefined
let max: number | undefined
if (aspectRatio !== undefined) {
const ratio = 1 / 2 // max of 1:2 ratio in feeds
constrained = Math.max(aspectRatio, ratio)
max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread
}
const cropDisabled = crop === 'none'
const contents = (
<div
ref={ref}
style={{display: 'flex', flex: 1, cursor: 'default'}}
onClick={evt => evt.stopPropagation()}>
<ErrorBoundary renderError={renderError} key={key}>
<ViewportObserver
sendPosition={sendPosition}
isAnyViewActive={currentActiveView !== null}>
<VideoEmbedInnerWeb
embed={embed}
active={active}
setActive={setActive}
onScreen={onScreen}
lastKnownTime={lastKnownTime}
/>
</ViewportObserver>
</ErrorBoundary>
</div>
)
return (
<View style={[a.pt_xs]}>
{cropDisabled ? (
<View style={[a.w_full, a.overflow_hidden, {aspectRatio: max ?? 1}]}>
{contents}
</View>
) : (
<ConstrainedImage
fullBleed={crop === 'square'}
aspectRatio={constrained || 1}>
{contents}
</ConstrainedImage>
)}
</View>
)
}
/**
* Renders a 100vh tall div and watches it with an IntersectionObserver to
* send the position of the div when it's near the screen.
*/
function ViewportObserver({
children,
sendPosition,
isAnyViewActive,
}: {
children: React.ReactNode
sendPosition: (position: number) => void
isAnyViewActive: boolean
}) {
const ref = useRef<HTMLDivElement>(null)
const [nearScreen, setNearScreen] = useState(false)
const [isFullscreen] = useFullscreen()
const isWithinMessage = useIsWithinMessage()
// Send position when scrolling. This is done with an IntersectionObserver
// observing a div of 100vh height
useEffect(() => {
if (!ref.current) return
if (isFullscreen && !isFirefox) return
const observer = new IntersectionObserver(
entries => {
const entry = entries[0]
if (!entry) return
const position =
entry.boundingClientRect.y + entry.boundingClientRect.height / 2
sendPosition(position)
setNearScreen(entry.isIntersecting)
},
{threshold: Array.from({length: 101}, (_, i) => i / 100)},
)
observer.observe(ref.current)
return () => observer.disconnect()
}, [sendPosition, isFullscreen])
// In case scrolling hasn't started yet, send up the position
useEffect(() => {
if (ref.current && !isAnyViewActive) {
const rect = ref.current.getBoundingClientRect()
const position = rect.y + rect.height / 2
sendPosition(position)
}
}, [isAnyViewActive, sendPosition])
return (
<View style={[a.flex_1, a.flex_row]}>
{nearScreen && children}
<div
ref={ref}
style={{
// Don't escape bounds when in a message
...(isWithinMessage
? {top: 0, height: '100%'}
: {top: 'calc(50% - 50vh)', height: '100vh'}),
position: 'absolute',
left: '50%',
width: 1,
pointerEvents: 'none',
}}
/>
</View>
)
}
function VideoError({error, retry}: {error: unknown; retry: () => void}) {
const {_} = useLingui()
let showRetryButton = true
let text = null
if (error instanceof VideoNotFoundError) {
text = _(msg`Video not found.`)
} else if (error instanceof HLSUnsupportedError) {
showRetryButton = false
text = _(
msg`Your browser does not support the video format. Please try a different browser.`,
)
} else {
text = _(msg`An error occurred while loading the video. Please try again.`)
}
return (
<VideoFallback.Container>
<VideoFallback.Text>{text}</VideoFallback.Text>
{showRetryButton && <VideoFallback.RetryButton onPress={retry} />}
</VideoFallback.Container>
)
}
+332
View File
@@ -0,0 +1,332 @@
import React from 'react'
import {View} from 'react-native'
import {
type $Typed,
type AppBskyFeedDefs,
AppBskyFeedPost,
AtUri,
moderatePost,
RichText as RichTextAPI,
} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useQueryClient} from '@tanstack/react-query'
import {usePalette} from '#/lib/hooks/usePalette'
import {makeProfileLink} from '#/lib/routes/links'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {unstableCacheProfileView} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {Link} from '#/view/com/util/Link'
import {PostMeta} from '#/view/com/util/PostMeta'
import {atoms as a, useTheme} from '#/alf'
import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {RichText} from '#/components/RichText'
import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
import {SubtleWebHover} from '#/components/SubtleWebHover'
import * as bsky from '#/types/bsky'
import {
type Embed as TEmbed,
type EmbedType,
parseEmbed,
} from '#/types/bsky/post'
import {ExternalEmbed} from './ExternalEmbed'
import {ModeratedFeedEmbed} from './FeedEmbed'
import {ImageEmbed} from './ImageEmbed'
import {ModeratedListEmbed} from './ListEmbed'
import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
import {
type CommonProps,
type EmbedProps,
PostEmbedViewContext,
QuoteEmbedViewContext,
} from './types'
import {VideoEmbed} from './VideoEmbed'
export {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
const embed = parseEmbed(rawEmbed)
switch (embed.type) {
case 'images':
case 'link':
case 'video': {
return <MediaEmbed embed={embed} {...rest} />
}
case 'feed':
case 'list':
case 'starter_pack':
case 'labeler':
case 'post':
case 'post_not_found':
case 'post_blocked':
case 'post_detached': {
return <RecordEmbed embed={embed} {...rest} />
}
case 'post_with_media': {
return (
<View style={rest.style}>
<MediaEmbed embed={embed.media} {...rest} />
<RecordEmbed embed={embed.view} {...rest} />
</View>
)
}
default: {
return null
}
}
}
function MediaEmbed({
embed,
...rest
}: CommonProps & {
embed: TEmbed
}) {
switch (embed.type) {
case 'images': {
return (
<ContentHider modui={rest.moderation?.ui('contentMedia')}>
<ImageEmbed embed={embed} {...rest} />
</ContentHider>
)
}
case 'link': {
return (
<ContentHider modui={rest.moderation?.ui('contentMedia')}>
<ExternalEmbed
link={embed.view.external}
onOpen={rest.onOpen}
style={[a.mt_sm, rest.style]}
/>
</ContentHider>
)
}
case 'video': {
return (
<ContentHider modui={rest.moderation?.ui('contentMedia')}>
<VideoEmbed embed={embed.view} />
</ContentHider>
)
}
default: {
return null
}
}
}
function RecordEmbed({
embed,
...rest
}: CommonProps & {
embed: TEmbed
}) {
switch (embed.type) {
case 'feed': {
return (
<View style={a.mt_sm}>
<ModeratedFeedEmbed embed={embed} {...rest} />
</View>
)
}
case 'list': {
return (
<View style={a.mt_sm}>
<ModeratedListEmbed embed={embed} />
</View>
)
}
case 'starter_pack': {
return (
<View style={a.mt_sm}>
<StarterPackCard starterPack={embed.view} />
</View>
)
}
case 'labeler': {
// not implemented
return null
}
case 'post': {
if (rest.isWithinQuote && !rest.allowNestedQuotes) {
return null
}
return (
<QuoteEmbed
{...rest}
embed={embed}
viewContext={
rest.viewContext === PostEmbedViewContext.Feed
? QuoteEmbedViewContext.FeedEmbedRecordWithMedia
: undefined
}
isWithinQuote={rest.isWithinQuote}
allowNestedQuotes={rest.allowNestedQuotes}
/>
)
}
case 'post_not_found': {
return (
<PostPlaceholderText>
<Trans>Deleted</Trans>
</PostPlaceholderText>
)
}
case 'post_blocked': {
return (
<PostPlaceholderText>
<Trans>Blocked</Trans>
</PostPlaceholderText>
)
}
case 'post_detached': {
return <PostDetachedEmbed embed={embed} />
}
default: {
return null
}
}
}
export function PostDetachedEmbed({
embed,
}: {
embed: EmbedType<'post_detached'>
}) {
const {currentAccount} = useSession()
const isViewerOwner = currentAccount?.did
? embed.view.uri.includes(currentAccount.did)
: false
return (
<PostPlaceholderText>
{isViewerOwner ? (
<Trans>Removed by you</Trans>
) : (
<Trans>Removed by author</Trans>
)}
</PostPlaceholderText>
)
}
/*
* Nests parent `Embed` component and therefore must live in this file to avoid
* circular imports.
*/
export function QuoteEmbed({
embed,
onOpen,
style,
isWithinQuote: parentIsWithinQuote,
allowNestedQuotes: parentAllowNestedQuotes,
}: Omit<CommonProps, 'viewContext'> & {
embed: EmbedType<'post'>
viewContext?: QuoteEmbedViewContext
}) {
const moderationOpts = useModerationOpts()
const quote = React.useMemo<$Typed<AppBskyFeedDefs.PostView>>(
() => ({
...embed.view,
$type: 'app.bsky.feed.defs#postView',
record: embed.view.value,
embed: embed.view.embeds?.[0],
}),
[embed],
)
const moderation = React.useMemo(() => {
return moderationOpts ? moderatePost(quote, moderationOpts) : undefined
}, [quote, moderationOpts])
const t = useTheme()
const queryClient = useQueryClient()
const pal = usePalette('default')
const itemUrip = new AtUri(quote.uri)
const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
const itemTitle = `Post by ${quote.author.handle}`
const richText = React.useMemo(() => {
if (
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
quote.record,
AppBskyFeedPost.isRecord,
)
)
return undefined
const {text, facets} = quote.record
return text.trim()
? new RichTextAPI({text: text, facets: facets})
: undefined
}, [quote.record])
const onBeforePress = React.useCallback(() => {
unstableCacheProfileView(queryClient, quote.author)
onOpen?.()
}, [queryClient, quote.author, onOpen])
const [hover, setHover] = React.useState(false)
return (
<View
onPointerEnter={() => {
setHover(true)
}}
onPointerLeave={() => {
setHover(false)
}}>
<ContentHider
modui={moderation?.ui('contentList')}
style={[
a.rounded_md,
a.p_md,
a.mt_sm,
a.border,
t.atoms.border_contrast_low,
style,
]}
childContainerStyle={[a.pt_sm]}>
<SubtleWebHover hover={hover} />
<Link
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}>
<View pointerEvents="none">
<PostMeta
author={quote.author}
moderation={moderation}
showAvatar
postHref={itemHref}
timestamp={quote.indexedAt}
/>
</View>
{moderation ? (
<PostAlerts
modui={moderation.ui('contentView')}
style={[a.py_xs]}
/>
) : null}
{richText ? (
<RichText
value={richText}
style={a.text_md}
numberOfLines={20}
disableLinks
/>
) : null}
{quote.embed && (
<Embed
embed={quote.embed}
moderation={moderation}
isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested
allowNestedQuotes={
parentIsWithinQuote ? false : parentAllowNestedQuotes
}
/>
)}
</Link>
</ContentHider>
</View>
)
}
+25
View File
@@ -0,0 +1,25 @@
import {type StyleProp, type ViewStyle} from 'react-native'
import {type AppBskyFeedDefs, type ModerationDecision} from '@atproto/api'
export enum PostEmbedViewContext {
ThreadHighlighted = 'ThreadHighlighted',
Feed = 'Feed',
FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
}
export enum QuoteEmbedViewContext {
FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia,
}
export type CommonProps = {
moderation?: ModerationDecision
onOpen?: () => void
style?: StyleProp<ViewStyle>
viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
allowNestedQuotes?: boolean
}
export type EmbedProps = CommonProps & {
embed?: AppBskyFeedDefs.PostView['embed']
}
+1 -1
View File
@@ -1,5 +1,5 @@
import {View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto/api'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+5 -4
View File
@@ -1,15 +1,16 @@
import React from 'react'
import {useWindowDimensions, View} from 'react-native'
import {AppBskyEmbedRecord} from '@atproto/api'
import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api'
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
import {atoms as a, native, tokens, useTheme, web} from '#/alf'
import {PostEmbedViewContext} from '#/components/Post/Embed'
import {Embed} from '#/components/Post/Embed'
import {MessageContextProvider} from './MessageContext'
let MessageItemEmbed = ({
embed,
}: {
embed: AppBskyEmbedRecord.View
embed: $Typed<AppBskyEmbedRecord.View>
}): React.ReactNode => {
const t = useTheme()
const screen = useWindowDimensions()
@@ -32,7 +33,7 @@ let MessageItemEmbed = ({
}),
]}>
<View style={{marginTop: tokens.space.sm * -1}}>
<PostEmbeds
<Embed
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
+2 -2
View File
@@ -8,7 +8,7 @@
*/
import React from 'react'
import {formatDistance, Locale} from 'date-fns'
import {formatDistance, type Locale} from 'date-fns'
import {
ca,
cy,
@@ -47,7 +47,7 @@ import {
zhTW,
} from 'date-fns/locale'
import {AppLanguage} from '#/locale/languages'
import {type AppLanguage} from '#/locale/languages'
import {useLanguagePrefs} from '#/state/preferences'
/**
+13 -3
View File
@@ -1,6 +1,16 @@
import React, {ComponentProps} from 'react'
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {AppBskyActorDefs, ModerationCause, ModerationUI} from '@atproto/api'
import React, {type ComponentProps} from 'react'
import {
Pressable,
type StyleProp,
StyleSheet,
View,
type ViewStyle,
} from 'react-native'
import {
type AppBskyActorDefs,
type ModerationCause,
type ModerationUI,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'