diff options
author | Eric Bailey <git@esb.lol> | 2025-06-13 12:05:41 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-06-13 12:05:41 -0500 |
commit | 45f0f7eefecae1922c2f30d4e7760d2b93b1ae56 (patch) | |
tree | a2fd6917867f18fe334b54dd3289775c2930bc85 /src/view | |
parent | ba0f5a9bdef5bd0447ded23cab1af222b65511cc (diff) | |
download | voidsky-45f0f7eefecae1922c2f30d4e7760d2b93b1ae56.tar.zst |
Port post embeds to new arch (#7408)
* Direct port of embeds to new arch (cherry picked from commit cc3fa1f6cea396dd9222486c633a508bfee1ecd6) * Re-org * Split out ListEmbed and FeedEmbed * Split out ImageEmbed * DRY up a bit * Port over ExternalLinkEmbed * Port over Player and Gif embeds * Migrate ComposerReplyTo * Replace other usages of old post-embeds * Migrate view contexts * Copy pasta VideoEmbed * Copy pasta GifEmbed * Swap in new file location * Clean up * Fix up native * Add back in correct moderation on List and Feed embeds * Format * Prettier * delete old video utils * move bandwidth-estimate.ts * Remove log * Add LazyQuoteEmbed for composer use * Clean up unused things * Remove remaining items * Prettier * Fix imports * Handle nested quotes same as prod * Add back silenced error handling * Fix lint --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src/view')
38 files changed, 84 insertions, 3804 deletions
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({ )} </LayoutAnimationConfig> {embed.quote?.uri ? ( - <View style={!video ? [a.mt_md] : []}> - <View style={[s.mt5, s.mb2, isWeb && s.mb10]}> + <View + style={[a.pb_sm, video ? [a.pt_md] : [a.pt_xl], isWeb && [a.pb_md]]}> + <View style={[a.relative]}> <View style={{pointerEvents: 'none'}}> <LazyQuoteEmbed uri={embed.quote.uri} /> </View> {canRemoveQuote && ( - <QuoteX onRemove={() => dispatch({type: 'embed_remove_quote'})} /> + <ExternalEmbedRemoveBtn + onRemove={() => dispatch({type: 'embed_remove_quote'})} + style={{top: 16}} + /> )} </View> </View> 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}) { <ComposerReplyToImages images={images} showFull={showFull} /> )} </View> - {showFull && quoteEmbed && <MaybeQuoteEmbed embed={quoteEmbed} />} + {showFull && parsedQuoteEmbed && parsedQuoteEmbed.type === 'post' && ( + <QuoteEmbed embed={parsedQuoteEmbed} /> + )} </View> </Pressable> ) 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 = ({ <View style={[a.overflow_hidden, t.atoms.border_contrast_medium]}> {linkInfo ? ( <View style={{pointerEvents: 'auto'}}> - <ExternalLinkEmbed link={linkInfo} hideAlt /> + <ExternalEmbed link={linkInfo} hideAlt /> </View> ) : error ? ( <Container style={[a.align_start, a.p_md, a.gap_xs]}> @@ -80,7 +81,7 @@ export const ExternalEmbedLink = ({ if (data) { if (data.type === 'external') { return ( - <ExternalLinkEmbed + <ExternalEmbed link={{ title: data.title || uri, uri, @@ -91,9 +92,29 @@ export const ExternalEmbedLink = ({ /> ) } else if (data.kind === 'feed') { - return <MaybeFeedCard view={data.view} /> + return ( + <ModeratedFeedEmbed + embed={{ + type: 'feed', + view: { + $type: 'app.bsky.feed.defs#generatorView', + ...data.view, + }, + }} + /> + ) } else if (data.kind === 'list') { - return <MaybeListCard view={data.view} /> + return ( + <ModeratedListEmbed + embed={{ + type: 'list', + view: { + $type: 'app.bsky.graph.defs#listView', + ...data.view, + }, + }} + /> + ) } else if (data.kind === 'starter-pack') { return <StarterPackEmbed starterPack={data.view} /> } 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 ( - <View style={[a.absolute, {top: 8, right: 8}, a.z_50]}> + <View style={[a.absolute, {top: 8, right: 8}, a.z_50, style]}> <Button label={_(msg`Remove attachment`)} onPress={onRemove} size="small" variant="solid" color="secondary" - shape="round"> + shape="round" + style={[t.atoms.shadow_sm]}> <ButtonIcon icon={X} size="sm" /> </Button> </View> 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 && ( <View style={[a.py_xs]}> - <PostEmbeds + <Embed embed={post.embed} moderation={moderation} viewContext={PostEmbedViewContext.ThreadHighlighted} @@ -697,7 +697,7 @@ let PostThreadItemLoaded = ({ ) : undefined} {post.embed && ( <View style={[a.pb_xs]}> - <PostEmbeds + <Embed embed={post.embed} moderation={moderation} viewContext={PostEmbedViewContext.Feed} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 1a48d64d8..d92ea6a9d 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -28,7 +28,6 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts' import {precacheProfile} from '#/state/queries/profile' import {useSession} from '#/state/session' import {Link, TextLink} from '#/view/com/util/Link' -import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' import {PostMeta} from '#/view/com/util/PostMeta' import {Text} from '#/view/com/util/text/Text' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' @@ -37,6 +36,7 @@ import {atoms as a} from '#/alf' import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' +import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {PostControls} from '#/components/PostControls' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {RichText} from '#/components/RichText' @@ -248,7 +248,7 @@ function PostInner({ /> ) : undefined} {post.embed ? ( - <PostEmbeds + <Embed embed={post.embed} moderation={moderation} viewContext={PostEmbedViewContext.Feed} diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index fd0d1c707..a5a7a777e 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -42,7 +42,6 @@ import { } from '#/state/unstable-post-source' import {FeedNameText} from '#/view/com/util/FeedInfoText' import {Link, TextLink, TextLinkOnWebOnly} from '#/view/com/util/Link' -import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' import {PostMeta} from '#/view/com/util/PostMeta' import {Text} from '#/view/com/util/text/Text' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' @@ -53,6 +52,8 @@ import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' import {type AppModerationCause} from '#/components/Pills' +import {Embed} from '#/components/Post/Embed' +import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {PostControls} from '#/components/PostControls' import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug' import {ProfileHoverCard} from '#/components/ProfileHoverCard' @@ -568,7 +569,7 @@ let PostContent = ({ ) : undefined} {postEmbed ? ( <View style={[a.pb_xs]}> - <PostEmbeds + <Embed embed={postEmbed} moderation={moderation} onOpen={onOpenEmbed} diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx index 6a11c7eaa..9a3e8a4ae 100644 --- a/src/view/com/util/Views.web.tsx +++ b/src/view/com/util/Views.web.tsx @@ -14,12 +14,12 @@ import React from 'react' import { - FlatList, - FlatListProps, - ScrollViewProps, + type FlatList, + type FlatListProps, + type ScrollViewProps, StyleSheet, View, - ViewProps, + type ViewProps, } from 'react-native' import Animated from 'react-native-reanimated' diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 1d35c88c5..323264ea4 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -8,9 +8,9 @@ import type React from 'react' import {type Dimensions} from '#/lib/media/types' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' -import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' import {atoms as a, useTheme} from '#/alf' import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {Text} from '#/components/Typography' type EventFunction = (index: number) => 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<string | null>(null) - const activeViewLocationRef = useRef(Infinity) - const {height: windowHeight} = useWindowDimensions() - - // minimising re-renders by using refs - const manuallySetRef = useRef(false) - const activeViewIdRef = useRef(activeViewId) - useEffect(() => { - activeViewIdRef.current = activeViewId - }, [activeViewId]) - - const setActiveView = useCallback( - (viewId: string) => { - setActiveViewId(viewId) - manuallySetRef.current = true - // we don't know the exact position, but it's definitely on screen - // so just guess that it's in the middle. Any value is fine - // so long as it's not offscreen - activeViewLocationRef.current = windowHeight / 2 - }, - [windowHeight], - ) - - const sendViewPosition = useCallback( - (viewId: string, y: number) => { - if (isNative) return - - if (viewId === activeViewIdRef.current) { - activeViewLocationRef.current = y - } else { - if ( - distanceToIdealPosition(y) < - distanceToIdealPosition(activeViewLocationRef.current) - ) { - // if the old view was manually set, only usurp if the old view is offscreen - if ( - manuallySetRef.current && - withinViewport(activeViewLocationRef.current) - ) { - return - } - - setActiveViewId(viewId) - activeViewLocationRef.current = y - manuallySetRef.current = false - } - } - - function distanceToIdealPosition(yPos: number) { - return Math.abs(yPos - windowHeight / 2.5) - } - - function withinViewport(yPos: number) { - return yPos > 0 && yPos < windowHeight - } - }, - [windowHeight], - ) - - const value = useMemo( - () => ({ - activeViewId, - setActiveView, - sendViewPosition, - }), - [activeViewId, setActiveView, sendViewPosition], - ) - - return <Context.Provider value={value}>{children}</Context.Provider> -} - -export function useActiveVideoWeb() { - const context = React.useContext(Context) - if (!context) { - throw new Error( - 'useActiveVideoWeb must be used within a ActiveVideoWebProvider', - ) - } - - const {activeViewId, setActiveView, sendViewPosition} = context - const id = useId() - - return { - active: activeViewId === id, - setActive: () => { - setActiveView(id) - }, - currentActiveView: activeViewId, - sendPosition: (y: number) => sendViewPosition(id, y), - } -} 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<Image>(null) - - const load = React.useCallback(() => { - setIsPlayerActive(true) - Image.prefetch(params.playerUri).then(() => { - // Replace the image once it's fetched - setIsPrefetched(true) - }) - }, [params.playerUri]) - - const onPlayPress = React.useCallback( - (event: GestureResponderEvent) => { - // Don't propagate on web - event.preventDefault() - - // Show consent if this is the first load - if (externalEmbedsPrefs?.[params.source] === undefined) { - consentDialogControl.open() - return - } - // If the player isn't active, we want to activate it and prefetch the gif - if (!isPlayerActive) { - load() - return - } - // Control animation on native - setIsAnimating(prev => { - if (prev) { - if (isNative) { - imageRef.current?.stopAnimating() - } - return false - } else { - if (isNative) { - imageRef.current?.startAnimating() - } - return true - } - }) - }, - [ - consentDialogControl, - externalEmbedsPrefs, - isPlayerActive, - load, - params.source, - ], - ) - - return ( - <> - <EmbedConsentDialog - control={consentDialogControl} - source={params.source} - onAccept={load} - /> - - <Pressable - style={[ - {height: 300}, - a.w_full, - a.overflow_hidden, - { - borderBottomLeftRadius: 0, - borderBottomRightRadius: 0, - }, - ]} - onPress={onPlayPress} - accessibilityRole="button" - accessibilityHint={_(msg`Plays the GIF`)} - accessibilityLabel={_(msg`Play ${link.title}`)}> - <Image - source={{ - uri: - !isPrefetched || (isWeb && !isAnimating) - ? link.thumb - : params.playerUri, - }} // Web uses the thumb to control playback - style={{flex: 1}} - ref={imageRef} - autoplay={isAnimating} - contentFit="contain" - accessibilityIgnoresInvertColors - accessibilityLabel={link.title} - accessibilityHint={link.title} - cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios - /> - - {(!isPrefetched || !isAnimating) && ( - <Fill style={[a.align_center, a.justify_center]}> - <Fill - style={[ - t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg, - { - opacity: 0.3, - }, - ]} - /> - - {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active - <PlayButtonIcon /> - ) : ( - // Activity indicator while gif loads - <ActivityIndicator size="large" color="white" /> - )} - </Fill> - )} - </Pressable> - </> - ) -} 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<ViewStyle> - hideAlt?: boolean -}) => { - const {_} = useLingui() - const t = useTheme() - const playHaptic = useHaptics() - const externalEmbedPrefs = useExternalEmbedsPrefs() - const niceUrl = toNiceDomain(link.uri) - const imageUri = link.thumb - const embedPlayerParams = React.useMemo(() => { - const params = parseEmbedPlayerFromUrl(link.uri) - - if (params && externalEmbedPrefs?.[params.source] !== 'hide') { - return params - } - }, [link.uri, externalEmbedPrefs]) - const hasMedia = Boolean(imageUri || embedPlayerParams) - - const onPress = useCallback(() => { - playHaptic('Light') - onOpen?.() - }, [playHaptic, onOpen]) - - const onShareExternal = useCallback(() => { - if (link.uri && isNative) { - playHaptic('Heavy') - shareUrl(link.uri) - } - }, [link.uri, playHaptic]) - - if (embedPlayerParams?.source === 'tenor') { - const parsedAlt = parseAltFromGIFDescription(link.description) - return ( - <View style={style}> - <GifEmbed - params={embedPlayerParams} - thumb={link.thumb} - altText={parsedAlt.alt} - isPreferredAltText={parsedAlt.isPreferred} - hideAlt={hideAlt} - /> - </View> - ) - } - - return ( - <Link - label={link.title || _(msg`Open link to ${niceUrl}`)} - to={link.uri} - shouldProxy={true} - onPress={onPress} - onLongPress={onShareExternal}> - {({hovered}) => ( - <View - style={[ - a.transition_color, - a.flex_col, - a.rounded_md, - a.overflow_hidden, - a.w_full, - a.border, - style, - hovered - ? t.atoms.border_contrast_high - : t.atoms.border_contrast_low, - ]}> - {imageUri && !embedPlayerParams ? ( - <Image - style={{ - aspectRatio: 1.91, - }} - source={{uri: imageUri}} - accessibilityIgnoresInvertColors - /> - ) : undefined} - - {embedPlayerParams?.isGif ? ( - <ExternalGifEmbed link={link} params={embedPlayerParams} /> - ) : embedPlayerParams ? ( - <ExternalPlayer link={link} params={embedPlayerParams} /> - ) : undefined} - - <View - style={[ - a.flex_1, - a.pt_sm, - {gap: 3}, - hasMedia && a.border_t, - hovered - ? t.atoms.border_contrast_high - : t.atoms.border_contrast_low, - ]}> - <View style={[{gap: 3}, a.pb_xs, a.px_md]}> - {!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && ( - <Text - emoji - numberOfLines={3} - style={[a.text_md, a.font_bold, a.leading_snug]}> - {link.title || link.uri} - </Text> - )} - {link.description ? ( - <Text - emoji - numberOfLines={link.thumb ? 2 : 4} - style={[a.text_sm, a.leading_snug]}> - {link.description} - </Text> - ) : undefined} - </View> - <View style={[a.px_md]}> - <Divider /> - <View - style={[ - a.flex_row, - a.align_center, - a.gap_2xs, - a.pb_sm, - { - paddingTop: 6, // off menu - }, - ]}> - <Globe - size="xs" - style={[ - a.transition_color, - hovered - ? t.atoms.text_contrast_medium - : t.atoms.text_contrast_low, - ]} - /> - <Text - numberOfLines={1} - style={[ - a.transition_color, - a.text_xs, - a.leading_snug, - hovered - ? t.atoms.text_contrast_high - : t.atoms.text_contrast_medium, - ]}> - {toNiceDomain(link.uri)} - </Text> - </View> - </View> - </View> - </View> - )} - </Link> - ) -} 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 ( - <View style={[a.absolute, a.inset_0, styles.overlayLayer]}> - <Pressable - accessibilityRole="button" - accessibilityLabel={_(msg`Play Video`)} - accessibilityHint={_(msg`Plays the video`)} - onPress={onPress} - style={[styles.overlayContainer]}> - {!isPlayerActive ? ( - <PlayButtonIcon /> - ) : ( - <ActivityIndicator size="large" color="white" /> - )} - </Pressable> - </View> - ) -} - -// This renders the webview/youtube player as a separate layer -function Player({ - params, - onLoad, - isPlayerActive, -}: { - isPlayerActive: boolean - params: EmbedPlayerParams - onLoad: () => void -}) { - // ensures we only load what's requested - // when it's a youtube video, we need to allow both bsky.app and youtube.com - const onShouldStartLoadWithRequest = React.useCallback( - (event: ShouldStartLoadRequest) => - event.url === params.playerUri || - (params.source.startsWith('youtube') && - event.url.includes('www.youtube.com')), - [params.playerUri, params.source], - ) - - // Don't show the player until it is active - if (!isPlayerActive) return null - - return ( - <EventStopper style={[a.absolute, a.inset_0, styles.playerLayer]}> - <WebView - javaScriptEnabled={true} - onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} - mediaPlaybackRequiresUserAction={false} - allowsInlineMediaPlayback - bounces={false} - allowsFullscreenVideo - nestedScrollEnabled - source={{uri: params.playerUri}} - onLoad={onLoad} - style={styles.webview} - setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads) - /> - </EventStopper> - ) -} - -// This renders the player area and handles the logic for when to show the player and when to show the overlay -export function ExternalPlayer({ - link, - params, -}: { - link: AppBskyEmbedExternal.ViewExternal - params: EmbedPlayerParams -}) { - const t = useTheme() - const navigation = useNavigation<NavigationProp>() - const insets = useSafeAreaInsets() - const windowDims = useWindowDimensions() - const externalEmbedsPrefs = useExternalEmbedsPrefs() - const consentDialogControl = useDialogControl() - - const [isPlayerActive, setPlayerActive] = React.useState(false) - const [isLoading, setIsLoading] = React.useState(true) - - const aspect = React.useMemo(() => { - return getPlayerAspect({ - type: params.type, - width: windowDims.width, - hasThumb: !!link.thumb, - }) - }, [params.type, windowDims.width, link.thumb]) - - const viewRef = useAnimatedRef() - const frameCallback = useFrameCallback(() => { - const measurement = measure(viewRef) - if (!measurement) return - - const {height: winHeight, width: winWidth} = windowDims - - // Get the proper screen height depending on what is going on - const realWinHeight = isNative // If it is native, we always want the larger number - ? winHeight > winWidth - ? winHeight - : winWidth - : winHeight // On web, we always want the actual screen height - - const top = measurement.pageY - const bot = measurement.pageY + measurement.height - - // We can use the same logic on all platforms against the screenHeight that we get above - const isVisible = top <= realWinHeight - insets.bottom && bot >= insets.top - - if (!isVisible) { - runOnJS(setPlayerActive)(false) - } - }, false) // False here disables autostarting the callback - - // watch for leaving the viewport due to scrolling - React.useEffect(() => { - // We don't want to do anything if the player isn't active - if (!isPlayerActive) return - - // Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will - // continue playing. We need to watch for the blur event - const unsubscribe = navigation.addListener('blur', () => { - setPlayerActive(false) - }) - - // Start watching for changes - frameCallback.setActive(true) - - return () => { - unsubscribe() - frameCallback.setActive(false) - } - }, [navigation, isPlayerActive, frameCallback]) - - const onLoad = React.useCallback(() => { - setIsLoading(false) - }, []) - - const onPlayPress = React.useCallback( - (event: GestureResponderEvent) => { - // Prevent this from propagating upward on web - event.preventDefault() - - if (externalEmbedsPrefs?.[params.source] === undefined) { - consentDialogControl.open() - return - } - - setPlayerActive(true) - }, - [externalEmbedsPrefs, consentDialogControl, params.source], - ) - - const onAcceptConsent = React.useCallback(() => { - setPlayerActive(true) - }, []) - - return ( - <> - <EmbedConsentDialog - control={consentDialogControl} - source={params.source} - onAccept={onAcceptConsent} - /> - - <Animated.View - ref={viewRef} - collapsable={false} - style={[aspect, a.overflow_hidden]}> - {link.thumb && (!isPlayerActive || isLoading) ? ( - <> - <Image - style={[a.flex_1]} - source={{uri: link.thumb}} - accessibilityIgnoresInvertColors - /> - <Fill - style={[ - t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg, - { - opacity: 0.3, - }, - ]} - /> - </> - ) : ( - <Fill - style={[ - { - backgroundColor: - t.name === 'light' ? t.palette.contrast_975 : 'black', - opacity: 0.3, - }, - ]} - /> - )} - <PlaceholderOverlay - isLoading={isLoading} - isPlayerActive={isPlayerActive} - onPress={onPlayPress} - /> - <Player - isPlayerActive={isPlayerActive} - params={params} - onLoad={onLoad} - /> - </Animated.View> - </> - ) -} - -const styles = StyleSheet.create({ - overlayContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - overlayLayer: { - zIndex: 2, - }, - playerLayer: { - zIndex: 3, - }, - webview: { - backgroundColor: 'transparent', - }, - gifContainer: { - width: '100%', - overflow: 'hidden', - }, -}) 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 ( - <Pressable - accessibilityRole="button" - accessibilityHint={_(msg`Plays or pauses the GIF`)} - accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)} - style={[ - a.absolute, - a.align_center, - a.justify_center, - !isLoaded && a.border, - t.atoms.border_contrast_medium, - a.inset_0, - a.w_full, - a.h_full, - { - zIndex: 2, - backgroundColor: !isLoaded - ? t.atoms.bg_contrast_25.backgroundColor - : undefined, - }, - ]} - onPress={onPress}> - {!isLoaded ? ( - <View> - <View style={[a.align_center, a.justify_center]}> - <Loader size="xl" /> - </View> - </View> - ) : !isPlaying ? ( - <PlayButtonIcon /> - ) : undefined} - </Pressable> - ) -} - -export function GifEmbed({ - params, - thumb, - altText, - isPreferredAltText, - hideAlt, - style = {width: '100%'}, -}: { - params: EmbedPlayerParams - thumb: string | undefined - altText: string - isPreferredAltText: boolean - hideAlt?: boolean - style?: StyleProp<ViewStyle> -}) { - const t = useTheme() - const {_} = useLingui() - const autoplayDisabled = useAutoplayDisabled() - - const playerRef = React.useRef<GifView>(null) - - const [playerState, setPlayerState] = React.useState<{ - isPlaying: boolean - isLoaded: boolean - }>({ - isPlaying: !autoplayDisabled, - isLoaded: false, - }) - - const onPlayerStateChange = React.useCallback( - (e: GifViewStateChangeEvent) => { - setPlayerState(e.nativeEvent) - }, - [], - ) - - const onPress = React.useCallback(() => { - playerRef.current?.toggleAsync() - }, []) - - return ( - <View - style={[ - a.rounded_md, - a.overflow_hidden, - a.border, - t.atoms.border_contrast_low, - {aspectRatio: params.dimensions!.width / params.dimensions!.height}, - style, - ]}> - <View - style={[ - a.absolute, - /* - * Aspect ratio was being clipped weirdly on web -esb - */ - { - top: -2, - bottom: -2, - left: -2, - right: -2, - }, - ]}> - <PlaybackControls - onPress={onPress} - isPlaying={playerState.isPlaying} - isLoaded={playerState.isLoaded} - /> - <GifView - source={params.playerUri} - placeholderSource={thumb} - style={[a.flex_1]} - autoplay={!autoplayDisabled} - onPlayerStateChange={onPlayerStateChange} - ref={playerRef} - accessibilityHint={_(msg`Animated GIF`)} - accessibilityLabel={altText} - /> - {!playerState.isPlaying && ( - <Fill - style={[ - t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg, - { - opacity: 0.3, - }, - ]} - /> - )} - {!hideAlt && isPreferredAltText && <AltText text={altText} />} - </View> - </View> - ) -} - -function AltText({text}: {text: string}) { - const control = Prompt.usePromptControl() - const largeAltBadge = useLargeAltBadgeEnabled() - - const {_} = useLingui() - return ( - <> - <TouchableOpacity - testID="altTextButton" - accessibilityRole="button" - accessibilityLabel={_(msg`Show alt text`)} - accessibilityHint="" - hitSlop={HITSLOP_20} - onPress={control.open} - style={styles.altContainer}> - <Text - style={[styles.alt, largeAltBadge && a.text_xs]} - accessible={false}> - <Trans>ALT</Trans> - </Text> - </TouchableOpacity> - <Prompt.Outer control={control}> - <Prompt.TitleText> - <Trans>Alt Text</Trans> - </Prompt.TitleText> - <Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText> - <Prompt.Actions> - <Prompt.Action - onPress={() => control.close()} - cta={_(msg`Close`)} - color="secondary" - /> - </Prompt.Actions> - </Prompt.Outer> - </> - ) -} - -const styles = StyleSheet.create({ - altContainer: { - backgroundColor: 'rgba(0, 0, 0, 0.75)', - borderRadius: 6, - paddingHorizontal: isWeb ? 8 : 6, - paddingVertical: isWeb ? 6 : 3, - position: 'absolute', - // Related to margin/gap hack. This keeps the alt label in the same position - // on all platforms - right: isWeb ? 8 : 5, - bottom: isWeb ? 8 : 5, - zIndex: 2, - }, - alt: { - color: 'white', - fontSize: isWeb ? 10 : 7, - fontWeight: '600', - }, -}) 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<ViewStyle> - 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 ( - <QuoteEmbedModerated - viewRecord={embed.record} - onOpen={onOpen} - style={style} - allowNestedQuotes={allowNestedQuotes} - viewContext={viewContext} - /> - ) - } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) { - return ( - <View - style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}> - <InfoCircleIcon size={18} style={pal.text} /> - <Text type="lg" style={pal.text}> - <Trans>Blocked</Trans> - </Text> - </View> - ) - } else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) { - return ( - <View - style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}> - <InfoCircleIcon size={18} style={pal.text} /> - <Text type="lg" style={pal.text}> - <Trans>Deleted</Trans> - </Text> - </View> - ) - } else if (AppBskyEmbedRecord.isViewDetached(embed.record)) { - const isViewerOwner = currentAccount?.did - ? embed.record.uri.includes(currentAccount.did) - : false - return ( - <View - style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}> - <InfoCircleIcon size={18} style={pal.text} /> - <Text type="lg" style={pal.text}> - {isViewerOwner ? ( - <Trans>Removed by you</Trans> - ) : ( - <Trans>Removed by author</Trans> - )} - </Text> - </View> - ) - } - return null -} - -function QuoteEmbedModerated({ - viewRecord, - onOpen, - style, - allowNestedQuotes, - viewContext, -}: { - viewRecord: AppBskyEmbedRecord.ViewRecord - onOpen?: () => void - style?: StyleProp<ViewStyle> - 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 ( - <QuoteEmbed - quote={postView} - moderation={moderation} - onOpen={onOpen} - style={style} - allowNestedQuotes={allowNestedQuotes} - viewContext={viewContext} - /> - ) -} - -export function QuoteEmbed({ - quote, - moderation, - onOpen, - style, - allowNestedQuotes, -}: { - quote: AppBskyFeedDefs.PostView - moderation?: ModerationDecision - onOpen?: () => void - style?: StyleProp<ViewStyle> - 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<AppBskyFeedPost.Record>( - quote.record, - AppBskyFeedPost.isRecord, - ) - ) - return undefined - const {text, facets} = quote.record - return text.trim() - ? new RichTextAPI({text: text, facets: facets}) - : undefined - }, [quote.record]) - - const 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 ( - <View - onPointerEnter={() => { - setHover(true) - }} - onPointerLeave={() => { - setHover(false) - }}> - <ContentHider - modui={moderation?.ui('contentList')} - style={[ - a.rounded_md, - a.p_md, - a.mt_sm, - a.border, - t.atoms.border_contrast_low, - style, - ]} - childContainerStyle={[a.pt_sm]}> - <SubtleWebHover hover={hover} /> - <Link - hoverStyle={{borderColor: pal.colors.borderLinkHover}} - href={itemHref} - title={itemTitle} - onBeforePress={onBeforePress}> - <View pointerEvents="none"> - <PostMeta - author={quote.author} - moderation={moderation} - showAvatar - postHref={itemHref} - timestamp={quote.indexedAt} - /> - </View> - {moderation ? ( - <PostAlerts - modui={moderation.ui('contentView')} - style={[a.py_xs]} - /> - ) : null} - {richText ? ( - <RichText - value={richText} - style={a.text_md} - numberOfLines={20} - disableLinks - /> - ) : null} - {embed && <PostEmbeds embed={embed} moderation={moderation} />} - </Link> - </ContentHider> - </View> - ) -} - -export function QuoteX({onRemove}: {onRemove: () => void}) { - const {_} = useLingui() - return ( - <TouchableOpacity - style={[ - a.absolute, - a.p_xs, - a.rounded_full, - a.align_center, - a.justify_center, - { - top: 16, - right: 10, - backgroundColor: 'rgba(0, 0, 0, 0.75)', - }, - ]} - onPress={onRemove} - accessibilityRole="button" - accessibilityLabel={_(msg`Remove quote`)} - accessibilityHint={_(msg`Removes quoted post`)} - onAccessibilityEscape={onRemove} - hitSlop={HITSLOP_20}> - <FontAwesomeIcon size={12} icon="xmark" style={s.white} /> - </TouchableOpacity> - ) -} - -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 <QuoteEmbed quote={data.view} moderation={moderation} /> -} - -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) => ( - <VideoError error={error} retry={() => setKey(key + 1)} /> - ), - [key], - ) - - let aspectRatio: number | undefined - const dims = embed.aspectRatio - if (dims) { - aspectRatio = dims.width / dims.height - if (Number.isNaN(aspectRatio)) { - aspectRatio = undefined - } - } - - let constrained: number | undefined - let max: number | undefined - if (aspectRatio !== undefined) { - const ratio = 1 / 2 // max of 1:2 ratio in feeds - constrained = Math.max(aspectRatio, ratio) - max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread - } - const cropDisabled = crop === 'none' - - const contents = ( - <ErrorBoundary renderError={renderError} key={key}> - <InnerWrapper embed={embed} /> - </ErrorBoundary> - ) - - return ( - <View style={[a.pt_xs]}> - {cropDisabled ? ( - <View - style={[ - a.w_full, - a.overflow_hidden, - {aspectRatio: max ?? 1}, - a.rounded_md, - a.overflow_hidden, - t.atoms.bg_contrast_25, - ]}> - {contents} - </View> - ) : ( - <ConstrainedImage - fullBleed={crop === 'square'} - aspectRatio={constrained || 1}> - {contents} - </ConstrainedImage> - )} - </View> - ) -} - -function InnerWrapper({embed}: Props) { - const {_} = useLingui() - const ref = React.useRef<{togglePlayback: () => void}>(null) - - const [status, setStatus] = React.useState<'playing' | 'paused' | 'pending'>( - 'pending', - ) - const [isLoading, setIsLoading] = React.useState(false) - const [isActive, setIsActive] = React.useState(false) - const showSpinner = useThrottledValue(isActive && isLoading, 100) - - const showOverlay = - !isActive || - isLoading || - (status === 'paused' && !isActive) || - status === 'pending' - - React.useEffect(() => { - if (!isActive && status !== 'pending') { - setStatus('pending') - } - }, [isActive, status]) - - return ( - <> - <VideoEmbedInnerNative - embed={embed} - setStatus={setStatus} - setIsLoading={setIsLoading} - setIsActive={setIsActive} - ref={ref} - /> - <ImageBackground - source={{uri: embed.thumbnail}} - accessibilityIgnoresInvertColors - style={[ - a.absolute, - a.inset_0, - { - backgroundColor: 'transparent', // If you don't add `backgroundColor` to the styles here, - // the play button won't show up on the first render on android 🥴😮💨 - display: showOverlay ? 'flex' : 'none', - }, - ]} - cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android - > - {showOverlay && ( - <Button - style={[a.flex_1, a.align_center, a.justify_center]} - onPress={() => { - ref.current?.togglePlayback() - }} - label={_(msg`Play video`)} - color="secondary"> - {showSpinner ? ( - <View - style={[ - a.rounded_full, - a.p_xs, - a.align_center, - a.justify_center, - ]}> - <ActivityIndicator size="large" color="white" /> - </View> - ) : ( - <PlayButtonIcon /> - )} - </Button> - )} - </ImageBackground> - </> - ) -} - -function VideoError({retry}: {error: unknown; retry: () => void}) { - return ( - <VideoFallback.Container> - <VideoFallback.Text> - <Trans> - An error occurred while loading the video. Please try again later. - </Trans> - </VideoFallback.Text> - <VideoFallback.RetryButton onPress={retry} /> - </VideoFallback.Container> - ) -} 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<HTMLDivElement>(null) - const {active, setActive, sendPosition, currentActiveView} = - useActiveVideoWeb() - const [onScreen, setOnScreen] = useState(false) - const [isFullscreen] = useFullscreen() - const lastKnownTime = useRef<number | undefined>() - - useEffect(() => { - if (!ref.current) return - if (isFullscreen && !isFirefox) return - const observer = new IntersectionObserver( - entries => { - const entry = entries[0] - if (!entry) return - setOnScreen(entry.isIntersecting) - sendPosition( - entry.boundingClientRect.y + entry.boundingClientRect.height / 2, - ) - }, - {threshold: 0.5}, - ) - observer.observe(ref.current) - return () => observer.disconnect() - }, [sendPosition, isFullscreen]) - - const [key, setKey] = useState(0) - const renderError = useCallback( - (error: unknown) => ( - <VideoError error={error} retry={() => setKey(key + 1)} /> - ), - [key], - ) - - let aspectRatio: number | undefined - const dims = embed.aspectRatio - if (dims) { - aspectRatio = dims.width / dims.height - if (Number.isNaN(aspectRatio)) { - aspectRatio = undefined - } - } - - let constrained: number | undefined - let max: number | undefined - if (aspectRatio !== undefined) { - const ratio = 1 / 2 // max of 1:2 ratio in feeds - constrained = Math.max(aspectRatio, ratio) - max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread - } - const cropDisabled = crop === 'none' - - const contents = ( - <div - ref={ref} - style={{display: 'flex', flex: 1, cursor: 'default'}} - onClick={evt => evt.stopPropagation()}> - <ErrorBoundary renderError={renderError} key={key}> - <ViewportObserver - sendPosition={sendPosition} - isAnyViewActive={currentActiveView !== null}> - <VideoEmbedInnerWeb - embed={embed} - active={active} - setActive={setActive} - onScreen={onScreen} - lastKnownTime={lastKnownTime} - /> - </ViewportObserver> - </ErrorBoundary> - </div> - ) - - return ( - <View style={[a.pt_xs]}> - {cropDisabled ? ( - <View style={[a.w_full, a.overflow_hidden, {aspectRatio: max ?? 1}]}> - {contents} - </View> - ) : ( - <ConstrainedImage - fullBleed={crop === 'square'} - aspectRatio={constrained || 1}> - {contents} - </ConstrainedImage> - )} - </View> - ) -} - -/** - * Renders a 100vh tall div and watches it with an IntersectionObserver to - * send the position of the div when it's near the screen. - */ -function ViewportObserver({ - children, - sendPosition, - isAnyViewActive, -}: { - children: React.ReactNode - sendPosition: (position: number) => void - isAnyViewActive: boolean -}) { - const ref = useRef<HTMLDivElement>(null) - const [nearScreen, setNearScreen] = useState(false) - const [isFullscreen] = useFullscreen() - const isWithinMessage = useIsWithinMessage() - - // Send position when scrolling. This is done with an IntersectionObserver - // observing a div of 100vh height - useEffect(() => { - if (!ref.current) return - if (isFullscreen && !isFirefox) return - const observer = new IntersectionObserver( - entries => { - const entry = entries[0] - if (!entry) return - const position = - entry.boundingClientRect.y + entry.boundingClientRect.height / 2 - sendPosition(position) - setNearScreen(entry.isIntersecting) - }, - {threshold: Array.from({length: 101}, (_, i) => i / 100)}, - ) - observer.observe(ref.current) - return () => observer.disconnect() - }, [sendPosition, isFullscreen]) - - // In case scrolling hasn't started yet, send up the position - useEffect(() => { - if (ref.current && !isAnyViewActive) { - const rect = ref.current.getBoundingClientRect() - const position = rect.y + rect.height / 2 - sendPosition(position) - } - }, [isAnyViewActive, sendPosition]) - - return ( - <View style={[a.flex_1, a.flex_row]}> - {nearScreen && children} - <div - ref={ref} - style={{ - // Don't escape bounds when in a message - ...(isWithinMessage - ? {top: 0, height: '100%'} - : {top: 'calc(50% - 50vh)', height: '100vh'}), - position: 'absolute', - left: '50%', - width: 1, - pointerEvents: 'none', - }} - /> - </View> - ) -} - -function VideoError({error, retry}: {error: unknown; retry: () => void}) { - const {_} = useLingui() - - let showRetryButton = true - let text = null - - if (error instanceof VideoNotFoundError) { - text = _(msg`Video not found.`) - } else if (error instanceof HLSUnsupportedError) { - showRetryButton = false - text = _( - msg`Your browser does not support the video format. Please try a different browser.`, - ) - } else { - text = _(msg`An error occurred while loading the video. Please try again.`) - } - - return ( - <VideoFallback.Container> - <VideoFallback.Text>{text}</VideoFallback.Text> - {showRetryButton && <VideoFallback.RetryButton onPress={retry} />} - </VideoFallback.Container> - ) -} 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<ViewStyle> -}) { - const t = useTheme() - const {_} = useLingui() - - if (isNaN(time)) { - return null - } - - const minutes = Math.floor(time / 60) - const seconds = String(time % 60).padStart(2, '0') - - return ( - <View - pointerEvents="none" - accessibilityLabel={_( - msg`Time remaining: ${plural(Number(time) || 0, { - one: '# second', - other: '# seconds', - })}`, - )} - accessibilityHint="" - style={[ - { - backgroundColor: 'rgba(0, 0, 0, 0.5)', - borderRadius: 6, - paddingHorizontal: 6, - paddingVertical: 3, - left: 6, - bottom: 6, - minHeight: 21, - }, - a.absolute, - a.justify_center, - style, - ]}> - <Text - style={[ - {color: t.palette.white, fontSize: 12, fontVariant: ['tabular-nums']}, - a.font_bold, - {lineHeight: 1.25}, - ]}> - {`${minutes}:${seconds}`} - </Text> - </View> - ) -} 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<BlueskyVideoView>(null) - const autoplayDisabled = useAutoplayDisabled() - const isWithinMessage = useIsWithinMessage() - const [muted, setMuted] = useVideoMuteState() - - const [isPlaying, setIsPlaying] = React.useState(false) - const [timeRemaining, setTimeRemaining] = React.useState(0) - const [error, setError] = React.useState<string>() - - React.useImperativeHandle(ref, () => ({ - togglePlayback: () => { - videoRef.current?.togglePlayback() - }, - })) - - if (error) { - throw new Error(error) - } - - return ( - <View style={[a.flex_1, a.relative]}> - <BlueskyVideoView - url={embed.playlist} - autoplay={!autoplayDisabled && !isWithinMessage} - beginMuted={autoplayDisabled ? false : muted} - style={[a.rounded_sm]} - onActiveChange={e => { - setIsActive(e.nativeEvent.isActive) - }} - onLoadingChange={e => { - setIsLoading(e.nativeEvent.isLoading) - }} - onMutedChange={e => { - setMuted(e.nativeEvent.isMuted) - }} - onStatusChange={e => { - setStatus(e.nativeEvent.status) - setIsPlaying(e.nativeEvent.status === 'playing') - }} - onTimeRemainingChange={e => { - setTimeRemaining(e.nativeEvent.timeRemaining) - }} - onError={e => { - setError(e.nativeEvent.error) - }} - ref={videoRef} - accessibilityLabel={ - embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`) - } - accessibilityHint="" - /> - <VideoControls - enterFullscreen={() => { - videoRef.current?.enterFullscreen(true) - }} - toggleMuted={() => { - videoRef.current?.toggleMuted() - }} - togglePlayback={() => { - videoRef.current?.togglePlayback() - }} - isPlaying={isPlaying} - timeRemaining={timeRemaining} - /> - <MediaInsetBorder /> - </View> - ) - }, -) - -function VideoControls({ - enterFullscreen, - toggleMuted, - togglePlayback, - timeRemaining, - isPlaying, -}: { - enterFullscreen: () => void - toggleMuted: () => void - togglePlayback: () => void - timeRemaining: number - isPlaying: boolean -}) { - const {_} = useLingui() - const t = useTheme() - const [muted] = useVideoMuteState() - - // show countdown when: - // 1. timeRemaining is a number - was seeing NaNs - // 2. duration is greater than 0 - means metadata has loaded - // 3. we're less than 5 second into the video - const showTime = !isNaN(timeRemaining) - - return ( - <View style={[a.absolute, a.inset_0]}> - <Pressable - onPress={enterFullscreen} - style={a.flex_1} - accessibilityLabel={_(msg`Video`)} - accessibilityHint={_(msg`Enters full screen`)} - accessibilityRole="button" - /> - <ControlButton - onPress={togglePlayback} - label={isPlaying ? _(msg`Pause`) : _(msg`Play`)} - accessibilityHint={_(msg`Plays or pauses the video`)} - style={{left: 6}}> - {isPlaying ? ( - <PauseIcon width={13} fill={t.palette.white} /> - ) : ( - <PlayIcon width={13} fill={t.palette.white} /> - )} - </ControlButton> - {showTime && <TimeIndicator time={timeRemaining} style={{left: 33}} />} - - <ControlButton - onPress={toggleMuted} - label={ - muted - ? _(msg({message: `Unmute`, context: 'video'})) - : _(msg({message: `Mute`, context: 'video'})) - } - accessibilityHint={_(msg`Toggles the sound`)} - style={{right: 6}}> - {muted ? ( - <MuteIcon width={13} fill={t.palette.white} /> - ) : ( - <UnmuteIcon width={13} fill={t.palette.white} /> - )} - </ControlButton> - </View> - ) -} - -function ControlButton({ - onPress, - children, - label, - accessibilityHint, - style, -}: { - onPress: () => void - children: React.ReactNode - label: string - accessibilityHint: string - style?: StyleProp<ViewStyle> -}) { - return ( - <View - style={[ - a.absolute, - a.rounded_full, - a.justify_center, - { - backgroundColor: 'rgba(0, 0, 0, 0.5)', - paddingHorizontal: 4, - paddingVertical: 4, - bottom: 6, - minHeight: 21, - minWidth: 21, - }, - style, - ]}> - <Pressable - onPress={onPress} - style={a.flex_1} - accessibilityLabel={label} - accessibilityHint={accessibilityHint} - accessibilityRole="button" - hitSlop={HITSLOP_30}> - {children} - </Pressable> - </View> - ) -} 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<number | undefined> -}) { - const containerRef = useRef<HTMLDivElement>(null) - const videoRef = useRef<HTMLVideoElement>(null) - const [focused, setFocused] = useState(false) - const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) - const [hlsLoading, setHlsLoading] = useState(false) - const figId = useId() - const {_} = useLingui() - - // send error up to error boundary - const [error, setError] = useState<Error | null>(null) - if (error) { - throw error - } - - const hlsRef = useHLS({ - playlist: embed.playlist, - setHasSubtitleTrack, - setError, - videoRef, - setHlsLoading, - }) - - useEffect(() => { - if (lastKnownTime.current && videoRef.current) { - videoRef.current.currentTime = lastKnownTime.current - } - }, [lastKnownTime]) - - return ( - <View - style={[a.flex_1, a.rounded_md, a.overflow_hidden]} - accessibilityLabel={_(msg`Embedded video player`)} - accessibilityHint=""> - <div ref={containerRef} style={{height: '100%', width: '100%'}}> - <figure style={{margin: 0, position: 'absolute', inset: 0}}> - <video - ref={videoRef} - poster={embed.thumbnail} - style={{width: '100%', height: '100%', objectFit: 'contain'}} - playsInline - preload="none" - muted={!focused} - aria-labelledby={embed.alt ? figId : undefined} - onTimeUpdate={e => { - lastKnownTime.current = e.currentTarget.currentTime - }} - /> - {embed.alt && ( - <figcaption - id={figId} - style={{ - position: 'absolute', - width: 1, - height: 1, - padding: 0, - margin: -1, - overflow: 'hidden', - clip: 'rect(0, 0, 0, 0)', - whiteSpace: 'nowrap', - borderWidth: 0, - }}> - {embed.alt} - </figcaption> - )} - </figure> - <Controls - videoRef={videoRef} - hlsRef={hlsRef} - active={active} - setActive={setActive} - focused={focused} - setFocused={setFocused} - hlsLoading={hlsLoading} - onScreen={onScreen} - fullscreenRef={containerRef} - hasSubtitleTrack={hasSubtitleTrack} - /> - </div> - <MediaInsetBorder /> - </View> - ) -} - -export class HLSUnsupportedError extends Error { - constructor() { - super('HLS is not supported') - } -} - -export class VideoNotFoundError extends Error { - constructor() { - super('Video not found') - } -} - -type CachedPromise<T> = Promise<T> & {value: undefined | T} -const promiseForHls = import( - // @ts-ignore - 'hls.js/dist/hls.min' -).then(mod => mod.default) as CachedPromise<typeof HlsTypes.default> -promiseForHls.value = undefined -promiseForHls.then(Hls => { - promiseForHls.value = Hls -}) - -function useHLS({ - playlist, - setHasSubtitleTrack, - setError, - videoRef, - setHlsLoading, -}: { - playlist: string - setHasSubtitleTrack: (v: boolean) => void - setError: (v: Error | null) => void - videoRef: React.RefObject<HTMLVideoElement> - setHlsLoading: (v: boolean) => void -}) { - const [Hls, setHls] = useState<typeof HlsTypes.default | undefined>( - () => promiseForHls.value, - ) - useEffect(() => { - if (!Hls) { - setHlsLoading(true) - promiseForHls.then(loadedHls => { - setHls(() => loadedHls) - setHlsLoading(false) - }) - } - }, [Hls, setHlsLoading]) - - const hlsRef = useRef<HlsTypes.default | undefined>(undefined) - const [lowQualityFragments, setLowQualityFragments] = useState< - HlsTypes.Fragment[] - >([]) - - // purge low quality segments from buffer on next frag change - const handleFragChange = useNonReactiveCallback( - ( - _event: HlsTypes.Events.FRAG_CHANGED, - {frag}: HlsTypes.FragChangedData, - ) => { - if (!Hls) return - if (!hlsRef.current) return - const hls = hlsRef.current - - // if the current quality level goes above 0, flush the low quality segments - if (hls.nextAutoLevel > 0) { - const flushed: HlsTypes.Fragment[] = [] - - for (const lowQualFrag of lowQualityFragments) { - // avoid if close to the current fragment - if (Math.abs(frag.start - lowQualFrag.start) < 0.1) { - continue - } - - hls.trigger(Hls.Events.BUFFER_FLUSHING, { - startOffset: lowQualFrag.start, - endOffset: lowQualFrag.end, - type: 'video', - }) - - flushed.push(lowQualFrag) - } - - setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f))) - } - }, - ) - - const flushOnLoop = useNonReactiveCallback(() => { - if (!Hls) return - if (!hlsRef.current) return - const hls = hlsRef.current - // the above callback will catch most stale frags, but there's a corner case - - // if there's only one segment in the video, it won't get flushed because it avoids - // flushing the currently active segment. Therefore, we have to catch it when we loop - if ( - hls.nextAutoLevel > 0 && - lowQualityFragments.length === 1 && - lowQualityFragments[0].start === 0 - ) { - const lowQualFrag = lowQualityFragments[0] - - hls.trigger(Hls.Events.BUFFER_FLUSHING, { - startOffset: lowQualFrag.start, - endOffset: lowQualFrag.end, - type: 'video', - }) - setLowQualityFragments([]) - } - }) - - useEffect(() => { - if (!videoRef.current) return - if (!Hls) return - if (!Hls.isSupported()) { - throw new HLSUnsupportedError() - } - - const hls = new Hls({ - maxMaxBufferLength: 10, // only load 10s ahead - // note: the amount buffered is affected by both maxBufferLength and maxBufferSize - // it will buffer until it is greater than *both* of those values - // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead - }) - hlsRef.current = hls - - const latestEstimate = BandwidthEstimate.get() - if (latestEstimate !== undefined) { - hls.bandwidthEstimate = latestEstimate - } - - hls.attachMedia(videoRef.current) - hls.loadSource(playlist) - - // manually loop, so if we've flushed the first buffer it doesn't get confused - const abortController = new AbortController() - const {signal} = abortController - const videoNode = videoRef.current - videoNode.addEventListener( - 'ended', - () => { - flushOnLoop() - videoNode.currentTime = 0 - videoNode.play() - }, - {signal}, - ) - - hls.on(Hls.Events.FRAG_LOADED, () => { - BandwidthEstimate.set(hls.bandwidthEstimate) - }) - - hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => { - if (data.subtitleTracks.length > 0) { - setHasSubtitleTrack(true) - } - }) - - hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => { - if (frag.level === 0) { - setLowQualityFragments(prev => [...prev, frag]) - } - }) - - hls.on(Hls.Events.ERROR, (_event, data) => { - if (data.fatal) { - if ( - data.details === 'manifestLoadError' && - data.response?.code === 404 - ) { - setError(new VideoNotFoundError()) - } else { - setError(data.error) - } - } else { - console.error(data.error) - } - }) - - hls.on(Hls.Events.FRAG_CHANGED, handleFragChange) - - return () => { - hlsRef.current = undefined - hls.detachMedia() - hls.destroy() - abortController.abort() - } - }, [ - playlist, - setError, - setHasSubtitleTrack, - videoRef, - handleFragChange, - flushOnLoop, - Hls, - ]) - - return hlsRef -} 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 ( - <View - style={[ - a.flex_1, - t.atoms.bg_contrast_25, - a.justify_center, - a.align_center, - a.px_lg, - a.border, - t.atoms.border_contrast_low, - a.rounded_sm, - a.gap_lg, - ]}> - {children} - </View> - ) -} - -export function Text({children}: {children: React.ReactNode}) { - const t = useTheme() - return ( - <TypoText - style={[ - a.text_center, - t.atoms.text_contrast_high, - a.text_md, - a.leading_snug, - {maxWidth: 300}, - ]}> - {children} - </TypoText> - ) -} - -export function RetryButton({onPress}: {onPress: () => void}) { - const {_} = useLingui() - - return ( - <Button - onPress={onPress} - size="small" - color="secondary_inverted" - variant="solid" - label={_(msg`Retry`)}> - <ButtonText> - <Trans>Retry</Trans> - </ButtonText> - </Button> - ) -} 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<Pick<SvgProps, 'fill' | 'width'>> - inactiveIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>> - onPress: () => void -}) { - const t = useTheme() - return ( - <PressableWithHover - accessibilityRole="button" - accessibilityLabel={active ? activeLabel : inactiveLabel} - accessibilityHint="" - onPress={onPress} - style={[ - a.p_xs, - a.rounded_full, - web({transition: 'background-color 0.1s'}), - ]} - hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.2)'}}> - {active ? ( - <ActiveIcon fill={t.palette.white} width={20} aria-hidden /> - ) : ( - <InactiveIcon fill={t.palette.white} width={20} aria-hidden /> - )} - </PressableWithHover> - ) -} 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<HTMLDivElement>(null) - const circleRef = useRef<HTMLDivElement>(null) - - const seek = useCallback( - (evt: React.PointerEvent<HTMLDivElement>) => { - if (!barRef.current) return - const {left, width} = barRef.current.getBoundingClientRect() - const x = evt.clientX - const percent = clamp((x - left) / width, 0, 1) * duration - onSeek(percent) - setSeekPosition(percent) - }, - [duration, onSeek], - ) - - const onPointerDown = useCallback( - (evt: React.PointerEvent<HTMLDivElement>) => { - const target = evt.target - if (target instanceof Element) { - evt.preventDefault() - target.setPointerCapture(evt.pointerId) - isSeekingRef.current = true - seek(evt) - setScrubberActive(true) - onSeekStart() - } - }, - [seek, onSeekStart], - ) - - const onPointerMove = useCallback( - (evt: React.PointerEvent<HTMLDivElement>) => { - if (isSeekingRef.current) { - evt.preventDefault() - seek(evt) - } - }, - [seek], - ) - - const onPointerUp = useCallback( - (evt: React.PointerEvent<HTMLDivElement>) => { - const target = evt.target - if (isSeekingRef.current && target instanceof Element) { - evt.preventDefault() - target.releasePointerCapture(evt.pointerId) - isSeekingRef.current = false - onSeekEnd() - setScrubberActive(false) - } - }, - [onSeekEnd], - ) - - useEffect(() => { - // HACK: there's divergent browser behaviour about what to do when - // a pointerUp event is fired outside the element that captured the - // pointer. Firefox clicks on the element the mouse is over, so we have - // to make everything unclickable while seeking -sfn - if (isFirefox && scrubberActive) { - document.body.classList.add('force-no-clicks') - - return () => { - document.body.classList.remove('force-no-clicks') - } - } - }, [scrubberActive, onSeekEnd]) - - useEffect(() => { - if (!circleRef.current) return - if (focused) { - const abortController = new AbortController() - const {signal} = abortController - circleRef.current.addEventListener( - 'keydown', - evt => { - // space: play/pause - // arrow left: seek backward - // arrow right: seek forward - - if (evt.key === ' ') { - evt.preventDefault() - drawFocus() - togglePlayPause() - } else if (evt.key === 'ArrowLeft') { - evt.preventDefault() - drawFocus() - seekLeft() - } else if (evt.key === 'ArrowRight') { - evt.preventDefault() - drawFocus() - seekRight() - } - }, - {signal}, - ) - - return () => abortController.abort() - } - }, [focused, seekLeft, seekRight, togglePlayPause, drawFocus]) - - const progress = scrubberActive ? seekPosition : currentTime - const progressPercent = (progress / duration) * 100 - - return ( - <View - testID="scrubber" - style={[ - {height: isTouchDevice ? 32 : 18, width: '100%'}, - a.flex_shrink_0, - a.px_xs, - ]} - onPointerEnter={onStartHover} - onPointerLeave={onEndHover}> - <div - ref={barRef} - style={{ - flex: 1, - display: 'flex', - alignItems: 'center', - position: 'relative', - cursor: scrubberActive ? 'grabbing' : 'grab', - padding: '4px 0', - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerCancel={onPointerUp}> - <View - style={[ - a.w_full, - a.rounded_full, - a.overflow_hidden, - {backgroundColor: 'rgba(255, 255, 255, 0.4)'}, - {height: hovered || scrubberActive ? 6 : 3}, - web({transition: 'height 0.1s ease'}), - ]}> - {duration > 0 && ( - <View - style={[ - a.h_full, - {backgroundColor: t.palette.white}, - {width: `${progressPercent}%`}, - ]} - /> - )} - </View> - <div - ref={circleRef} - aria-label={_( - msg`Seek slider. Use the arrow keys to seek forwards and backwards, and space to play/pause`, - )} - role="slider" - aria-valuemax={duration} - aria-valuemin={0} - aria-valuenow={currentTime} - aria-valuetext={_( - msg`${formatTime(currentTime)} of ${formatTime(duration)}`, - )} - tabIndex={0} - onFocus={onFocus} - onBlur={onBlur} - style={{ - position: 'absolute', - height: 16, - width: 16, - left: `calc(${progressPercent}% - 8px)`, - borderRadius: 8, - pointerEvents: 'none', - }}> - <View - style={[ - a.w_full, - a.h_full, - a.rounded_full, - {backgroundColor: t.palette.white}, - { - transform: [ - { - scale: - hovered || scrubberActive || focused - ? scrubberActive - ? 1 - : 0.6 - : 0, - }, - ], - }, - ]} - /> - </div> - </div> - </View> - ) -} 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<HTMLVideoElement> - hlsRef: React.RefObject<Hls | undefined> - active: boolean - setActive: () => void - focused: boolean - setFocused: (focused: boolean) => void - onScreen: boolean - fullscreenRef: React.RefObject<HTMLDivElement> - hlsLoading: boolean - hasSubtitleTrack: boolean -}) { - const { - play, - pause, - playing, - muted, - changeMuted, - togglePlayPause, - currentTime, - duration, - buffering, - error, - canPlay, - } = useVideoElement(videoRef) - const t = useTheme() - const {_} = useLingui() - const subtitlesEnabled = useSubtitlesEnabled() - const setSubtitlesEnabled = useSetSubtitlesEnabled() - const { - state: hovered, - onIn: onHover, - onOut: onEndHover, - } = useInteractionState() - const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) - const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() - const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) - const showSpinner = hlsLoading || buffering - const { - state: volumeHovered, - onIn: onVolumeHover, - onOut: onVolumeEndHover, - } = useInteractionState() - - const onKeyDown = useCallback(() => { - setInteractingViaKeypress(true) - }, []) - - useEffect(() => { - if (interactingViaKeypress) { - document.addEventListener('click', () => setInteractingViaKeypress(false)) - return () => { - document.removeEventListener('click', () => - setInteractingViaKeypress(false), - ) - } - } - }, [interactingViaKeypress]) - - useEffect(() => { - if (isFullscreen) { - document.documentElement.style.scrollbarGutter = 'unset' - return () => { - document.documentElement.style.removeProperty('scrollbar-gutter') - } - } - }, [isFullscreen]) - - // pause + unfocus when another video is active - useEffect(() => { - if (!active) { - pause() - setFocused(false) - } - }, [active, pause, setFocused]) - - // autoplay/pause based on visibility - const isWithinMessage = useIsWithinMessage() - const autoplayDisabled = useAutoplayDisabled() || isWithinMessage - useEffect(() => { - if (active) { - if (onScreen) { - if (!autoplayDisabled) play() - } else { - pause() - } - } - }, [onScreen, pause, active, play, autoplayDisabled]) - - // use minimal quality when not focused - useEffect(() => { - if (!hlsRef.current) return - if (focused) { - // allow 30s of buffering - hlsRef.current.config.maxMaxBufferLength = 30 - } else { - // back to what we initially set - hlsRef.current.config.maxMaxBufferLength = 10 - } - }, [hlsRef, focused]) - - useEffect(() => { - if (!hlsRef.current) return - if (hasSubtitleTrack && subtitlesEnabled && canPlay) { - hlsRef.current.subtitleTrack = 0 - } else { - hlsRef.current.subtitleTrack = -1 - } - }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay]) - - // clicking on any button should focus the player, if it's not already focused - const drawFocus = useCallback(() => { - if (!active) { - setActive() - } - setFocused(true) - }, [active, setActive, setFocused]) - - const onPressEmptySpace = useCallback(() => { - if (!focused) { - drawFocus() - if (autoplayDisabled) play() - } else { - togglePlayPause() - } - }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play]) - - const onPressPlayPause = useCallback(() => { - drawFocus() - togglePlayPause() - }, [drawFocus, togglePlayPause]) - - const onPressSubtitles = useCallback(() => { - drawFocus() - setSubtitlesEnabled(!subtitlesEnabled) - }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled]) - - const onPressFullscreen = useCallback(() => { - drawFocus() - toggleFullscreen() - }, [drawFocus, toggleFullscreen]) - - const onSeek = useCallback( - (time: number) => { - if (!videoRef.current) return - if (videoRef.current.fastSeek) { - videoRef.current.fastSeek(time) - } else { - videoRef.current.currentTime = time - } - }, - [videoRef], - ) - - const playStateBeforeSeekRef = useRef(false) - - const onSeekStart = useCallback(() => { - drawFocus() - playStateBeforeSeekRef.current = playing - pause() - }, [playing, pause, drawFocus]) - - const onSeekEnd = useCallback(() => { - if (playStateBeforeSeekRef.current) { - play() - } - }, [play]) - - const seekLeft = useCallback(() => { - if (!videoRef.current) return - // eslint-disable-next-line @typescript-eslint/no-shadow - const currentTime = videoRef.current.currentTime - // eslint-disable-next-line @typescript-eslint/no-shadow - const duration = videoRef.current.duration || 0 - onSeek(clamp(currentTime - 5, 0, duration)) - }, [onSeek, videoRef]) - - const seekRight = useCallback(() => { - if (!videoRef.current) return - // eslint-disable-next-line @typescript-eslint/no-shadow - const currentTime = videoRef.current.currentTime - // eslint-disable-next-line @typescript-eslint/no-shadow - const duration = videoRef.current.duration || 0 - onSeek(clamp(currentTime + 5, 0, duration)) - }, [onSeek, videoRef]) - - const [showCursor, setShowCursor] = useState(true) - const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>() - const onPointerMoveEmptySpace = useCallback(() => { - setShowCursor(true) - if (cursorTimeoutRef.current) { - clearTimeout(cursorTimeoutRef.current) - } - cursorTimeoutRef.current = setTimeout(() => { - setShowCursor(false) - onEndHover() - }, 2000) - }, [onEndHover]) - const onPointerLeaveEmptySpace = useCallback(() => { - setShowCursor(false) - if (cursorTimeoutRef.current) { - clearTimeout(cursorTimeoutRef.current) - } - }, []) - - // these are used to trigger the hover state. on mobile, the hover state - // should stick around for a bit after they tap, and if the controls aren't - // present this initial tab should *only* show the controls and not activate anything - - const onPointerDown = useCallback( - (evt: React.PointerEvent<HTMLDivElement>) => { - if (evt.pointerType !== 'mouse' && !hovered) { - evt.preventDefault() - } - clearTimeout(timeoutRef.current) - }, - [hovered], - ) - - const timeoutRef = useRef<ReturnType<typeof setTimeout>>() - - const onHoverWithTimeout = useCallback(() => { - onHover() - clearTimeout(timeoutRef.current) - }, [onHover]) - - const onEndHoverWithTimeout = useCallback( - (evt: React.PointerEvent<HTMLDivElement>) => { - // if touch, end after 3s - // if mouse, end immediately - if (evt.pointerType !== 'mouse') { - setTimeout(onEndHover, 3000) - } else { - onEndHover() - } - }, - [onEndHover], - ) - - const showControls = - ((focused || autoplayDisabled) && !playing) || - (interactingViaKeypress ? hasFocus : hovered) - - return ( - <div - style={{ - position: 'absolute', - inset: 0, - overflow: 'hidden', - display: 'flex', - flexDirection: 'column', - }} - onClick={evt => { - evt.stopPropagation() - setInteractingViaKeypress(false) - }} - onPointerEnter={onHoverWithTimeout} - onPointerMove={onHoverWithTimeout} - onPointerLeave={onEndHoverWithTimeout} - onPointerDown={onPointerDown} - onFocus={onFocus} - onBlur={onBlur} - onKeyDown={onKeyDown}> - <Pressable - accessibilityRole="button" - onPointerEnter={onPointerMoveEmptySpace} - onPointerMove={onPointerMoveEmptySpace} - onPointerLeave={onPointerLeaveEmptySpace} - accessibilityLabel={_( - !focused - ? msg`Unmute video` - : playing - ? msg`Pause video` - : msg`Play video`, - )} - accessibilityHint="" - style={[ - a.flex_1, - web({cursor: showCursor || !playing ? 'pointer' : 'none'}), - ]} - onPress={onPressEmptySpace} - /> - {!showControls && !focused && duration > 0 && ( - <TimeIndicator time={Math.floor(duration - currentTime)} /> - )} - <View - style={[ - a.flex_shrink_0, - a.w_full, - a.px_xs, - web({ - background: - 'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))', - }), - {opacity: showControls ? 1 : 0}, - {transition: 'opacity 0.2s ease-in-out'}, - ]}> - {(!volumeHovered || isTouchDevice) && ( - <Scrubber - duration={duration} - currentTime={currentTime} - onSeek={onSeek} - onSeekStart={onSeekStart} - onSeekEnd={onSeekEnd} - seekLeft={seekLeft} - seekRight={seekRight} - togglePlayPause={togglePlayPause} - drawFocus={drawFocus} - /> - )} - <View - style={[ - a.flex_1, - a.px_xs, - a.pb_sm, - a.gap_sm, - a.flex_row, - a.align_center, - ]}> - <ControlButton - active={playing} - activeLabel={_(msg`Pause`)} - inactiveLabel={_(msg`Play`)} - activeIcon={PauseIcon} - inactiveIcon={PlayIcon} - onPress={onPressPlayPause} - /> - <View style={a.flex_1} /> - <Text - style={[ - a.px_xs, - {color: t.palette.white, fontVariant: ['tabular-nums']}, - ]}> - {formatTime(currentTime)} / {formatTime(duration)} - </Text> - {hasSubtitleTrack && ( - <ControlButton - active={subtitlesEnabled} - activeLabel={_(msg`Disable subtitles`)} - inactiveLabel={_(msg`Enable subtitles`)} - activeIcon={CCActiveIcon} - inactiveIcon={CCInactiveIcon} - onPress={onPressSubtitles} - /> - )} - <VolumeControl - muted={muted} - changeMuted={changeMuted} - hovered={volumeHovered} - onHover={onVolumeHover} - onEndHover={onVolumeEndHover} - drawFocus={drawFocus} - /> - {!isIPhoneWeb && ( - <ControlButton - active={isFullscreen} - activeLabel={_(msg`Exit fullscreen`)} - inactiveLabel={_(msg`Enter fullscreen`)} - activeIcon={ArrowsInIcon} - inactiveIcon={ArrowsOutIcon} - onPress={onPressFullscreen} - /> - )} - </View> - </View> - {(showSpinner || error) && ( - <View - pointerEvents="none" - style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> - {showSpinner && <Loader fill={t.palette.white} size="lg" />} - {error && ( - <Text style={{color: t.palette.white}}> - <Trans>An error occurred</Trans> - </Text> - )} - </View> - )} - </div> - ) -} 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<HTMLInputElement>) => { - drawFocus() - const vol = sliderVolumeToVideoVolume(Number(evt.target.value)) - setVolume(vol) - changeMuted(vol === 0) - }, - [setVolume, drawFocus, changeMuted], - ) - - const sliderVolume = muted ? 0 : videoVolumeToSliderVolume(volume) - - const isZeroVolume = volume === 0 - const onPressMute = useCallback(() => { - drawFocus() - if (isZeroVolume) { - setVolume(1) - changeMuted(false) - } else { - changeMuted(prevMuted => !prevMuted) - } - }, [drawFocus, setVolume, isZeroVolume, changeMuted]) - - return ( - <View - onPointerEnter={onHover} - onPointerLeave={onEndHover} - style={[a.relative]}> - {hovered && !isTouchDevice && ( - <Animated.View - entering={FadeIn.duration(100)} - exiting={FadeOut.duration(100)} - style={[a.absolute, a.w_full, {height: 100, bottom: '100%'}]}> - <View - style={[ - a.flex_1, - a.mb_xs, - a.px_2xs, - a.py_xs, - {backgroundColor: 'rgba(0, 0, 0, 0.6)'}, - a.rounded_xs, - a.align_center, - ]}> - <input - type="range" - min={0} - max={100} - value={sliderVolume} - aria-label={_(msg`Volume`)} - style={ - // Ridiculous safari hack for old version of safari. Fixed in sonoma beta -h - isSafari ? {height: 92, minHeight: '100%'} : {height: '100%'} - } - onChange={onVolumeChange} - // @ts-expect-error for old versions of firefox, and then re-using it for targeting the CSS -sfn - orient="vertical" - /> - </View> - </Animated.View> - )} - <ControlButton - active={muted || volume === 0} - activeLabel={_(msg({message: `Unmute`, context: 'video'}))} - inactiveLabel={_(msg({message: `Mute`, context: 'video'}))} - activeIcon={MuteIcon} - inactiveIcon={UnmuteIcon} - onPress={onPressMute} - /> - </View> - ) -} - -function sliderVolumeToVideoVolume(value: number) { - return Math.pow(value / 100, 4) -} - -function videoVolumeToSliderVolume(value: number) { - return Math.round(Math.pow(value, 1 / 4) * 100) -} 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<HTMLVideoElement>) { - const [playing, setPlaying] = useState(false) - const [muted, setMuted] = useState(true) - const [currentTime, setCurrentTime] = useState(0) - const [volume, setVolume] = useVideoVolumeState() - const [duration, setDuration] = useState(0) - const [buffering, setBuffering] = useState(false) - const [error, setError] = useState(false) - const [canPlay, setCanPlay] = useState(false) - const playWhenReadyRef = useRef(false) - - useEffect(() => { - if (!ref.current) return - ref.current.volume = volume - }, [ref, volume]) - - useEffect(() => { - if (!ref.current) return - - let bufferingTimeout: ReturnType<typeof setTimeout> | undefined - - function round(num: number) { - return Math.round(num * 100) / 100 - } - - // Initial values - setCurrentTime(round(ref.current.currentTime) || 0) - setDuration(round(ref.current.duration) || 0) - setMuted(ref.current.muted) - setPlaying(!ref.current.paused) - setVolume(ref.current.volume) - - const handleTimeUpdate = () => { - if (!ref.current) return - setCurrentTime(round(ref.current.currentTime) || 0) - // HACK: Safari randomly fires `stalled` events when changing between segments - // let's just clear the buffering state if the video is still progressing -sfn - if (isSafari) { - if (bufferingTimeout) clearTimeout(bufferingTimeout) - setBuffering(false) - } - } - - const handleDurationChange = () => { - if (!ref.current) return - setDuration(round(ref.current.duration) || 0) - } - - const handlePlay = () => { - setPlaying(true) - } - - const handlePause = () => { - setPlaying(false) - } - - const handleVolumeChange = () => { - if (!ref.current) return - setMuted(ref.current.muted) - } - - const handleError = () => { - setError(true) - } - - const handleCanPlay = async () => { - if (bufferingTimeout) clearTimeout(bufferingTimeout) - setBuffering(false) - setCanPlay(true) - - if (!ref.current) return - if (playWhenReadyRef.current) { - try { - await ref.current.play() - } catch (e: any) { - if ( - !e.message?.includes(`The request is not allowed by the user agent`) - ) { - throw e - } - } - playWhenReadyRef.current = false - } - } - - const handleCanPlayThrough = () => { - if (bufferingTimeout) clearTimeout(bufferingTimeout) - setBuffering(false) - } - - const handleWaiting = () => { - if (bufferingTimeout) clearTimeout(bufferingTimeout) - bufferingTimeout = setTimeout(() => { - setBuffering(true) - }, 500) // Delay to avoid frequent buffering state changes - } - - const handlePlaying = () => { - if (bufferingTimeout) clearTimeout(bufferingTimeout) - setBuffering(false) - setError(false) - } - - const handleStalled = () => { - if (bufferingTimeout) clearTimeout(bufferingTimeout) - bufferingTimeout = setTimeout(() => { - setBuffering(true) - }, 500) // Delay to avoid frequent buffering state changes - } - - const handleEnded = () => { - setPlaying(false) - setBuffering(false) - setError(false) - } - - const abortController = new AbortController() - - ref.current.addEventListener('timeupdate', handleTimeUpdate, { - signal: abortController.signal, - }) - ref.current.addEventListener('durationchange', handleDurationChange, { - signal: abortController.signal, - }) - ref.current.addEventListener('play', handlePlay, { - signal: abortController.signal, - }) - ref.current.addEventListener('pause', handlePause, { - signal: abortController.signal, - }) - ref.current.addEventListener('volumechange', handleVolumeChange, { - signal: abortController.signal, - }) - ref.current.addEventListener('error', handleError, { - signal: abortController.signal, - }) - ref.current.addEventListener('canplay', handleCanPlay, { - signal: abortController.signal, - }) - ref.current.addEventListener('canplaythrough', handleCanPlayThrough, { - signal: abortController.signal, - }) - ref.current.addEventListener('waiting', handleWaiting, { - signal: abortController.signal, - }) - ref.current.addEventListener('playing', handlePlaying, { - signal: abortController.signal, - }) - ref.current.addEventListener('stalled', handleStalled, { - signal: abortController.signal, - }) - ref.current.addEventListener('ended', handleEnded, { - signal: abortController.signal, - }) - - return () => { - abortController.abort() - clearTimeout(bufferingTimeout) - } - }, [ref, setVolume]) - - const play = useCallback(() => { - if (!ref.current) return - - if (ref.current.ended) { - ref.current.currentTime = 0 - } - - if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { - playWhenReadyRef.current = true - } else { - const promise = ref.current.play() - if (promise !== undefined) { - promise.catch(err => { - console.error('Error playing video:', err) - }) - } - } - }, [ref]) - - const pause = useCallback(() => { - if (!ref.current) return - - ref.current.pause() - playWhenReadyRef.current = false - }, [ref]) - - const togglePlayPause = useCallback(() => { - if (!ref.current) return - - if (ref.current.paused) { - play() - } else { - pause() - } - }, [ref, play, pause]) - - const changeMuted = useCallback( - (newMuted: boolean | ((prev: boolean) => boolean)) => { - if (!ref.current) return - - const value = - typeof newMuted === 'function' ? newMuted(ref.current.muted) : newMuted - ref.current.muted = value - }, - [ref], - ) - - return { - play, - pause, - togglePlayPause, - duration, - currentTime, - playing, - muted, - changeMuted, - buffering, - error, - canPlay, - } -} - -export function formatTime(time: number) { - if (isNaN(time)) { - return '--' - } - - time = Math.round(time) - - const minutes = Math.floor(time / 60) - const seconds = String(time % 60).padStart(2, '0') - - return `${minutes}:${seconds}` -} 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<React.SetStateAction<boolean>> - // web - volume: number - setVolume: React.Dispatch<React.SetStateAction<number>> -} | null>(null) - -export function Provider({children}: {children: React.ReactNode}) { - const [muted, setMuted] = React.useState(true) - const [volume, setVolume] = React.useState(1) - - const value = React.useMemo( - () => ({ - muted, - setMuted, - volume, - setVolume, - }), - [muted, setMuted, volume, setVolume], - ) - - return <Context.Provider value={value}>{children}</Context.Provider> -} - -export function useVideoVolumeState() { - const context = React.useContext(Context) - if (!context) { - throw new Error( - 'useVideoVolumeState must be used within a VideoVolumeProvider', - ) - } - return [context.volume, context.setVolume] as const -} - -export function useVideoMuteState() { - const context = React.useContext(Context) - if (!context) { - throw new Error( - 'useVideoMuteState must be used within a VideoVolumeProvider', - ) - } - return [context.muted, context.setMuted] as const -} 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<ViewStyle> - allowNestedQuotes?: boolean - viewContext?: PostEmbedViewContext -}) { - const {openLightbox} = useLightboxControls() - - // quote post with media - // = - if (AppBskyEmbedRecordWithMedia.isView(embed)) { - return ( - <View style={style}> - <PostEmbeds - embed={embed.media} - moderation={moderation} - onOpen={onOpen} - viewContext={viewContext} - /> - <MaybeQuoteEmbed - embed={embed.record} - onOpen={onOpen} - viewContext={ - viewContext === PostEmbedViewContext.Feed - ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia - : undefined - } - /> - </View> - ) - } - - if (AppBskyEmbedRecord.isView(embed)) { - // custom feed embed (i.e. generator view) - if (AppBskyFeedDefs.isGeneratorView(embed.record)) { - return ( - <View style={a.mt_sm}> - <MaybeFeedCard view={embed.record} /> - </View> - ) - } - - // list embed - if (AppBskyGraphDefs.isListView(embed.record)) { - return ( - <View style={a.mt_sm}> - <MaybeListCard view={embed.record} /> - </View> - ) - } - - // starter pack embed - if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) { - return ( - <View style={a.mt_sm}> - <StarterPackCard starterPack={embed.record} /> - </View> - ) - } - - // quote post - // = - return ( - <MaybeQuoteEmbed - embed={embed} - style={style} - onOpen={onOpen} - allowNestedQuotes={allowNestedQuotes} - /> - ) - } - - // 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<any>[], - fetchedDims: (Dimensions | null)[], - ) => { - runOnUI(() => { - 'worklet' - const rects: (MeasuredDimensions | null)[] = [] - for (const r of refs) { - rects.push(measure(r)) - } - runOnJS(_openLightbox)(index, rects, fetchedDims) - })() - } - const onPressIn = (_: number) => { - InteractionManager.runAfterInteractions(() => { - Image.prefetch(items.map(i => i.uri)) - }) - } - - if (images.length === 1) { - const image = images[0] - return ( - <ContentHider modui={moderation?.ui('contentMedia')}> - <View style={[a.mt_sm, style]}> - <AutoSizedImage - crop={ - viewContext === PostEmbedViewContext.ThreadHighlighted - ? 'none' - : viewContext === - PostEmbedViewContext.FeedEmbedRecordWithMedia - ? 'square' - : 'constrained' - } - image={image} - onPress={(containerRef, dims) => - onPress(0, [containerRef], [dims]) - } - onPressIn={() => onPressIn(0)} - hideBadge={ - viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia - } - /> - </View> - </ContentHider> - ) - } - - return ( - <ContentHider modui={moderation?.ui('contentMedia')}> - <View style={[a.mt_sm, style]}> - <ImageLayoutGrid - images={embed.images} - onPress={onPress} - onPressIn={onPressIn} - viewContext={viewContext} - /> - </View> - </ContentHider> - ) - } - } - - // external link embed - // = - if (AppBskyEmbedExternal.isView(embed)) { - const link = embed.external - return ( - <ContentHider modui={moderation?.ui('contentMedia')}> - <ExternalLinkEmbed - link={link} - onOpen={onOpen} - style={[a.mt_sm, style]} - /> - </ContentHider> - ) - } - - // video embed - // = - if (AppBskyEmbedVideo.isView(embed)) { - return ( - <ContentHider modui={moderation?.ui('contentMedia')}> - <VideoEmbed - embed={embed} - crop={ - viewContext === PostEmbedViewContext.ThreadHighlighted - ? 'none' - : viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia - ? 'square' - : 'constrained' - } - /> - </ContentHider> - ) - } - - return <View /> -} - -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 ( - <ContentHider modui={moderation?.ui('contentList')}> - <FeedSourceCard - feedUri={view.uri} - style={[pal.view, pal.border, styles.customFeedOuter]} - showLikes - /> - </ContentHider> - ) -} - -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 ( - <ContentHider modui={moderation?.ui('contentList')}> - <View - style={[ - a.border, - t.atoms.border_contrast_medium, - a.p_md, - a.rounded_sm, - ]}> - <ListCard.Default view={view} /> - </View> - </ContentHider> - ) -} - -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, -} |