Refactor to new arch

This commit is contained in:
Eric Bailey
2025-01-10 11:45:17 -06:00
parent 71aa7e2fd2
commit 775d837f8c
11 changed files with 696 additions and 505 deletions
+81
View File
@@ -0,0 +1,81 @@
module.exports = {
root: true,
extends: [
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'prettier',
],
parser: '@typescript-eslint/parser',
plugins: [
'@typescript-eslint',
'react',
'simple-import-sort',
'eslint-plugin-react-compiler',
],
rules: {
'react/no-unescaped-entities': 0,
'react/prop-types': 0,
'react-native/no-inline-styles': 0,
'simple-import-sort/imports': [
'error',
{
groups: [
// Side effect imports.
['^\\u0000'],
// Node.js builtins prefixed with `node:`.
['^node:'],
// Packages.
// Things that start with a letter (or digit or underscore), or `@` followed by a letter.
// React/React Native priortized, followed by expo
// Followed by all packages excluding unprefixed relative ones
[
'^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)',
'^(expo(.*)$)|^(expo$)',
'^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w',
],
// Relative imports.
// Ideally, anything that starts with a dot or #
// due to unprefixed relative imports being used, we whitelist the relative paths we use
// (?:$|\\/) matches end of string or /
[
'^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)',
'^(?:#\\/)?view(?:$|\\/)',
'^(?:#\\/)?screens(?:$|\\/)',
'^(?:#\\/)?alf(?:$|\\/)',
'^(?:#\\/)?components(?:$|\\/)',
'^#\\/',
'^\\.',
],
// anything else - hopefully we don't have any of these
['^'],
],
},
],
'simple-import-sort/exports': 'error',
'react-compiler/react-compiler': 'warn',
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@atproto/api',
importNames: ['moderatePost'],
message:
'Please use `moderatePost_wrapped` from `#/lib/moderatePost_wrapped` instead.',
},
],
},
],
},
ignorePatterns: [
'coverage',
'*.lock',
'.husky',
'*.html',
],
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
},
}
+74
View File
@@ -0,0 +1,74 @@
import {AppBskyFeedDefs, moderateFeedGenerator} from '@atproto/api'
import {ModeratorData} from '../data/getModeratorData.js'
import {PostData} from '../data/getPostData.js'
import {atoms as a, theme as t} from '../theme/index.js'
import {formatCount} from '../util/formatCount.js'
import {getModerationCauseInfo} from '../util/getModerationCauseInfo.js'
import {Box} from './Box.js'
import {Image} from './Image.js'
import {ModeratedEmbed} from './ModeratedEmbed.js'
import {Text} from './Text.js'
export function FeedCard({
embed,
data,
moderatorData,
}: {
embed: AppBskyFeedDefs.GeneratorView
data: PostData
moderatorData: ModeratorData
}) {
const feedModeration = moderateFeedGenerator(
embed,
moderatorData.moderationOptions,
)
const modui = feedModeration.ui('contentList')
const info = getModerationCauseInfo({
cause: modui.blurs.at(0),
moderatorData,
})
if (info) {
return <ModeratedEmbed info={info} />
}
const {avatar, displayName, likeCount = 0, creator} = embed
const image = data.images.get(avatar || '')
return (
<Box
cx={[
a.w_full,
a.gap_sm,
a.rounded_sm,
a.p_md,
a.border,
t.atoms.border_contrast_low,
]}>
<Box cx={[a.flex_row, a.align_center, a.gap_sm]}>
{image && (
<Image
image={image}
cx={[
a.rounded_sm,
{
width: '40px',
},
]}
/>
)}
<Box cx={[a.pt_2xs]}>
<Text cx={[a.text_md, a.font_bold, a.pb_2xs]}>{displayName}</Text>
<Text cx={[a.text_sm, a.leading_snug]}>By @{creator.handle}</Text>
</Box>
</Box>
{likeCount > 1 && (
<Text cx={[a.text_sm, a.leading_snug]}>
Liked by {formatCount(likeCount)} users
</Text>
)}
</Box>
)
}
+27 -1
View File
@@ -1,7 +1,8 @@
import React from 'react'
import {Image as ImageSource} from '../data/getPostData.js'
import {style as s} from '../theme/index.js'
import {atoms as a, style as s, theme as t} from '../theme/index.js'
import {Box} from './Box.js'
export type ImageProps = Omit<
React.ImgHTMLAttributes<HTMLImageElement>,
@@ -21,3 +22,28 @@ export function Image({image, cx, ...rest}: ImageProps) {
/>
)
}
export function SquareImage({image}: {image: ImageSource}) {
return (
<Box
cx={[
a.relative,
a.rounded_sm,
a.overflow_hidden,
a.w_full,
t.atoms.bg_contrast_25,
{paddingTop: '100%'},
]}>
<Image
image={image}
cx={[
a.absolute,
a.inset_0,
{
objectFit: 'cover',
},
]}
/>
</Box>
)
}
+59
View File
@@ -0,0 +1,59 @@
import {Image as ImageSource} from '../data/getPostData.js'
import {atoms as a, theme as t} from '../theme/index.js'
import {toShortUrl} from '../util/toShortUrl.js'
import {Box} from './Box.js'
import {Image} from './Image.js'
import {Text} from './Text.js'
export function LinkCard({
image,
uri,
title,
description,
}: {
image?: ImageSource
uri?: string
title: string
description: string
}) {
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.p_md, t.atoms.bg]}>
{uri && (
<Text cx={[a.text_xs, a.pb_sm, t.atoms.text_contrast_medium]}>
{toShortUrl(uri)}
</Text>
)}
<Text cx={[a.text_md, a.font_bold, a.pb_xs]}>{title}</Text>
<Text cx={[a.text_sm, a.leading_snug]}>{description}</Text>
</Box>
</Box>
)
}
+67
View File
@@ -0,0 +1,67 @@
import {AppBskyGraphDefs, moderateUserList} from '@atproto/api'
import {ModeratorData} from '../data/getModeratorData.js'
import {PostData} from '../data/getPostData.js'
import {atoms as a, theme as t} from '../theme/index.js'
import {getModerationCauseInfo} from '../util/getModerationCauseInfo.js'
import {Box} from './Box.js'
import {Image} from './Image.js'
import {ModeratedEmbed} from './ModeratedEmbed.js'
import {Text} from './Text.js'
export function ListCard({
embed,
data,
moderatorData,
}: {
embed: AppBskyGraphDefs.ListView
data: PostData
moderatorData: ModeratorData
}) {
const listModeration = moderateUserList(
embed,
moderatorData.moderationOptions,
)
const modui = listModeration.ui('contentList')
const info = getModerationCauseInfo({
cause: modui.blurs.at(0),
moderatorData,
})
if (info) {
return <ModeratedEmbed info={info} />
}
const {avatar, name, creator} = embed
const image = data.images.get(avatar || '')
return (
<Box
cx={[
a.w_full,
a.gap_sm,
a.rounded_sm,
a.p_md,
a.border,
t.atoms.border_contrast_low,
]}>
<Box cx={[a.flex_row, a.align_center, a.gap_sm]}>
{image && (
<Image
image={image}
cx={[
a.rounded_sm,
{
width: '40px',
},
]}
/>
)}
<Box cx={[a.pt_2xs]}>
<Text cx={[a.text_md, a.font_bold, a.pb_2xs]}>{name}</Text>
<Text cx={[a.text_sm, a.leading_snug]}>By @{creator.handle}</Text>
</Box>
</Box>
</Box>
)
}
@@ -0,0 +1,25 @@
import {atoms as a, theme as t} from '../theme/index.js'
import {ModerationCauseInfo} from '../util/getModerationCauseInfo.js'
import {Box} from './Box.js'
import {Text} from './Text.js'
export function ModeratedEmbed({info}: {info: ModerationCauseInfo}) {
return (
<Box
cx={[
a.flex_row,
a.align_center,
a.gap_sm,
a.rounded_sm,
a.p_md,
t.atoms.bg_contrast_25,
]}>
<info.icon size={20} fill={t.atoms.text_contrast_low.color} />
<Box cx={[a.gap_xs]}>
<Text cx={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
{info.name}
</Text>
</Box>
</Box>
)
}
+3 -500
View File
@@ -1,42 +1,22 @@
/* eslint-disable bsky-internal/avoid-unwrapped-text, react-native-a11y/has-valid-accessibility-ignores-invert-colors */
import React from 'react'
import {
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphDefs,
AppBskyGraphStarterpack,
moderateFeedGenerator,
moderateUserList,
ModerationDecision,
RichText as RichTextApi,
} from '@atproto/api'
import {ModeratorData} from '../data/getModeratorData.js'
import {Image as ImageSource, PostData} from '../data/getPostData.js'
import {PostData} from '../data/getPostData.js'
import {atoms as a, gradient, theme as t} from '../theme/index.js'
import {formatCount} from '../util/formatCount.js'
import {formatDate} from '../util/formatDate.js'
import {
getModerationCauseInfo,
ModerationCauseInfo,
} from '../util/getModerationCauseInfo.js'
import {getStarterPackImageUri} from '../util/getStarterPackImageUri.js'
import {moderatePost} from '../util/moderatePost.js'
import {toShortUrl} from '../util/toShortUrl.js'
import {viewRecordToPostView} from '../util/viewRecordToPostView.js'
import {Avatar} from './Avatar.js'
import {Box} from './Box.js'
import * as Grid from './Grid.js'
import {CircleInfo} from './icons/CircleInfo.js'
import {Heart} from './icons/Heart.js'
import {Logomark} from './icons/Logomark.js'
import {Logotype} from './icons/Logotype.js'
import {Repost} from './icons/Repost.js'
import {Image} from './Image.js'
import {PostEmbed} from './PostEmbed.js'
import {RichText} from './RichText.js'
import {Text} from './Text.js'
@@ -105,7 +85,7 @@ export function Post({
{post.embed && (
<Box cx={[a.pt_sm]}>
<Embeds
<PostEmbed
embed={post.embed}
data={data}
moderation={moderation}
@@ -191,480 +171,3 @@ export function Logo() {
</Box>
)
}
export function Embeds({
embed,
data,
moderation,
moderatorData,
hideNestedEmbeds,
}: {
embed: AppBskyFeedDefs.PostView['embed']
data: PostData
moderation: ModerationDecision
moderatorData: ModeratorData
hideNestedEmbeds?: boolean
}) {
/**
* If record-with-media, pass through the existing moderation into `Embeds`
* and move on to `QuoteEmbed`'s own moderation.
*/
if (AppBskyEmbedRecordWithMedia.isView(embed)) {
return (
<Box cx={[a.gap_md]}>
<Embeds
embed={embed.media}
data={data}
moderation={moderation}
moderatorData={moderatorData}
/>
{!hideNestedEmbeds && (
<QuoteEmbed
embed={embed.record}
data={data}
moderatorData={moderatorData}
/>
)}
</Box>
)
}
const mod = moderation.ui('contentMedia')
const info = getModerationCauseInfo({
cause: mod.blurs.at(0),
moderatorData,
})
if (info) {
return <ModeratedEmbed info={info} />
}
if (AppBskyEmbedExternal.isView(embed)) {
const {title, description, uri, thumb} = embed.external
const image = data.images.get(thumb)
return (
<LinkCard
image={image}
title={title}
description={description}
uri={uri}
/>
)
}
if (AppBskyEmbedImages.isView(embed)) {
const {images} = embed
if (images.length > 0) {
const imgs = images.map(({fullsize}) => data.images.get(fullsize))
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={a.p_xs.padding}>
<Grid.Column gutter={a.p_xs.padding} width={1 / 2}>
<SquareImage image={imgs[0]} />
</Grid.Column>
<Grid.Column gutter={a.p_xs.padding} width={1 / 2}>
<SquareImage image={imgs[1]} />
</Grid.Column>
</Grid.Row>
)
} else if (imgs.length === 3) {
return (
<Grid.Row gutter={a.p_xs.padding}>
<Grid.Column gutter={a.p_xs.padding} width={2 / 3}>
<SquareImage image={imgs[0]} />
</Grid.Column>
<Grid.Column gutter={a.p_xs.padding} width={1 / 3} cx={[a.gap_sm]}>
<SquareImage image={imgs[1]} />
<SquareImage image={imgs[2]} />
</Grid.Column>
</Grid.Row>
)
} else {
return (
<Grid.Row gutter={a.p_xs.padding}>
<Grid.Column gutter={a.p_xs.padding} width={1 / 2} cx={[a.gap_sm]}>
<SquareImage image={imgs[0]} />
<SquareImage image={imgs[1]} />
</Grid.Column>
<Grid.Column gutter={a.p_xs.padding} width={1 / 2} cx={[a.gap_sm]}>
<SquareImage image={imgs[2]} />
<SquareImage image={imgs[3]} />
</Grid.Column>
</Grid.Row>
)
}
}
}
if (AppBskyEmbedRecord.isView(embed)) {
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
return (
<FeedCard
embed={embed.record}
data={data}
moderatorData={moderatorData}
/>
)
}
if (AppBskyGraphDefs.isListView(embed.record)) {
return (
<ListCard
embed={embed.record}
data={data}
moderatorData={moderatorData}
/>
)
}
if (
AppBskyGraphDefs.isValidStarterPackViewBasic(embed.record) &&
AppBskyGraphStarterpack.isValidRecord(embed.record.record)
) {
const uri = getStarterPackImageUri(embed.record)
const {name, description} = embed.record.record
const image = data.images.get(uri)
return <LinkCard image={image} title={name} description={description} />
}
return (
<QuoteEmbed embed={embed} data={data} moderatorData={moderatorData} />
)
}
return null
}
export function FeedCard({
embed,
data,
moderatorData,
}: {
embed: AppBskyFeedDefs.GeneratorView
data: PostData
moderatorData: ModeratorData
}) {
const feedModeration = moderateFeedGenerator(
embed,
moderatorData.moderationOptions,
)
const modui = feedModeration.ui('contentList')
const info = getModerationCauseInfo({
cause: modui.blurs.at(0),
moderatorData,
})
if (info) {
return <ModeratedEmbed info={info} />
}
const {avatar, displayName, likeCount, creator} = embed
const image = data.images.get(avatar)
return (
<Box
cx={[
a.w_full,
a.gap_sm,
a.rounded_sm,
a.p_md,
a.border,
t.atoms.border_contrast_low,
]}>
<Box cx={[a.flex_row, a.align_center, a.gap_sm]}>
{image && (
<Image
image={image}
cx={[
a.rounded_sm,
{
width: '40px',
},
]}
/>
)}
<Box cx={[a.pt_2xs]}>
<Text cx={[a.text_md, a.font_bold, a.pb_2xs]}>{displayName}</Text>
<Text cx={[a.text_sm, a.leading_snug]}>By @{creator.handle}</Text>
</Box>
</Box>
{likeCount > 1 && (
<Text cx={[a.text_sm, a.leading_snug]}>
Liked by {formatCount(likeCount)} users
</Text>
)}
</Box>
)
}
export function ListCard({
embed,
data,
moderatorData,
}: {
embed: AppBskyGraphDefs.ListView
data: PostData
moderatorData: ModeratorData
}) {
const listModeration = moderateUserList(
embed,
moderatorData.moderationOptions,
)
const modui = listModeration.ui('contentList')
const info = getModerationCauseInfo({
cause: modui.blurs.at(0),
moderatorData,
})
if (info) {
return <ModeratedEmbed info={info} />
}
const {avatar, name, creator} = embed
const image = data.images.get(avatar)
return (
<Box
cx={[
a.w_full,
a.gap_sm,
a.rounded_sm,
a.p_md,
a.border,
t.atoms.border_contrast_low,
]}>
<Box cx={[a.flex_row, a.align_center, a.gap_sm]}>
{image && (
<Image
image={image}
cx={[
a.rounded_sm,
{
width: '40px',
},
]}
/>
)}
<Box cx={[a.pt_2xs]}>
<Text cx={[a.text_md, a.font_bold, a.pb_2xs]}>{name}</Text>
<Text cx={[a.text_sm, a.leading_snug]}>By @{creator.handle}</Text>
</Box>
</Box>
</Box>
)
}
export function LinkCard({
image,
uri,
title,
description,
}: {
image?: ImageSource
uri?: string
title: string
description: string
}) {
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.p_md, t.atoms.bg]}>
{uri && (
<Text cx={[a.text_xs, a.pb_sm, t.atoms.text_contrast_medium]}>
{toShortUrl(uri)}
</Text>
)}
<Text cx={[a.text_md, a.font_bold, a.pb_xs]}>{title}</Text>
<Text cx={[a.text_sm, a.leading_snug]}>{description}</Text>
</Box>
</Box>
)
}
export function SquareImage({image}: {image: ImageSource}) {
return (
<Box
cx={[
a.relative,
a.rounded_sm,
a.overflow_hidden,
a.w_full,
t.atoms.bg_contrast_25,
{paddingTop: '100%'},
]}>
<Image
image={image}
cx={[
a.absolute,
a.inset_0,
{
objectFit: 'cover',
},
]}
/>
</Box>
)
}
export function QuoteEmbed({
embed,
data,
moderatorData,
}: {
embed: AppBskyEmbedRecord.View
data: PostData
moderatorData: ModeratorData
}) {
if (
AppBskyEmbedRecord.isValidViewRecord(embed.record) &&
AppBskyFeedPost.isValidRecord(embed.record.value)
) {
const {author, value: post, embeds} = embed.record
const avatar = data.images.get(author.avatar)
const rt = post.text
? new RichTextApi({
text: post.text,
facets: post.facets,
})
: undefined
const postView = viewRecordToPostView(embed.record)
const moderation = moderatePost(postView, moderatorData.moderationOptions)
const mod = moderation.ui('contentView')
const info = getModerationCauseInfo({
cause: mod.blurs.at(0),
moderatorData,
})
if (info) {
return <ModeratedEmbed info={info} />
}
return (
<Box
cx={[
a.w_full,
a.p_md,
a.rounded_sm,
a.border,
t.atoms.border_contrast_low,
]}>
<Box cx={[a.flex_row, a.align_center, a.gap_xs, a.pb_sm]}>
<Avatar
size={20}
image={avatar}
profile={author}
moderatorData={moderatorData}
/>
<Box cx={[a.flex_row, a.align_center, a.gap_xs]}>
<Text cx={[a.text_sm, a.font_bold]}>
{author.displayName || author.handle}
</Text>
<Text cx={[a.text_xs, t.atoms.text_contrast_medium]}>
@{author.handle}
</Text>
</Box>
</Box>
{rt && <RichText value={rt} cx={[a.text_sm]} />}
{Boolean(embeds && embeds.length) && (
<Box cx={[a.pt_sm]}>
<Embeds
embed={embeds[0]}
data={data}
moderation={moderation}
moderatorData={moderatorData}
hideNestedEmbeds
/>
</Box>
)}
</Box>
)
} else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
return <NotQuoteEmbed>Quoted post is blocked</NotQuoteEmbed>
} else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) {
return (
<NotQuoteEmbed>
Quoted post not found, it may have been deleted.
</NotQuoteEmbed>
)
} else if (AppBskyEmbedRecord.isViewDetached(embed.record)) {
return <NotQuoteEmbed>Quoted post detached by author</NotQuoteEmbed>
}
return null
}
export function NotQuoteEmbed({children}: {children: React.ReactNode}) {
return (
<Box
cx={[
a.flex_row,
a.align_center,
a.gap_sm,
a.rounded_sm,
a.p_md,
t.atoms.bg_contrast_25,
]}>
<CircleInfo size={20} fill={t.atoms.text_contrast_low.color} />
<Text cx={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
{children}
</Text>
</Box>
)
}
export function ModeratedEmbed({info}: {info: ModerationCauseInfo}) {
return (
<Box
cx={[
a.flex_row,
a.align_center,
a.gap_sm,
a.rounded_sm,
a.p_md,
t.atoms.bg_contrast_25,
]}>
<info.icon size={20} fill={t.atoms.text_contrast_low.color} />
<Box cx={[a.gap_xs]}>
<Text cx={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
{info.name}
</Text>
</Box>
</Box>
)
}
+240
View File
@@ -0,0 +1,240 @@
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} from '../theme/index.js'
import {getStarterPackImageUri} from '../util/getStarterPackImageUri.js'
import {Embed, EmbedType,parseEmbed} from '../util/parseEmbed.js'
import {Box} from './Box.js'
import {FeedCard} from './FeedCard.js'
import * as Grid from './Grid.js'
import {Image, SquareImage} from './Image.js'
import {LinkCard} from './LinkCard.js'
import {ListCard} from './ListCard.js'
import {NotQuotePost,QuotePost} from './PostEmbed/QuotePost.js'
type CommonProps = {
data: PostData
moderation: ModerationDecision
moderatorData: ModeratorData
hideNestedEmbeds?: boolean
}
export function PostEmbed({
embed: rawEmbed,
...rest
}: CommonProps & {
embed: AppBskyFeedDefs.PostView['embed']
}) {
const embed = parseEmbed(rawEmbed)
switch (embed.type) {
case 'images':
case 'link':
case 'video':
return <MediaEmbeds embed={embed} {...rest} />
case 'feed':
case 'list':
case 'starter_pack':
case 'post':
case 'post_blocked':
case 'post_detached':
case 'post_not_found':
return <RecordEmbeds embed={embed} {...rest} />
case 'post_with_media':
return (
<Box cx={[a.gap_md]}>
<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 null
}
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 (!AppBskyGraphStarterpack.isValidRecord(embed.view.record)) return null
const {name, description} = embed.view.record
const image = rest.data.images.get(uri)
return <LinkCard image={image} title={name} description={description} />
}
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 image = rest.data.images.get(thumb)
return (
<LinkCard image={image} title={title} description={description} uri={uri} />
)
}
export function ImagesEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'images'>
}) {
const {images} = embed.view
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={a.p_xs.padding}>
<Grid.Column gutter={a.p_xs.padding} width={1 / 2}>
<SquareImage image={imgs[0]} />
</Grid.Column>
<Grid.Column gutter={a.p_xs.padding} width={1 / 2}>
<SquareImage image={imgs[1]} />
</Grid.Column>
</Grid.Row>
)
} else if (imgs.length === 3) {
return (
<Grid.Row gutter={a.p_xs.padding}>
<Grid.Column gutter={a.p_xs.padding} width={2 / 3}>
<SquareImage image={imgs[0]} />
</Grid.Column>
<Grid.Column gutter={a.p_xs.padding} width={1 / 3} cx={[a.gap_sm]}>
<SquareImage image={imgs[1]} />
<SquareImage image={imgs[2]} />
</Grid.Column>
</Grid.Row>
)
} else {
return (
<Grid.Row gutter={a.p_xs.padding}>
<Grid.Column gutter={a.p_xs.padding} width={1 / 2} cx={[a.gap_sm]}>
<SquareImage image={imgs[0]} />
<SquareImage image={imgs[1]} />
</Grid.Column>
<Grid.Column gutter={a.p_xs.padding} width={1 / 2} cx={[a.gap_sm]}>
<SquareImage image={imgs[2]} />
<SquareImage image={imgs[3]} />
</Grid.Column>
</Grid.Row>
)
}
}
}
@@ -0,0 +1,113 @@
import React from 'react'
import {
AppBskyEmbedRecord,
AppBskyFeedPost,
RichText as RichTextApi,
} from '@atproto/api'
import {ModeratorData} from '../../data/getModeratorData.js'
import {PostData} from '../../data/getPostData.js'
import {atoms as a, theme as t} from '../../theme/index.js'
import {getModerationCauseInfo} from '../../util/getModerationCauseInfo.js'
import {moderatePost} from '../../util/moderatePost.js'
import {viewRecordToPostView} from '../../util/viewRecordToPostView.js'
import {Avatar} from '../Avatar.js'
import {Box} from '../Box.js'
import {CircleInfo} from '../icons/CircleInfo.js'
import {ModeratedEmbed} from '../ModeratedEmbed.js'
import {PostEmbed} from '../PostEmbed.js'
import {RichText} from '../RichText.js'
import {Text} from '../Text.js'
export function QuotePost({
embed,
data,
moderatorData,
}: {
embed: AppBskyEmbedRecord.ViewRecord
data: PostData
moderatorData: ModeratorData
}) {
const {author, value: post, embeds} = embed
const avatar = data.images.get(author.avatar || '')
const rt =
AppBskyFeedPost.isValidRecord(post) && post.text
? new RichTextApi({
text: post.text,
facets: post.facets,
})
: undefined
const postView = viewRecordToPostView(embed)
const moderation = moderatePost(postView, moderatorData.moderationOptions)
const mod = moderation.ui('contentView')
const info = getModerationCauseInfo({
cause: mod.blurs.at(0),
moderatorData,
})
if (info) {
return <ModeratedEmbed info={info} />
}
return (
<Box
cx={[
a.w_full,
a.p_md,
a.rounded_sm,
a.border,
t.atoms.border_contrast_low,
]}>
<Box cx={[a.flex_row, a.align_center, a.gap_xs, a.pb_sm]}>
<Avatar
size={20}
image={avatar}
profile={author}
moderatorData={moderatorData}
/>
<Box cx={[a.flex_row, a.align_center, a.gap_xs]}>
<Text cx={[a.text_sm, a.font_bold]}>
{author.displayName || author.handle}
</Text>
<Text cx={[a.text_xs, t.atoms.text_contrast_medium]}>
@{author.handle}
</Text>
</Box>
</Box>
{rt && <RichText value={rt} cx={[a.text_sm]} />}
{embeds && embeds.length && (
<Box cx={[a.pt_sm]}>
<PostEmbed
embed={embeds[0]}
data={data}
moderation={moderation}
moderatorData={moderatorData}
hideNestedEmbeds
/>
</Box>
)}
</Box>
)
}
export function NotQuotePost({children}: {children: React.ReactNode}) {
return (
<Box
cx={[
a.flex_row,
a.align_center,
a.gap_sm,
a.rounded_sm,
a.p_md,
t.atoms.bg_contrast_25,
]}>
<CircleInfo size={20} fill={t.atoms.text_contrast_low.color} />
<Text cx={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
{children}
</Text>
</Box>
)
}
+6 -3
View File
@@ -2,7 +2,7 @@ import {AppBskyFeedDefs} from '@atproto/api'
import {httpLogger} from '../logger.js'
import {getStarterPackImageUri} from '../util/getStarterPackImageUri.js'
import {Embed,parseEmbed} from '../util/parseEmbed.js'
import {Embed, parseEmbed} from '../util/parseEmbed.js'
import {getImage} from './getImage.js'
export type Metadata = {
@@ -103,8 +103,10 @@ export function getEmbedData(embed: Embed, images: Map<string, Metadata>) {
},
})
}
for (const _e of embed.view.embeds) {
getEmbedData(parseEmbed(_e), images)
if (embed.view.embeds) {
for (const _e of embed.view.embeds) {
getEmbedData(parseEmbed(_e), images)
}
}
break
}
@@ -125,6 +127,7 @@ export async function getPostData(
post: AppBskyFeedDefs.PostView,
): Promise<PostData> {
const images: Map<string, Metadata> = new Map()
console.log(JSON.stringify(post, null, 2))
if (post.author.avatar) {
images.set(post.author.avatar, {
@@ -50,7 +50,7 @@ export function getModerationCauseInfo({
cause,
moderatorData,
}: {
cause: ModerationCause
cause?: ModerationCause
moderatorData: ModeratorData
}): ModerationCauseInfo | undefined {
if (!cause) return undefined