Files
bsky-social-app/bskyogcard/src/components/PostEmbed.tsx
T
2025-09-11 13:25:27 -05:00

558 lines
14 KiB
TypeScript

import {
AppBskyFeedDefs,
AppBskyGraphStarterpack,
ModerationDecision,
} from '@atproto/api'
import {ModeratorData} from '../data/getModeratorData.js'
import {Image as ImageSource, PostData} from '../data/getPostData.js'
import {atoms as a, theme as t} from '../theme/index.js'
import * as bsky from '../types/bsky.js'
import {getModerationCauseInfo} from '../util/getModerationCauseInfo.js'
import {getStarterPackImageUri} from '../util/getStarterPackImageUri.js'
import {Embed, EmbedType, parseEmbed} from '../util/parseEmbed.js'
import {parseEmbedPlayerFromUrl} from '../util/parseEmbedPlayerFromUrl.js'
import {sanitizeHandle} from '../util/sanitizeHandle.js'
import {Box} from './Box.js'
import {FeedCard} from './FeedCard.js'
import * as Grid from './Grid.js'
import {PlayFilled as Play} from './icons/Play.js'
import {StarterPack} from './icons/StarterPack.js'
import {Image, SquareImage} from './Image.js'
import {LinkCard} from './LinkCard.js'
import {ListCard} from './ListCard.js'
import {ModeratedEmbed} from './ModeratedEmbed.js'
import {NotQuotePost, QuotePost} from './PostEmbed/QuotePost.js'
import {Text} from './Text.js'
type CommonProps = {
data: PostData
moderation: ModerationDecision
moderatorData: ModeratorData
hideNestedEmbeds?: boolean
}
export function PostEmbed({
embed: rawEmbed,
cx,
...rest
}: CommonProps & {
embed: AppBskyFeedDefs.PostView['embed']
cx?: (Record<string, any> | undefined)[]
}) {
const embed = parseEmbed(rawEmbed)
switch (embed.type) {
case 'images':
case 'link':
case 'video':
return (
<Box cx={cx}>
<MediaEmbeds embed={embed} {...rest} />
</Box>
)
case 'feed':
case 'list':
case 'starter_pack':
case 'post':
case 'post_blocked':
case 'post_detached':
case 'post_not_found':
return rest.hideNestedEmbeds ? null : (
<Box cx={cx}>
<RecordEmbeds embed={embed} {...rest} />
</Box>
)
case 'post_with_media':
return (
<Box cx={[a.gap_md, ...cx]}>
<MediaEmbeds embed={embed.media} {...rest} />
{!rest.hideNestedEmbeds && (
<RecordEmbeds embed={embed.view} {...rest} hideNestedEmbeds />
)}
</Box>
)
default:
return null
}
}
export function MediaEmbeds({
embed,
...rest
}: CommonProps & {
embed: Embed
}) {
switch (embed.type) {
case 'images': {
return <ImagesEmbed embed={embed} {...rest} />
}
case 'link': {
return <LinkEmbed embed={embed} {...rest} />
}
case 'video': {
return <VideoEmbed embed={embed} {...rest} />
}
default:
return null
}
}
export function RecordEmbeds({
embed,
...rest
}: CommonProps & {
embed: Embed
}) {
switch (embed.type) {
case 'feed': {
return <FeedEmbed embed={embed} {...rest} />
}
case 'list': {
return <ListEmbed embed={embed} {...rest} />
}
case 'starter_pack': {
return <StarterPackEmbed embed={embed} {...rest} />
}
case 'post': {
return <QuoteEmbed embed={embed} {...rest} />
}
case 'post_blocked': {
return <NotQuotePost>Quoted post is blocked</NotQuotePost>
}
case 'post_detached': {
return <NotQuotePost>Quoted post detached by author</NotQuotePost>
}
case 'post_not_found': {
return (
<NotQuotePost>
Quoted post not found, it may have been deleted.
</NotQuotePost>
)
}
}
}
export function QuoteEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'post'>
}) {
return <QuotePost embed={embed.view} {...rest} />
}
export function StarterPackEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'starter_pack'>
}) {
const uri = getStarterPackImageUri(embed.view)
if (
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
embed.view.record,
AppBskyGraphStarterpack.isRecord,
)
) {
return null
}
const {name, description} = embed.view.record
const image = rest.data.images.get(uri)
return (
<Box
cx={[
a.w_full,
a.rounded_sm,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
]}>
{image && (
<Box
cx={[
a.relative,
a.w_full,
t.atoms.bg_contrast_25,
{paddingTop: (630 / 1200) * 100 + '%'},
]}>
<Image
image={image}
cx={[
a.absolute,
a.inset_0,
{
objectFit: 'cover',
},
]}
/>
</Box>
)}
<Box cx={[a.px_md, a.py_sm, t.atoms.bg]}>
<Box cx={[a.w_full, a.gap_xs]}>
<Box cx={[a.flex_row, a.align_center, a.gap_xs, a.w_full]}>
<StarterPack size={40} fill={t.palette.primary_500} />
<Box cx={[a.flex_1]}>
<Text
cx={[a.text_sm, a.font_bold, a.leading_snug, a.line_clamp_2]}>
{name}
</Text>
<Text
cx={[
a.leading_snug,
a.text_xs,
t.atoms.text_contrast_medium,
a.line_clamp_1,
]}>
{`Starter pack by ${sanitizeHandle(
embed.view.creator.handle,
'@',
)}`}
</Text>
</Box>
</Box>
{description ? (
<Box cx={[a.flex_1]}>
<Text cx={[a.leading_snug, a.text_xs, a.line_clamp_3]}>
{description}
</Text>
</Box>
) : null}
{embed.view.joinedAllTimeCount >= 50 && (
<Text cx={[a.font_bold, a.text_xs, t.atoms.text_contrast_medium]}>
{embed.view.joinedAllTimeCount} users have joined!
</Text>
)}
</Box>
</Box>
</Box>
)
}
export function ListEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'list'>
}) {
return (
<ListCard
embed={embed.view}
data={rest.data}
moderatorData={rest.moderatorData}
/>
)
}
export function FeedEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'feed'>
}) {
return (
<FeedCard
embed={embed.view}
data={rest.data}
moderatorData={rest.moderatorData}
/>
)
}
export function LinkEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'link'>
}) {
const {title, description, uri, thumb} = embed.view.external
if (!thumb) return null
const modui = rest.moderation.ui('contentMedia')
const info = getModerationCauseInfo({
cause: modui.blurs.at(0),
moderatorData: rest.moderatorData,
})
if (info) {
return <ModeratedEmbed info={info} />
}
const image = rest.data.images.get(thumb)
const player = parseEmbedPlayerFromUrl(uri)
const gif = rest.data.images.get(player?.playerUri)
if (gif) {
return (
<Box cx={[a.rounded_sm, a.overflow_hidden]}>
<Image image={gif} />
<Box
cx={[
a.absolute,
a.inset_0,
{
top: '75%',
backgroundImage:
'linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.75))',
},
]}
/>
<Box
cx={[
a.absolute,
a.rounded_xs,
a.px_xs,
a.py_2xs,
{
backgroundColor: t.palette.contrast_25,
bottom: a.p_md.padding,
right: a.p_md.padding,
},
]}>
<Text cx={[t.atoms.text, a.font_heavy, a.text_sm]}>GIF</Text>
</Box>
</Box>
)
}
return (
<LinkCard image={image} title={title} description={description} uri={uri} />
)
}
export function VideoEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'video'>
}) {
const {thumbnail} = embed.view
const modui = rest.moderation.ui('contentMedia')
const info = getModerationCauseInfo({
cause: modui.blurs.at(0),
moderatorData: rest.moderatorData,
})
if (info) {
return <ModeratedEmbed info={info} />
}
const img = rest.data.images.get(thumbnail)
if (img) {
return (
<Box cx={[a.relative, a.rounded_sm, a.w_full, a.overflow_hidden]}>
<Image image={img} />
<Box
cx={[
a.absolute,
a.inset_0,
a.rounded_full,
{
margin: 'auto',
width: '48px',
height: '48px',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
},
]}>
<Box
cx={[
a.align_center,
a.justify_center,
a.absolute,
a.inset_0,
a.rounded_full,
{
margin: 'auto',
width: '48px',
height: '48px',
backgroundColor: t.palette.contrast_25,
opacity: 0.7,
},
]}>
<Play size={32} />
</Box>
</Box>
</Box>
)
} else {
return null
}
}
export function ImagesEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'images'>
}) {
const gutter = a.p_2xs.padding
const {images} = embed.view
const modui = rest.moderation.ui('contentMedia')
const info = getModerationCauseInfo({
cause: modui.blurs.at(0),
moderatorData: rest.moderatorData,
})
if (info) {
return <ModeratedEmbed info={info} />
}
if (images.length > 0) {
const imgs = images
.map(({fullsize}) => rest.data.images.get(fullsize))
.filter(Boolean) as ImageSource[]
if (imgs.length === 1) {
return (
<Box cx={[a.rounded_sm, a.w_full, a.overflow_hidden]}>
<Image image={imgs[0]} />
</Box>
)
} else if (imgs.length === 2) {
return (
<Grid.Row gutter={gutter}>
<Grid.Column width={1 / 2} gutter={gutter}>
<SquareImage
image={imgs[0]}
style={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
insetBorderStyle={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
/>
</Grid.Column>
<Grid.Column width={1 / 2} gutter={gutter}>
<SquareImage
image={imgs[1]}
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}
insetBorderStyle={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}
/>
</Grid.Column>
</Grid.Row>
)
} else if (imgs.length === 3) {
return (
<Grid.Row gutter={gutter}>
<Grid.Column gutter={gutter} width={1 / 2}>
<SquareImage
image={imgs[0]}
style={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
insetBorderStyle={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
/>
</Grid.Column>
<Grid.Column gutter={gutter} width={1 / 2} cx={[a.gap_xs]}>
<SquareImage
image={imgs[1]}
style={{
// awkward number found through trial and error to account for gap
paddingTop: '48.75%',
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
}}
insetBorderStyle={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
}}
/>
<SquareImage
image={imgs[2]}
style={{
paddingTop: '48.75%',
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: 0,
}}
insetBorderStyle={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: 0,
}}
/>
</Grid.Column>
</Grid.Row>
)
} else {
return (
<Grid.Row gutter={gutter}>
<Grid.Column gutter={gutter} width={1 / 2} cx={[a.gap_xs]}>
<SquareImage
image={imgs[0]}
style={{
// aspect ratio of 1.5 - matches app
paddingTop: '66.666%',
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
borderBottomLeftRadius: 0,
}}
insetBorderStyle={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
borderBottomLeftRadius: 0,
}}
/>
<SquareImage
image={imgs[2]}
style={{
paddingTop: '66.666%',
borderTopLeftRadius: 0,
borderBottomRightRadius: 0,
borderTopRightRadius: 0,
}}
insetBorderStyle={{
borderTopLeftRadius: 0,
borderBottomRightRadius: 0,
borderTopRightRadius: 0,
}}
/>
</Grid.Column>
<Grid.Column gutter={gutter} width={1 / 2} cx={[a.gap_xs]}>
<SquareImage
image={imgs[1]}
style={{
paddingTop: '66.666%',
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
}}
insetBorderStyle={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
}}
/>
<SquareImage
image={imgs[3]}
style={{
paddingTop: '66.666%',
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: 0,
}}
insetBorderStyle={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: 0,
}}
/>
</Grid.Column>
</Grid.Row>
)
}
}
}