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/components/Post/Embed/index.tsx | |
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/components/Post/Embed/index.tsx')
-rw-r--r-- | src/components/Post/Embed/index.tsx | 332 |
1 files changed, 332 insertions, 0 deletions
diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx new file mode 100644 index 000000000..ace85dc98 --- /dev/null +++ b/src/components/Post/Embed/index.tsx @@ -0,0 +1,332 @@ +import React from 'react' +import {View} from 'react-native' +import { + type $Typed, + type AppBskyFeedDefs, + AppBskyFeedPost, + AtUri, + moderatePost, + RichText as RichTextAPI, +} from '@atproto/api' +import {Trans} from '@lingui/macro' +import {useQueryClient} from '@tanstack/react-query' + +import {usePalette} from '#/lib/hooks/usePalette' +import {makeProfileLink} from '#/lib/routes/links' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {unstableCacheProfileView} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {Link} from '#/view/com/util/Link' +import {PostMeta} from '#/view/com/util/PostMeta' +import {atoms as a, useTheme} from '#/alf' +import {ContentHider} from '#/components/moderation/ContentHider' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {RichText} from '#/components/RichText' +import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard' +import {SubtleWebHover} from '#/components/SubtleWebHover' +import * as bsky from '#/types/bsky' +import { + type Embed as TEmbed, + type EmbedType, + parseEmbed, +} from '#/types/bsky/post' +import {ExternalEmbed} from './ExternalEmbed' +import {ModeratedFeedEmbed} from './FeedEmbed' +import {ImageEmbed} from './ImageEmbed' +import {ModeratedListEmbed} from './ListEmbed' +import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder' +import { + type CommonProps, + type EmbedProps, + PostEmbedViewContext, + QuoteEmbedViewContext, +} from './types' +import {VideoEmbed} from './VideoEmbed' + +export {PostEmbedViewContext, QuoteEmbedViewContext} from './types' + +export function Embed({embed: rawEmbed, ...rest}: EmbedProps) { + const embed = parseEmbed(rawEmbed) + + switch (embed.type) { + case 'images': + case 'link': + case 'video': { + return <MediaEmbed embed={embed} {...rest} /> + } + case 'feed': + case 'list': + case 'starter_pack': + case 'labeler': + case 'post': + case 'post_not_found': + case 'post_blocked': + case 'post_detached': { + return <RecordEmbed embed={embed} {...rest} /> + } + case 'post_with_media': { + return ( + <View style={rest.style}> + <MediaEmbed embed={embed.media} {...rest} /> + <RecordEmbed embed={embed.view} {...rest} /> + </View> + ) + } + default: { + return null + } + } +} + +function MediaEmbed({ + embed, + ...rest +}: CommonProps & { + embed: TEmbed +}) { + switch (embed.type) { + case 'images': { + return ( + <ContentHider modui={rest.moderation?.ui('contentMedia')}> + <ImageEmbed embed={embed} {...rest} /> + </ContentHider> + ) + } + case 'link': { + return ( + <ContentHider modui={rest.moderation?.ui('contentMedia')}> + <ExternalEmbed + link={embed.view.external} + onOpen={rest.onOpen} + style={[a.mt_sm, rest.style]} + /> + </ContentHider> + ) + } + case 'video': { + return ( + <ContentHider modui={rest.moderation?.ui('contentMedia')}> + <VideoEmbed embed={embed.view} /> + </ContentHider> + ) + } + default: { + return null + } + } +} + +function RecordEmbed({ + embed, + ...rest +}: CommonProps & { + embed: TEmbed +}) { + switch (embed.type) { + case 'feed': { + return ( + <View style={a.mt_sm}> + <ModeratedFeedEmbed embed={embed} {...rest} /> + </View> + ) + } + case 'list': { + return ( + <View style={a.mt_sm}> + <ModeratedListEmbed embed={embed} /> + </View> + ) + } + case 'starter_pack': { + return ( + <View style={a.mt_sm}> + <StarterPackCard starterPack={embed.view} /> + </View> + ) + } + case 'labeler': { + // not implemented + return null + } + case 'post': { + if (rest.isWithinQuote && !rest.allowNestedQuotes) { + return null + } + + return ( + <QuoteEmbed + {...rest} + embed={embed} + viewContext={ + rest.viewContext === PostEmbedViewContext.Feed + ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia + : undefined + } + isWithinQuote={rest.isWithinQuote} + allowNestedQuotes={rest.allowNestedQuotes} + /> + ) + } + case 'post_not_found': { + return ( + <PostPlaceholderText> + <Trans>Deleted</Trans> + </PostPlaceholderText> + ) + } + case 'post_blocked': { + return ( + <PostPlaceholderText> + <Trans>Blocked</Trans> + </PostPlaceholderText> + ) + } + case 'post_detached': { + return <PostDetachedEmbed embed={embed} /> + } + default: { + return null + } + } +} + +export function PostDetachedEmbed({ + embed, +}: { + embed: EmbedType<'post_detached'> +}) { + const {currentAccount} = useSession() + const isViewerOwner = currentAccount?.did + ? embed.view.uri.includes(currentAccount.did) + : false + + return ( + <PostPlaceholderText> + {isViewerOwner ? ( + <Trans>Removed by you</Trans> + ) : ( + <Trans>Removed by author</Trans> + )} + </PostPlaceholderText> + ) +} + +/* + * Nests parent `Embed` component and therefore must live in this file to avoid + * circular imports. + */ +export function QuoteEmbed({ + embed, + onOpen, + style, + isWithinQuote: parentIsWithinQuote, + allowNestedQuotes: parentAllowNestedQuotes, +}: Omit<CommonProps, 'viewContext'> & { + embed: EmbedType<'post'> + viewContext?: QuoteEmbedViewContext +}) { + const moderationOpts = useModerationOpts() + const quote = React.useMemo<$Typed<AppBskyFeedDefs.PostView>>( + () => ({ + ...embed.view, + $type: 'app.bsky.feed.defs#postView', + record: embed.view.value, + embed: embed.view.embeds?.[0], + }), + [embed], + ) + const moderation = React.useMemo(() => { + return moderationOpts ? moderatePost(quote, moderationOpts) : undefined + }, [quote, moderationOpts]) + + const t = useTheme() + const queryClient = useQueryClient() + const pal = usePalette('default') + const itemUrip = new AtUri(quote.uri) + const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey) + const itemTitle = `Post by ${quote.author.handle}` + + const richText = React.useMemo(() => { + if ( + !bsky.dangerousIsType<AppBskyFeedPost.Record>( + quote.record, + AppBskyFeedPost.isRecord, + ) + ) + return undefined + const {text, facets} = quote.record + return text.trim() + ? new RichTextAPI({text: text, facets: facets}) + : undefined + }, [quote.record]) + + const onBeforePress = React.useCallback(() => { + unstableCacheProfileView(queryClient, quote.author) + onOpen?.() + }, [queryClient, quote.author, onOpen]) + + const [hover, setHover] = React.useState(false) + return ( + <View + onPointerEnter={() => { + setHover(true) + }} + onPointerLeave={() => { + setHover(false) + }}> + <ContentHider + modui={moderation?.ui('contentList')} + style={[ + a.rounded_md, + a.p_md, + a.mt_sm, + a.border, + t.atoms.border_contrast_low, + style, + ]} + childContainerStyle={[a.pt_sm]}> + <SubtleWebHover hover={hover} /> + <Link + hoverStyle={{borderColor: pal.colors.borderLinkHover}} + href={itemHref} + title={itemTitle} + onBeforePress={onBeforePress}> + <View pointerEvents="none"> + <PostMeta + author={quote.author} + moderation={moderation} + showAvatar + postHref={itemHref} + timestamp={quote.indexedAt} + /> + </View> + {moderation ? ( + <PostAlerts + modui={moderation.ui('contentView')} + style={[a.py_xs]} + /> + ) : null} + {richText ? ( + <RichText + value={richText} + style={a.text_md} + numberOfLines={20} + disableLinks + /> + ) : null} + {quote.embed && ( + <Embed + embed={quote.embed} + moderation={moderation} + isWithinQuote={parentIsWithinQuote ?? true} + // already within quote? override nested + allowNestedQuotes={ + parentIsWithinQuote ? false : parentAllowNestedQuotes + } + /> + )} + </Link> + </ContentHider> + </View> + ) +} |