diff --git a/bskyogcard/src/components/Avatar.tsx b/bskyogcard/src/components/Avatar.tsx
new file mode 100644
index 0000000000..22d459e4f6
--- /dev/null
+++ b/bskyogcard/src/components/Avatar.tsx
@@ -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 (
+
+ {image && }
+
+ )
+}
diff --git a/bskyogcard/src/components/Image.tsx b/bskyogcard/src/components/Image.tsx
index b82d77224e..b969f64d29 100644
--- a/bskyogcard/src/components/Image.tsx
+++ b/bskyogcard/src/components/Image.tsx
@@ -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,
diff --git a/bskyogcard/src/components/LinkCard.tsx b/bskyogcard/src/components/LinkCard.tsx
deleted file mode 100644
index 07a08b72c5..0000000000
--- a/bskyogcard/src/components/LinkCard.tsx
+++ /dev/null
@@ -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 (
-
- {image && (
-
-
-
- )}
-
- {uri && (
-
- {toShortUrl(uri)}
-
- )}
- {title}
- {description}
-
-
- )
-}
diff --git a/bskyogcard/src/components/Post.tsx b/bskyogcard/src/components/Post.tsx
index 6733ebae6b..79d7decadc 100644
--- a/bskyogcard/src/components/Post.tsx
+++ b/bskyogcard/src/components/Post.tsx
@@ -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 (
+ 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)`,
+ },
+ ]}>
-
- {avatar && }
-
+
{post.author.displayName || post.author.handle}
@@ -85,12 +99,17 @@ export function Post({
{rt && }
{post.embed && (
-
-
+
+
)}
-
+
{formatDate(post.record.createdAt)}
@@ -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 (
+
+
+ {!hideNestedEmbeds && (
+
+ )}
+
+ )
+ }
+
+ const mod = moderation.ui('contentMedia')
+ const info = getModerationCauseInfo({
+ cause: mod.blurs.at(0),
+ moderatorData,
+ })
+
+ if (info) {
+ return
+ }
+
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
+ return (
+
+ )
}
+
if (AppBskyGraphDefs.isListView(embed.record)) {
- return
+ return (
+
+ )
}
+
if (
AppBskyGraphDefs.isStarterPackViewBasic(embed.record) &&
AppBskyGraphStarterpack.isRecord(embed.record.record)
@@ -257,15 +328,9 @@ export function Embeds({
const image = data.images.get(uri)
return
}
- return
- }
- if (AppBskyEmbedRecordWithMedia.isView(embed)) {
return (
-
-
- {!hideNestedEmbeds && }
-
+
)
}
@@ -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
+ }
+
const {avatar, displayName, likeCount, creator} = embed
const image = data.images.get(avatar)
+
return (
+ }
+
const {avatar, name, creator} = embed
const image = data.images.get(avatar)
+
return (
+ {image && (
+
+
+
+ )}
+
+ {uri && (
+
+ {toShortUrl(uri)}
+
+ )}
+ {title}
+ {description}
+
+
+ )
+}
+
export function SquareImage({image}: {image: ImageSource}) {
return (
l.val === `!no-unauthenticated`)
+ const postView = viewRecordToPostView(embed.record)
+ const moderation = moderatePost(postView, moderatorData.moderationOptions)
- if (notPublic) {
- return (
-
- The author of the quoted post has requested their posts not be
- displayed on external sites
-
- )
+ const mod = moderation.ui('contentView')
+ const info = getModerationCauseInfo({
+ cause: mod.blurs.at(0),
+ moderatorData,
+ })
+
+ if (info) {
+ return
}
return (
@@ -419,18 +575,12 @@ export function QuoteEmbed({
t.atoms.border_contrast_low,
]}>
-
- {avatar && }
-
+
{author.displayName || author.handle}
@@ -449,7 +599,13 @@ export function QuoteEmbed({
{Boolean(embeds && embeds.length) && (
-
+
)}
@@ -486,3 +642,24 @@ export function NotQuoteEmbed({children}: {children: React.ReactNode}) {
)
}
+
+export function ModeratedEmbed({info}: {info: ModerationCauseInfo}) {
+ return (
+
+
+
+
+ {info.name}
+
+
+
+ )
+}
diff --git a/bskyogcard/src/components/icons/CircleInfo.tsx b/bskyogcard/src/components/icons/CircleInfo.tsx
index 9469e83be3..4ebd27fc51 100644
--- a/bskyogcard/src/components/icons/CircleInfo.tsx
+++ b/bskyogcard/src/components/icons/CircleInfo.tsx
@@ -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 (
+ )
+}
diff --git a/bskyogcard/src/components/icons/Heart.tsx b/bskyogcard/src/components/icons/Heart.tsx
index b5a59396bf..ef52d38cef 100644
--- a/bskyogcard/src/components/icons/Heart.tsx
+++ b/bskyogcard/src/components/icons/Heart.tsx
@@ -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 (
+ )
+}
diff --git a/bskyogcard/src/components/icons/types.ts b/bskyogcard/src/components/icons/types.ts
new file mode 100644
index 0000000000..c012278d6e
--- /dev/null
+++ b/bskyogcard/src/components/icons/types.ts
@@ -0,0 +1 @@
+export type IconProps = {size: number; fill?: string}
diff --git a/bskyogcard/src/util/getImage.ts b/bskyogcard/src/data/getImage.ts
similarity index 100%
rename from bskyogcard/src/util/getImage.ts
rename to bskyogcard/src/data/getImage.ts
diff --git a/bskyogcard/src/data/getModeratorData.ts b/bskyogcard/src/data/getModeratorData.ts
new file mode 100644
index 0000000000..713bb181a8
--- /dev/null
+++ b/bskyogcard/src/data/getModeratorData.ts
@@ -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>
+
+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[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,
+ }
+}
diff --git a/bskyogcard/src/data/getPost.ts b/bskyogcard/src/data/getPost.ts
new file mode 100644
index 0000000000..c456c72c72
--- /dev/null
+++ b/bskyogcard/src/data/getPost.ts
@@ -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)
+}
diff --git a/bskyogcard/src/util/resolvePostData.ts b/bskyogcard/src/data/getPostData.ts
similarity index 98%
rename from bskyogcard/src/util/resolvePostData.ts
rename to bskyogcard/src/data/getPostData.ts
index f8fb3e8175..9a45e8cfe6 100644
--- a/bskyogcard/src/util/resolvePostData.ts
+++ b/bskyogcard/src/data/getPostData.ts
@@ -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 {
diff --git a/bskyogcard/src/routes/post.tsx b/bskyogcard/src/routes/post.tsx
index 6bda46505a..91c3cf1abf 100644
--- a/bskyogcard/src/routes/post.tsx
+++ b/bskyogcard/src/routes/post.tsx
@@ -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(
- ,
+ ,
{
fonts: ctx.fonts,
width: WIDTH,
diff --git a/bskyogcard/src/theme/index.ts b/bskyogcard/src/theme/index.ts
index 5f0b569a23..cc1bde60cc 100644
--- a/bskyogcard/src/theme/index.ts
+++ b/bskyogcard/src/theme/index.ts
@@ -87,7 +87,7 @@ export const theme = {
}
export function style(styleObjects: Record[]) {
- return Object.assign({}, ...styleObjects)
+ return Object.assign({}, ...styleObjects.filter(Boolean))
}
export function gradient(color: keyof typeof tokens.gradients) {
diff --git a/bskyogcard/src/util/getModerationCauseInfo.ts b/bskyogcard/src/util/getModerationCauseInfo.ts
new file mode 100644
index 0000000000..e281c155d7
--- /dev/null
+++ b/bskyogcard/src/util/getModerationCauseInfo.ts
@@ -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
+ 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,
+ }
+ }
+}
diff --git a/bskyogcard/src/util/moderatePost.ts b/bskyogcard/src/util/moderatePost.ts
new file mode 100644
index 0000000000..2adc274ecb
--- /dev/null
+++ b/bskyogcard/src/util/moderatePost.ts
@@ -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[0]
+export type Options = Parameters[1]
+
+function translateOldLabels(subject: Parameters[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)
+}
diff --git a/bskyogcard/src/util/moderation.ts b/bskyogcard/src/util/moderation.ts
deleted file mode 100644
index 6d9db5e14c..0000000000
--- a/bskyogcard/src/util/moderation.ts
+++ /dev/null
@@ -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[0]
-export type Options = Parameters[1]
-
-function translateOldLabels(subject: Parameters[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)
-}
diff --git a/bskyogcard/src/util/viewRecordToPostView.ts b/bskyogcard/src/util/viewRecordToPostView.ts
new file mode 100644
index 0000000000..e66f5a963f
--- /dev/null
+++ b/bskyogcard/src/util/viewRecordToPostView.ts
@@ -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],
+ }
+}