diff options
Diffstat (limited to 'src/view/com/util')
-rw-r--r-- | src/view/com/util/post-embeds/ExternalGifEmbed.tsx | 170 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/ExternalLinkEmbed.tsx | 71 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx | 148 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/QuoteEmbed.tsx | 1 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/index.tsx | 18 |
5 files changed, 306 insertions, 102 deletions
diff --git a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx new file mode 100644 index 000000000..f06c8b794 --- /dev/null +++ b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx @@ -0,0 +1,170 @@ +import {EmbedPlayerParams, getGifDims} from 'lib/strings/embed-player' +import React from 'react' +import {Image, ImageLoadEventData} from 'expo-image' +import { + ActivityIndicator, + GestureResponderEvent, + LayoutChangeEvent, + Pressable, + StyleSheet, + View, +} from 'react-native' +import {isIOS, isNative, isWeb} from '#/platform/detection' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useExternalEmbedsPrefs} from 'state/preferences' +import {useModalControls} from 'state/modals' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {AppBskyEmbedExternal} from '@atproto/api' + +export function ExternalGifEmbed({ + link, + params, +}: { + link: AppBskyEmbedExternal.ViewExternal + params: EmbedPlayerParams +}) { + const externalEmbedsPrefs = useExternalEmbedsPrefs() + const {openModal} = useModalControls() + const {_} = useLingui() + + const thumbHasLoaded = React.useRef(false) + const viewWidth = React.useRef(0) + + // 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) + const [imageDims, setImageDims] = React.useState({height: 100, width: 1}) + + // 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) { + openModal({ + name: 'embed-consent', + source: params.source, + onAccept: load, + }) + 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 + } + }) + }, + [externalEmbedsPrefs, isPlayerActive, load, openModal, params.source], + ) + + const onLoad = React.useCallback((e: ImageLoadEventData) => { + if (thumbHasLoaded.current) return + setImageDims(getGifDims(e.source.height, e.source.width, viewWidth.current)) + thumbHasLoaded.current = true + }, []) + + const onLayout = React.useCallback((e: LayoutChangeEvent) => { + viewWidth.current = e.nativeEvent.layout.width + }, []) + + return ( + <Pressable + style={[ + {height: imageDims.height}, + styles.topRadius, + styles.gifContainer, + ]} + onPress={onPlayPress} + onLayout={onLayout} + accessibilityRole="button" + accessibilityHint={_(msg`Plays the GIF`)} + accessibilityLabel={_(msg`Play ${link.title}`)}> + {(!isPrefetched || !isAnimating) && ( // If we have not loaded or are not animating, show the overlay + <View style={[styles.layer, styles.overlayLayer]}> + <View style={[styles.overlayContainer, styles.topRadius]}> + {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active + <FontAwesomeIcon icon="play" size={42} color="white" /> + ) : ( + // Activity indicator while gif loads + <ActivityIndicator size="large" color="white" /> + )} + </View> + </View> + )} + <Image + source={{ + uri: + !isPrefetched || (isWeb && !isAnimating) + ? link.thumb + : params.playerUri, + }} // Web uses the thumb to control playback + style={{flex: 1}} + ref={imageRef} + onLoad={onLoad} + autoplay={isAnimating} + contentFit="contain" + accessibilityIgnoresInvertColors + accessibilityLabel={link.title} + accessibilityHint={link.title} + cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios + /> + </Pressable> + ) +} + +const styles = StyleSheet.create({ + topRadius: { + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + }, + layer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + overlayContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.5)', + }, + overlayLayer: { + zIndex: 2, + }, + gifContainer: { + width: '100%', + overflow: 'hidden', + }, +}) diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index 27aa804d3..af62aa2b3 100644 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -8,6 +8,8 @@ import {AppBskyEmbedExternal} from '@atproto/api' import {toNiceDomain} from 'lib/strings/url-helpers' import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player' import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed' +import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed' +import {useExternalEmbedsPrefs} from 'state/preferences' export const ExternalLinkEmbed = ({ link, @@ -16,36 +18,27 @@ export const ExternalLinkEmbed = ({ }) => { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() + const externalEmbedPrefs = useExternalEmbedsPrefs() - const embedPlayerParams = React.useMemo( - () => parseEmbedPlayerFromUrl(link.uri), - [link.uri], - ) + const embedPlayerParams = React.useMemo(() => { + const params = parseEmbedPlayerFromUrl(link.uri) + + if (params && externalEmbedPrefs?.[params.source] !== 'hide') { + return params + } + }, [link.uri, externalEmbedPrefs]) return ( - <View - style={{ - flexDirection: !isMobile && !embedPlayerParams ? 'row' : 'column', - }}> + <View style={{flexDirection: 'column'}}> {link.thumb && !embedPlayerParams ? ( <View - style={ - !isMobile - ? { - borderTopLeftRadius: 6, - borderBottomLeftRadius: 6, - width: 120, - aspectRatio: 1, - overflow: 'hidden', - } - : { - borderTopLeftRadius: 6, - borderTopRightRadius: 6, - width: '100%', - height: 200, - overflow: 'hidden', - } - }> + style={{ + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + width: '100%', + height: isMobile ? 200 : 300, + overflow: 'hidden', + }}> <Image style={styles.extImage} source={{uri: link.thumb}} @@ -53,15 +46,17 @@ export const ExternalLinkEmbed = ({ /> </View> ) : undefined} - {embedPlayerParams && ( - <ExternalPlayer link={link} params={embedPlayerParams} /> - )} + {(embedPlayerParams?.isGif && ( + <ExternalGifEmbed link={link} params={embedPlayerParams} /> + )) || + (embedPlayerParams && ( + <ExternalPlayer link={link} params={embedPlayerParams} /> + ))} <View style={{ paddingHorizontal: isMobile ? 10 : 14, paddingTop: 8, paddingBottom: 10, - flex: !isMobile ? 1 : undefined, }}> <Text type="sm" @@ -69,16 +64,15 @@ export const ExternalLinkEmbed = ({ style={[pal.textLight, styles.extUri]}> {toNiceDomain(link.uri)} </Text> - <Text - type="lg-bold" - numberOfLines={isMobile ? 4 : 2} - style={[pal.text]}> - {link.title || link.uri} - </Text> - {link.description ? ( + {!embedPlayerParams?.isGif && ( + <Text type="lg-bold" numberOfLines={4} style={[pal.text]}> + {link.title || link.uri} + </Text> + )} + {link.description && !embedPlayerParams?.hideDetails ? ( <Text type="md" - numberOfLines={isMobile ? 4 : 2} + numberOfLines={4} style={[pal.text, styles.extDescription]}> {link.description} </Text> @@ -90,8 +84,7 @@ export const ExternalLinkEmbed = ({ const styles = StyleSheet.create({ extImage: { - width: '100%', - height: 200, + flex: 1, }, extUri: { marginTop: 2, diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx index 580cf363a..8b0858b69 100644 --- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx @@ -1,22 +1,32 @@ import React from 'react' import { ActivityIndicator, - Dimensions, GestureResponderEvent, Pressable, StyleSheet, + useWindowDimensions, View, } from 'react-native' +import Animated, { + measure, + runOnJS, + useAnimatedRef, + useFrameCallback, +} from 'react-native-reanimated' import {Image} from 'expo-image' import {WebView} from 'react-native-webview' -import YoutubePlayer from 'react-native-youtube-iframe' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import {AppBskyEmbedExternal} from '@atproto/api' import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player' import {EventStopper} from '../EventStopper' -import {AppBskyEmbedExternal} from '@atproto/api' import {isNative} from 'platform/detection' -import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' +import {useExternalEmbedsPrefs} from 'state/preferences' +import {useModalControls} from 'state/modals' interface ShouldStartLoadRequest { url: string @@ -32,6 +42,8 @@ function PlaceholderOverlay({ 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 @@ -39,8 +51,8 @@ function PlaceholderOverlay({ <View style={[styles.layer, styles.overlayLayer]}> <Pressable accessibilityRole="button" - accessibilityLabel="Play Video" - accessibilityHint="" + accessibilityLabel={_(msg`Play Video`)} + accessibilityHint={_(msg`Play Video`)} onPress={onPress} style={[styles.overlayContainer, styles.topRadius]}> {!isPlayerActive ? ( @@ -77,31 +89,21 @@ function Player({ return ( <View style={[styles.layer, styles.playerLayer]}> <EventStopper> - {isNative && params.type === 'youtube_video' ? ( - <YoutubePlayer - videoId={params.videoId} - play - height={height} - onReady={onLoad} - webViewStyle={[styles.webview, styles.topRadius]} + <View style={{height, width: '100%'}}> + <WebView + javaScriptEnabled={true} + onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} + mediaPlaybackRequiresUserAction={false} + allowsInlineMediaPlayback + bounces={false} + allowsFullscreenVideo + nestedScrollEnabled + source={{uri: params.playerUri}} + onLoad={onLoad} + setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads) + style={[styles.webview, styles.topRadius]} /> - ) : ( - <View style={{height, width: '100%'}}> - <WebView - javaScriptEnabled={true} - onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} - mediaPlaybackRequiresUserAction={false} - allowsInlineMediaPlayback - bounces={false} - allowsFullscreenVideo - nestedScrollEnabled - source={{uri: params.playerUri}} - onLoad={onLoad} - setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads) - style={[styles.webview, styles.topRadius]} - /> - </View> - )} + </View> </EventStopper> </View> ) @@ -116,6 +118,10 @@ export function ExternalPlayer({ params: EmbedPlayerParams }) { const navigation = useNavigation<NavigationProp>() + const insets = useSafeAreaInsets() + const windowDims = useWindowDimensions() + const externalEmbedsPrefs = useExternalEmbedsPrefs() + const {openModal} = useModalControls() const [isPlayerActive, setPlayerActive] = React.useState(false) const [isLoading, setIsLoading] = React.useState(true) @@ -124,34 +130,51 @@ export function ExternalPlayer({ height: 0, }) - const viewRef = React.useRef<View>(null) + 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) }) - const interval = setInterval(() => { - viewRef.current?.measure((x, y, w, h, pageX, pageY) => { - const window = Dimensions.get('window') - const top = pageY - const bot = pageY + h - const isVisible = isNative - ? top >= 0 && bot <= window.height - : !(top >= window.height || bot <= 0) - if (!isVisible) { - setPlayerActive(false) - } - }) - }, 1e3) + // Start watching for changes + frameCallback.setActive(true) + return () => { unsubscribe() - clearInterval(interval) + frameCallback.setActive(false) } - }, [viewRef, navigation]) + }, [navigation, isPlayerActive, frameCallback]) // calculate height for the player and the screen size const height = React.useMemo( @@ -168,12 +191,26 @@ export function ExternalPlayer({ setIsLoading(false) }, []) - const onPlayPress = React.useCallback((event: GestureResponderEvent) => { - // Prevent this from propagating upward on web - event.preventDefault() + const onPlayPress = React.useCallback( + (event: GestureResponderEvent) => { + // Prevent this from propagating upward on web + event.preventDefault() - setPlayerActive(true) - }, []) + if (externalEmbedsPrefs?.[params.source] === undefined) { + openModal({ + name: 'embed-consent', + source: params.source, + onAccept: () => { + setPlayerActive(true) + }, + }) + return + } + + setPlayerActive(true) + }, + [externalEmbedsPrefs, openModal, params.source], + ) // measure the layout to set sizing const onLayout = React.useCallback( @@ -187,7 +224,7 @@ export function ExternalPlayer({ ) return ( - <View + <Animated.View ref={viewRef} style={{height}} collapsable={false} @@ -205,7 +242,6 @@ export function ExternalPlayer({ accessibilityIgnoresInvertColors /> )} - <PlaceholderOverlay isLoading={isLoading} isPlayerActive={isPlayerActive} @@ -217,7 +253,7 @@ export function ExternalPlayer({ height={height} onLoad={onLoad} /> - </View> + </Animated.View> ) } @@ -248,4 +284,8 @@ const styles = StyleSheet.create({ webview: { backgroundColor: 'transparent', }, + gifContainer: { + width: '100%', + overflow: 'hidden', + }, }) diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index e793f983e..fbb89af27 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -98,6 +98,7 @@ export function QuoteEmbed({ return ( <Link style={[styles.container, pal.borderDark, style]} + hoverStyle={{borderColor: pal.colors.borderLinkHover}} href={itemHref} title={itemTitle}> <PostMeta diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index c94ce9684..00a102e7b 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -63,7 +63,7 @@ export function PostEmbeds({ const mediaModeration = isModOnQuote ? {} : moderation const quoteModeration = isModOnQuote ? moderation : {} return ( - <View style={[styles.stackContainer, style]}> + <View style={style}> <PostEmbeds embed={embed.media} moderation={mediaModeration} /> <ContentHider moderation={quoteModeration}> <MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} /> @@ -168,11 +168,14 @@ export function PostEmbeds({ const link = embed.external return ( - <View style={[styles.extOuter, pal.view, pal.border, style]}> - <Link asAnchor href={link.uri}> - <ExternalLinkEmbed link={link} /> - </Link> - </View> + <Link + asAnchor + anchorNoUnderline + href={link.uri} + style={[styles.extOuter, pal.view, pal.borderDark, style]} + hoverStyle={{borderColor: pal.colors.borderLinkHover}}> + <ExternalLinkEmbed link={link} /> + </Link> ) } @@ -180,9 +183,6 @@ export function PostEmbeds({ } const styles = StyleSheet.create({ - stackContainer: { - gap: 6, - }, imagesContainer: { marginTop: 8, }, |