Moderation and cleanup
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
/* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */
|
||||
import React from 'react'
|
||||
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
|
||||
|
||||
import {ModeratorData} from '../data/getModeratorData.js'
|
||||
import {Image as ImageSource} from '../data/getPostData.js'
|
||||
import {atoms as a, theme as t} from '../theme/index.js'
|
||||
import {Box} from './Box.js'
|
||||
import {Image} from './Image.js'
|
||||
|
||||
export function Avatar({
|
||||
size,
|
||||
image,
|
||||
profile,
|
||||
moderatorData,
|
||||
}: {
|
||||
size: number
|
||||
image: ImageSource
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
moderatorData: ModeratorData
|
||||
}) {
|
||||
const moderation = moderateProfile(profile, moderatorData.moderationOptions)
|
||||
const modui = moderation.ui('avatar')
|
||||
const blur = !!modui?.blurs[0]
|
||||
return (
|
||||
<Box
|
||||
cx={[
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
width: size + 'px',
|
||||
height: size + 'px',
|
||||
},
|
||||
blur && {
|
||||
filter: 'blur(2.5px)',
|
||||
},
|
||||
]}>
|
||||
{image && <Image height="100%" width="100%" image={image} />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
import {Image as ImageSource} from '../data/getPostData.js'
|
||||
import {style as s} from '../theme/index.js'
|
||||
import {Image as ImageSource} from '../util/resolvePostData.js'
|
||||
|
||||
export type ImageProps = Omit<
|
||||
React.ImgHTMLAttributes<HTMLImageElement>,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */
|
||||
import React from 'react'
|
||||
|
||||
import {atoms as a, theme as t} from '../theme/index.js'
|
||||
import {Image as ImageSource} from '../util/resolvePostData.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>
|
||||
)
|
||||
}
|
||||
@@ -9,14 +9,25 @@ import {
|
||||
AppBskyFeedPost,
|
||||
AppBskyGraphDefs,
|
||||
AppBskyGraphStarterpack,
|
||||
moderateFeedGenerator,
|
||||
moderateUserList,
|
||||
ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {ModeratorData} from '../data/getModeratorData.js'
|
||||
import {Image as ImageSource, 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 {Image as ImageSource, PostData} from '../util/resolvePostData.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'
|
||||
@@ -25,25 +36,24 @@ import {Logomark} from './icons/Logomark.js'
|
||||
import {Logotype} from './icons/Logotype.js'
|
||||
import {Repost} from './icons/Repost.js'
|
||||
import {Image} from './Image.js'
|
||||
import {LinkCard} from './LinkCard.js'
|
||||
import {RichText} from './RichText.js'
|
||||
import {Text} from './Text.js'
|
||||
|
||||
export function Post({
|
||||
post,
|
||||
data,
|
||||
moderation,
|
||||
moderatorData,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
data: PostData
|
||||
moderation: ModerationDecision
|
||||
moderatorData: ModeratorData
|
||||
}) {
|
||||
if (AppBskyFeedPost.isRecord(post.record)) {
|
||||
const avatar = data.images.get(post.author.avatar)
|
||||
const text = post.record.text
|
||||
const rt = data.texts.get(text)
|
||||
const hasInteractions = post.likeCount > 0 || post.repostCount > 0
|
||||
console.log(moderation.ui('contentView'))
|
||||
const moderation = moderatePost(post, moderatorData.moderationOptions)
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -58,20 +68,24 @@ export function Post({
|
||||
},
|
||||
]}>
|
||||
<Box
|
||||
cx={[a.flex, a.flex_col, a.w_full, a.p_xl, a.rounded_md, t.atoms.bg]}>
|
||||
cx={[
|
||||
a.flex,
|
||||
a.flex_col,
|
||||
a.w_full,
|
||||
a.p_xl,
|
||||
a.rounded_md,
|
||||
t.atoms.bg,
|
||||
{
|
||||
boxShadow: `0 0 20px rgb(0, 25, 51, 0.2)`,
|
||||
},
|
||||
]}>
|
||||
<Box cx={[a.flex_row, a.align_center, a.gap_sm, a.pb_sm]}>
|
||||
<Box
|
||||
cx={[
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
},
|
||||
]}>
|
||||
{avatar && <Image height="100%" width="100%" image={avatar} />}
|
||||
</Box>
|
||||
<Avatar
|
||||
size={48}
|
||||
image={avatar}
|
||||
profile={post.author}
|
||||
moderatorData={moderatorData}
|
||||
/>
|
||||
<Box cx={[a.flex_col]}>
|
||||
<Text cx={[a.text_md, a.font_bold, a.pb_2xs]}>
|
||||
{post.author.displayName || post.author.handle}
|
||||
@@ -85,12 +99,17 @@ export function Post({
|
||||
{rt && <RichText value={rt} />}
|
||||
|
||||
{post.embed && (
|
||||
<Box cx={[a.pt_md]}>
|
||||
<Embeds embed={post.embed} data={data} />
|
||||
<Box cx={[a.pt_sm]}>
|
||||
<Embeds
|
||||
embed={post.embed}
|
||||
data={data}
|
||||
moderation={moderation}
|
||||
moderatorData={moderatorData}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box cx={[a.flex_row, a.align_center, a.justify_between, a.pt_lg]}>
|
||||
<Box cx={[a.flex_row, a.align_center, a.justify_between, a.pt_md]}>
|
||||
<Text cx={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{formatDate(post.record.createdAt)}
|
||||
</Text>
|
||||
@@ -171,12 +190,50 @@ export function Logo() {
|
||||
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)
|
||||
@@ -243,11 +300,25 @@ export function Embeds({
|
||||
|
||||
if (AppBskyEmbedRecord.isView(embed)) {
|
||||
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
|
||||
return <FeedCard embed={embed.record} data={data} />
|
||||
return (
|
||||
<FeedCard
|
||||
embed={embed.record}
|
||||
data={data}
|
||||
moderatorData={moderatorData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (AppBskyGraphDefs.isListView(embed.record)) {
|
||||
return <ListCard embed={embed.record} data={data} />
|
||||
return (
|
||||
<ListCard
|
||||
embed={embed.record}
|
||||
data={data}
|
||||
moderatorData={moderatorData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
AppBskyGraphDefs.isStarterPackViewBasic(embed.record) &&
|
||||
AppBskyGraphStarterpack.isRecord(embed.record.record)
|
||||
@@ -257,15 +328,9 @@ export function Embeds({
|
||||
const image = data.images.get(uri)
|
||||
return <LinkCard image={image} title={name} description={description} />
|
||||
}
|
||||
return <QuoteEmbed embed={embed} data={data} />
|
||||
}
|
||||
|
||||
if (AppBskyEmbedRecordWithMedia.isView(embed)) {
|
||||
return (
|
||||
<Box cx={[a.gap_md]}>
|
||||
<Embeds embed={embed.media} data={data} />
|
||||
{!hideNestedEmbeds && <QuoteEmbed embed={embed.record} data={data} />}
|
||||
</Box>
|
||||
<QuoteEmbed embed={embed} data={data} moderatorData={moderatorData} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -275,12 +340,29 @@ export function Embeds({
|
||||
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={[
|
||||
@@ -321,12 +403,29 @@ export function FeedCard({
|
||||
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={[
|
||||
@@ -358,6 +457,59 @@ export function ListCard({
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -386,9 +538,11 @@ export function SquareImage({image}: {image: ImageSource}) {
|
||||
export function QuoteEmbed({
|
||||
embed,
|
||||
data,
|
||||
moderatorData,
|
||||
}: {
|
||||
embed: AppBskyEmbedRecord.View
|
||||
data: PostData
|
||||
moderatorData: ModeratorData
|
||||
}) {
|
||||
if (
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record) &&
|
||||
@@ -398,15 +552,17 @@ export function QuoteEmbed({
|
||||
const {author, value: post, embeds} = embed.record
|
||||
const avatar = data.images.get(author.avatar)
|
||||
const rt = data.texts.get(post.text)
|
||||
const notPublic = author.labels.some(l => l.val === `!no-unauthenticated`)
|
||||
const postView = viewRecordToPostView(embed.record)
|
||||
const moderation = moderatePost(postView, moderatorData.moderationOptions)
|
||||
|
||||
if (notPublic) {
|
||||
return (
|
||||
<NotQuoteEmbed>
|
||||
The author of the quoted post has requested their posts not be
|
||||
displayed on external sites
|
||||
</NotQuoteEmbed>
|
||||
)
|
||||
const mod = moderation.ui('contentView')
|
||||
const info = getModerationCauseInfo({
|
||||
cause: mod.blurs.at(0),
|
||||
moderatorData,
|
||||
})
|
||||
|
||||
if (info) {
|
||||
return <ModeratedEmbed info={info} />
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -419,18 +575,12 @@ export function QuoteEmbed({
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Box cx={[a.flex_row, a.align_center, a.gap_xs, a.pb_sm]}>
|
||||
<Box
|
||||
cx={[
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
},
|
||||
]}>
|
||||
{avatar && <Image height="100%" width="100%" image={avatar} />}
|
||||
</Box>
|
||||
<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}
|
||||
@@ -449,7 +599,13 @@ export function QuoteEmbed({
|
||||
|
||||
{Boolean(embeds && embeds.length) && (
|
||||
<Box cx={[a.pt_sm]}>
|
||||
<Embeds embed={embeds[0]} data={data} hideNestedEmbeds />
|
||||
<Embeds
|
||||
embed={embeds[0]}
|
||||
data={data}
|
||||
moderation={moderation}
|
||||
moderatorData={moderatorData}
|
||||
hideNestedEmbeds
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
@@ -486,3 +642,24 @@ export function NotQuoteEmbed({children}: {children: React.ReactNode}) {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
|
||||
export function CircleInfo({size, fill}: {size: number; fill?: string}) {
|
||||
import {IconProps} from './types.js'
|
||||
|
||||
export function CircleInfo({size, fill}: IconProps) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 24 24" width={size} height={size}>
|
||||
<path
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
|
||||
import {IconProps} from './types.js'
|
||||
|
||||
export function EyeSlash({size, fill}: IconProps) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 24 24" width={size} height={size}>
|
||||
<path
|
||||
fill={fill}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.293 2.293a1 1 0 0 1 1.414 0L7.335 5.92l.03.03 3.22 3.222 4.243 4.242 3.22 3.22.03.03 3.63 3.629a1 1 0 0 1-1.415 1.414l-3.09-3.09c-2.65 1.478-5.625 1.778-8.421.869-3.039-.987-5.779-3.37-7.67-7.027a1 1 0 0 1 0-.918c1.086-2.1 2.452-3.78 3.996-5.019L2.293 3.707a1 1 0 0 1 0-1.414Zm4.24 5.654 2.021 2.021a4 4 0 0 0 5.478 5.478l1.688 1.688c-2.042.982-4.246 1.124-6.32.45-2.34-.76-4.594-2.586-6.265-5.584.97-1.739 2.135-3.083 3.398-4.053Zm3.535 3.535 2.45 2.45a2 2 0 0 1-2.45-2.45Zm.81-5.405c3.573-.49 7.45 1.369 9.987 5.923a14.797 14.797 0 0 1-1.347 2.02 1 1 0 1 0 1.564 1.247 17.078 17.078 0 0 0 1.806-2.808 1 1 0 0 0 0-.918c-2.833-5.479-7.584-8.088-12.281-7.446a1 1 0 0 0 .271 1.982Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
|
||||
export function Heart({size, fill}: {size: number; fill?: string}) {
|
||||
import {IconProps} from './types.js'
|
||||
|
||||
export function Heart({size, fill}: IconProps) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 24 24" width={size} height={size}>
|
||||
<path
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
|
||||
export function Logomark({size, fill}: {size: number; fill?: string}) {
|
||||
import {IconProps} from './types.js'
|
||||
|
||||
export function Logomark({size, fill}: IconProps) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 500 441" width={size}>
|
||||
<path
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
|
||||
export function Logotype({size, fill}: {size: number; fill?: string}) {
|
||||
import {IconProps} from './types.js'
|
||||
|
||||
export function Logotype({size, fill}: IconProps) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 398 108" width={size}>
|
||||
<path
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
|
||||
export function Repost({size, fill}: {size: number; fill?: string}) {
|
||||
import {IconProps} from './types.js'
|
||||
|
||||
export function Repost({size, fill}: IconProps) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 24 24" width={size} height={size}>
|
||||
<path
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
|
||||
import {IconProps} from './types.js'
|
||||
|
||||
export function Warning({size, fill}: IconProps) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 24 24" width={size} height={size}>
|
||||
<path
|
||||
fill={fill}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M11.14 4.494a.995.995 0 0 1 1.72 0l7.001 12.008a.996.996 0 0 1-.86 1.498H4.999a.996.996 0 0 1-.86-1.498L11.14 4.494Zm3.447-1.007c-1.155-1.983-4.019-1.983-5.174 0L2.41 15.494C1.247 17.491 2.686 20 4.998 20h14.004c2.312 0 3.751-2.509 2.587-4.506L14.587 3.487ZM13 9.019a1 1 0 1 0-2 0v2.994a1 1 0 1 0 2 0V9.02Zm-1 4.731a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type IconProps = {size: number; fill?: string}
|
||||
@@ -0,0 +1,50 @@
|
||||
/* eslint-disable no-restricted-imports */
|
||||
import {
|
||||
AppBskyLabelerDefs,
|
||||
AtpAgent,
|
||||
BSKY_LABELER_DID,
|
||||
DEFAULT_LABEL_SETTINGS,
|
||||
interpretLabelValueDefinitions,
|
||||
moderatePost,
|
||||
} from '@atproto/api'
|
||||
|
||||
export type ModeratorData = Awaited<ReturnType<typeof getModeratorData>>
|
||||
|
||||
export const DEFAULT_LABELS: typeof DEFAULT_LABEL_SETTINGS = Object.fromEntries(
|
||||
Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, 'hide']),
|
||||
)
|
||||
|
||||
export async function getModeratorData(agent: AtpAgent) {
|
||||
const {data} = await agent.app.bsky.labeler.getServices({
|
||||
dids: [BSKY_LABELER_DID],
|
||||
detailed: true,
|
||||
})
|
||||
if (!data || !data.views[0]) {
|
||||
throw new Error(`Could not fetch label definitions`)
|
||||
}
|
||||
const labeler = data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed
|
||||
const definitions = interpretLabelValueDefinitions(labeler)
|
||||
const moderationOptions: Parameters<typeof moderatePost>[1] = {
|
||||
userDid: undefined,
|
||||
prefs: {
|
||||
adultContentEnabled: false,
|
||||
labels: DEFAULT_LABELS,
|
||||
mutedWords: [],
|
||||
hiddenPosts: [],
|
||||
labelers: [
|
||||
{
|
||||
did: BSKY_LABELER_DID,
|
||||
labels: DEFAULT_LABELS,
|
||||
},
|
||||
],
|
||||
},
|
||||
labelDefs: {
|
||||
[BSKY_LABELER_DID]: definitions,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
labeler,
|
||||
moderationOptions,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {AtpAgent, AtUri} from '@atproto/api'
|
||||
|
||||
export async function getPost({uri, agent}: {uri: AtUri; agent: AtpAgent}) {
|
||||
if (!uri.hostname.startsWith('did:')) {
|
||||
const res = await agent.resolveHandle({
|
||||
handle: uri.hostname,
|
||||
})
|
||||
uri.hostname = res.data.did
|
||||
}
|
||||
const {data} = await agent.getPosts({
|
||||
uris: [uri.toString()],
|
||||
})
|
||||
return data.posts.at(0)
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from '@atproto/api'
|
||||
|
||||
import {httpLogger} from '../logger.js'
|
||||
import {getStarterPackImageUri} from '../util/getStarterPackImageUri.js'
|
||||
import {getImage} from './getImage.js'
|
||||
import {getStarterPackImageUri} from './getStarterPackImageUri.js'
|
||||
|
||||
export type Metadata = {
|
||||
aspectRatio: {
|
||||
@@ -43,7 +43,7 @@ function normalizeAspectRatio(aspectRatio?: {
|
||||
return aspectRatio
|
||||
}
|
||||
|
||||
export async function resolvePostData(
|
||||
export async function getPostData(
|
||||
post: AppBskyFeedDefs.PostView,
|
||||
agent: AtpAgent,
|
||||
): Promise<PostData> {
|
||||
@@ -6,10 +6,11 @@ import satori from 'satori'
|
||||
|
||||
import {Post} from '../components/Post.js'
|
||||
import {AppContext} from '../context.js'
|
||||
import {getModeratorData} from '../data/getModeratorData.js'
|
||||
import {getPost} from '../data/getPost.js'
|
||||
import {getPostData} from '../data/getPostData.js'
|
||||
import {httpLogger} from '../logger.js'
|
||||
import {loadEmojiAsSvg} from '../util.js'
|
||||
import {getModerationOptions, moderatePost} from '../util/moderation.js'
|
||||
import {resolvePostData} from '../util/resolvePostData.js'
|
||||
import {handler, originVerifyMiddleware} from './util.js'
|
||||
|
||||
const WIDTH = 600
|
||||
@@ -23,17 +24,10 @@ export default function (ctx: AppContext, app: Express) {
|
||||
let uri = AtUri.make(actor, 'app.bsky.feed.post', rkey)
|
||||
|
||||
try {
|
||||
if (!actor.startsWith('did:')) {
|
||||
const res = await ctx.appviewAgent.resolveHandle({
|
||||
handle: actor,
|
||||
})
|
||||
actor = res.data.did
|
||||
}
|
||||
uri = AtUri.make(actor, 'app.bsky.feed.post', rkey)
|
||||
const {data} = await ctx.appviewAgent.getPosts({
|
||||
uris: [uri.toString()],
|
||||
const post = await getPost({
|
||||
uri,
|
||||
agent: ctx.appviewAgent,
|
||||
})
|
||||
const post = data.posts.at(0)
|
||||
|
||||
if (!AppBskyFeedPost.isRecord(post.record)) {
|
||||
return res.status(404).end('not found')
|
||||
@@ -45,17 +39,13 @@ export default function (ctx: AppContext, app: Express) {
|
||||
return res.status(404).end('not found')
|
||||
}
|
||||
|
||||
const [postData, moderationOptions] = await Promise.all([
|
||||
resolvePostData(post, ctx.appviewAgent),
|
||||
getModerationOptions(ctx.appviewAgent),
|
||||
const [postData, moderatorData] = await Promise.all([
|
||||
getPostData(post, ctx.appviewAgent),
|
||||
getModeratorData(ctx.appviewAgent),
|
||||
])
|
||||
|
||||
const svg = await satori(
|
||||
<Post
|
||||
post={post}
|
||||
data={postData}
|
||||
moderation={moderatePost(post, moderationOptions)}
|
||||
/>,
|
||||
<Post post={post} data={postData} moderatorData={moderatorData} />,
|
||||
{
|
||||
fonts: ctx.fonts,
|
||||
width: WIDTH,
|
||||
|
||||
@@ -87,7 +87,7 @@ export const theme = {
|
||||
}
|
||||
|
||||
export function style(styleObjects: Record<string, any>[]) {
|
||||
return Object.assign({}, ...styleObjects)
|
||||
return Object.assign({}, ...styleObjects.filter(Boolean))
|
||||
}
|
||||
|
||||
export function gradient(color: keyof typeof tokens.gradients) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import {ModerationCause} from '@atproto/api'
|
||||
|
||||
import {CircleInfo} from '../components/icons/CircleInfo.js'
|
||||
import {EyeSlash} from '../components/icons/EyeSlash.js'
|
||||
import {IconProps} from '../components/icons/types.js'
|
||||
import {Warning} from '../components/icons/Warning.js'
|
||||
import {ModeratorData} from '../data/getModeratorData.js'
|
||||
|
||||
export type ModerationCauseInfo = {
|
||||
icon: React.ComponentType<IconProps>
|
||||
name: string
|
||||
description?: string
|
||||
source: string
|
||||
sourceType: 'label' | string
|
||||
sourceDid: string
|
||||
}
|
||||
|
||||
const globalLabelStrings = {
|
||||
'!hide': {
|
||||
name: `Content Blocked`,
|
||||
description: `This content has been hidden by the moderators.`,
|
||||
},
|
||||
'!warn': {
|
||||
name: `Content Warning`,
|
||||
description: `This content has received a general warning from moderators.`,
|
||||
},
|
||||
'!no-unauthenticated': {
|
||||
name: `Sign-in Required`,
|
||||
description: `This user has requested that their content only be shown to signed-in users.`,
|
||||
},
|
||||
porn: {
|
||||
name: `Adult Content`,
|
||||
description: `Explicit sexual images.`,
|
||||
},
|
||||
sexual: {
|
||||
name: `Sexually Suggestive`,
|
||||
description: `Does not include nudity.`,
|
||||
},
|
||||
nudity: {
|
||||
name: `Non-sexual Nudity`,
|
||||
description: `E.g. artistic nudes.`,
|
||||
},
|
||||
'graphic-media': {
|
||||
name: `Graphic Media`,
|
||||
description: `Explicit or potentially disturbing media.`,
|
||||
},
|
||||
}
|
||||
|
||||
export function getModerationCauseInfo({
|
||||
cause,
|
||||
moderatorData,
|
||||
}: {
|
||||
cause: ModerationCause
|
||||
moderatorData: ModeratorData
|
||||
}): ModerationCauseInfo | undefined {
|
||||
if (!cause) return undefined
|
||||
|
||||
if (cause.type === 'label') {
|
||||
const def = cause.labelDef
|
||||
const {name, description}: {name: string; description: string} =
|
||||
def.locales.find(l => l.lang === 'en') ||
|
||||
globalLabelStrings[def.identifier]
|
||||
const source =
|
||||
moderatorData.labeler.creator.displayName ||
|
||||
'@' + moderatorData.labeler.creator.handle
|
||||
|
||||
return {
|
||||
icon:
|
||||
def.identifier === '!no-unauthenticated'
|
||||
? EyeSlash
|
||||
: def.severity === 'alert'
|
||||
? Warning
|
||||
: CircleInfo,
|
||||
name,
|
||||
description,
|
||||
source,
|
||||
sourceType: cause.source.type,
|
||||
// sourceAvi: labeler?.creator.avatar,
|
||||
sourceDid: cause.label.src,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/* eslint-disable no-restricted-imports */
|
||||
import {
|
||||
BSKY_LABELER_DID,
|
||||
moderatePost as defaultModeratePost,
|
||||
} from '@atproto/api'
|
||||
|
||||
type ModeratePost = typeof defaultModeratePost
|
||||
export type Subject = Parameters<ModeratePost>[0]
|
||||
export type Options = Parameters<ModeratePost>[1]
|
||||
|
||||
function translateOldLabels(subject: Parameters<ModeratePost>[0]) {
|
||||
if (subject.labels) {
|
||||
for (const label of subject.labels) {
|
||||
if (
|
||||
label.val === 'gore' &&
|
||||
(!label.src || label.src === BSKY_LABELER_DID)
|
||||
) {
|
||||
label.val = 'graphic-media'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function moderatePost(post: Subject, opts: Options) {
|
||||
// HACK
|
||||
// temporarily translate 'gore' into 'graphic-media' during the transition period
|
||||
// can remove this in a few months
|
||||
// -prf
|
||||
translateOldLabels(post)
|
||||
|
||||
return defaultModeratePost(post, opts)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/* eslint-disable-next-line no-restricted-imports */
|
||||
import {
|
||||
AppBskyLabelerDefs,
|
||||
AtpAgent,
|
||||
BSKY_LABELER_DID,
|
||||
DEFAULT_LABEL_SETTINGS,
|
||||
interpretLabelValueDefinitions,
|
||||
moderatePost as defaultModeratePost,
|
||||
} from '@atproto/api'
|
||||
|
||||
type ModeratePost = typeof defaultModeratePost
|
||||
export type Subject = Parameters<ModeratePost>[0]
|
||||
export type Options = Parameters<ModeratePost>[1]
|
||||
|
||||
function translateOldLabels(subject: Parameters<ModeratePost>[0]) {
|
||||
if (subject.labels) {
|
||||
for (const label of subject.labels) {
|
||||
if (
|
||||
label.val === 'gore' &&
|
||||
(!label.src || label.src === BSKY_LABELER_DID)
|
||||
) {
|
||||
label.val = 'graphic-media'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_LABELS: typeof DEFAULT_LABEL_SETTINGS = Object.fromEntries(
|
||||
Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, 'hide']),
|
||||
)
|
||||
|
||||
export function moderatePost(post: Subject, opts: Options) {
|
||||
// HACK
|
||||
// temporarily translate 'gore' into 'graphic-media' during the transition period
|
||||
// can remove this in a few months
|
||||
// -prf
|
||||
translateOldLabels(post)
|
||||
|
||||
return defaultModeratePost(post, opts)
|
||||
}
|
||||
|
||||
export async function getModerationOptions(agent: AtpAgent) {
|
||||
const labelDefs = await getLabelDefinitions(agent)
|
||||
const opts: Options = {
|
||||
userDid: undefined,
|
||||
prefs: {
|
||||
adultContentEnabled: false,
|
||||
labels: DEFAULT_LABELS,
|
||||
mutedWords: [],
|
||||
hiddenPosts: [],
|
||||
labelers: [
|
||||
{
|
||||
did: BSKY_LABELER_DID,
|
||||
labels: DEFAULT_LABELS,
|
||||
},
|
||||
],
|
||||
},
|
||||
labelDefs: {
|
||||
[BSKY_LABELER_DID]: labelDefs,
|
||||
},
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
export async function getLabelDefinitions(agent: AtpAgent) {
|
||||
const {data} = await agent.app.bsky.labeler.getServices({
|
||||
dids: [BSKY_LABELER_DID],
|
||||
detailed: true,
|
||||
})
|
||||
if (!data || !data.views[0]) {
|
||||
throw new Error(`Could not fetch label definitions`)
|
||||
}
|
||||
const labeler = data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed
|
||||
|
||||
return interpretLabelValueDefinitions(labeler)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {AppBskyEmbedRecord, AppBskyFeedDefs} from '@atproto/api'
|
||||
|
||||
export 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],
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user