diff --git a/bskyogcard/.eslintrc.cjs b/bskyogcard/.eslintrc.cjs new file mode 100644 index 0000000000..2bafdbe83a --- /dev/null +++ b/bskyogcard/.eslintrc.cjs @@ -0,0 +1,81 @@ +module.exports = { + root: true, + extends: [ + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'prettier', + ], + parser: '@typescript-eslint/parser', + plugins: [ + '@typescript-eslint', + 'react', + 'simple-import-sort', + 'eslint-plugin-react-compiler', + ], + rules: { + 'react/no-unescaped-entities': 0, + 'react/prop-types': 0, + 'react-native/no-inline-styles': 0, + 'simple-import-sort/imports': [ + 'error', + { + groups: [ + // Side effect imports. + ['^\\u0000'], + // Node.js builtins prefixed with `node:`. + ['^node:'], + // Packages. + // Things that start with a letter (or digit or underscore), or `@` followed by a letter. + // React/React Native priortized, followed by expo + // Followed by all packages excluding unprefixed relative ones + [ + '^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)', + '^(expo(.*)$)|^(expo$)', + '^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w', + ], + // Relative imports. + // Ideally, anything that starts with a dot or # + // due to unprefixed relative imports being used, we whitelist the relative paths we use + // (?:$|\\/) matches end of string or / + [ + '^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)', + '^(?:#\\/)?view(?:$|\\/)', + '^(?:#\\/)?screens(?:$|\\/)', + '^(?:#\\/)?alf(?:$|\\/)', + '^(?:#\\/)?components(?:$|\\/)', + '^#\\/', + '^\\.', + ], + // anything else - hopefully we don't have any of these + ['^'], + ], + }, + ], + 'simple-import-sort/exports': 'error', + 'react-compiler/react-compiler': 'warn', + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@atproto/api', + importNames: ['moderatePost'], + message: + 'Please use `moderatePost_wrapped` from `#/lib/moderatePost_wrapped` instead.', + }, + ], + }, + ], + }, + ignorePatterns: [ + 'coverage', + '*.lock', + '.husky', + '*.html', + ], + parserOptions: { + sourceType: 'module', + ecmaVersion: 'latest', + }, +} + diff --git a/bskyogcard/src/components/FeedCard.tsx b/bskyogcard/src/components/FeedCard.tsx new file mode 100644 index 0000000000..bef859c76a --- /dev/null +++ b/bskyogcard/src/components/FeedCard.tsx @@ -0,0 +1,74 @@ +import {AppBskyFeedDefs, moderateFeedGenerator} from '@atproto/api' + +import {ModeratorData} from '../data/getModeratorData.js' +import {PostData} from '../data/getPostData.js' +import {atoms as a, theme as t} from '../theme/index.js' +import {formatCount} from '../util/formatCount.js' +import {getModerationCauseInfo} from '../util/getModerationCauseInfo.js' +import {Box} from './Box.js' +import {Image} from './Image.js' +import {ModeratedEmbed} from './ModeratedEmbed.js' +import {Text} from './Text.js' + +export function FeedCard({ + embed, + data, + moderatorData, +}: { + embed: AppBskyFeedDefs.GeneratorView + data: PostData + moderatorData: ModeratorData +}) { + const feedModeration = moderateFeedGenerator( + embed, + moderatorData.moderationOptions, + ) + const modui = feedModeration.ui('contentList') + const info = getModerationCauseInfo({ + cause: modui.blurs.at(0), + moderatorData, + }) + + if (info) { + return + } + + const {avatar, displayName, likeCount = 0, creator} = embed + const image = data.images.get(avatar || '') + + return ( + + + {image && ( + + )} + + {displayName} + By @{creator.handle} + + + + {likeCount > 1 && ( + + Liked by {formatCount(likeCount)} users + + )} + + ) +} diff --git a/bskyogcard/src/components/Image.tsx b/bskyogcard/src/components/Image.tsx index b969f64d29..1d6ce670d8 100644 --- a/bskyogcard/src/components/Image.tsx +++ b/bskyogcard/src/components/Image.tsx @@ -1,7 +1,8 @@ import React from 'react' import {Image as ImageSource} from '../data/getPostData.js' -import {style as s} from '../theme/index.js' +import {atoms as a, style as s, theme as t} from '../theme/index.js' +import {Box} from './Box.js' export type ImageProps = Omit< React.ImgHTMLAttributes, @@ -21,3 +22,28 @@ export function Image({image, cx, ...rest}: ImageProps) { /> ) } + +export function SquareImage({image}: {image: ImageSource}) { + return ( + + + + ) +} diff --git a/bskyogcard/src/components/LinkCard.tsx b/bskyogcard/src/components/LinkCard.tsx new file mode 100644 index 0000000000..523e0f5f58 --- /dev/null +++ b/bskyogcard/src/components/LinkCard.tsx @@ -0,0 +1,59 @@ +import {Image as ImageSource} from '../data/getPostData.js' +import {atoms as a, theme as t} from '../theme/index.js' +import {toShortUrl} from '../util/toShortUrl.js' +import {Box} from './Box.js' +import {Image} from './Image.js' +import {Text} from './Text.js' + +export function LinkCard({ + image, + uri, + title, + description, +}: { + image?: ImageSource + uri?: string + title: string + description: string +}) { + return ( + + {image && ( + + + + )} + + {uri && ( + + {toShortUrl(uri)} + + )} + {title} + {description} + + + ) +} diff --git a/bskyogcard/src/components/ListCard.tsx b/bskyogcard/src/components/ListCard.tsx new file mode 100644 index 0000000000..df5d6b7ddb --- /dev/null +++ b/bskyogcard/src/components/ListCard.tsx @@ -0,0 +1,67 @@ +import {AppBskyGraphDefs, moderateUserList} from '@atproto/api' + +import {ModeratorData} from '../data/getModeratorData.js' +import {PostData} from '../data/getPostData.js' +import {atoms as a, theme as t} from '../theme/index.js' +import {getModerationCauseInfo} from '../util/getModerationCauseInfo.js' +import {Box} from './Box.js' +import {Image} from './Image.js' +import {ModeratedEmbed} from './ModeratedEmbed.js' +import {Text} from './Text.js' + +export function ListCard({ + embed, + data, + moderatorData, +}: { + embed: AppBskyGraphDefs.ListView + data: PostData + moderatorData: ModeratorData +}) { + const listModeration = moderateUserList( + embed, + moderatorData.moderationOptions, + ) + const modui = listModeration.ui('contentList') + const info = getModerationCauseInfo({ + cause: modui.blurs.at(0), + moderatorData, + }) + + if (info) { + return + } + + const {avatar, name, creator} = embed + const image = data.images.get(avatar || '') + + return ( + + + {image && ( + + )} + + {name} + By @{creator.handle} + + + + ) +} diff --git a/bskyogcard/src/components/ModeratedEmbed.tsx b/bskyogcard/src/components/ModeratedEmbed.tsx new file mode 100644 index 0000000000..19880a0892 --- /dev/null +++ b/bskyogcard/src/components/ModeratedEmbed.tsx @@ -0,0 +1,25 @@ +import {atoms as a, theme as t} from '../theme/index.js' +import {ModerationCauseInfo} from '../util/getModerationCauseInfo.js' +import {Box} from './Box.js' +import {Text} from './Text.js' + +export function ModeratedEmbed({info}: {info: ModerationCauseInfo}) { + return ( + + + + + {info.name} + + + + ) +} diff --git a/bskyogcard/src/components/Post.tsx b/bskyogcard/src/components/Post.tsx index 1673995375..c59c3f4345 100644 --- a/bskyogcard/src/components/Post.tsx +++ b/bskyogcard/src/components/Post.tsx @@ -1,42 +1,22 @@ -/* eslint-disable bsky-internal/avoid-unwrapped-text, react-native-a11y/has-valid-accessibility-ignores-invert-colors */ -import React from 'react' import { - AppBskyEmbedExternal, - AppBskyEmbedImages, - AppBskyEmbedRecord, - AppBskyEmbedRecordWithMedia, AppBskyFeedDefs, AppBskyFeedPost, - AppBskyGraphDefs, - AppBskyGraphStarterpack, - moderateFeedGenerator, - moderateUserList, - ModerationDecision, RichText as RichTextApi, } from '@atproto/api' import {ModeratorData} from '../data/getModeratorData.js' -import {Image as ImageSource, PostData} from '../data/getPostData.js' +import {PostData} from '../data/getPostData.js' import {atoms as a, gradient, theme as t} from '../theme/index.js' import {formatCount} from '../util/formatCount.js' import {formatDate} from '../util/formatDate.js' -import { - getModerationCauseInfo, - ModerationCauseInfo, -} from '../util/getModerationCauseInfo.js' -import {getStarterPackImageUri} from '../util/getStarterPackImageUri.js' import {moderatePost} from '../util/moderatePost.js' -import {toShortUrl} from '../util/toShortUrl.js' -import {viewRecordToPostView} from '../util/viewRecordToPostView.js' import {Avatar} from './Avatar.js' import {Box} from './Box.js' -import * as Grid from './Grid.js' -import {CircleInfo} from './icons/CircleInfo.js' import {Heart} from './icons/Heart.js' import {Logomark} from './icons/Logomark.js' import {Logotype} from './icons/Logotype.js' import {Repost} from './icons/Repost.js' -import {Image} from './Image.js' +import {PostEmbed} from './PostEmbed.js' import {RichText} from './RichText.js' import {Text} from './Text.js' @@ -105,7 +85,7 @@ export function Post({ {post.embed && ( - ) } - -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) - return ( - - ) - } - - if (AppBskyEmbedImages.isView(embed)) { - const {images} = embed - - if (images.length > 0) { - const imgs = images.map(({fullsize}) => data.images.get(fullsize)) - if (imgs.length === 1) { - return ( - - - - ) - } else if (imgs.length === 2) { - return ( - - - - - - - - - ) - } else if (imgs.length === 3) { - return ( - - - - - - - - - - ) - } else { - return ( - - - - - - - - - - - ) - } - } - } - - if (AppBskyEmbedRecord.isView(embed)) { - if (AppBskyFeedDefs.isGeneratorView(embed.record)) { - return ( - - ) - } - - if (AppBskyGraphDefs.isListView(embed.record)) { - return ( - - ) - } - - if ( - AppBskyGraphDefs.isValidStarterPackViewBasic(embed.record) && - AppBskyGraphStarterpack.isValidRecord(embed.record.record) - ) { - const uri = getStarterPackImageUri(embed.record) - const {name, description} = embed.record.record - const image = data.images.get(uri) - return - } - - return ( - - ) - } - - return null -} - -export function FeedCard({ - embed, - data, - moderatorData, -}: { - embed: AppBskyFeedDefs.GeneratorView - data: PostData - moderatorData: ModeratorData -}) { - const feedModeration = moderateFeedGenerator( - embed, - moderatorData.moderationOptions, - ) - const modui = feedModeration.ui('contentList') - const info = getModerationCauseInfo({ - cause: modui.blurs.at(0), - moderatorData, - }) - - if (info) { - return - } - - const {avatar, displayName, likeCount, creator} = embed - const image = data.images.get(avatar) - - return ( - - - {image && ( - - )} - - {displayName} - By @{creator.handle} - - - - {likeCount > 1 && ( - - Liked by {formatCount(likeCount)} users - - )} - - ) -} - -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 - } - - const {avatar, name, creator} = embed - const image = data.images.get(avatar) - - return ( - - - {image && ( - - )} - - {name} - By @{creator.handle} - - - - ) -} - -export function LinkCard({ - image, - uri, - title, - description, -}: { - image?: ImageSource - uri?: string - title: string - description: string -}) { - return ( - - {image && ( - - - - )} - - {uri && ( - - {toShortUrl(uri)} - - )} - {title} - {description} - - - ) -} - -export function SquareImage({image}: {image: ImageSource}) { - return ( - - - - ) -} - -export function QuoteEmbed({ - embed, - data, - moderatorData, -}: { - embed: AppBskyEmbedRecord.View - data: PostData - moderatorData: ModeratorData -}) { - if ( - AppBskyEmbedRecord.isValidViewRecord(embed.record) && - AppBskyFeedPost.isValidRecord(embed.record.value) - ) { - const {author, value: post, embeds} = embed.record - const avatar = data.images.get(author.avatar) - const rt = post.text - ? new RichTextApi({ - text: post.text, - facets: post.facets, - }) - : undefined - const postView = viewRecordToPostView(embed.record) - const moderation = moderatePost(postView, moderatorData.moderationOptions) - - const mod = moderation.ui('contentView') - const info = getModerationCauseInfo({ - cause: mod.blurs.at(0), - moderatorData, - }) - - if (info) { - return - } - - return ( - - - - - - {author.displayName || author.handle} - - - @{author.handle} - - - - - {rt && } - - {Boolean(embeds && embeds.length) && ( - - - - )} - - ) - } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) { - return Quoted post is blocked - } else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) { - return ( - - Quoted post not found, it may have been deleted. - - ) - } else if (AppBskyEmbedRecord.isViewDetached(embed.record)) { - return Quoted post detached by author - } - return null -} - -export function NotQuoteEmbed({children}: {children: React.ReactNode}) { - return ( - - - - {children} - - - ) -} - -export function ModeratedEmbed({info}: {info: ModerationCauseInfo}) { - return ( - - - - - {info.name} - - - - ) -} diff --git a/bskyogcard/src/components/PostEmbed.tsx b/bskyogcard/src/components/PostEmbed.tsx new file mode 100644 index 0000000000..1556b99985 --- /dev/null +++ b/bskyogcard/src/components/PostEmbed.tsx @@ -0,0 +1,240 @@ +import { + AppBskyFeedDefs, + AppBskyGraphStarterpack, + ModerationDecision, +} from '@atproto/api' + +import {ModeratorData} from '../data/getModeratorData.js' +import {Image as ImageSource, PostData} from '../data/getPostData.js' +import {atoms as a} from '../theme/index.js' +import {getStarterPackImageUri} from '../util/getStarterPackImageUri.js' +import {Embed, EmbedType,parseEmbed} from '../util/parseEmbed.js' +import {Box} from './Box.js' +import {FeedCard} from './FeedCard.js' +import * as Grid from './Grid.js' +import {Image, SquareImage} from './Image.js' +import {LinkCard} from './LinkCard.js' +import {ListCard} from './ListCard.js' +import {NotQuotePost,QuotePost} from './PostEmbed/QuotePost.js' + +type CommonProps = { + data: PostData + moderation: ModerationDecision + moderatorData: ModeratorData + hideNestedEmbeds?: boolean +} + +export function PostEmbed({ + embed: rawEmbed, + ...rest +}: CommonProps & { + embed: AppBskyFeedDefs.PostView['embed'] +}) { + const embed = parseEmbed(rawEmbed) + + switch (embed.type) { + case 'images': + case 'link': + case 'video': + return + case 'feed': + case 'list': + case 'starter_pack': + case 'post': + case 'post_blocked': + case 'post_detached': + case 'post_not_found': + return + case 'post_with_media': + return ( + + + {!rest.hideNestedEmbeds && ( + + )} + + ) + default: + return null + } +} + +export function MediaEmbeds({ + embed, + ...rest +}: CommonProps & { + embed: Embed +}) { + switch (embed.type) { + case 'images': { + return + } + case 'link': { + return + } + case 'video': { + return null + } + default: + return null + } +} + +export function RecordEmbeds({ + embed, + ...rest +}: CommonProps & { + embed: Embed +}) { + switch (embed.type) { + case 'feed': { + return + } + case 'list': { + return + } + case 'starter_pack': { + return + } + case 'post': { + return + } + case 'post_blocked': { + return Quoted post is blocked + } + case 'post_detached': { + return Quoted post detached by author + } + case 'post_not_found': { + return ( + + Quoted post not found, it may have been deleted. + + ) + } + } +} + +export function QuoteEmbed({ + embed, + ...rest +}: CommonProps & { + embed: EmbedType<'post'> +}) { + return +} + +export function StarterPackEmbed({ + embed, + ...rest +}: CommonProps & { + embed: EmbedType<'starter_pack'> +}) { + const uri = getStarterPackImageUri(embed.view) + if (!AppBskyGraphStarterpack.isValidRecord(embed.view.record)) return null + const {name, description} = embed.view.record + const image = rest.data.images.get(uri) + return +} + +export function ListEmbed({ + embed, + ...rest +}: CommonProps & { + embed: EmbedType<'list'> +}) { + return ( + + ) +} + +export function FeedEmbed({ + embed, + ...rest +}: CommonProps & { + embed: EmbedType<'feed'> +}) { + return ( + + ) +} + +export function LinkEmbed({ + embed, + ...rest +}: CommonProps & { + embed: EmbedType<'link'> +}) { + const {title, description, uri, thumb} = embed.view.external + if (!thumb) return null + const image = rest.data.images.get(thumb) + return ( + + ) +} + +export function ImagesEmbed({ + embed, + ...rest +}: CommonProps & { + embed: EmbedType<'images'> +}) { + const {images} = embed.view + + if (images.length > 0) { + const imgs = images + .map(({fullsize}) => rest.data.images.get(fullsize)) + .filter(Boolean) as ImageSource[] + if (imgs.length === 1) { + return ( + + + + ) + } else if (imgs.length === 2) { + return ( + + + + + + + + + ) + } else if (imgs.length === 3) { + return ( + + + + + + + + + + ) + } else { + return ( + + + + + + + + + + + ) + } + } +} diff --git a/bskyogcard/src/components/PostEmbed/QuotePost.tsx b/bskyogcard/src/components/PostEmbed/QuotePost.tsx new file mode 100644 index 0000000000..a96af2cd30 --- /dev/null +++ b/bskyogcard/src/components/PostEmbed/QuotePost.tsx @@ -0,0 +1,113 @@ +import React from 'react' +import { + AppBskyEmbedRecord, + AppBskyFeedPost, + RichText as RichTextApi, +} from '@atproto/api' + +import {ModeratorData} from '../../data/getModeratorData.js' +import {PostData} from '../../data/getPostData.js' +import {atoms as a, theme as t} from '../../theme/index.js' +import {getModerationCauseInfo} from '../../util/getModerationCauseInfo.js' +import {moderatePost} from '../../util/moderatePost.js' +import {viewRecordToPostView} from '../../util/viewRecordToPostView.js' +import {Avatar} from '../Avatar.js' +import {Box} from '../Box.js' +import {CircleInfo} from '../icons/CircleInfo.js' +import {ModeratedEmbed} from '../ModeratedEmbed.js' +import {PostEmbed} from '../PostEmbed.js' +import {RichText} from '../RichText.js' +import {Text} from '../Text.js' + +export function QuotePost({ + embed, + data, + moderatorData, +}: { + embed: AppBskyEmbedRecord.ViewRecord + data: PostData + moderatorData: ModeratorData +}) { + const {author, value: post, embeds} = embed + const avatar = data.images.get(author.avatar || '') + const rt = + AppBskyFeedPost.isValidRecord(post) && post.text + ? new RichTextApi({ + text: post.text, + facets: post.facets, + }) + : undefined + const postView = viewRecordToPostView(embed) + const moderation = moderatePost(postView, moderatorData.moderationOptions) + + const mod = moderation.ui('contentView') + const info = getModerationCauseInfo({ + cause: mod.blurs.at(0), + moderatorData, + }) + + if (info) { + return + } + + return ( + + + + + + {author.displayName || author.handle} + + + @{author.handle} + + + + + {rt && } + + {embeds && embeds.length && ( + + + + )} + + ) +} + +export function NotQuotePost({children}: {children: React.ReactNode}) { + return ( + + + + {children} + + + ) +} diff --git a/bskyogcard/src/data/getPostData.ts b/bskyogcard/src/data/getPostData.ts index 44323b0c5e..e2b07ba522 100644 --- a/bskyogcard/src/data/getPostData.ts +++ b/bskyogcard/src/data/getPostData.ts @@ -2,7 +2,7 @@ import {AppBskyFeedDefs} from '@atproto/api' import {httpLogger} from '../logger.js' import {getStarterPackImageUri} from '../util/getStarterPackImageUri.js' -import {Embed,parseEmbed} from '../util/parseEmbed.js' +import {Embed, parseEmbed} from '../util/parseEmbed.js' import {getImage} from './getImage.js' export type Metadata = { @@ -103,8 +103,10 @@ export function getEmbedData(embed: Embed, images: Map) { }, }) } - for (const _e of embed.view.embeds) { - getEmbedData(parseEmbed(_e), images) + if (embed.view.embeds) { + for (const _e of embed.view.embeds) { + getEmbedData(parseEmbed(_e), images) + } } break } @@ -125,6 +127,7 @@ export async function getPostData( post: AppBskyFeedDefs.PostView, ): Promise { const images: Map = new Map() + console.log(JSON.stringify(post, null, 2)) if (post.author.avatar) { images.set(post.author.avatar, { diff --git a/bskyogcard/src/util/getModerationCauseInfo.ts b/bskyogcard/src/util/getModerationCauseInfo.ts index e281c155d7..8b84f980d9 100644 --- a/bskyogcard/src/util/getModerationCauseInfo.ts +++ b/bskyogcard/src/util/getModerationCauseInfo.ts @@ -50,7 +50,7 @@ export function getModerationCauseInfo({ cause, moderatorData, }: { - cause: ModerationCause + cause?: ModerationCause moderatorData: ModeratorData }): ModerationCauseInfo | undefined { if (!cause) return undefined