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], + } +}