From 45f0f7eefecae1922c2f30d4e7760d2b93b1ae56 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 13 Jun 2025 12:05:41 -0500 Subject: 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 --- bskylink/src/routes/index.ts | 4 +- bskylink/src/routes/redirect.ts | 4 +- bskylink/src/routes/root.ts | 4 +- src/App.native.tsx | 2 +- src/App.web.tsx | 4 +- src/alf/util/systemUI.ts | 2 +- src/components/ContextMenu/Backdrop.tsx | 2 +- src/components/FeedInterstitials.tsx | 18 +- src/components/Layout/Header/index.tsx | 8 +- src/components/Menu/context.tsx | 2 +- .../Post/Embed/ExternalEmbed/ExternalGif.tsx | 147 +++++++ .../Post/Embed/ExternalEmbed/ExternalPlayer.tsx | 281 ++++++++++++++ src/components/Post/Embed/ExternalEmbed/Gif.tsx | 224 +++++++++++ src/components/Post/Embed/ExternalEmbed/index.tsx | 182 +++++++++ src/components/Post/Embed/FeedEmbed.tsx | 52 +++ src/components/Post/Embed/ImageEmbed.tsx | 106 +++++ src/components/Post/Embed/LazyQuoteEmbed.tsx | 37 ++ src/components/Post/Embed/ListEmbed.tsx | 42 ++ src/components/Post/Embed/PostPlaceholder.tsx | 33 ++ .../Embed/VideoEmbed/ActiveVideoWebContext.tsx | 114 ++++++ .../VideoEmbed/VideoEmbedInner/TimeIndicator.tsx | 64 +++ .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 210 ++++++++++ .../VideoEmbedInner/VideoEmbedInnerNative.web.tsx | 3 + .../VideoEmbedInner/VideoEmbedInnerWeb.native.tsx | 3 + .../VideoEmbedInner/VideoEmbedInnerWeb.tsx | 307 +++++++++++++++ .../VideoEmbed/VideoEmbedInner/VideoFallback.tsx | 61 +++ .../VideoEmbedInner/bandwidth-estimate.ts | 11 + .../VideoEmbedInner/web-controls/ControlButton.tsx | 42 ++ .../VideoEmbedInner/web-controls/Scrubber.tsx | 238 ++++++++++++ .../web-controls/VideoControls.native.tsx | 3 + .../VideoEmbedInner/web-controls/VideoControls.tsx | 427 +++++++++++++++++++++ .../VideoEmbedInner/web-controls/VolumeControl.tsx | 110 ++++++ .../VideoEmbedInner/web-controls/utils.tsx | 240 ++++++++++++ .../Post/Embed/VideoEmbed/VideoVolumeContext.tsx | 47 +++ src/components/Post/Embed/VideoEmbed/index.tsx | 167 ++++++++ src/components/Post/Embed/VideoEmbed/index.web.tsx | 207 ++++++++++ src/components/Post/Embed/index.tsx | 332 ++++++++++++++++ src/components/Post/Embed/types.ts | 25 ++ src/components/dms/ActionsWrapper.tsx | 2 +- src/components/dms/MessageItemEmbed.tsx | 9 +- src/components/hooks/dates.ts | 4 +- src/components/moderation/PostHider.tsx | 16 +- src/locale/helpers.ts | 2 +- src/screens/Login/LoginForm.tsx | 4 +- src/screens/Onboarding/StepProfile/index.tsx | 6 +- .../PostThread/components/ThreadItemAnchor.tsx | 4 +- .../PostThread/components/ThreadItemPost.tsx | 4 +- .../PostThread/components/ThreadItemTreePost.tsx | 4 +- src/screens/Profile/Header/Handle.tsx | 4 +- src/screens/Signup/StepInfo/Policies.tsx | 4 +- .../StarterPack/StarterPackLandingScreen.tsx | 2 +- src/screens/StarterPack/Wizard/State.tsx | 4 +- src/screens/Takendown.tsx | 2 +- src/screens/VideoFeed/components/Scrubber.tsx | 2 +- src/state/messages/events/agent.ts | 8 +- src/state/queries/postgate/util.ts | 7 +- src/state/session/types.ts | 4 +- src/state/threadgate-hidden-replies.tsx | 2 +- src/view/com/composer/Composer.tsx | 15 +- src/view/com/composer/ComposerReplyTo.tsx | 13 +- src/view/com/composer/ExternalEmbed.tsx | 37 +- src/view/com/composer/ExternalEmbedRemoveBtn.tsx | 13 +- src/view/com/composer/GifAltText.tsx | 8 +- src/view/com/composer/labels/LabelsBtn.tsx | 6 +- .../com/composer/photos/ImageAltTextDialog.tsx | 6 +- src/view/com/composer/photos/OpenCameraBtn.tsx | 2 +- src/view/com/post-thread/PostThreadItem.tsx | 6 +- src/view/com/post/Post.tsx | 4 +- src/view/com/posts/PostFeedItem.tsx | 5 +- src/view/com/util/Views.web.tsx | 8 +- src/view/com/util/images/Gallery.tsx | 2 +- src/view/com/util/images/ImageLayoutGrid.tsx | 2 +- .../com/util/post-embeds/ActiveVideoWebContext.tsx | 114 ------ src/view/com/util/post-embeds/ExternalGifEmbed.tsx | 147 ------- .../com/util/post-embeds/ExternalLinkEmbed.tsx | 182 --------- .../com/util/post-embeds/ExternalPlayerEmbed.tsx | 281 -------------- src/view/com/util/post-embeds/GifEmbed.tsx | 224 ----------- src/view/com/util/post-embeds/QuoteEmbed.tsx | 337 ---------------- src/view/com/util/post-embeds/VideoEmbed.tsx | 167 -------- src/view/com/util/post-embeds/VideoEmbed.web.tsx | 207 ---------- .../post-embeds/VideoEmbedInner/TimeIndicator.tsx | 64 --- .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 210 ---------- .../VideoEmbedInner/VideoEmbedInnerNative.web.tsx | 3 - .../VideoEmbedInner/VideoEmbedInnerWeb.native.tsx | 3 - .../VideoEmbedInner/VideoEmbedInnerWeb.tsx | 307 --------------- .../post-embeds/VideoEmbedInner/VideoFallback.tsx | 61 --- .../VideoEmbedInner/bandwidth-estimate.ts | 11 - .../VideoEmbedInner/web-controls/ControlButton.tsx | 42 -- .../VideoEmbedInner/web-controls/Scrubber.tsx | 238 ------------ .../web-controls/VideoControls.native.tsx | 3 - .../VideoEmbedInner/web-controls/VideoControls.tsx | 427 --------------------- .../VideoEmbedInner/web-controls/VolumeControl.tsx | 110 ------ .../VideoEmbedInner/web-controls/utils.tsx | 240 ------------ .../com/util/post-embeds/VideoVolumeContext.tsx | 47 --- src/view/com/util/post-embeds/index.tsx | 327 ---------------- src/view/com/util/post-embeds/types.ts | 9 - 96 files changed, 3880 insertions(+), 3867 deletions(-) create mode 100644 src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx create mode 100644 src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx create mode 100644 src/components/Post/Embed/ExternalEmbed/Gif.tsx create mode 100644 src/components/Post/Embed/ExternalEmbed/index.tsx create mode 100644 src/components/Post/Embed/FeedEmbed.tsx create mode 100644 src/components/Post/Embed/ImageEmbed.tsx create mode 100644 src/components/Post/Embed/LazyQuoteEmbed.tsx create mode 100644 src/components/Post/Embed/ListEmbed.tsx create mode 100644 src/components/Post/Embed/PostPlaceholder.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/index.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/index.web.tsx create mode 100644 src/components/Post/Embed/index.tsx create mode 100644 src/components/Post/Embed/types.ts delete mode 100644 src/view/com/util/post-embeds/ActiveVideoWebContext.tsx delete mode 100644 src/view/com/util/post-embeds/ExternalGifEmbed.tsx delete mode 100644 src/view/com/util/post-embeds/ExternalLinkEmbed.tsx delete mode 100644 src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx delete mode 100644 src/view/com/util/post-embeds/GifEmbed.tsx delete mode 100644 src/view/com/util/post-embeds/QuoteEmbed.tsx delete mode 100644 src/view/com/util/post-embeds/VideoEmbed.tsx delete mode 100644 src/view/com/util/post-embeds/VideoEmbed.web.tsx delete mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx delete mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx delete mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx delete mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx delete mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx delete mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx delete mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/bandwidth-estimate.ts delete mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx delete mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx delete mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx delete mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx delete mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx delete mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx delete mode 100644 src/view/com/util/post-embeds/VideoVolumeContext.tsx delete mode 100644 src/view/com/util/post-embeds/index.tsx delete mode 100644 src/view/com/util/post-embeds/types.ts diff --git a/bskylink/src/routes/index.ts b/bskylink/src/routes/index.ts index 9fd20d276..d0122ff8b 100644 --- a/bskylink/src/routes/index.ts +++ b/bskylink/src/routes/index.ts @@ -1,6 +1,6 @@ -import {Express} from 'express' +import {type Express} from 'express' -import {AppContext} from '../context.js' +import {type AppContext} from '../context.js' import {default as createShortLink} from './createShortLink.js' import {default as health} from './health.js' import {default as redirect} from './redirect.js' diff --git a/bskylink/src/routes/redirect.ts b/bskylink/src/routes/redirect.ts index 468d25019..7d68e4245 100644 --- a/bskylink/src/routes/redirect.ts +++ b/bskylink/src/routes/redirect.ts @@ -2,9 +2,9 @@ import assert from 'node:assert' import {DAY, SECOND} from '@atproto/common' import escapeHTML from 'escape-html' -import {Express} from 'express' +import {type Express} from 'express' -import {AppContext} from '../context.js' +import {type AppContext} from '../context.js' import {handler} from './util.js' const INTERNAL_IP_REGEX = new RegExp( diff --git a/bskylink/src/routes/root.ts b/bskylink/src/routes/root.ts index 12bdf1515..8c6c4afc3 100644 --- a/bskylink/src/routes/root.ts +++ b/bskylink/src/routes/root.ts @@ -1,6 +1,6 @@ -import {Express} from 'express' +import {type Express} from 'express' -import {AppContext} from '../context.js' +import {type AppContext} from '../context.js' import {handler} from './util.js' export default function (ctx: AppContext, app: Express) { diff --git a/src/App.native.tsx b/src/App.native.tsx index 25d186dcf..81d4a870e 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -59,7 +59,6 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import {TestCtrls} from '#/view/com/testing/TestCtrls' -import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' import * as Toast from '#/view/com/util/Toast' import {Shell} from '#/view/shell' import {ThemeProvider as Alf} from '#/alf' @@ -69,6 +68,7 @@ import {NuxDialogs} from '#/components/dialogs/nuxs' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' import {Provider as PortalProvider} from '#/components/Portal' +import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {Splash} from '#/Splash' import {BottomSheetProvider} from '../modules/bottom-sheet' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' diff --git a/src/App.web.tsx b/src/App.web.tsx index fa8e24e53..b706774fd 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -48,8 +48,6 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' -import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext' -import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' import * as Toast from '#/view/com/util/Toast' import {ToastContainer} from '#/view/com/util/Toast.web' import {Shell} from '#/view/shell/index' @@ -60,6 +58,8 @@ import {NuxDialogs} from '#/components/dialogs/nuxs' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' import {Provider as PortalProvider} from '#/components/Portal' +import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' +import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' diff --git a/src/alf/util/systemUI.ts b/src/alf/util/systemUI.ts index c973e10ea..9e5769c4c 100644 --- a/src/alf/util/systemUI.ts +++ b/src/alf/util/systemUI.ts @@ -1,7 +1,7 @@ import * as SystemUI from 'expo-system-ui' import {isAndroid} from '#/platform/detection' -import {Theme} from '../types' +import {type Theme} from '../types' export function setSystemUITheme(themeType: 'theme' | 'lightbox', t: Theme) { if (isAndroid) { diff --git a/src/components/ContextMenu/Backdrop.tsx b/src/components/ContextMenu/Backdrop.tsx index 027bf9849..37fcebf49 100644 --- a/src/components/ContextMenu/Backdrop.tsx +++ b/src/components/ContextMenu/Backdrop.tsx @@ -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' diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 6ecc3f5a8..a92e7be7f 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -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 diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx index 44faa9649..d68f4bd1d 100644 --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -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 { diff --git a/src/components/Menu/context.tsx b/src/components/Menu/context.tsx index d810a03de..076bc8151 100644 --- a/src/components/Menu/context.tsx +++ b/src/components/Menu/context.tsx @@ -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(null) diff --git a/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx new file mode 100644 index 000000000..8a12f0374 --- /dev/null +++ b/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx @@ -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(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 ( + <> + + + + + + {(!isPrefetched || !isAnimating) && ( + + + + {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active + + ) : ( + // Activity indicator while gif loads + + )} + + )} + + + ) +} diff --git a/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx new file mode 100644 index 000000000..7f6d53340 --- /dev/null +++ b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx @@ -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 ( + + + {!isPlayerActive ? ( + + ) : ( + + )} + + + ) +} + +// 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 ( + + + + ) +} + +// 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() + 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 ( + <> + + + + {link.thumb && (!isPlayerActive || isLoading) ? ( + <> + + + + ) : ( + + )} + + + + + ) +} + +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', + }, +}) diff --git a/src/components/Post/Embed/ExternalEmbed/Gif.tsx b/src/components/Post/Embed/ExternalEmbed/Gif.tsx new file mode 100644 index 000000000..a839294f1 --- /dev/null +++ b/src/components/Post/Embed/ExternalEmbed/Gif.tsx @@ -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 ( + + {!isLoaded ? ( + + + + + + ) : !isPlaying ? ( + + ) : undefined} + + ) +} + +export function GifEmbed({ + params, + thumb, + altText, + isPreferredAltText, + hideAlt, + style = {width: '100%'}, +}: { + params: EmbedPlayerParams + thumb: string | undefined + altText: string + isPreferredAltText: boolean + hideAlt?: boolean + style?: StyleProp +}) { + const t = useTheme() + const {_} = useLingui() + const autoplayDisabled = useAutoplayDisabled() + + const playerRef = React.useRef(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 ( + + + + + {!playerState.isPlaying && ( + + )} + {!hideAlt && isPreferredAltText && } + + + ) +} + +function AltText({text}: {text: string}) { + const control = Prompt.usePromptControl() + const largeAltBadge = useLargeAltBadgeEnabled() + + const {_} = useLingui() + return ( + <> + + + ALT + + + + + Alt Text + + {text} + + control.close()} + cta={_(msg`Close`)} + color="secondary" + /> + + + + ) +} + +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', + }, +}) diff --git a/src/components/Post/Embed/ExternalEmbed/index.tsx b/src/components/Post/Embed/ExternalEmbed/index.tsx new file mode 100644 index 000000000..714eaecd6 --- /dev/null +++ b/src/components/Post/Embed/ExternalEmbed/index.tsx @@ -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 + 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 ( + + + + ) + } + + return ( + + {({hovered}) => ( + + {imageUri && !embedPlayerParams ? ( + + ) : undefined} + + {embedPlayerParams?.isGif ? ( + + ) : embedPlayerParams ? ( + + ) : undefined} + + + + {!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && ( + + {link.title || link.uri} + + )} + {link.description ? ( + + {link.description} + + ) : undefined} + + + + + + + {toNiceDomain(link.uri)} + + + + + + )} + + ) +} diff --git a/src/components/Post/Embed/FeedEmbed.tsx b/src/components/Post/Embed/FeedEmbed.tsx new file mode 100644 index 000000000..fad4cd4d8 --- /dev/null +++ b/src/components/Post/Embed/FeedEmbed.tsx @@ -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 ( + + ) +} + +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 ( + + + + ) +} + +const styles = StyleSheet.create({ + customFeedOuter: { + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 12, + }, +}) diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx new file mode 100644 index 000000000..030d237a0 --- /dev/null +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -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[], + 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 ( + + onPress(0, [containerRef], [dims])} + onPressIn={() => onPressIn(0)} + hideBadge={ + rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + } + /> + + ) + } + + return ( + + + + ) + } +} diff --git a/src/components/Post/Embed/LazyQuoteEmbed.tsx b/src/components/Post/Embed/LazyQuoteEmbed.tsx new file mode 100644 index 000000000..fdc1c6309 --- /dev/null +++ b/src/components/Post/Embed/LazyQuoteEmbed.tsx @@ -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 ? ( + + ) : ( + + ) +} diff --git a/src/components/Post/Embed/ListEmbed.tsx b/src/components/Post/Embed/ListEmbed.tsx new file mode 100644 index 000000000..dc79a7579 --- /dev/null +++ b/src/components/Post/Embed/ListEmbed.tsx @@ -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 ( + + + + ) +} + +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 ( + + + + ) +} diff --git a/src/components/Post/Embed/PostPlaceholder.tsx b/src/components/Post/Embed/PostPlaceholder.tsx new file mode 100644 index 000000000..840234026 --- /dev/null +++ b/src/components/Post/Embed/PostPlaceholder.tsx @@ -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 ( + + + + {children} + + + ) +} + +const styles = StyleSheet.create({ + errorContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + borderRadius: 8, + marginTop: 8, + paddingVertical: 14, + paddingHorizontal: 14, + borderWidth: StyleSheet.hairlineWidth, + }, +}) diff --git a/src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx b/src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx new file mode 100644 index 000000000..a038403b2 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx @@ -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(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 {children} +} + +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), + } +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx new file mode 100644 index 000000000..95401309f --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx @@ -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 +}) { + 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 ( + + + {`${minutes}:${seconds}`} + + + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx new file mode 100644 index 000000000..88879d45a --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -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(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() + + React.useImperativeHandle(ref, () => ({ + togglePlayback: () => { + videoRef.current?.togglePlayback() + }, + })) + + if (error) { + throw new Error(error) + } + + return ( + + { + 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="" + /> + { + videoRef.current?.enterFullscreen(true) + }} + toggleMuted={() => { + videoRef.current?.toggleMuted() + }} + togglePlayback={() => { + videoRef.current?.togglePlayback() + }} + isPlaying={isPlaying} + timeRemaining={timeRemaining} + /> + + + ) + }, +) + +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 ( + + + + {isPlaying ? ( + + ) : ( + + )} + + {showTime && } + + + {muted ? ( + + ) : ( + + )} + + + ) +} + +function ControlButton({ + onPress, + children, + label, + accessibilityHint, + style, +}: { + onPress: () => void + children: React.ReactNode + label: string + accessibilityHint: string + style?: StyleProp +}) { + return ( + + + {children} + + + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx new file mode 100644 index 000000000..2760c7faf --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx @@ -0,0 +1,3 @@ +export function VideoEmbedInnerNative() { + throw new Error('VideoEmbedInnerNative may not be used on web.') +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx new file mode 100644 index 000000000..8664aae14 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx @@ -0,0 +1,3 @@ +export function VideoEmbedInnerWeb() { + throw new Error('VideoEmbedInnerWeb may not be used on native.') +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx new file mode 100644 index 000000000..ce3a7b2c9 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -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 +}) { + const containerRef = useRef(null) + const videoRef = useRef(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(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 ( + +
+
+
+ +
+ +
+ ) +} + +export class HLSUnsupportedError extends Error { + constructor() { + super('HLS is not supported') + } +} + +export class VideoNotFoundError extends Error { + constructor() { + super('Video not found') + } +} + +type CachedPromise = Promise & {value: undefined | T} +const promiseForHls = import( + // @ts-ignore + 'hls.js/dist/hls.min' +).then(mod => mod.default) as CachedPromise +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 + setHlsLoading: (v: boolean) => void +}) { + const [Hls, setHls] = useState( + () => promiseForHls.value, + ) + useEffect(() => { + if (!Hls) { + setHlsLoading(true) + promiseForHls.then(loadedHls => { + setHls(() => loadedHls) + setHlsLoading(false) + }) + } + }, [Hls, setHlsLoading]) + + const hlsRef = useRef(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 +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx new file mode 100644 index 000000000..1b46163cc --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx @@ -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 ( + + {children} + + ) +} + +export function Text({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + + {children} + + ) +} + +export function RetryButton({onPress}: {onPress: () => void}) { + const {_} = useLingui() + + return ( + + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts new file mode 100644 index 000000000..122e10aef --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts @@ -0,0 +1,11 @@ +let latestBandwidthEstimate: number | undefined + +export function get() { + return latestBandwidthEstimate +} + +export function set(estimate: number) { + if (!isNaN(estimate)) { + latestBandwidthEstimate = estimate + } +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx new file mode 100644 index 000000000..1b69a3e25 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx @@ -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> + inactiveIcon: React.ComponentType> + onPress: () => void +}) { + const t = useTheme() + return ( + + {active ? ( + + ) : ( + + )} + + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx new file mode 100644 index 000000000..96960bad4 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx @@ -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(null) + const circleRef = useRef(null) + + const seek = useCallback( + (evt: React.PointerEvent) => { + 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) => { + 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) => { + if (isSeekingRef.current) { + evt.preventDefault() + seek(evt) + } + }, + [seek], + ) + + const onPointerUp = useCallback( + (evt: React.PointerEvent) => { + 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 ( + +
+ + {duration > 0 && ( + + )} + +
+ +
+
+
+ ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx new file mode 100644 index 000000000..e2e24ed36 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx @@ -0,0 +1,3 @@ +export function Controls() { + throw new Error('VideoWebControls may not be used on native.') +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx new file mode 100644 index 000000000..6d14deafc --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx @@ -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 + hlsRef: React.RefObject + active: boolean + setActive: () => void + focused: boolean + setFocused: (focused: boolean) => void + onScreen: boolean + fullscreenRef: React.RefObject + 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>() + 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) => { + if (evt.pointerType !== 'mouse' && !hovered) { + evt.preventDefault() + } + clearTimeout(timeoutRef.current) + }, + [hovered], + ) + + const timeoutRef = useRef>() + + const onHoverWithTimeout = useCallback(() => { + onHover() + clearTimeout(timeoutRef.current) + }, [onHover]) + + const onEndHoverWithTimeout = useCallback( + (evt: React.PointerEvent) => { + // 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 ( +
{ + evt.stopPropagation() + setInteractingViaKeypress(false) + }} + onPointerEnter={onHoverWithTimeout} + onPointerMove={onHoverWithTimeout} + onPointerLeave={onEndHoverWithTimeout} + onPointerDown={onPointerDown} + onFocus={onFocus} + onBlur={onBlur} + onKeyDown={onKeyDown}> + + {!showControls && !focused && duration > 0 && ( + + )} + + {(!volumeHovered || isTouchDevice) && ( + + )} + + + + + {formatTime(currentTime)} / {formatTime(duration)} + + {hasSubtitleTrack && ( + + )} + + {!isIPhoneWeb && ( + + )} + + + {(showSpinner || error) && ( + + {showSpinner && } + {error && ( + + An error occurred + + )} + + )} +
+ ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx new file mode 100644 index 000000000..e0b688075 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx @@ -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) => { + 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 ( + + {hovered && !isTouchDevice && ( + + + + + + )} + + + ) +} + +function sliderVolumeToVideoVolume(value: number) { + return Math.pow(value / 100, 4) +} + +function videoVolumeToSliderVolume(value: number) { + return Math.round(Math.pow(value, 1 / 4) * 100) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx new file mode 100644 index 000000000..320f61a5f --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx @@ -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) { + 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 | 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}` +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx b/src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx new file mode 100644 index 000000000..6343081da --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx @@ -0,0 +1,47 @@ +import React from 'react' + +const Context = React.createContext<{ + // native + muted: boolean + setMuted: React.Dispatch> + // web + volume: number + setVolume: React.Dispatch> +} | 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 {children} +} + +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 +} diff --git a/src/components/Post/Embed/VideoEmbed/index.tsx b/src/components/Post/Embed/VideoEmbed/index.tsx new file mode 100644 index 000000000..fe29ecad6 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/index.tsx @@ -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) => ( + 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 = ( + + + + ) + + return ( + + {cropDisabled ? ( + + {contents} + + ) : ( + + {contents} + + )} + + ) +} + +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 ( + <> + + + {showOverlay && ( + + )} + + + ) +} + +function VideoError({retry}: {error: unknown; retry: () => void}) { + return ( + + + + An error occurred while loading the video. Please try again later. + + + + + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx new file mode 100644 index 000000000..53adc3b6a --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx @@ -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(null) + const {active, setActive, sendPosition, currentActiveView} = + useActiveVideoWeb() + const [onScreen, setOnScreen] = useState(false) + const [isFullscreen] = useFullscreen() + const lastKnownTime = useRef() + + 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) => ( + 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 = ( +
evt.stopPropagation()}> + + + + + +
+ ) + + return ( + + {cropDisabled ? ( + + {contents} + + ) : ( + + {contents} + + )} + + ) +} + +/** + * 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(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 ( + + {nearScreen && children} +
+ + ) +} + +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 ( + + {text} + {showRetryButton && } + + ) +} diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx new file mode 100644 index 000000000..ace85dc98 --- /dev/null +++ b/src/components/Post/Embed/index.tsx @@ -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 + } + case 'feed': + case 'list': + case 'starter_pack': + case 'labeler': + case 'post': + case 'post_not_found': + case 'post_blocked': + case 'post_detached': { + return + } + case 'post_with_media': { + return ( + + + + + ) + } + default: { + return null + } + } +} + +function MediaEmbed({ + embed, + ...rest +}: CommonProps & { + embed: TEmbed +}) { + switch (embed.type) { + case 'images': { + return ( + + + + ) + } + case 'link': { + return ( + + + + ) + } + case 'video': { + return ( + + + + ) + } + default: { + return null + } + } +} + +function RecordEmbed({ + embed, + ...rest +}: CommonProps & { + embed: TEmbed +}) { + switch (embed.type) { + case 'feed': { + return ( + + + + ) + } + case 'list': { + return ( + + + + ) + } + case 'starter_pack': { + return ( + + + + ) + } + case 'labeler': { + // not implemented + return null + } + case 'post': { + if (rest.isWithinQuote && !rest.allowNestedQuotes) { + return null + } + + return ( + + ) + } + case 'post_not_found': { + return ( + + Deleted + + ) + } + case 'post_blocked': { + return ( + + Blocked + + ) + } + case 'post_detached': { + return + } + 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 ( + + {isViewerOwner ? ( + Removed by you + ) : ( + Removed by author + )} + + ) +} + +/* + * 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 & { + embed: EmbedType<'post'> + viewContext?: QuoteEmbedViewContext +}) { + const moderationOpts = useModerationOpts() + const quote = React.useMemo<$Typed>( + () => ({ + ...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( + 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 ( + { + setHover(true) + }} + onPointerLeave={() => { + setHover(false) + }}> + + + + + + + {moderation ? ( + + ) : null} + {richText ? ( + + ) : null} + {quote.embed && ( + + )} + + + + ) +} diff --git a/src/components/Post/Embed/types.ts b/src/components/Post/Embed/types.ts new file mode 100644 index 000000000..b719d00b4 --- /dev/null +++ b/src/components/Post/Embed/types.ts @@ -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 + viewContext?: PostEmbedViewContext + isWithinQuote?: boolean + allowNestedQuotes?: boolean +} + +export type EmbedProps = CommonProps & { + embed?: AppBskyFeedDefs.PostView['embed'] +} diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx index 120a5f8ad..eb9f0a09a 100644 --- a/src/components/dms/ActionsWrapper.tsx +++ b/src/components/dms/ActionsWrapper.tsx @@ -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' diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index f1c6189d0..6390300c1 100644 --- a/src/components/dms/MessageItemEmbed.tsx +++ b/src/components/dms/MessageItemEmbed.tsx @@ -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 }): React.ReactNode => { const t = useTheme() const screen = useWindowDimensions() @@ -32,7 +33,7 @@ let MessageItemEmbed = ({ }), ]}> - - - - diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 17d0f94f7..de060c6c2 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -72,7 +72,7 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {mimeToExt} from '#/lib/media/video/util' import {logEvent} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' -import {colors, s} from '#/lib/styles' +import {colors} from '#/lib/styles' import {logger} from '#/logger' import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection' import {useDialogStateControlContext} from '#/state/dialogs' @@ -97,6 +97,7 @@ import { ExternalEmbedGif, ExternalEmbedLink, } from '#/view/com/composer/ExternalEmbed' +import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' import {GifAltTextDialog} from '#/view/com/composer/GifAltText' import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn' import {Gallery} from '#/view/com/composer/photos/Gallery' @@ -116,7 +117,6 @@ import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn' import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress' -import {LazyQuoteEmbed, QuoteX} from '#/view/com/util/post-embeds/QuoteEmbed' import {Text} from '#/view/com/util/text/Text' import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' @@ -125,6 +125,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed' import * as Prompt from '#/components/Prompt' import {Text as NewText} from '#/components/Typography' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' @@ -1149,13 +1150,17 @@ function ComposerEmbeds({ )} {embed.quote?.uri ? ( - - + + {canRemoveQuote && ( - dispatch({type: 'embed_remove_quote'})} /> + dispatch({type: 'embed_remove_quote'})} + style={{top: 16}} + /> )} diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx index 0ced14359..acab84f65 100644 --- a/src/view/com/composer/ComposerReplyTo.tsx +++ b/src/view/com/composer/ComposerReplyTo.tsx @@ -13,12 +13,13 @@ import {useLingui} from '@lingui/react' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {type ComposerOptsPostRef} from '#/state/shell/composer' -import {MaybeQuoteEmbed} from '#/view/com/util/post-embeds/QuoteEmbed' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme, web} from '#/alf' +import {QuoteEmbed} from '#/components/Post/Embed' import {Text} from '#/components/Typography' import {useSimpleVerificationState} from '#/components/verification' import {VerificationCheck} from '#/components/verification/VerificationCheck' +import {parseEmbed} from '#/types/bsky/post' export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { const t = useTheme() @@ -51,6 +52,12 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { } return null }, [embed]) + const parsedQuoteEmbed = quoteEmbed + ? parseEmbed({ + $type: 'app.bsky.embed.record#view', + ...quoteEmbed, + }) + : null const images = useMemo(() => { if (AppBskyEmbedImages.isView(embed)) { @@ -124,7 +131,9 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { )} - {showFull && quoteEmbed && } + {showFull && parsedQuoteEmbed && parsedQuoteEmbed.type === 'post' && ( + + )} ) diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index d819b28b7..e4bdabac3 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -1,19 +1,20 @@ import React from 'react' -import {StyleProp, View, ViewStyle} from 'react-native' +import {type StyleProp, View, type ViewStyle} from 'react-native' import {cleanError} from '#/lib/strings/errors' import { useResolveGifQuery, useResolveLinkQuery, } from '#/state/queries/resolve-link' -import {Gif} from '#/state/queries/tenor' +import {type Gif} from '#/state/queries/tenor' import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' -import {ExternalLinkEmbed} from '#/view/com/util/post-embeds/ExternalLinkEmbed' import {atoms as a, useTheme} from '#/alf' import {Loader} from '#/components/Loader' +import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed' +import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed' +import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed' import {Embed as StarterPackEmbed} from '#/components/StarterPack/StarterPackCard' import {Text} from '#/components/Typography' -import {MaybeFeedCard, MaybeListCard} from '../util/post-embeds' export const ExternalEmbedGif = ({ onRemove, @@ -44,7 +45,7 @@ export const ExternalEmbedGif = ({ {linkInfo ? ( - + ) : error ? ( @@ -80,7 +81,7 @@ export const ExternalEmbedLink = ({ if (data) { if (data.type === 'external') { return ( - ) } else if (data.kind === 'feed') { - return + return ( + + ) } else if (data.kind === 'list') { - return + return ( + + ) } else if (data.kind === 'starter-pack') { return } diff --git a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx index 92102f847..1e363d018 100644 --- a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx +++ b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx @@ -2,22 +2,27 @@ import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme, type ViewStyleProp} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' -export function ExternalEmbedRemoveBtn({onRemove}: {onRemove: () => void}) { +export function ExternalEmbedRemoveBtn({ + onRemove, + style, +}: {onRemove: () => void} & ViewStyleProp) { + const t = useTheme() const {_} = useLingui() return ( - + diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx index 4d2539c4e..ceee17eaa 100644 --- a/src/view/com/composer/GifAltText.tsx +++ b/src/view/com/composer/GifAltText.tsx @@ -6,23 +6,23 @@ import {useLingui} from '@lingui/react' import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants' import {parseAltFromGIFDescription} from '#/lib/gif-alt-text' import { - EmbedPlayerParams, + type EmbedPlayerParams, parseEmbedPlayerFromUrl, } from '#/lib/strings/embed-player' import {isAndroid} from '#/platform/detection' import {useResolveGifQuery} from '#/state/queries/resolve-link' -import {Gif} from '#/state/queries/tenor' +import {type Gif} from '#/state/queries/tenor' import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {DialogControlProps} from '#/components/Dialog' +import {type DialogControlProps} from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {GifEmbed} from '#/components/Post/Embed/ExternalEmbed/Gif' import {Text} from '#/components/Typography' -import {GifEmbed} from '../util/post-embeds/GifEmbed' import {AltTextReminder} from './photos/Gallery' export function GifAltTextDialog({ diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx index 9548ed065..902d89b7b 100644 --- a/src/view/com/composer/labels/LabelsBtn.tsx +++ b/src/view/com/composer/labels/LabelsBtn.tsx @@ -4,10 +4,10 @@ import {useLingui} from '@lingui/react' import { ADULT_CONTENT_LABELS, - AdultSelfLabel, + type AdultSelfLabel, OTHER_SELF_LABELS, - OtherSelfLabel, - SelfLabel, + type OtherSelfLabel, + type SelfLabel, } from '#/lib/moderation' import {isWeb} from '#/platform/detection' import {atoms as a, native, useTheme, web} from '#/alf' diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx index c0ce32af3..724149937 100644 --- a/src/view/com/composer/photos/ImageAltTextDialog.tsx +++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {ImageStyle, useWindowDimensions, View} from 'react-native' +import {type ImageStyle, useWindowDimensions, View} from 'react-native' import {Image} from 'expo-image' import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -7,12 +7,12 @@ import {useLingui} from '@lingui/react' import {MAX_ALT_TEXT} from '#/lib/constants' import {enforceLen} from '#/lib/strings/helpers' import {isAndroid, isWeb} from '#/platform/detection' -import {ComposerImage} from '#/state/gallery' +import {type ComposerImage} from '#/state/gallery' import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {DialogControlProps} from '#/components/Dialog' +import {type DialogControlProps} from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {Text} from '#/components/Typography' diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index 1c9440eb1..8bd1aa27b 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -8,7 +8,7 @@ import {useCameraPermission} from '#/lib/hooks/usePermissions' import {openCamera} from '#/lib/media/picker' import {logger} from '#/logger' import {isMobileWeb, isNative} from '#/platform/detection' -import {ComposerImage, createComposerImage} from '#/state/gallery' +import {type ComposerImage, createComposerImage} from '#/state/gallery' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera' diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 5184047cb..15f5539c9 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -46,7 +46,6 @@ import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {Link, TextLink} from '#/view/com/util/Link' import {formatCount} from '#/view/com/util/numeric/format' -import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' import {PostMeta} from '#/view/com/util/PostMeta' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' @@ -62,6 +61,7 @@ import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' import {PostHider} from '#/components/moderation/PostHider' import {type AppModerationCause} from '#/components/Pills' +import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {PostControls} from '#/components/PostControls' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' @@ -465,7 +465,7 @@ let PostThreadItemLoaded = ({ ) : undefined} {post.embed && ( - - ) : undefined} {post.embed ? ( - - void diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index b91d7a7ad..757d952a1 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -3,8 +3,8 @@ import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native' import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated' import {type AppBskyEmbedImages} from '@atproto/api' -import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' import {atoms as a, useBreakpoints} from '#/alf' +import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {type Dimensions} from '../../lightbox/ImageViewing/@types' import {GalleryItem} from './Gallery' diff --git a/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx b/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx deleted file mode 100644 index a038403b2..000000000 --- a/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx +++ /dev/null @@ -1,114 +0,0 @@ -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(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 {children} -} - -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), - } -} diff --git a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx deleted file mode 100644 index 39c1d109e..000000000 --- a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx +++ /dev/null @@ -1,147 +0,0 @@ -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 ExternalGifEmbed({ - 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(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 ( - <> - - - - - - {(!isPrefetched || !isAnimating) && ( - - - - {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active - - ) : ( - // Activity indicator while gif loads - - )} - - )} - - - ) -} diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx deleted file mode 100644 index 7ca11f60d..000000000 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ /dev/null @@ -1,182 +0,0 @@ -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 {ExternalGifEmbed} from '#/view/com/util/post-embeds/ExternalGifEmbed' -import {ExternalPlayer} from '#/view/com/util/post-embeds/ExternalPlayerEmbed' -import {GifEmbed} from '#/view/com/util/post-embeds/GifEmbed' -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' - -export const ExternalLinkEmbed = ({ - link, - onOpen, - style, - hideAlt, -}: { - link: AppBskyEmbedExternal.ViewExternal - onOpen?: () => void - style?: StyleProp - 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 ( - - - - ) - } - - return ( - - {({hovered}) => ( - - {imageUri && !embedPlayerParams ? ( - - ) : undefined} - - {embedPlayerParams?.isGif ? ( - - ) : embedPlayerParams ? ( - - ) : undefined} - - - - {!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && ( - - {link.title || link.uri} - - )} - {link.description ? ( - - {link.description} - - ) : undefined} - - - - - - - {toNiceDomain(link.uri)} - - - - - - )} - - ) -} diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx deleted file mode 100644 index e78abdf17..000000000 --- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx +++ /dev/null @@ -1,281 +0,0 @@ -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 {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' -import {EventStopper} from '../EventStopper' - -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 ( - - - {!isPlayerActive ? ( - - ) : ( - - )} - - - ) -} - -// 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 ( - - - - ) -} - -// 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() - 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 ( - <> - - - - {link.thumb && (!isPlayerActive || isLoading) ? ( - <> - - - - ) : ( - - )} - - - - - ) -} - -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', - }, -}) diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx deleted file mode 100644 index a839294f1..000000000 --- a/src/view/com/util/post-embeds/GifEmbed.tsx +++ /dev/null @@ -1,224 +0,0 @@ -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 ( - - {!isLoaded ? ( - - - - - - ) : !isPlaying ? ( - - ) : undefined} - - ) -} - -export function GifEmbed({ - params, - thumb, - altText, - isPreferredAltText, - hideAlt, - style = {width: '100%'}, -}: { - params: EmbedPlayerParams - thumb: string | undefined - altText: string - isPreferredAltText: boolean - hideAlt?: boolean - style?: StyleProp -}) { - const t = useTheme() - const {_} = useLingui() - const autoplayDisabled = useAutoplayDisabled() - - const playerRef = React.useRef(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 ( - - - - - {!playerState.isPlaying && ( - - )} - {!hideAlt && isPreferredAltText && } - - - ) -} - -function AltText({text}: {text: string}) { - const control = Prompt.usePromptControl() - const largeAltBadge = useLargeAltBadgeEnabled() - - const {_} = useLingui() - return ( - <> - - - ALT - - - - - Alt Text - - {text} - - control.close()} - cta={_(msg`Close`)} - color="secondary" - /> - - - - ) -} - -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', - }, -}) diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx deleted file mode 100644 index f788af1f8..000000000 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ /dev/null @@ -1,337 +0,0 @@ -import React from 'react' -import { - StyleProp, - StyleSheet, - TouchableOpacity, - View, - ViewStyle, -} from 'react-native' -import { - AppBskyEmbedExternal, - AppBskyEmbedImages, - AppBskyEmbedRecord, - AppBskyEmbedRecordWithMedia, - AppBskyEmbedVideo, - AppBskyFeedDefs, - AppBskyFeedPost, - moderatePost, - ModerationDecision, - RichText as RichTextAPI, -} from '@atproto/api' -import {AtUri} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useQueryClient} from '@tanstack/react-query' - -import {HITSLOP_20} from '#/lib/constants' -import {usePalette} from '#/lib/hooks/usePalette' -import {InfoCircleIcon} from '#/lib/icons' -import {makeProfileLink} from '#/lib/routes/links' -import {s} from '#/lib/styles' -import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {precacheProfile} from '#/state/queries/profile' -import {useResolveLinkQuery} from '#/state/queries/resolve-link' -import {useSession} from '#/state/session' -import {atoms as a, useTheme} from '#/alf' -import {RichText} from '#/components/RichText' -import {SubtleWebHover} from '#/components/SubtleWebHover' -import * as bsky from '#/types/bsky' -import {ContentHider} from '../../../../components/moderation/ContentHider' -import {PostAlerts} from '../../../../components/moderation/PostAlerts' -import {Link} from '../Link' -import {PostMeta} from '../PostMeta' -import {Text} from '../text/Text' -import {PostEmbeds} from '.' -import {QuoteEmbedViewContext} from './types' - -export function MaybeQuoteEmbed({ - embed, - onOpen, - style, - allowNestedQuotes, - viewContext, -}: { - embed: AppBskyEmbedRecord.View - onOpen?: () => void - style?: StyleProp - allowNestedQuotes?: boolean - viewContext?: QuoteEmbedViewContext -}) { - const t = useTheme() - const pal = usePalette('default') - const {currentAccount} = useSession() - if ( - AppBskyEmbedRecord.isViewRecord(embed.record) && - AppBskyFeedPost.isRecord(embed.record.value) && - AppBskyFeedPost.validateRecord(embed.record.value).success - ) { - return ( - - ) - } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) { - return ( - - - - Blocked - - - ) - } else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) { - return ( - - - - Deleted - - - ) - } else if (AppBskyEmbedRecord.isViewDetached(embed.record)) { - const isViewerOwner = currentAccount?.did - ? embed.record.uri.includes(currentAccount.did) - : false - return ( - - - - {isViewerOwner ? ( - Removed by you - ) : ( - Removed by author - )} - - - ) - } - return null -} - -function QuoteEmbedModerated({ - viewRecord, - onOpen, - style, - allowNestedQuotes, - viewContext, -}: { - viewRecord: AppBskyEmbedRecord.ViewRecord - onOpen?: () => void - style?: StyleProp - allowNestedQuotes?: boolean - viewContext?: QuoteEmbedViewContext -}) { - const moderationOpts = useModerationOpts() - const postView = React.useMemo( - () => viewRecordToPostView(viewRecord), - [viewRecord], - ) - const moderation = React.useMemo(() => { - return moderationOpts ? moderatePost(postView, moderationOpts) : undefined - }, [postView, moderationOpts]) - - return ( - - ) -} - -export function QuoteEmbed({ - quote, - moderation, - onOpen, - style, - allowNestedQuotes, -}: { - quote: AppBskyFeedDefs.PostView - moderation?: ModerationDecision - onOpen?: () => void - style?: StyleProp - allowNestedQuotes?: boolean - viewContext?: QuoteEmbedViewContext -}) { - 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( - quote.record, - AppBskyFeedPost.isRecord, - ) - ) - return undefined - const {text, facets} = quote.record - return text.trim() - ? new RichTextAPI({text: text, facets: facets}) - : undefined - }, [quote.record]) - - const embed = React.useMemo(() => { - const e = quote.embed - - if (allowNestedQuotes) { - return e - } else { - if ( - AppBskyEmbedImages.isView(e) || - AppBskyEmbedExternal.isView(e) || - AppBskyEmbedVideo.isView(e) - ) { - return e - } else if ( - AppBskyEmbedRecordWithMedia.isView(e) && - (AppBskyEmbedImages.isView(e.media) || - AppBskyEmbedExternal.isView(e.media) || - AppBskyEmbedVideo.isView(e.media)) - ) { - return e.media - } - } - }, [quote.embed, allowNestedQuotes]) - - const onBeforePress = React.useCallback(() => { - precacheProfile(queryClient, quote.author) - onOpen?.() - }, [queryClient, quote.author, onOpen]) - - const [hover, setHover] = React.useState(false) - return ( - { - setHover(true) - }} - onPointerLeave={() => { - setHover(false) - }}> - - - - - - - {moderation ? ( - - ) : null} - {richText ? ( - - ) : null} - {embed && } - - - - ) -} - -export function QuoteX({onRemove}: {onRemove: () => void}) { - const {_} = useLingui() - return ( - - - - ) -} - -export function LazyQuoteEmbed({uri}: {uri: string}) { - const {data} = useResolveLinkQuery(uri) - const moderationOpts = useModerationOpts() - if (!data || data.type !== 'record' || data.kind !== 'post') { - return null - } - const moderation = moderationOpts - ? moderatePost(data.view, moderationOpts) - : undefined - return -} - -function viewRecordToPostView( - viewRecord: AppBskyEmbedRecord.ViewRecord, -): AppBskyFeedDefs.PostView { - const {value, embeds, ...rest} = viewRecord - return { - ...rest, - $type: 'app.bsky.feed.defs#postView', - record: value, - embed: embeds?.[0], - } -} - -const styles = StyleSheet.create({ - errorContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - borderRadius: 8, - marginTop: 8, - paddingVertical: 14, - paddingHorizontal: 14, - borderWidth: StyleSheet.hairlineWidth, - }, - alert: { - marginBottom: 6, - }, -}) diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx deleted file mode 100644 index b45027089..000000000 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ /dev/null @@ -1,167 +0,0 @@ -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 {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' -import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' -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 {ErrorBoundary} from '../ErrorBoundary' -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) => ( - 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 = ( - - - - ) - - return ( - - {cropDisabled ? ( - - {contents} - - ) : ( - - {contents} - - )} - - ) -} - -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 ( - <> - - - {showOverlay && ( - - )} - - - ) -} - -function VideoError({retry}: {error: unknown; retry: () => void}) { - return ( - - - - An error occurred while loading the video. Please try again later. - - - - - ) -} diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx deleted file mode 100644 index b0ded6754..000000000 --- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx +++ /dev/null @@ -1,207 +0,0 @@ -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 {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' -import { - HLSUnsupportedError, - VideoEmbedInnerWeb, - VideoNotFoundError, -} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb' -import {atoms as a} from '#/alf' -import {useIsWithinMessage} from '#/components/dms/MessageContext' -import {useFullscreen} from '#/components/hooks/useFullscreen' -import {ErrorBoundary} from '../ErrorBoundary' -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(null) - const {active, setActive, sendPosition, currentActiveView} = - useActiveVideoWeb() - const [onScreen, setOnScreen] = useState(false) - const [isFullscreen] = useFullscreen() - const lastKnownTime = useRef() - - 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) => ( - 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 = ( -
evt.stopPropagation()}> - - - - - -
- ) - - return ( - - {cropDisabled ? ( - - {contents} - - ) : ( - - {contents} - - )} - - ) -} - -/** - * 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(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 ( - - {nearScreen && children} -
- - ) -} - -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 ( - - {text} - {showRetryButton && } - - ) -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx deleted file mode 100644 index 95401309f..000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx +++ /dev/null @@ -1,64 +0,0 @@ -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 -}) { - 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 ( - - - {`${minutes}:${seconds}`} - - - ) -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx deleted file mode 100644 index 8b44f5448..000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ /dev/null @@ -1,210 +0,0 @@ -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 {useVideoMuteState} from '#/view/com/util/post-embeds/VideoVolumeContext' -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 {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(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() - - React.useImperativeHandle(ref, () => ({ - togglePlayback: () => { - videoRef.current?.togglePlayback() - }, - })) - - if (error) { - throw new Error(error) - } - - return ( - - { - 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="" - /> - { - videoRef.current?.enterFullscreen(true) - }} - toggleMuted={() => { - videoRef.current?.toggleMuted() - }} - togglePlayback={() => { - videoRef.current?.togglePlayback() - }} - isPlaying={isPlaying} - timeRemaining={timeRemaining} - /> - - - ) - }, -) - -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 ( - - - - {isPlaying ? ( - - ) : ( - - )} - - {showTime && } - - - {muted ? ( - - ) : ( - - )} - - - ) -} - -function ControlButton({ - onPress, - children, - label, - accessibilityHint, - style, -}: { - onPress: () => void - children: React.ReactNode - label: string - accessibilityHint: string - style?: StyleProp -}) { - return ( - - - {children} - - - ) -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx deleted file mode 100644 index 2760c7faf..000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function VideoEmbedInnerNative() { - throw new Error('VideoEmbedInnerNative may not be used on web.') -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx deleted file mode 100644 index 8664aae14..000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function VideoEmbedInnerWeb() { - throw new Error('VideoEmbedInnerWeb may not be used on native.') -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx deleted file mode 100644 index ce3a7b2c9..000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ /dev/null @@ -1,307 +0,0 @@ -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 -}) { - const containerRef = useRef(null) - const videoRef = useRef(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(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 ( - -
-
-
- -
- -
- ) -} - -export class HLSUnsupportedError extends Error { - constructor() { - super('HLS is not supported') - } -} - -export class VideoNotFoundError extends Error { - constructor() { - super('Video not found') - } -} - -type CachedPromise = Promise & {value: undefined | T} -const promiseForHls = import( - // @ts-ignore - 'hls.js/dist/hls.min' -).then(mod => mod.default) as CachedPromise -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 - setHlsLoading: (v: boolean) => void -}) { - const [Hls, setHls] = useState( - () => promiseForHls.value, - ) - useEffect(() => { - if (!Hls) { - setHlsLoading(true) - promiseForHls.then(loadedHls => { - setHls(() => loadedHls) - setHlsLoading(false) - }) - } - }, [Hls, setHlsLoading]) - - const hlsRef = useRef(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 -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx deleted file mode 100644 index 1b46163cc..000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx +++ /dev/null @@ -1,61 +0,0 @@ -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 ( - - {children} - - ) -} - -export function Text({children}: {children: React.ReactNode}) { - const t = useTheme() - return ( - - {children} - - ) -} - -export function RetryButton({onPress}: {onPress: () => void}) { - const {_} = useLingui() - - return ( - - ) -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/bandwidth-estimate.ts b/src/view/com/util/post-embeds/VideoEmbedInner/bandwidth-estimate.ts deleted file mode 100644 index 122e10aef..000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/bandwidth-estimate.ts +++ /dev/null @@ -1,11 +0,0 @@ -let latestBandwidthEstimate: number | undefined - -export function get() { - return latestBandwidthEstimate -} - -export function set(estimate: number) { - if (!isNaN(estimate)) { - latestBandwidthEstimate = estimate - } -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx deleted file mode 100644 index 651046445..000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react' -import {SvgProps} from 'react-native-svg' - -import {atoms as a, useTheme, web} from '#/alf' -import {PressableWithHover} from '../../../PressableWithHover' - -export function ControlButton({ - active, - activeLabel, - inactiveLabel, - activeIcon: ActiveIcon, - inactiveIcon: InactiveIcon, - onPress, -}: { - active: boolean - activeLabel: string - inactiveLabel: string - activeIcon: React.ComponentType> - inactiveIcon: React.ComponentType> - onPress: () => void -}) { - const t = useTheme() - return ( - - {active ? ( - - ) : ( - - )} - - ) -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx deleted file mode 100644 index 96960bad4..000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx +++ /dev/null @@ -1,238 +0,0 @@ -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(null) - const circleRef = useRef(null) - - const seek = useCallback( - (evt: React.PointerEvent) => { - 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) => { - 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) => { - if (isSeekingRef.current) { - evt.preventDefault() - seek(evt) - } - }, - [seek], - ) - - const onPointerUp = useCallback( - (evt: React.PointerEvent) => { - 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 ( - -
- - {duration > 0 && ( - - )} - -
- -
-
-
- ) -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx deleted file mode 100644 index e2e24ed36..000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function Controls() { - throw new Error('VideoWebControls may not be used on native.') -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx deleted file mode 100644 index 6d14deafc..000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx +++ /dev/null @@ -1,427 +0,0 @@ -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 - hlsRef: React.RefObject - active: boolean - setActive: () => void - focused: boolean - setFocused: (focused: boolean) => void - onScreen: boolean - fullscreenRef: React.RefObject - 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>() - 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) => { - if (evt.pointerType !== 'mouse' && !hovered) { - evt.preventDefault() - } - clearTimeout(timeoutRef.current) - }, - [hovered], - ) - - const timeoutRef = useRef>() - - const onHoverWithTimeout = useCallback(() => { - onHover() - clearTimeout(timeoutRef.current) - }, [onHover]) - - const onEndHoverWithTimeout = useCallback( - (evt: React.PointerEvent) => { - // 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 ( -
{ - evt.stopPropagation() - setInteractingViaKeypress(false) - }} - onPointerEnter={onHoverWithTimeout} - onPointerMove={onHoverWithTimeout} - onPointerLeave={onEndHoverWithTimeout} - onPointerDown={onPointerDown} - onFocus={onFocus} - onBlur={onBlur} - onKeyDown={onKeyDown}> - - {!showControls && !focused && duration > 0 && ( - - )} - - {(!volumeHovered || isTouchDevice) && ( - - )} - - - - - {formatTime(currentTime)} / {formatTime(duration)} - - {hasSubtitleTrack && ( - - )} - - {!isIPhoneWeb && ( - - )} - - - {(showSpinner || error) && ( - - {showSpinner && } - {error && ( - - An error occurred - - )} - - )} -
- ) -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx deleted file mode 100644 index 90ffb9e6b..000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx +++ /dev/null @@ -1,110 +0,0 @@ -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 '../../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) => { - 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 ( - - {hovered && !isTouchDevice && ( - - - - - - )} - - - ) -} - -function sliderVolumeToVideoVolume(value: number) { - return Math.pow(value / 100, 4) -} - -function videoVolumeToSliderVolume(value: number) { - return Math.round(Math.pow(value, 1 / 4) * 100) -} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx deleted file mode 100644 index 108814ea2..000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react' - -import {isSafari} from '#/lib/browser' -import {useVideoVolumeState} from '../../VideoVolumeContext' - -export function useVideoElement(ref: React.RefObject) { - 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 | 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}` -} diff --git a/src/view/com/util/post-embeds/VideoVolumeContext.tsx b/src/view/com/util/post-embeds/VideoVolumeContext.tsx deleted file mode 100644 index 6343081da..000000000 --- a/src/view/com/util/post-embeds/VideoVolumeContext.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react' - -const Context = React.createContext<{ - // native - muted: boolean - setMuted: React.Dispatch> - // web - volume: number - setVolume: React.Dispatch> -} | 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 {children} -} - -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 -} diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx deleted file mode 100644 index 4cf71f948..000000000 --- a/src/view/com/util/post-embeds/index.tsx +++ /dev/null @@ -1,327 +0,0 @@ -import React from 'react' -import { - InteractionManager, - type StyleProp, - StyleSheet, - View, - type ViewStyle, -} from 'react-native' -import { - type AnimatedRef, - measure, - type MeasuredDimensions, - runOnJS, - runOnUI, -} from 'react-native-reanimated' -import {Image} from 'expo-image' -import { - AppBskyEmbedExternal, - AppBskyEmbedImages, - AppBskyEmbedRecord, - AppBskyEmbedRecordWithMedia, - AppBskyEmbedVideo, - AppBskyFeedDefs, - AppBskyGraphDefs, - moderateFeedGenerator, - moderateUserList, - type ModerationDecision, -} from '@atproto/api' - -import {usePalette} from '#/lib/hooks/usePalette' -import {useLightboxControls} from '#/state/lightbox' -import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' -import {atoms as a, useTheme} from '#/alf' -import * as ListCard from '#/components/ListCard' -import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard' -import {ContentHider} from '../../../../components/moderation/ContentHider' -import {type Dimensions} from '../../lightbox/ImageViewing/@types' -import {AutoSizedImage} from '../images/AutoSizedImage' -import {ImageLayoutGrid} from '../images/ImageLayoutGrid' -import {ExternalLinkEmbed} from './ExternalLinkEmbed' -import {MaybeQuoteEmbed} from './QuoteEmbed' -import {PostEmbedViewContext, QuoteEmbedViewContext} from './types' -import {VideoEmbed} from './VideoEmbed' - -export * from './types' - -type Embed = - | AppBskyEmbedRecord.View - | AppBskyEmbedImages.View - | AppBskyEmbedVideo.View - | AppBskyEmbedExternal.View - | AppBskyEmbedRecordWithMedia.View - | {$type: string; [k: string]: unknown} - -export function PostEmbeds({ - embed, - moderation, - onOpen, - style, - allowNestedQuotes, - viewContext, -}: { - embed?: Embed - moderation?: ModerationDecision - onOpen?: () => void - style?: StyleProp - allowNestedQuotes?: boolean - viewContext?: PostEmbedViewContext -}) { - const {openLightbox} = useLightboxControls() - - // quote post with media - // = - if (AppBskyEmbedRecordWithMedia.isView(embed)) { - return ( - - - - - ) - } - - if (AppBskyEmbedRecord.isView(embed)) { - // custom feed embed (i.e. generator view) - if (AppBskyFeedDefs.isGeneratorView(embed.record)) { - return ( - - - - ) - } - - // list embed - if (AppBskyGraphDefs.isListView(embed.record)) { - return ( - - - - ) - } - - // starter pack embed - if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) { - return ( - - - - ) - } - - // quote post - // = - return ( - - ) - } - - // image embed - // = - if (AppBskyEmbedImages.isView(embed)) { - const {images} = embed - - if (images.length > 0) { - const items = embed.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[], - 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 ( - - - - onPress(0, [containerRef], [dims]) - } - onPressIn={() => onPressIn(0)} - hideBadge={ - viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia - } - /> - - - ) - } - - return ( - - - - - - ) - } - } - - // external link embed - // = - if (AppBskyEmbedExternal.isView(embed)) { - const link = embed.external - return ( - - - - ) - } - - // video embed - // = - if (AppBskyEmbedVideo.isView(embed)) { - return ( - - - - ) - } - - return -} - -export function MaybeFeedCard({view}: {view: AppBskyFeedDefs.GeneratorView}) { - const pal = usePalette('default') - const moderationOpts = useModerationOpts() - const moderation = React.useMemo(() => { - return moderationOpts - ? moderateFeedGenerator(view, moderationOpts) - : undefined - }, [view, moderationOpts]) - - return ( - - - - ) -} - -export function MaybeListCard({view}: {view: AppBskyGraphDefs.ListView}) { - const moderationOpts = useModerationOpts() - const moderation = React.useMemo(() => { - return moderationOpts ? moderateUserList(view, moderationOpts) : undefined - }, [view, moderationOpts]) - const t = useTheme() - - return ( - - - - - - ) -} - -const styles = StyleSheet.create({ - altContainer: { - backgroundColor: 'rgba(0, 0, 0, 0.75)', - borderRadius: 6, - paddingHorizontal: 6, - paddingVertical: 3, - position: 'absolute', - right: 6, - bottom: 6, - }, - alt: { - color: 'white', - fontSize: 7, - fontWeight: '600', - }, - customFeedOuter: { - borderWidth: StyleSheet.hairlineWidth, - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 12, - }, -}) diff --git a/src/view/com/util/post-embeds/types.ts b/src/view/com/util/post-embeds/types.ts deleted file mode 100644 index 08e903276..000000000 --- a/src/view/com/util/post-embeds/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -export enum PostEmbedViewContext { - ThreadHighlighted = 'ThreadHighlighted', - Feed = 'Feed', - FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia', -} - -export enum QuoteEmbedViewContext { - FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia, -} -- cgit 1.4.1