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
@@ -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>
)
}