Moderation and cleanup

This commit is contained in:
Eric Bailey
2024-08-29 11:37:01 -05:00
parent d15585bea4
commit 998f4038b0
22 changed files with 523 additions and 218 deletions
+42
View File
@@ -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 -1
View File
@@ -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>,
-62
View File
@@ -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>
)
}
+228 -51
View File
@@ -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>
)
}
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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>
)
}
+1
View File
@@ -0,0 +1 @@
export type IconProps = {size: number; fill?: string}
+50
View File
@@ -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,
}
}
+14
View File
@@ -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> {
+10 -20
View File
@@ -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,
+1 -1
View File
@@ -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,
}
}
}
+32
View File
@@ -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)
}
-76
View File
@@ -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],
}
}