Refactor to new arch
This commit is contained in:
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user