Compare commits

...

45 Commits

Author SHA1 Message Date
Eric Bailey cc0dfa6cab WIP ALFing 2025-09-11 13:44:27 -05:00
Eric Bailey a05c90ae29 Add lighter weight for all remote fonts 2025-09-11 13:25:27 -05:00
Eric Bailey 196f761cf8 Ope fix typo 2025-09-11 13:25:27 -05:00
Eric Bailey 7ab9df946e Ignore remote fonts 2025-09-11 13:25:27 -05:00
Samuel Newman 777ae21051 reference original file, rm pointerevent 2025-09-11 13:25:27 -05:00
Eric Bailey 0956a40f7a Fix fonts 2025-09-11 13:25:27 -05:00
Eric Bailey 9fcd10be32 Comments 2025-09-11 13:25:27 -05:00
Eric Bailey 55566dfcae Use shared config for mat handling 2025-09-11 13:25:27 -05:00
Eric Bailey 4c9bf824bd Add port to startup log 2025-09-11 13:25:27 -05:00
Samuel Newman d7cef8eb51 improve feed/list cards 2025-09-11 13:25:27 -05:00
Samuel Newman ccde215545 clamp displayname to one line 2025-09-11 13:25:27 -05:00
Samuel Newman e78fa95c3e add verification 2025-09-11 13:25:27 -05:00
Samuel Newman 25b56c40d2 add verification tests 2025-09-11 13:25:27 -05:00
Samuel Newman a827da385c tweak embed spacing 2025-09-11 13:25:27 -05:00
Samuel Newman f6cc7a74d8 appease prettier 2025-09-11 13:25:27 -05:00
Samuel Newman cfc6ea86e3 default avatars 2025-09-11 13:25:27 -05:00
Samuel Newman 450c6bcda4 make tester more ergonomic (reload on click) 2025-09-11 13:25:27 -05:00
Samuel Newman 1ce2325488 fix overflow in starter pack 2025-09-11 13:25:27 -05:00
Samuel Newman 462ae8736c update @atproto/api to 0.15 2025-09-11 13:25:27 -05:00
Samuel Newman 8e2118095a update extern link cards to match app 2025-09-11 13:25:27 -05:00
Samuel Newman 25649bd718 fix long displaynames/handles 2025-09-11 13:25:27 -05:00
Samuel Newman 75ff59e438 fix double quote posts 2025-09-11 13:25:27 -05:00
Samuel Newman c6c9c45819 labeller avatars, fix overflow/alignment 2025-09-11 13:25:27 -05:00
Samuel Newman cc86d596a4 match image ratios to app 2025-09-11 13:25:27 -05:00
Eric Bailey c959e11376 Fix link moderation 2025-09-11 13:25:27 -05:00
Eric Bailey 9a8b2f9e3a More tests, fix bug 2025-09-11 13:25:27 -05:00
Eric Bailey 116fef81e6 Handle video and gifs, add test page 2025-09-11 13:25:27 -05:00
Eric Bailey 110c74c610 Starter pack embed 2025-09-11 13:25:27 -05:00
Eric Bailey 223ba68a0b Handle images 2025-09-11 13:25:27 -05:00
Eric Bailey 5a4e75238f Try to fix fonts, unsure 2025-09-11 13:25:27 -05:00
Eric Bailey 47101bfe2c Reorg post 2025-09-11 13:25:27 -05:00
Eric Bailey 023dbc3032 Adjust avi and type sizing 2025-09-11 13:25:27 -05:00
Eric Bailey d74c1678b2 Set up colors and display options 2025-09-11 13:25:27 -05:00
Eric Bailey 775d837f8c Refactor to new arch 2025-09-11 13:25:27 -05:00
Eric Bailey 71aa7e2fd2 Fix types, port getPostData to new interface 2025-09-11 13:25:27 -05:00
Eric Bailey cd2ddd21ef Hack text breaks 2025-09-11 13:25:27 -05:00
Eric Bailey 0a364f6957 Use only column gap 2025-09-11 13:25:27 -05:00
Eric Bailey 6574157c66 Handle emoji-only 2025-09-11 13:25:27 -05:00
Eric Bailey 95aebf5724 Simplify box 2025-09-11 13:25:26 -05:00
Eric Bailey 9fcd83e06f Don't resolve facets twice 2025-09-11 13:25:26 -05:00
Eric Bailey 97fcc42c57 Fix font style 2025-09-11 13:25:26 -05:00
Eric Bailey c4de2e8986 Add italic fonts 2025-09-11 13:25:26 -05:00
Eric Bailey 998f4038b0 Moderation and cleanup 2025-09-11 13:25:26 -05:00
Eric Bailey d15585bea4 Add moderation setup 2025-09-11 13:25:26 -05:00
Eric Bailey e74bd093ee Initial work for postgen 2025-09-11 13:25:26 -05:00
90 changed files with 4148 additions and 96 deletions
+81
View File
@@ -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',
},
}
+2
View File
@@ -0,0 +1,2 @@
700-Noto*
400-Noto*
+10 -5
View File
@@ -4,24 +4,29 @@
"type": "module",
"main": "src/index.ts",
"scripts": {
"start": "node --loader ts-node/esm ./src/bin.ts",
"dev": "node --watch-path ./src --loader ts-node/esm ./src/bin.ts",
"start": " NODE_ENV=production node --loader ts-node/esm ./src/bin.ts",
"dev": "LOG_ENABLED=true NODE_ENV=development node --watch-path ./src --loader ts-node/esm ./src/bin.ts | pino-pretty",
"build": "tsc && cp -r src/assets dist/",
"install-fonts": "node --loader ts-node/esm scripts/install-fonts.ts"
},
"dependencies": {
"@atproto/api": "0.12.19-next.0",
"@atproto/common": "^0.4.0",
"@atproto/api": "^0.15.14",
"@atproto/common": "^0.4.11",
"@bsky.app/alf": "^0.1.0",
"@resvg/resvg-js": "^2.6.2",
"express": "^4.19.2",
"http-terminator": "^3.2.0",
"node-vibrant": "^4.0.3",
"pino": "^9.2.0",
"react": "^18.3.1",
"satori": "^0.10.13",
"twemoji": "^14.0.2"
"twemoji": "^14.0.2",
"whatwg-mimetype": "^4.0.0"
},
"devDependencies": {
"@types/node": "^20.14.3",
"pino-pretty": "^11.2.2",
"prettier": "^3.5.3",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
}
+29 -23
View File
@@ -2,36 +2,42 @@ import {writeFile} from 'node:fs/promises'
import * as path from 'node:path'
import {fileURLToPath} from 'node:url'
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
import {
ADDITIONAL_FONTS,
ADDITIONAL_FONTS_POSTSCRIPT_NAMES,
} from '../src/util/fonts.js'
const FONTS = [
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-jp@5.0/japanese-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-tc@5.0/chinese-traditional-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-sc@5.0/chinese-simplified-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hk@5.0/chinese-hongkong-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-kr@5.0/korean-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-thai@5.0/thai-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-arabic@5.0/arabic-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hebrew@5.0/hebrew-700-normal.ttf',
]
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
const outDir = path.join(__DIRNAME, '..', 'src', 'assets', 'fonts')
async function main() {
await Promise.all(
FONTS.map(async urlStr => {
const url = new URL(urlStr)
const res = await fetch(url)
const font = await res.arrayBuffer()
const filename = url.pathname
.split('/')
.slice(-2)
.join('/')
.replace(/@[\d.]+\//, '-')
ADDITIONAL_FONTS.map(async urlStr => {
// get last part of the URL
const raw = urlStr.split('/').slice(-1).join('')
// style and weight are known parts of the path, rest is the font family
const [styleWithExtension, weight, ...fontFamilyParts] = raw
.split('-')
.reverse()
const style = styleWithExtension.split('.')[0]
// re-reverse the font family parts to get the original order
const rawFontFamily = fontFamilyParts.reverse().join('-')
// get the postscript name from the map
const postScriptName = ADDITIONAL_FONTS_POSTSCRIPT_NAMES[rawFontFamily]
// our filename format: `weight-postScriptName-style.ttf`
const filename = `${weight}-${postScriptName}-${style}.ttf`
// fetch the file
const res = await fetch(urlStr)
if (!res.ok) {
throw new Error(`HTTP ${res.status}: fetching failed for ${filename}`)
throw new Error(`HTTP ${res.status}: fetching failed for ${urlStr}`)
}
console.log(`Writing font ${filename}...`)
await writeFile(
path.join(__DIRNAME, '..', 'src', 'assets', 'fonts', filename),
Buffer.from(font),
path.join(outDir, filename),
Buffer.from(await res.arrayBuffer()),
)
}),
)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -9,7 +9,7 @@ async function main() {
const cfg = envToCfg(env)
const card = await CardService.create(cfg)
await card.start()
httpLogger.info('card service is running')
httpLogger.info(`card service is running on port ${cfg.service.port}`)
process.on('SIGTERM', async () => {
httpLogger.info('card service is stopping')
await card.destroy()
+46
View File
@@ -0,0 +1,46 @@
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 {DefaultUser} from './icons/avatars/DefaultUser.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]
const isLabeller = !!profile.associated?.labeler
return (
<Box
cx={[
isLabeller ? a.rounded_xs : 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} />
) : (
<DefaultUser size={size} />
)}
</Box>
)
}
+11
View File
@@ -0,0 +1,11 @@
import {atoms as a, style as s} from '../theme/index.js'
export function Box({
children,
cx = [],
}: {
children?: React.ReactNode
cx?: (Record<string, any> | undefined)[]
}) {
return <div style={s([a.flex, a.flex_col, ...cx])}>{children}</div>
}
-2
View File
@@ -1,5 +1,3 @@
import React from 'react'
export function Butterfly(props: React.SVGAttributes<SVGSVGElement>) {
return (
<svg
+94
View File
@@ -0,0 +1,94 @@
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 {sanitizeHandle} from '../util/sanitizeHandle.js'
import {Box} from './Box.js'
import {DefaultFeed} from './icons/avatars/DefaultFeed.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 <ModeratedEmbed info={info} />
}
const {avatar, displayName, likeCount = 0, creator} = embed
const image = data.images.get(avatar || '')
return (
<Box
cx={[
a.w_full,
a.gap_sm,
a.rounded_sm,
a.p_md,
a.border,
t.atoms.border_contrast_low,
]}>
<Box cx={[a.flex_row, a.align_center, a.gap_sm]}>
{image ? (
<Image
image={image}
cx={[
a.rounded_sm,
{
width: '40px',
},
]}
/>
) : (
<DefaultFeed size={40} />
)}
<Box cx={[a.pt_2xs, a.flex_1]}>
<Text cx={[a.text_md, a.font_bold, a.pb_2xs, a.line_clamp_1]}>
{displayName}
</Text>
<Text
cx={[
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
a.line_clamp_1,
]}>
{`By ${sanitizeHandle(creator.handle, '@')}`}
</Text>
</Box>
</Box>
{likeCount > 1 && (
<Text
cx={[
a.text_xs,
t.atoms.text_contrast_medium,
a.font_bold,
a.leading_snug,
]}>
Liked by {formatCount(likeCount)} users
</Text>
)}
</Box>
)
}
+16
View File
@@ -0,0 +1,16 @@
export function Globe(props: React.SVGAttributes<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
{...props}>
<path
fill="currentColor"
fillRule="evenodd"
d="M4.4 9.493C4.14 10.28 4 11.124 4 12a8 8 0 1 0 10.899-7.459l-.953 3.81a1 1 0 0 1-.726.727l-3.444.866-.772 1.533a1 1 0 0 1-1.493.35L4.4 9.493Zm.883-1.84L7.756 9.51l.44-.874a1 1 0 0 1 .649-.52l3.306-.832.807-3.227a7.99 7.99 0 0 0-7.676 3.597ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8.43.162a1 1 0 0 1 .77-.29l1.89.121a1 1 0 0 1 .494.168l2.869 1.928a1 1 0 0 1 .336 1.277l-.973 1.946a1 1 0 0 1-.894.553h-2.92a1 1 0 0 1-.831-.445L9.225 14.5a1 1 0 0 1 .126-1.262l1.08-1.076Zm.915 1.913.177-.177 1.171.074 1.914 1.286-.303.607h-1.766l-1.194-1.79Z"
clipRule="evenodd"
/>
</svg>
)
}
+53
View File
@@ -0,0 +1,53 @@
import {atoms as a} from '../theme/index.js'
import {Box} from './Box.js'
export function Row({
children,
gutter = 0,
cx,
}: {
children: React.ReactNode
gutter?: number
cx?: Record<string, any>[]
}) {
return (
<Box
cx={[
a.flex_row,
{
marginLeft: -gutter,
marginRight: -gutter,
},
...(cx || []),
]}>
{children}
</Box>
)
}
export function Column({
children,
gutter,
cx,
width = 1,
}: {
children: React.ReactNode
gutter?: number
cx?: Record<string, any>[]
width?: number
}) {
return (
<Box
cx={[
a.flex_col,
{
paddingLeft: gutter,
paddingRight: gutter,
width: `${width * 100}%`,
},
...(cx || []),
]}>
{children}
</Box>
)
}
+57
View File
@@ -0,0 +1,57 @@
import {Image as ImageSource} from '../data/getPostData.js'
import {atoms as a, style as s, StyleProp, theme as t} from '../theme/index.js'
import {Box} from './Box.js'
import {MediaInsetBorder} from './MediaInsetBorder.js'
export type ImageProps = Omit<
React.ImgHTMLAttributes<HTMLImageElement>,
'src' | 'style'
> & {
image: ImageSource
cx?: Record<string, any>[]
}
export function Image({image, cx, ...rest}: ImageProps) {
const {image: src, mime = 'image/jpeg'} = image
return (
<img
{...rest}
src={`data:${mime};base64,${src.toString('base64')}`}
style={cx ? s(cx) : undefined}
/>
)
}
export function SquareImage({
image,
style,
insetBorderStyle,
}: {
image: ImageSource
insetBorderStyle?: StyleProp['style']
} & StyleProp) {
return (
<Box
cx={[
a.relative,
a.rounded_md,
a.overflow_hidden,
a.w_full,
t.atoms.bg_contrast_25,
{paddingTop: '100%'},
style,
]}>
<Image
image={image}
cx={[
a.absolute,
a.inset_0,
{
objectFit: 'cover',
},
]}
/>
<MediaInsetBorder style={insetBorderStyle} />
</Box>
)
}
-2
View File
@@ -1,5 +1,3 @@
import React from 'react'
export function Img(
props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
) {
+89
View File
@@ -0,0 +1,89 @@
import {Image as ImageSource} from '../data/getPostData.js'
import {atoms as a, style as s, theme as t} from '../theme/index.js'
import {toNiceDomain} from '../util/toNiceDomain.js'
import {Box} from './Box.js'
import {Globe} from './Globe.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.px_md, a.pt_md, t.atoms.bg]}>
<Text cx={[a.text_md, a.font_bold, a.pb_xs, a.line_clamp_3]}>
{title}
</Text>
<Text
cx={[
a.text_sm,
a.leading_snug,
a.pb_sm,
image ? a.line_clamp_2 : a.line_clamp_4,
]}>
{description}
</Text>
<Box
cx={[
a.border_t,
t.atoms.border_contrast_low,
a.flex_row,
a.align_center,
{paddingTop: 6},
a.pb_sm,
a.gap_2xs,
]}>
<Globe height={12} width={12} style={t.atoms.text_contrast_medium} />
<Text
cx={[
a.text_xs,
a.pb_sm,
t.atoms.text_contrast_medium,
a.max_w_full,
// I don't know why, but the text element is too tall. manually set the height
{overflowX: 'hidden', height: 14},
a.line_clamp_1,
]}>
{toNiceDomain(uri)}
</Text>
</Box>
</Box>
</Box>
)
}
+81
View File
@@ -0,0 +1,81 @@
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 {sanitizeHandle} from '../util/sanitizeHandle.js'
import {Box} from './Box.js'
import {DefaultFeed} from './icons/avatars/DefaultFeed.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 <ModeratedEmbed info={info} />
}
const {avatar, name, creator} = embed
const image = data.images.get(avatar || '')
return (
<Box
cx={[
a.w_full,
a.gap_sm,
a.rounded_sm,
a.p_md,
a.border,
t.atoms.border_contrast_low,
]}>
<Box cx={[a.flex_row, a.align_center, a.gap_sm]}>
{image ? (
<Image
image={image}
cx={[
a.rounded_sm,
{
width: '40px',
},
]}
/>
) : (
<DefaultFeed size={40} />
)}
<Box cx={[a.pt_2xs, a.flex_1]}>
<Text cx={[a.text_md, a.font_bold, a.pb_2xs, a.line_clamp_1]}>
{name}
</Text>
<Text
cx={[
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
a.line_clamp_1,
]}>
{`By ${sanitizeHandle(creator.handle, '@')}`}
</Text>
</Box>
</Box>
</Box>
)
}
@@ -0,0 +1,43 @@
import {atoms as a, StyleProp, theme as t} from '../theme/index.js'
import {Box} from './Box.js'
/**
* Applies and thin border within a bounding box. Used to contrast media from
* bg of the container.
*
* @see `#/components/MediaInsetBorder.tsx`
*/
export function MediaInsetBorder({
children,
style,
opaque,
}: {
children?: React.ReactNode
/**
* Used where this border needs to match adjacent borders, such as in
* external link previews
*/
opaque?: boolean
} & StyleProp) {
const isLight = t.name === 'light'
return (
<Box
cx={[
a.absolute,
a.inset_0,
a.rounded_md,
a.border,
opaque
? [t.atoms.border_contrast_low]
: [
isLight
? t.atoms.border_contrast_low
: t.atoms.border_contrast_high,
{opacity: 0.6},
],
style,
]}>
{children}
</Box>
)
}
@@ -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 (
<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>
)
}
+208
View File
@@ -0,0 +1,208 @@
import {
AppBskyFeedDefs,
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 * as bsky from '../../types/bsky/index.js'
import {formatCount} from '../../util/formatCount.js'
import {formatDate} from '../../util/formatDate.js'
import {computeMatBorderRadius, MAT_WIDTH} from '../../util/frameMatting.js'
import {moderatePost} from '../../util/moderatePost.js'
import {sanitizeHandle} from '../../util/sanitizeHandle.js'
import {getVerificationState} from '../../util/verificationState.js'
import {Avatar} from '../Avatar.js'
import {Box} from '../Box.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 {PostEmbed} from '../PostEmbed.js'
import {RichText} from '../RichText.js'
import {Text} from '../Text.js'
import {VerificationCheck} from '../VerificationCheck.js'
export function Post({
post,
data,
moderatorData,
displayOptions: opts = {
mat: false,
},
}: {
post: AppBskyFeedDefs.PostView
data: PostData
moderatorData: ModeratorData
displayOptions?: {
mat?: boolean
}
}) {
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
const avatar = data.images.get(post.author.avatar)
const rt = post.record.text
? new RichTextApi({
text: post.record.text,
facets: post.record.facets,
})
: undefined
const hasInteractions = post.likeCount > 0 || post.repostCount > 0
const moderation = moderatePost(post, moderatorData.moderationOptions)
const verification = getVerificationState({profile: post.author})
const bgTop = avatar?.colors?.muted ?? '#1083fe'
const bgBottom = avatar?.colors?.lightMuted ?? '#67b0fe'
const rounding = a.rounded_md
return (
<Box
cx={[
a.flex,
a.align_center,
a.w_full,
a.h_full,
opts.mat && [
{
padding: MAT_WIDTH,
borderRadius: computeMatBorderRadius(rounding.borderRadius),
backgroundImage: `linear-gradient(to bottom, ${bgTop} 0%, ${bgBottom} 100%)`,
},
],
]}>
<Box
cx={[
a.flex,
a.flex_col,
a.w_full,
a.p_xl,
rounding,
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, a.w_full]}>
<Box cx={[a.flex_shrink_0]}>
<Avatar
size={42}
image={avatar}
profile={post.author}
moderatorData={moderatorData}
/>
</Box>
<Box cx={[a.flex_col, a.flex_1]}>
<Box cx={[a.flex_1, a.flex_row]}>
<Text cx={[a.text_md, a.font_bold, a.pb_2xs, a.line_clamp_1]}>
{post.author.displayName || post.author.handle}
</Text>
{verification.isVerified && (
<Box cx={[a.pl_xs, a.pt_2xs]}>
<VerificationCheck
size={12}
verifier={verification.role === 'verifier'}
fill={t.palette.primary_500}
/>
</Box>
)}
</Box>
<Text
cx={[a.text_sm, t.atoms.text_contrast_medium, a.line_clamp_1]}>
{sanitizeHandle(post.author.handle, '@')}
</Text>
</Box>
</Box>
{rt && <RichText value={rt} />}
{post.embed && (
<PostEmbed
embed={post.embed}
data={data}
moderation={moderation}
moderatorData={moderatorData}
cx={[a.pt_sm]}
/>
)}
<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>
{!hasInteractions && <Logo />}
</Box>
{hasInteractions && (
<Box cx={[a.pt_md]}>
<Box
cx={[
a.w_full,
a.pb_md,
a.border_t,
t.atoms.border_contrast_low,
]}
/>
<Box cx={[a.flex_row, a.align_center, a.justify_between]}>
<Box cx={[a.flex_row, a.align_center, a.gap_2xl]}>
{post.likeCount > 0 && (
<Box cx={[a.flex_row, a.align_center, a.gap_sm]}>
<Heart size={22} fill={t.palette.red_400} />
<Box
cx={[
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
{formatCount(post.likeCount)}
</Box>
</Box>
)}
{post.repostCount > 0 && (
<Box cx={[a.flex_row, a.align_center, a.gap_sm]}>
<Repost size={22} fill={t.palette.green_600} />
<Box
cx={[
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
{formatCount(post.repostCount)}
</Box>
</Box>
)}
</Box>
<Logo />
</Box>
</Box>
)}
</Box>
</Box>
)
}
return null
}
export function Logo() {
return (
<Box cx={[a.flex_row, a.align_center]}>
<Logomark size={20} fill={t.palette.primary_500} />
<Box
cx={[
{
paddingTop: '3px',
paddingLeft: '6px',
},
]}>
<Logotype size={64} fill={t.palette.contrast_800} />
</Box>
</Box>
)
}
+558
View File
@@ -0,0 +1,558 @@
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, theme as t} from '../theme/index.js'
import * as bsky from '../types/bsky/index.js'
import {formatCount} from '../util/formatCount.js'
import {getModerationCauseInfo} from '../util/getModerationCauseInfo.js'
import {getStarterPackImageUri} from '../util/getStarterPackImageUri.js'
import {Embed, EmbedType, parseEmbed} from '../util/parseEmbed.js'
import {parseEmbedPlayerFromUrl} from '../util/parseEmbedPlayerFromUrl.js'
import {sanitizeHandle} from '../util/sanitizeHandle.js'
import {Box} from './Box.js'
import {FeedCard} from './FeedCard.js'
import * as Grid from './Grid.js'
import {PlayFilled as Play} from './icons/Play.js'
import {StarterPack} from './icons/StarterPack.js'
import {Image, SquareImage} from './Image.js'
import {LinkCard} from './LinkCard.js'
import {ListCard} from './ListCard.js'
import {ModeratedEmbed} from './ModeratedEmbed.js'
import {NotQuotePost, QuotePost} from './PostEmbed/QuotePost.js'
import {Text} from './Text.js'
type CommonProps = {
data: PostData
moderation: ModerationDecision
moderatorData: ModeratorData
hideNestedEmbeds?: boolean
}
export function PostEmbed({
embed: rawEmbed,
cx,
...rest
}: CommonProps & {
embed: AppBskyFeedDefs.PostView['embed']
cx?: (Record<string, any> | undefined)[]
}) {
const embed = parseEmbed(rawEmbed)
switch (embed.type) {
case 'images':
case 'link':
case 'video':
return (
<Box cx={cx}>
<MediaEmbeds embed={embed} {...rest} />
</Box>
)
case 'feed':
case 'list':
case 'starter_pack':
case 'post':
case 'post_blocked':
case 'post_detached':
case 'post_not_found':
return rest.hideNestedEmbeds ? null : (
<Box cx={cx}>
<RecordEmbeds embed={embed} {...rest} />
</Box>
)
case 'post_with_media':
return (
<Box cx={[a.gap_md, ...cx]}>
<MediaEmbeds embed={embed.media} {...rest} />
{!rest.hideNestedEmbeds && (
<RecordEmbeds embed={embed.view} {...rest} hideNestedEmbeds />
)}
</Box>
)
default:
return null
}
}
export function MediaEmbeds({
embed,
...rest
}: CommonProps & {
embed: Embed
}) {
switch (embed.type) {
case 'images': {
return <ImagesEmbed embed={embed} {...rest} />
}
case 'link': {
return <LinkEmbed embed={embed} {...rest} />
}
case 'video': {
return <VideoEmbed embed={embed} {...rest} />
}
default:
return null
}
}
export function RecordEmbeds({
embed,
...rest
}: CommonProps & {
embed: Embed
}) {
switch (embed.type) {
case 'feed': {
return <FeedEmbed embed={embed} {...rest} />
}
case 'list': {
return <ListEmbed embed={embed} {...rest} />
}
case 'starter_pack': {
return <StarterPackEmbed embed={embed} {...rest} />
}
case 'post': {
return <QuoteEmbed embed={embed} {...rest} />
}
case 'post_blocked': {
return <NotQuotePost>Quoted post is blocked</NotQuotePost>
}
case 'post_detached': {
return <NotQuotePost>Quoted post detached by author</NotQuotePost>
}
case 'post_not_found': {
return (
<NotQuotePost>
Quoted post not found, it may have been deleted.
</NotQuotePost>
)
}
}
}
export function QuoteEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'post'>
}) {
return <QuotePost embed={embed.view} {...rest} />
}
export function StarterPackEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'starter_pack'>
}) {
const uri = getStarterPackImageUri(embed.view)
if (
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
embed.view.record,
AppBskyGraphStarterpack.isRecord,
)
) {
return null
}
const {name, description} = embed.view.record
const image = rest.data.images.get(uri)
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.px_md, a.py_sm, t.atoms.bg]}>
<Box cx={[a.w_full, a.gap_xs]}>
<Box cx={[a.flex_row, a.align_center, a.gap_xs, a.w_full]}>
<StarterPack size={40} fill={t.palette.primary_500} />
<Box cx={[a.flex_1]}>
<Text
cx={[a.text_sm, a.font_bold, a.leading_snug, a.line_clamp_2]}>
{name}
</Text>
<Text
cx={[
a.leading_snug,
a.text_xs,
t.atoms.text_contrast_medium,
a.line_clamp_1,
]}>
{`Starter pack by ${sanitizeHandle(
embed.view.creator.handle,
'@',
)}`}
</Text>
</Box>
</Box>
{description ? (
<Box cx={[a.flex_1]}>
<Text cx={[a.leading_snug, a.text_xs, a.line_clamp_3]}>
{description}
</Text>
</Box>
) : null}
{embed.view.joinedAllTimeCount >= 50 && (
<Text cx={[a.font_bold, a.text_xs, t.atoms.text_contrast_medium]}>
{formatCount(embed.view.joinedAllTimeCount)} users have joined!
</Text>
)}
</Box>
</Box>
</Box>
)
}
export function ListEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'list'>
}) {
return (
<ListCard
embed={embed.view}
data={rest.data}
moderatorData={rest.moderatorData}
/>
)
}
export function FeedEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'feed'>
}) {
return (
<FeedCard
embed={embed.view}
data={rest.data}
moderatorData={rest.moderatorData}
/>
)
}
export function LinkEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'link'>
}) {
const {title, description, uri, thumb} = embed.view.external
if (!thumb) return null
const modui = rest.moderation.ui('contentMedia')
const info = getModerationCauseInfo({
cause: modui.blurs.at(0),
moderatorData: rest.moderatorData,
})
if (info) {
return <ModeratedEmbed info={info} />
}
const image = rest.data.images.get(thumb)
const player = parseEmbedPlayerFromUrl(uri)
const gif = rest.data.images.get(player?.playerUri)
if (gif) {
return (
<Box cx={[a.rounded_sm, a.overflow_hidden]}>
<Image image={gif} />
<Box
cx={[
a.absolute,
a.inset_0,
{
top: '75%',
backgroundImage:
'linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.75))',
},
]}
/>
<Box
cx={[
a.absolute,
a.rounded_xs,
a.px_xs,
a.py_2xs,
{
backgroundColor: t.palette.contrast_25,
bottom: a.p_md.padding,
right: a.p_md.padding,
},
]}>
<Text cx={[t.atoms.text, a.font_bold, a.text_sm]}>GIF</Text>
</Box>
</Box>
)
}
return (
<LinkCard image={image} title={title} description={description} uri={uri} />
)
}
export function VideoEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'video'>
}) {
const {thumbnail} = embed.view
const modui = rest.moderation.ui('contentMedia')
const info = getModerationCauseInfo({
cause: modui.blurs.at(0),
moderatorData: rest.moderatorData,
})
if (info) {
return <ModeratedEmbed info={info} />
}
const img = rest.data.images.get(thumbnail)
if (img) {
return (
<Box cx={[a.relative, a.rounded_sm, a.w_full, a.overflow_hidden]}>
<Image image={img} />
<Box
cx={[
a.absolute,
a.inset_0,
a.rounded_full,
{
margin: 'auto',
width: '48px',
height: '48px',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
},
]}>
<Box
cx={[
a.align_center,
a.justify_center,
a.absolute,
a.inset_0,
a.rounded_full,
{
margin: 'auto',
width: '48px',
height: '48px',
backgroundColor: t.palette.contrast_25,
opacity: 0.7,
},
]}>
<Play size={32} />
</Box>
</Box>
</Box>
)
} else {
return null
}
}
export function ImagesEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'images'>
}) {
const gutter = a.p_2xs.padding
const {images} = embed.view
const modui = rest.moderation.ui('contentMedia')
const info = getModerationCauseInfo({
cause: modui.blurs.at(0),
moderatorData: rest.moderatorData,
})
if (info) {
return <ModeratedEmbed info={info} />
}
if (images.length > 0) {
const imgs = images
.map(({fullsize}) => rest.data.images.get(fullsize))
.filter(Boolean) as ImageSource[]
if (imgs.length === 1) {
return (
<Box cx={[a.rounded_sm, a.w_full, a.overflow_hidden]}>
<Image image={imgs[0]} />
</Box>
)
} else if (imgs.length === 2) {
return (
<Grid.Row gutter={gutter}>
<Grid.Column width={1 / 2} gutter={gutter}>
<SquareImage
image={imgs[0]}
style={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
insetBorderStyle={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
/>
</Grid.Column>
<Grid.Column width={1 / 2} gutter={gutter}>
<SquareImage
image={imgs[1]}
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}
insetBorderStyle={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}
/>
</Grid.Column>
</Grid.Row>
)
} else if (imgs.length === 3) {
return (
<Grid.Row gutter={gutter}>
<Grid.Column gutter={gutter} width={1 / 2}>
<SquareImage
image={imgs[0]}
style={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
insetBorderStyle={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
/>
</Grid.Column>
<Grid.Column gutter={gutter} width={1 / 2} cx={[a.gap_xs]}>
<SquareImage
image={imgs[1]}
style={{
// awkward number found through trial and error to account for gap
paddingTop: '48.75%',
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
}}
insetBorderStyle={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
}}
/>
<SquareImage
image={imgs[2]}
style={{
paddingTop: '48.75%',
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: 0,
}}
insetBorderStyle={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: 0,
}}
/>
</Grid.Column>
</Grid.Row>
)
} else {
return (
<Grid.Row gutter={gutter}>
<Grid.Column gutter={gutter} width={1 / 2} cx={[a.gap_xs]}>
<SquareImage
image={imgs[0]}
style={{
// aspect ratio of 1.5 - matches app
paddingTop: '66.666%',
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
borderBottomLeftRadius: 0,
}}
insetBorderStyle={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
borderBottomLeftRadius: 0,
}}
/>
<SquareImage
image={imgs[2]}
style={{
paddingTop: '66.666%',
borderTopLeftRadius: 0,
borderBottomRightRadius: 0,
borderTopRightRadius: 0,
}}
insetBorderStyle={{
borderTopLeftRadius: 0,
borderBottomRightRadius: 0,
borderTopRightRadius: 0,
}}
/>
</Grid.Column>
<Grid.Column gutter={gutter} width={1 / 2} cx={[a.gap_xs]}>
<SquareImage
image={imgs[1]}
style={{
paddingTop: '66.666%',
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
}}
insetBorderStyle={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
}}
/>
<SquareImage
image={imgs[3]}
style={{
paddingTop: '66.666%',
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: 0,
}}
insetBorderStyle={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: 0,
}}
/>
</Grid.Column>
</Grid.Row>
)
}
}
}
@@ -0,0 +1,149 @@
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 * as bsky from '../../types/bsky/index.js'
import {getModerationCauseInfo} from '../../util/getModerationCauseInfo.js'
import {moderatePost} from '../../util/moderatePost.js'
import {sanitizeHandle} from '../../util/sanitizeHandle.js'
import {getVerificationState} from '../../util/verificationState.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'
import {VerificationCheck} from '../VerificationCheck.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 =
bsky.dangerousIsType<AppBskyFeedPost.Record>(
post,
AppBskyFeedPost.isRecord,
) && post.text
? new RichTextApi({
text: post.text,
facets: post.facets,
})
: undefined
const postView = viewRecordToPostView(embed)
const moderation = moderatePost(postView, moderatorData.moderationOptions)
const verification = getVerificationState({profile: embed.author})
const mod = moderation.ui('contentView')
const info = getModerationCauseInfo({
cause: mod.blurs.at(0),
moderatorData,
})
if (info) {
return <ModeratedEmbed info={info} />
}
return (
<Box
cx={[
a.w_full,
a.p_md,
a.rounded_sm,
a.border,
t.atoms.border_contrast_low,
]}>
<Box
cx={[
a.flex_row,
a.align_center,
{gap: 6},
a.pb_xs,
a.w_full,
a.overflow_hidden,
]}>
<Avatar
size={16}
image={avatar}
profile={author}
moderatorData={moderatorData}
/>
<Box cx={[a.flex_row, a.align_center, a.gap_xs, a.pt_2xs]}>
<Text
cx={[
a.text_sm,
a.font_bold,
a.flex_shrink_0,
{maxWidth: '70%'},
a.line_clamp_1,
]}>
{author.displayName || author.handle}
</Text>
{verification.isVerified && (
<Box cx={[{marginTop: -2}]}>
<VerificationCheck
size={12}
verifier={verification.role === 'verifier'}
fill={t.palette.primary_500}
/>
</Box>
)}
<Text
cx={[
a.text_xs,
t.atoms.text_contrast_medium,
{flexShrink: 10},
a.line_clamp_1,
]}>
{sanitizeHandle(author.handle, '@')}
</Text>
</Box>
</Box>
{rt && <RichText value={rt} cx={[a.text_sm]} />}
{embeds && embeds.length && (
<PostEmbed
embed={embeds[0]}
data={data}
moderation={moderation}
moderatorData={moderatorData}
hideNestedEmbeds
cx={[a.pt_sm]}
/>
)}
</Box>
)
}
export function NotQuotePost({children}: {children: React.ReactNode}) {
return (
<Box
cx={[
a.flex_row,
a.align_center,
a.gap_sm,
a.rounded_sm,
a.p_md,
t.atoms.bg_contrast_25,
]}>
<CircleInfo size={20} fill={t.atoms.text_contrast_low.color} />
<Text cx={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
{children}
</Text>
</Box>
)
}
+86
View File
@@ -0,0 +1,86 @@
import {AppBskyRichtextFacet, RichText as RichTextApi} from '@atproto/api'
import {atoms as a, style as s, theme as t} from '../theme/index.js'
import {toShortUrl} from '../util/toShortUrl.js'
import {Text} from './Text.js'
export function RichText({
value,
disableLinks,
cx = [],
}: {
value: RichTextApi
disableLinks?: boolean
cx?: Record<string, any>[]
}) {
const {text, facets} = value
const baseStyles = [a.leading_snug, {whiteSpace: 'pre-wrap'}, ...cx]
const linkStyles = [
{
color: t.palette.primary_500,
},
]
const fontSize: number = s(baseStyles).fontSize || a.text_sm.fontSize
if (!facets?.length) {
if (isOnlyEmoji(text)) {
return (
<Text cx={[...baseStyles, {fontSize: fontSize * 1.85}]}>{text}</Text>
)
}
}
const els = []
let key = 0
// N.B. must access segments via `richText.segments`, not via destructuring
for (const segment of value.segments()) {
const {text, link, mention, tag} = segment
if (
mention &&
AppBskyRichtextFacet.validateMention(mention).success &&
!disableLinks
) {
els.push(
<span key={key} style={s(linkStyles)}>
{text}
</span>,
)
} else if (link && AppBskyRichtextFacet.validateLink(link).success) {
const url = toShortUrl(text)
if (disableLinks) {
els.push(url)
} else {
els.push(
<span key={key} style={s(linkStyles)}>
{url}
</span>,
)
}
} else if (
!disableLinks &&
tag &&
AppBskyRichtextFacet.validateTag(tag).success
) {
els.push(
<span key={key} style={s(linkStyles)}>
{text}
</span>,
)
} else {
els.push(text)
}
if (/\n/.test(text)) {
els.push(<span key={key} style={{width: '100%'}} />)
}
key++
}
return <Text cx={baseStyles}>{els}</Text>
}
export function isOnlyEmoji(text: string) {
return (
text.length <= 15 &&
/^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+$/u.test(text)
)
}
+7 -3
View File
@@ -1,7 +1,6 @@
/* eslint-disable bsky-internal/avoid-unwrapped-text */
import React from 'react'
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import * as bsky from '../types/bsky/index.js'
import {Butterfly} from './Butterfly.js'
import {Img} from './Img.js'
@@ -18,7 +17,10 @@ export function StarterPack(props: {
images: Map<string, Buffer>
}) {
const {starterPack, images} = props
const record = AppBskyGraphStarterpack.isRecord(starterPack.record)
const record = bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
starterPack.record,
AppBskyGraphStarterpack.isRecord,
)
? starterPack.record
: null
const imagesArray = [...images.values()]
@@ -106,6 +108,7 @@ export function StarterPack(props: {
color: 'white',
padding: 60,
fontSize: 40,
fontWeight: '700',
}}>
JOIN THE CONVERSATION
</div>
@@ -134,6 +137,7 @@ export function StarterPack(props: {
fontSize: isLongTitle ? 55 : 65,
display: 'flex',
textAlign: 'center',
fontWeight: '700',
}}>
{record?.name || 'Starter Pack'}
</div>
+31
View File
@@ -0,0 +1,31 @@
import {atoms as a, style as s, theme as t} from '../theme/index.js'
import {FONT_FAMILY_DEF} from '../util/fonts.js'
const SCALE_MULTIPLIER = 1 - 0.0625
export function Text({
children,
cx,
}: {
children?: React.ReactNode
cx?: Record<string, any>[]
}) {
const styles = s([
a.flex,
a.flex_wrap,
{
columnGap: '1px',
},
a.font_normal,
a.leading_tight,
// a.tracking_wide, // 0.25
t.atoms.text,
{
fontFamily: FONT_FAMILY_DEF,
fontWeight: 400,
},
...(cx ?? []),
])
styles.fontSize = (styles.fontSize || a.text_md.fontSize) * SCALE_MULTIPLIER
return <div style={styles}>{children}</div>
}
@@ -0,0 +1,12 @@
import {type IconProps} from './icons/types.js'
import {VerifiedCheck} from './icons/VerifiedCheck.js'
import {VerifierCheck} from './icons/VerifierCheck.js'
export function VerificationCheck({
verifier,
...rest
}: IconProps & {
verifier?: boolean
}) {
return verifier ? <VerifierCheck {...rest} /> : <VerifiedCheck {...rest} />
}
@@ -0,0 +1,14 @@
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
fill={fill}
fillRule="evenodd"
clipRule="evenodd"
d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-4a1 1 0 0 1-1-1Zm1-3a1 1 0 1 0 2 0 1 1 0 0 0-2 0Z"
/>
</svg>
)
}
@@ -0,0 +1,14 @@
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>
)
}
+14
View File
@@ -0,0 +1,14 @@
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
fill={fill}
fillRule="evenodd"
clipRule="evenodd"
d="M12.489 21.372c8.528-4.78 10.626-10.47 9.022-14.47-.779-1.941-2.414-3.333-4.342-3.763-1.697-.378-3.552.003-5.169 1.287-1.617-1.284-3.472-1.665-5.17-1.287-1.927.43-3.562 1.822-4.34 3.764-1.605 4 .493 9.69 9.021 14.47a1 1 0 0 0 .978 0Z"
/>
</svg>
)
}
@@ -0,0 +1,12 @@
import {IconProps} from './types.js'
export function Logomark({size, fill}: IconProps) {
return (
<svg fill="none" viewBox="0 0 500 441" width={size}>
<path
fill={fill}
d="M108.382 29.634C165.705 72.668 227.36 159.93 250 206.754c22.64-46.824 84.295-134.084 141.618-177.12C432.98-1.42 500-25.447 500 51.008c0 15.269-8.755 128.269-13.889 146.615-17.848 63.779-82.883 80.047-140.735 70.2 101.122 17.211 126.846 74.218 71.291 131.225-105.511 108.268-151.649-27.165-163.471-61.867-2.167-6.361-3.181-9.338-3.196-6.807-.015-2.531-1.029.446-3.196 6.807-11.822 34.702-57.96 170.135-163.47 61.867-55.556-57.007-29.832-114.014 71.29-131.225-57.852 9.847-122.887-6.421-140.735-70.2C8.755 179.278 0 66.278 0 51.009 0-25.446 67.02-1.419 108.382 29.634Z"
/>
</svg>
)
}
@@ -0,0 +1,12 @@
import {IconProps} from './types.js'
export function Logotype({size, fill}: IconProps) {
return (
<svg fill="none" viewBox="0 0 398 108" width={size}>
<path
fill={fill}
d="M53.139 40.276c9.306 3.396 14.247 11.24 14.247 20.022 0 14.87-9.766 23.886-28.494 23.886H.632V.818h36.996c17.809 0 26.426 9.25 26.426 21.544 0 8.196-3.677 14.167-10.915 17.914Zm-16.66-26.462H16.143v21.779h20.336c7.928 0 12.179-4.215 12.179-11.24 0-6.44-4.366-10.539-12.179-10.539ZM16.143 71.07h21.945c8.732 0 13.442-4.098 13.442-11.474 0-7.728-4.48-11.592-13.442-11.592H16.143V71.07ZM88.892 84.184H74.415V.818h14.477v83.366ZM136.892 57.605V23.767h14.477v60.417h-14.017v-8.782c-4.481 6.791-10.686 10.187-18.613 10.187-12.524 0-20.681-7.728-20.681-21.778V23.767h14.476v37.585c0 7.61 3.677 11.474 11.145 11.474 7.009 0 13.213-5.268 13.213-15.22ZM217.264 55.03v3.512h-44.35c1.034 10.42 6.664 15.572 15.396 15.572 6.664 0 11.145-2.927 13.558-8.664h13.902c-3.102 12.294-13.443 20.139-27.575 20.139-8.847 0-15.97-2.927-21.37-8.665-5.4-5.737-8.158-13.347-8.158-22.949 0-9.484 2.643-17.094 8.043-22.949 5.4-5.737 12.409-8.664 21.256-8.664 8.961 0 16.085 3.044 21.37 9.016 5.285 5.971 7.928 13.933 7.928 23.651Zm-29.413-21.194c-7.928 0-13.443 4.684-14.822 14.402h29.758c-1.264-8.781-6.549-14.402-14.936-14.402ZM249.35 85.823c-17.234 0-26.311-6.908-27.115-20.841h14.132c.804 7.493 4.481 10.303 13.213 10.303 7.813 0 11.719-2.459 11.719-7.26 0-4.331-2.757-6.439-11.604-7.961l-6.779-1.17c-12.983-2.226-19.417-8.314-19.417-18.267 0-11.357 8.847-18.265 24.587-18.265 16.89 0 25.622 6.79 26.196 20.49H260.61c-.345-7.376-4.596-9.952-12.524-9.952-6.894 0-10.34 2.342-10.34 7.025 0 4.215 2.987 6.089 9.881 7.376l7.468 1.171c14.362 2.693 20.566 8.08 20.566 18.383 0 12.177-9.651 18.968-26.311 18.968ZM339.201 84.184h-16.545l-17.235-28.101-8.961 9.133v18.968h-14.247V.818h14.247v48.006l24.128-25.057h17.234l-22.405 22.832 23.784 37.585ZM378.008 38.988l4.826-15.221H398L375.136 89.1c-2.413 6.674-5.4 11.475-9.192 14.168-3.791 2.693-9.191 3.981-16.315 3.981-2.413 0-4.481-.117-6.319-.351V95.307h5.515c6.549 0 9.766-4.098 9.766-9.718 0-2.81-.919-6.908-2.758-12.177l-17.234-49.645h15.626l4.825 15.104c3.562 11.358 6.664 22.598 9.422 33.721 2.528-9.6 5.745-20.841 9.536-33.604Z"
/>
</svg>
)
}
+14
View File
@@ -0,0 +1,14 @@
import {IconProps} from './types.js'
export function PlayFilled({size, fill}: IconProps) {
return (
<svg fill="none" viewBox="0 0 24 24" width={size} height={size}>
<path
fill={fill}
fillRule="evenodd"
clipRule="evenodd"
d="M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z"
/>
</svg>
)
}
@@ -0,0 +1,14 @@
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
fill={fill}
fillRule="evenodd"
clipRule="evenodd"
d="M17.957 2.293a1 1 0 1 0-1.414 1.414L17.836 5H6a3 3 0 0 0-3 3v3a1 1 0 1 0 2 0V8a1 1 0 0 1 1-1h11.836l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.47-2.47a1.75 1.75 0 0 0 0-2.474l-2.47-2.47ZM20 12a1 1 0 0 1 1 1v3a3 3 0 0 1-3 3H6.164l1.293 1.293a1 1 0 1 1-1.414 1.414l-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47a1 1 0 0 1 1.414 1.414L6.164 17H18a1 1 0 0 0 1-1v-3a1 1 0 0 1 1-1Z"
/>
</svg>
)
}
@@ -0,0 +1,20 @@
import {IconProps} from './types.js'
export function StarterPack({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.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672c.734-.197 1.17-.951.973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z"
/>
<path
fill={fill}
fillRule="evenodd"
clipRule="evenodd"
d="M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z"
/>
</svg>
)
}
@@ -0,0 +1,15 @@
import {IconProps} from './types.js'
export function VerifiedCheck({size, fill}: IconProps) {
return (
<svg fill="none" viewBox="0 0 24 24" width={size} height={size}>
<circle cx="12" cy="12" r="11.5" fill={fill} />
<path
fill="#fff"
fillRule="evenodd"
clipRule="evenodd"
d="M17.659 8.175a1.361 1.361 0 0 1 0 1.925l-6.224 6.223a1.361 1.361 0 0 1-1.925 0L6.4 13.212a1.361 1.361 0 0 1 1.925-1.925l2.149 2.148 5.26-5.26a1.361 1.361 0 0 1 1.925 0Z"
/>
</svg>
)
}
@@ -0,0 +1,20 @@
import {IconProps} from './types.js'
export function VerifierCheck({size, fill}: IconProps) {
return (
<svg fill="none" viewBox="0 0 24 24" width={size} height={size}>
<path
fill={fill}
fillRule="evenodd"
clipRule="evenodd"
d="M8.792 1.615a4.154 4.154 0 0 1 6.416 0 4.154 4.154 0 0 0 3.146 1.515 4.154 4.154 0 0 1 4 5.017 4.154 4.154 0 0 0 .777 3.404 4.154 4.154 0 0 1-1.427 6.255 4.153 4.153 0 0 0-2.177 2.73 4.154 4.154 0 0 1-5.781 2.784 4.154 4.154 0 0 0-3.492 0 4.154 4.154 0 0 1-5.78-2.784 4.154 4.154 0 0 0-2.178-2.73A4.154 4.154 0 0 1 .87 11.551a4.154 4.154 0 0 0 .776-3.404A4.154 4.154 0 0 1 5.646 3.13a4.154 4.154 0 0 0 3.146-1.515Z"
/>
<path
fill="#fff"
fillRule="evenodd"
clipRule="evenodd"
d="M17.861 8.26a1.438 1.438 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L5.97 13.58a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z"
/>
</svg>
)
}
@@ -0,0 +1,14 @@
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,13 @@
import {IconProps} from '../types.js'
export function DefaultFeed({size}: IconProps) {
return (
<svg fill="none" viewBox="0 0 32 32" width={size} height={size}>
<rect width="32" height="32" rx="4" fill="#0070FF" />
<path
d="M13.5 7.25C13.5 6.55859 14.0586 6 14.75 6C20.9648 6 26 11.0352 26 17.25C26 17.9414 25.4414 18.5 24.75 18.5C24.0586 18.5 23.5 17.9414 23.5 17.25C23.5 12.418 19.582 8.5 14.75 8.5C14.0586 8.5 13.5 7.94141 13.5 7.25ZM8.36719 14.6172L12.4336 18.6836L13.543 17.5742C13.5156 17.4727 13.5 17.3633 13.5 17.25C13.5 16.5586 14.0586 16 14.75 16C15.4414 16 16 16.5586 16 17.25C16 17.9414 15.4414 18.5 14.75 18.5C14.6367 18.5 14.5312 18.4844 14.4258 18.457L13.3164 19.5664L17.3828 23.6328C17.9492 24.1992 17.8438 25.1484 17.0977 25.4414C16.1758 25.8008 15.1758 26 14.125 26C9.63672 26 6 22.3633 6 17.875C6 16.8242 6.19922 15.8242 6.5625 14.9023C6.85547 14.1602 7.80469 14.0508 8.37109 14.6172H8.36719ZM14.75 9.75C18.8906 9.75 22.25 13.1094 22.25 17.25C22.25 17.9414 21.6914 18.5 21 18.5C20.3086 18.5 19.75 17.9414 19.75 17.25C19.75 14.4883 17.5117 12.25 14.75 12.25C14.0586 12.25 13.5 11.6914 13.5 11C13.5 10.3086 14.0586 9.75 14.75 9.75Z"
fill="white"
/>
</svg>
)
}
@@ -0,0 +1,20 @@
import {IconProps} from '../types.js'
export function DefaultList({size}: IconProps) {
return (
<svg fill="none" viewBox="0 0 32 32" width={size} height={size}>
<path
d="M28 0H4C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H28C30.2091 32 32 30.2091 32 28V4C32 1.79086 30.2091 0 28 0Z"
fill="#0070FF"
/>
<path
d="M22.1529 22.3542C23.4522 22.4603 24.7593 22.293 25.9899 21.8629C26.0369 21.2838 25.919 20.7032 25.6497 20.1884C25.3805 19.6735 24.9711 19.2454 24.4687 18.9535C23.9663 18.6617 23.3916 18.518 22.8109 18.5392C22.2303 18.5603 21.6676 18.7454 21.1878 19.0731M22.1529 22.3542C22.1489 21.1917 21.8142 20.0534 21.1878 19.0741ZM10.8111 19.0741C10.3313 18.7468 9.7687 18.5619 9.18826 18.5409C8.60781 18.5199 8.03327 18.6636 7.53107 18.9554C7.02888 19.2472 6.61953 19.6752 6.35036 20.1899C6.08119 20.7046 5.96319 21.285 6.01001 21.8639C7.23969 22.2964 8.5461 22.4632 9.84497 22.3531M10.8111 19.0741C10.1851 20.0535 9.84865 21.1908 9.84497 22.3531ZM19.0759 10.077C19.0759 10.8931 18.7518 11.6757 18.1747 12.2527C17.5977 12.8298 16.815 13.154 15.9989 13.154C15.1829 13.154 14.4002 12.8298 13.8232 12.2527C13.2461 11.6757 12.922 10.8931 12.922 10.077C12.922 9.26092 13.2461 8.47828 13.8232 7.90123C14.4002 7.32418 15.1829 7 15.9989 7C16.815 7 17.5977 7.32418 18.1747 7.90123C18.7518 8.47828 19.0759 9.26092 19.0759 10.077ZM25.2299 13.154C25.2299 13.457 25.1702 13.7571 25.0542 14.0371C24.9383 14.3171 24.7683 14.5715 24.554 14.7858C24.3397 15.0001 24.0853 15.1701 23.8053 15.2861C23.5253 15.402 23.2252 15.4617 22.9222 15.4617C22.6191 15.4617 22.319 15.402 22.039 15.2861C21.759 15.1701 21.5046 15.0001 21.2903 14.7858C21.0761 14.5715 20.9061 14.3171 20.7901 14.0371C20.6741 13.7571 20.6144 13.457 20.6144 13.154C20.6144 12.5419 20.8576 11.9549 21.2903 11.5222C21.7231 11.0894 22.3101 10.8462 22.9222 10.8462C23.5342 10.8462 24.1212 11.0894 24.554 11.5222C24.9868 11.9549 25.2299 12.5419 25.2299 13.154ZM11.3835 13.154C11.3835 13.457 11.3238 13.7571 11.2078 14.0371C11.0918 14.3171 10.9218 14.5715 10.7075 14.7858C10.4932 15.0001 10.2388 15.1701 9.95886 15.2861C9.67887 15.402 9.37878 15.4617 9.07572 15.4617C8.77266 15.4617 8.47257 15.402 8.19259 15.2861C7.9126 15.1701 7.6582 15.0001 7.4439 14.7858C7.22961 14.5715 7.05962 14.3171 6.94365 14.0371C6.82767 13.7571 6.76798 13.457 6.76798 13.154C6.76798 12.5419 7.01112 11.9549 7.4439 11.5222C7.87669 11.0894 8.46367 10.8462 9.07572 10.8462C9.68777 10.8462 10.2748 11.0894 10.7075 11.5222C11.1403 11.9549 11.3835 12.5419 11.3835 13.154Z"
fill="white"
/>
<path
d="M22 22C22 25.3137 19.3137 25.5 16 25.5C12.6863 25.5 10 25.3137 10 22C10 18.6863 12.6863 16 16 16C19.3137 16 22 18.6863 22 22Z"
fill="white"
/>
</svg>
)
}
@@ -0,0 +1,16 @@
import {IconProps} from '../types.js'
export function DefaultUser({size}: IconProps) {
return (
<svg fill="none" viewBox="0 0 24 24" width={size} height={size}>
<circle cx="12" cy="12" r="12" fill="#0070ff" />
<circle cx="12" cy="9.5" r="3.5" fill="#fff" />
<path
strokeLinecap="round"
strokeLinejoin="round"
fill="#fff"
d="M 12.058 22.784 C 9.422 22.784 7.007 21.836 5.137 20.262 C 5.667 17.988 8.534 16.25 11.99 16.25 C 15.494 16.25 18.391 18.036 18.864 20.357 C 17.01 21.874 14.64 22.784 12.058 22.784 Z"
/>
</svg>
)
}
+1
View File
@@ -0,0 +1 @@
export type IconProps = {size: number; fill?: string}
+3 -14
View File
@@ -1,12 +1,7 @@
import {readdirSync, readFileSync} from 'node:fs'
import * as path from 'node:path'
import {fileURLToPath} from 'node:url'
import {AtpAgent} from '@atproto/api'
import {Config} from './config.js'
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
import {getFontDefinitions} from './util/fonts.js'
export type AppContextOptions = {
cfg: Config
@@ -28,14 +23,8 @@ export class AppContext {
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl})
const fontDirectory = path.join(__DIRNAME, 'assets', 'fonts')
const fontFiles = readdirSync(fontDirectory)
const fonts = fontFiles.map(file => {
return {
name: path.basename(file, path.extname(file)),
data: readFileSync(path.join(fontDirectory, file)),
}
})
const fonts = getFontDefinitions()
return new AppContext({
cfg,
appviewAgent,
+17
View File
@@ -0,0 +1,17 @@
import MIMEType from 'whatwg-mimetype'
export async function getImage(url: string) {
const response = await fetch(url)
const mimeType = new MIMEType(response.headers.get('content-type') ?? '')
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
if (response.status !== 200) return null
// TODO suss
const overwriteOctectStream =
url.endsWith('.jpg') && mimeType.essence === 'application/octet-stream'
return {
mime: overwriteOctectStream
? 'image/jpeg'
: (mimeType.essence as string | undefined),
image: Buffer.from(arrayBuf),
}
}
+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)
}
+211
View File
@@ -0,0 +1,211 @@
import {AppBskyFeedDefs} from '@atproto/api'
import {Vibrant} from 'node-vibrant/node'
import {httpLogger} from '../logger.js'
import {getStarterPackImageUri} from '../util/getStarterPackImageUri.js'
import {Embed, parseEmbed} from '../util/parseEmbed.js'
import {parseEmbedPlayerFromUrl} from '../util/parseEmbedPlayerFromUrl.js'
import {getImage} from './getImage.js'
export type Metadata = {
colors?: {
vibrant: string
darkVibrant: string
lightVibrant: string
muted: string
darkMuted: string
lightMuted: string
}
aspectRatio: {
width: number
height: number
}
}
export type Image = Metadata & {
mime: 'image/jpeg' | 'image/png' | string | undefined
image: Buffer
}
export type PostData = {
images: Map<string, Image>
}
function normalizeAspectRatio(aspectRatio?: {
width: number
height: number
}): Metadata['aspectRatio'] {
if (!aspectRatio)
return {
width: 1000,
height: 1000,
}
return aspectRatio
}
export function getEmbedData(embed: Embed, images: Map<string, Metadata>) {
switch (embed.type) {
case 'images': {
for (const image of embed.view.images) {
images.set(image.fullsize, {
aspectRatio: normalizeAspectRatio(image.aspectRatio),
})
}
break
}
case 'link': {
if (embed.view.external.thumb) {
images.set(embed.view.external.thumb, {
aspectRatio: {
width: 1200,
height: 630,
},
})
}
const player = parseEmbedPlayerFromUrl(embed.view.external.uri)
if (player) {
if (['giphy_gif', 'tenor_gif'].includes(player.type)) {
images.set(player.playerUri, {
aspectRatio: {
width: 1000,
height: 1000,
},
})
}
}
break
}
case 'video': {
if (embed.view.thumbnail) {
images.set(embed.view.thumbnail, {
aspectRatio: normalizeAspectRatio(embed.view.aspectRatio),
})
}
break
}
case 'feed': {
if (embed.view.avatar) {
images.set(embed.view.avatar, {
aspectRatio: {
width: 1000,
height: 1000,
},
})
}
break
}
case 'list': {
if (embed.view.avatar) {
images.set(embed.view.avatar, {
aspectRatio: {
width: 1000,
height: 1000,
},
})
}
break
}
case 'starter_pack': {
const uri = getStarterPackImageUri(embed.view)
images.set(uri, {
aspectRatio: {
width: 1200,
height: 630,
},
})
break
}
case 'post': {
if (embed.view.author.avatar) {
images.set(embed.view.author.avatar, {
aspectRatio: {
width: 1000,
height: 1000,
},
})
}
if (embed.view.embeds) {
for (const _e of embed.view.embeds) {
getEmbedData(parseEmbed(_e), images)
}
}
break
}
case 'post_with_media': {
getEmbedData(embed.view, images)
getEmbedData(embed.media, images)
break
}
case 'post_blocked':
case 'post_detached':
case 'post_not_found':
default:
break
}
}
export async function getImageColors(image: Buffer) {
const palette = await Vibrant.from(image).getPalette()
return {
vibrant: palette.Vibrant?.hex,
darkVibrant: palette.DarkVibrant?.hex,
lightVibrant: palette.LightVibrant?.hex,
muted: palette.Muted?.hex,
darkMuted: palette.DarkMuted?.hex,
lightMuted: palette.LightMuted?.hex,
}
}
export async function getPostData(
post: AppBskyFeedDefs.PostView,
): Promise<PostData> {
const images: Map<string, Metadata> = new Map()
if (post.author.avatar) {
images.set(post.author.avatar, {
aspectRatio: {
width: 1000,
height: 1000,
},
})
}
if (post.embed) {
getEmbedData(parseEmbed(post.embed), images)
}
const deduped = Array.from(images.entries())
const resolved = await Promise.all(
deduped.map(async ([url, meta]) => {
try {
const imageData = await getImage(url)
const palette = await getImageColors(imageData.image)
return [
url,
{
colors: palette,
...meta,
...imageData,
},
] as const
} catch (err) {
httpLogger.warn({err, uri: url}, 'could not fetch image')
return [
url,
{
...meta,
image: null,
},
] as const
}
}),
)
const extracted = resolved.filter(([, i]) => i.image !== null) as [
string,
Image,
][]
return {
images: new Map(extracted),
}
}
+2
View File
@@ -2,6 +2,7 @@ import {Express} from 'express'
import {AppContext} from '../context.js'
import {default as health} from './health.js'
import {default as post} from './post.js'
import {default as starterPack} from './starter-pack.js'
export * from './util.js'
@@ -9,5 +10,6 @@ export * from './util.js'
export default function (ctx: AppContext, app: Express) {
app = health(ctx, app) // GET /_health
app = starterPack(ctx, app) // GET /start/:actor/:rkey
app = post(ctx, app) // POST /profile/:handle/post/:rkey
return app
}
+95
View File
@@ -0,0 +1,95 @@
import {AppBskyFeedPost, AtUri} from '@atproto/api'
import resvg from '@resvg/resvg-js'
import {Express} from 'express'
import satori from 'satori'
import {Post} from '../components/Post/index.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 {
getRenderOptions,
parseDisplayOptionsFromQuery,
} from '../util/postDisplayOptions.js'
import {handler, originVerifyMiddleware} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
'/profile/:actor/post/:rkey',
originVerifyMiddleware(ctx),
handler(async (req, res) => {
let {actor, rkey} = req.params
let uri = AtUri.make(actor, 'app.bsky.feed.post', rkey)
try {
const post = await getPost({
uri,
agent: ctx.appviewAgent,
})
if (!AppBskyFeedPost.isRecord(post.record)) {
return res.status(404).end('not found')
}
const notPublic = post.author.labels.some(
l => l.val === `!no-unauthenticated`,
)
if (notPublic) {
return res.status(404).end('not found')
}
const [postData, moderatorData] = await Promise.all([
/*
* Fetch any remote post data, like images and their metadata.
*/
getPostData(post),
/*
* Fetches labeler definitions and builds `moderationOpts`
*/
getModeratorData(ctx.appviewAgent),
])
const displayOptions = parseDisplayOptionsFromQuery(req.query)
const {width} = getRenderOptions(displayOptions)
const svg = await satori(
<Post
post={post}
data={postData}
moderatorData={moderatorData}
displayOptions={displayOptions}
/>,
{
fonts: ctx.fonts,
width,
loadAdditionalAsset: async (code, text) => {
if (code === 'emoji') {
return await loadEmojiAsSvg(text)
}
},
},
)
const output = await resvg.renderAsync(svg, {
fitTo: {
mode: 'width',
value: width * 2,
},
// logLevel: 'trace',
})
res.statusCode = 200
res.setHeader('content-type', 'image/png')
res.setHeader('cdn-tag', [...postData.images.keys()].join(','))
return res.end(output.asPng())
} catch (err) {
httpLogger.warn(
{err, uri: uri.toString()},
`Failed to render post ${uri}`,
)
return res.status(404).end('not found')
}
}),
)
}
+8 -3
View File
@@ -1,7 +1,6 @@
import assert from 'node:assert'
import React from 'react'
import {AppBskyGraphDefs, AtUri} from '@atproto/api'
import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
import resvg from '@resvg/resvg-js'
import {Express} from 'express'
import satori from 'satori'
@@ -37,7 +36,13 @@ export default function (ctx: AppContext, app: Express) {
return res.status(404).end('not found')
}
const imageEntries = await Promise.all(
[starterPack.creator]
(
[] as (
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileView
)[]
)
.concat([starterPack.creator])
.concat(starterPack.listItemsSample.map(li => li.subject))
// has avatar
.filter(p => p.avatar)
+30
View File
@@ -0,0 +1,30 @@
import {atoms as baseAtoms} from '@bsky.app/alf'
export const atoms = {
...baseAtoms,
inline_block: {
display: 'inline-block',
},
text_overflow_ellipsis: {
textOverflow: 'ellipsis',
},
text_overflow_clip: {
textOverflow: 'clip',
},
line_clamp_1: {
display: 'block',
lineClamp: 1,
},
line_clamp_2: {
display: 'block',
lineClamp: 2,
},
line_clamp_3: {
display: 'block',
lineClamp: 3,
},
line_clamp_4: {
display: 'block',
lineClamp: 4,
},
} as const
+84
View File
@@ -0,0 +1,84 @@
export const BLUE_HUE = 211
export const RED_HUE = 346
export const GREEN_HUE = 152
/**
* Smooth progression of lightness "stops" for generating HSL colors.
*/
export const COLOR_STOPS = [
0, 0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.85, 0.9, 0.95, 1,
]
export function generateScale(start: number, end: number) {
const range = end - start
return COLOR_STOPS.map(stop => {
return start + range * stop
})
}
export const defaultScale = generateScale(6, 100)
// dim shifted 6% lighter
export const dimScale = generateScale(12, 100)
export const colors = {
white: '#FFFFFF',
black: '#000000',
contrast_0: `hsl(${BLUE_HUE}, 20%, ${defaultScale[14]}%)`,
contrast_25: `hsl(${BLUE_HUE}, 20%, ${defaultScale[13]}%)`,
contrast_50: `hsl(${BLUE_HUE}, 20%, ${defaultScale[12]}%)`,
contrast_100: `hsl(${BLUE_HUE}, 20%, ${defaultScale[11]}%)`,
contrast_200: `hsl(${BLUE_HUE}, 20%, ${defaultScale[10]}%)`,
contrast_300: `hsl(${BLUE_HUE}, 20%, ${defaultScale[9]}%)`,
contrast_400: `hsl(${BLUE_HUE}, 20%, ${defaultScale[8]}%)`,
contrast_500: `hsl(${BLUE_HUE}, 20%, ${defaultScale[7]}%)`,
contrast_600: `hsl(${BLUE_HUE}, 24%, ${defaultScale[6]}%)`,
contrast_700: `hsl(${BLUE_HUE}, 24%, ${defaultScale[5]}%)`,
contrast_800: `hsl(${BLUE_HUE}, 28%, ${defaultScale[4]}%)`,
contrast_900: `hsl(${BLUE_HUE}, 28%, ${defaultScale[3]}%)`,
contrast_950: `hsl(${BLUE_HUE}, 28%, ${defaultScale[2]}%)`,
contrast_975: `hsl(${BLUE_HUE}, 28%, ${defaultScale[1]}%)`,
contrast_1000: `hsl(${BLUE_HUE}, 28%, ${defaultScale[0]}%)`,
primary_25: `hsl(${BLUE_HUE}, 99%, 97%)`,
primary_50: `hsl(${BLUE_HUE}, 99%, 95%)`,
primary_100: `hsl(${BLUE_HUE}, 99%, 90%)`,
primary_200: `hsl(${BLUE_HUE}, 99%, 80%)`,
primary_300: `hsl(${BLUE_HUE}, 99%, 70%)`,
primary_400: `hsl(${BLUE_HUE}, 99%, 60%)`,
primary_500: `hsl(${BLUE_HUE}, 99%, 53%)`,
primary_600: `hsl(${BLUE_HUE}, 99%, 42%)`,
primary_700: `hsl(${BLUE_HUE}, 99%, 34%)`,
primary_800: `hsl(${BLUE_HUE}, 99%, 26%)`,
primary_900: `hsl(${BLUE_HUE}, 99%, 18%)`,
primary_950: `hsl(${BLUE_HUE}, 99%, 10%)`,
primary_975: `hsl(${BLUE_HUE}, 99%, 7%)`,
green_25: `hsl(${GREEN_HUE}, 82%, 97%)`,
green_50: `hsl(${GREEN_HUE}, 82%, 95%)`,
green_100: `hsl(${GREEN_HUE}, 82%, 90%)`,
green_200: `hsl(${GREEN_HUE}, 82%, 80%)`,
green_300: `hsl(${GREEN_HUE}, 82%, 70%)`,
green_400: `hsl(${GREEN_HUE}, 82%, 60%)`,
green_500: `hsl(${GREEN_HUE}, 82%, 50%)`,
green_600: `hsl(${GREEN_HUE}, 82%, 42%)`,
green_700: `hsl(${GREEN_HUE}, 82%, 34%)`,
green_800: `hsl(${GREEN_HUE}, 82%, 26%)`,
green_900: `hsl(${GREEN_HUE}, 82%, 18%)`,
green_950: `hsl(${GREEN_HUE}, 82%, 10%)`,
green_975: `hsl(${GREEN_HUE}, 82%, 7%)`,
red_25: `hsl(${RED_HUE}, 91%, 97%)`,
red_50: `hsl(${RED_HUE}, 91%, 95%)`,
red_100: `hsl(${RED_HUE}, 91%, 90%)`,
red_200: `hsl(${RED_HUE}, 91%, 80%)`,
red_300: `hsl(${RED_HUE}, 91%, 70%)`,
red_400: `hsl(${RED_HUE}, 91%, 60%)`,
red_500: `hsl(${RED_HUE}, 91%, 50%)`,
red_600: `hsl(${RED_HUE}, 91%, 42%)`,
red_700: `hsl(${RED_HUE}, 91%, 34%)`,
red_800: `hsl(${RED_HUE}, 91%, 26%)`,
red_900: `hsl(${RED_HUE}, 91%, 18%)`,
red_950: `hsl(${RED_HUE}, 91%, 10%)`,
red_975: `hsl(${RED_HUE}, 91%, 7%)`,
} as const
+109
View File
@@ -0,0 +1,109 @@
import {colors} from './colors.js'
import * as tokens from './tokens.js'
export {atoms} from './atoms.js'
export * as tokens from './tokens.js'
const fontFamily = 'Inter'
export const theme = {
name: 'light',
palette: colors,
atoms: {
text: {
color: colors.black,
fontFamily,
},
text_contrast_low: {
color: colors.contrast_500,
fontFamily,
},
text_contrast_medium: {
color: colors.contrast_700,
fontFamily,
},
text_contrast_high: {
color: colors.contrast_900,
fontFamily,
},
text_inverted: {
color: colors.white,
fontFamily,
},
bg: {
backgroundColor: colors.white,
},
bg_contrast_25: {
backgroundColor: colors.contrast_25,
},
bg_contrast_50: {
backgroundColor: colors.contrast_50,
},
bg_contrast_100: {
backgroundColor: colors.contrast_100,
},
bg_contrast_200: {
backgroundColor: colors.contrast_200,
},
bg_contrast_300: {
backgroundColor: colors.contrast_300,
},
bg_contrast_400: {
backgroundColor: colors.contrast_400,
},
bg_contrast_500: {
backgroundColor: colors.contrast_500,
},
bg_contrast_600: {
backgroundColor: colors.contrast_600,
},
bg_contrast_700: {
backgroundColor: colors.contrast_700,
},
bg_contrast_800: {
backgroundColor: colors.contrast_800,
},
bg_contrast_900: {
backgroundColor: colors.contrast_900,
},
bg_contrast_950: {
backgroundColor: colors.contrast_950,
},
bg_contrast_975: {
backgroundColor: colors.contrast_975,
},
bg_primary_500: {
backgroundColor: colors.primary_500,
},
border_contrast_low: {
borderColor: colors.contrast_100,
},
border_contrast_medium: {
borderColor: colors.contrast_200,
},
border_contrast_high: {
borderColor: colors.contrast_300,
},
},
}
export type StyleObject = Record<string, any>
export type StyleProp = {
style?: StyleObject
}
export function style(
styleObjects: (StyleObject | boolean | null | undefined)[],
) {
return Object.assign({}, ...styleObjects.filter(Boolean).flat())
}
export function gradient(color: keyof typeof tokens.gradients) {
const {values} = tokens.gradients[color]
return values
.map(([pos, color]) => {
return `${color} ${pos * 100}%`
})
.join(', ')
}
+112
View File
@@ -0,0 +1,112 @@
export const space = {
_2xs: 2,
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
_2xl: 24,
_3xl: 28,
_4xl: 32,
_5xl: 40,
} as const
export const fontSize = {
_2xs: 10,
xs: 12,
sm: 14,
md: 16,
lg: 18,
xl: 20,
_2xl: 22,
_3xl: 26,
_4xl: 32,
_5xl: 40,
} as const
export const lineHeight = {
none: 1,
normal: 1.5,
relaxed: 1.625,
} as const
export const borderRadius = {
_2xs: 2,
xs: 4,
sm: 8,
md: 12,
lg: 20,
full: 999,
} as const
export const fontWeight = {
normal: '400',
semibold: '500',
bold: '600',
heavy: '700',
} as const
export const gradients = {
sky: {
values: [
[0, '#0A7AFF'],
[1, '#59B9FF'],
],
hover_value: '#0A7AFF',
},
midnight: {
values: [
[0, '#022C5E'],
[1, '#4079BC'],
],
hover_value: '#022C5E',
},
sunrise: {
values: [
[0, '#4E90AE'],
[0.4, '#AEA3AB'],
[0.8, '#E6A98F'],
[1, '#F3A84C'],
],
hover_value: '#AEA3AB',
},
sunset: {
values: [
[0, '#6772AF'],
[0.6, '#B88BB6'],
[1, '#FFA6AC'],
],
hover_value: '#B88BB6',
},
summer: {
values: [
[0, '#FF6A56'],
[0.3, '#FF9156'],
[1, '#FFDD87'],
],
hover_value: '#FF9156',
},
nordic: {
values: [
[0, '#083367'],
[1, '#9EE8C1'],
],
hover_value: '#3A7085',
},
bonfire: {
values: [
[0, '#203E4E'],
[0.4, '#755B62'],
[0.8, '#CD7765'],
[1, '#EF956E'],
],
hover_value: '#755B62',
},
neutral: {
values: [
[0, '#E0E5EB'],
[1, '#EFF2F5'],
],
hover_value: '#755B62',
},
} as const
+49
View File
@@ -0,0 +1,49 @@
import {ValidationResult} from '@atproto/lexicon'
export * as profile from './profile.js'
/**
* Fast type checking without full schema validation, for use with data we
* trust, or for non-critical path use cases. Why? Our SDK's `is*` identity
* utils do not assert the type of the entire object, only the `$type` string.
*
* For full validation of the object schema, use the `validate` export from
* this file.
*
* Usage:
* ```ts
* import * as bsky from '#/types/bsky'
*
* if (bsky.dangerousIsType<AppBskyFeedPost.Record>(item, AppBskyFeedPost.isRecord)) {
* // `item` has type `$Typed<AppBskyFeedPost.Record>` here
* }
* ```
*/
export function dangerousIsType<R extends {$type?: string}>(
record: unknown,
identity: <V>(v: V) => v is V & {$type: NonNullable<R['$type']>},
): record is R {
return identity(record)
}
/**
* Fully validates the object schema, which has a performance cost.
*
* For faster checks with data we trust, like that from our app view, use the
* `dangerousIsType` export from this same file.
*
* Usage:
* ```ts
* import * as bsky from '#/types/bsky'
*
* if (bsky.validate(item, AppBskyFeedPost.validateRecord)) {
* // `item` has type `$Typed<AppBskyFeedPost.Record>` here
* }
* ```
*/
export function validate<R extends {$type?: string}>(
record: unknown,
validator: (v: unknown) => ValidationResult<R>,
): record is R {
return validator(record).success
}
+10
View File
@@ -0,0 +1,10 @@
import {type AppBskyActorDefs, type ChatBskyActorDefs} from '@atproto/api'
/**
* Matches any profile view exported by our SDK
*/
export type AnyProfileView =
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileView
| AppBskyActorDefs.ProfileViewDetailed
| ChatBskyActorDefs.ProfileViewBasic
+74
View File
@@ -0,0 +1,74 @@
import {readdirSync, readFileSync} from 'node:fs'
import * as path from 'node:path'
const __DIRNAME = path.join(process.cwd(), 'src')
export const ADDITIONAL_FONTS = [
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-jp@5.0/japanese-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-jp@5.0/japanese-400-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-tc@5.0/chinese-traditional-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-tc@5.0/chinese-traditional-400-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-sc@5.0/chinese-simplified-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-sc@5.0/chinese-simplified-400-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hk@5.0/chinese-hongkong-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hk@5.0/chinese-hongkong-400-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-kr@5.0/korean-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-kr@5.0/korean-400-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-thai@5.0/thai-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-thai@5.0/thai-400-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-arabic@5.0/arabic-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-arabic@5.0/arabic-400-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hebrew@5.0/hebrew-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hebrew@5.0/hebrew-400-normal.ttf',
]
/**
* CSS `fontFamily` defs must use the PostScript names of the fonts. When
* adding additional fonts above, you'll need to figure out the PostScript name
* and add it to the map below.
*/
export const ADDITIONAL_FONTS_POSTSCRIPT_NAMES: Record<string, string> = {
arabic: 'Noto Sans Arabic',
hebrew: 'Noto Sans Hebrew',
japanese: 'Noto Sans JP Thin',
korean: 'Noto Sans KR',
thai: 'Noto Sans Thai',
'chinese-hongkong': 'Noto Sans HK',
'chinese-simplified': 'Noto Sans SC',
'chinese-traditional': 'Noto Sans TC',
}
/**
* Precomputed, for use in the runtime CSS defs
*/
export const FONT_FAMILY_DEF = `Inter, ${Object.values(
ADDITIONAL_FONTS_POSTSCRIPT_NAMES,
).join(', ')}, sans-serif`
/**
* Get all TTF files from our fonts dir
*/
export function getFontFiles() {
const fontDirectory = path.join(__DIRNAME, 'assets', 'fonts')
return readdirSync(fontDirectory)
.filter(file => file.endsWith('.ttf'))
.map(file => path.join(fontDirectory, file))
}
/**
* Get all font files and format for use by Satori
*/
export function getFontDefinitions() {
return getFontFiles().map(file => {
const basename = path.basename(file, path.extname(file))
const italic = basename.includes('Italic')
const [weight, postScriptName] = basename.split('-')
return {
name: postScriptName,
data: readFileSync(file),
weight: parseInt(weight),
style: italic ? 'italic' : 'normal',
}
})
}
+10
View File
@@ -0,0 +1,10 @@
export function formatCount(count: number) {
return Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
// `1,953` shouldn't be rounded up to 2k, it should be truncated.
// @ts-ignore
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingmode
roundingMode: 'trunc',
}).format(count)
}
+11
View File
@@ -0,0 +1,11 @@
export function formatDate(date: number | string | Date) {
const d = new Date(date)
return `${d.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})} at ${d.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})}`
}
+9
View File
@@ -0,0 +1,9 @@
export const MAT_WIDTH = 12
export function getWidthWithMat(width: number) {
return width + MAT_WIDTH * 2
}
export function computeMatBorderRadius(borderRadius: number) {
return borderRadius + MAT_WIDTH / 2
}
@@ -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,13 @@
import {AppBskyGraphDefs, AtUri} from '@atproto/api'
const DEV = process.env.NODE_ENV === 'development'
const HOST = DEV
? `http://localhost:3000/start`
: `https://ogcard.cdn.bsky.app/start`
export function getStarterPackImageUri(
record: AppBskyGraphDefs.StarterPackViewBasic,
) {
const urip = new AtUri(record.uri)
return `${HOST}/${record.creator.did}/${urip.rkey}`
}
+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)
}
+152
View File
@@ -0,0 +1,152 @@
/**
* This file is a copy of what exists in the social-app
*/
import {
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyEmbedVideo,
AppBskyFeedDefs,
AppBskyGraphDefs,
AppBskyLabelerDefs,
} from '@atproto/api'
export type Embed =
| {
type: 'post'
view: AppBskyEmbedRecord.ViewRecord
}
| {
type: 'post_not_found'
view: AppBskyEmbedRecord.ViewNotFound
}
| {
type: 'post_blocked'
view: AppBskyEmbedRecord.ViewBlocked
}
| {
type: 'post_detached'
view: AppBskyEmbedRecord.ViewDetached
}
| {
type: 'feed'
view: AppBskyFeedDefs.GeneratorView
}
| {
type: 'list'
view: AppBskyGraphDefs.ListView
}
| {
type: 'labeler'
view: AppBskyLabelerDefs.LabelerView
}
| {
type: 'starter_pack'
view: AppBskyGraphDefs.StarterPackViewBasic
}
| {
type: 'images'
view: AppBskyEmbedImages.View
}
| {
type: 'link'
view: AppBskyEmbedExternal.View
}
| {
type: 'video'
view: AppBskyEmbedVideo.View
}
| {
type: 'post_with_media'
view: Embed
media: Embed
}
| {
type: 'unknown'
view: null
}
export type EmbedType<T extends Embed['type']> = Extract<Embed, {type: T}>
export function parseEmbedRecordView({record}: AppBskyEmbedRecord.View): Embed {
if (AppBskyEmbedRecord.isViewRecord(record)) {
return {
type: 'post',
view: record,
}
} else if (AppBskyEmbedRecord.isViewNotFound(record)) {
return {
type: 'post_not_found',
view: record,
}
} else if (AppBskyEmbedRecord.isViewBlocked(record)) {
return {
type: 'post_blocked',
view: record,
}
} else if (AppBskyEmbedRecord.isViewDetached(record)) {
return {
type: 'post_detached',
view: record,
}
} else if (AppBskyFeedDefs.isGeneratorView(record)) {
return {
type: 'feed',
view: record,
}
} else if (AppBskyGraphDefs.isListView(record)) {
return {
type: 'list',
view: record,
}
} else if (AppBskyLabelerDefs.isLabelerView(record)) {
return {
type: 'labeler',
view: record,
}
} else if (AppBskyGraphDefs.isStarterPackViewBasic(record)) {
return {
type: 'starter_pack',
view: record,
}
} else {
return {
type: 'unknown',
view: null,
}
}
}
export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed {
if (AppBskyEmbedImages.isView(embed)) {
return {
type: 'images',
view: embed,
}
} else if (AppBskyEmbedExternal.isView(embed)) {
return {
type: 'link',
view: embed,
}
} else if (AppBskyEmbedVideo.isView(embed)) {
return {
type: 'video',
view: embed,
}
} else if (AppBskyEmbedRecord.isView(embed)) {
return parseEmbedRecordView(embed)
} else if (AppBskyEmbedRecordWithMedia.isView(embed)) {
return {
type: 'post_with_media',
view: parseEmbedRecordView(embed.record),
media: parseEmbed(embed.media),
}
} else {
return {
type: 'unknown',
view: null,
}
}
}
@@ -0,0 +1,214 @@
export const embedPlayerSources = ['giphy', 'tenor'] as const
export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
export type EmbedPlayerType = 'giphy_gif' | 'tenor_gif'
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
giphy: 'GIPHY',
tenor: 'Tenor',
}
export interface EmbedPlayerParams {
type: EmbedPlayerType
playerUri: string
isGif?: boolean
source: EmbedPlayerSource
metaUri?: string
hideDetails?: boolean
dimensions?: {
height: number
width: number
}
}
const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i
const gifFilenameRegex = /^(\S+)\.(webp|gif|mp4)$/i
export function parseEmbedPlayerFromUrl(
url: string,
): EmbedPlayerParams | undefined {
let urlp
try {
urlp = new URL(url)
} catch (e) {
return undefined
}
if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') {
const [_, gifs, nameAndId] = urlp.pathname.split('/')
/*
* nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name)
* We want to get the id of the gif, then direct to media.giphy.com/media/{id}/giphy.webp so we can
* use it in an <Image> component
*/
if (gifs === 'gifs' && nameAndId) {
const gifId = nameAndId.split('-').pop()
if (gifId) {
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${gifId}`,
playerUri: `https://i.giphy.com/media/${gifId}/200.webp`,
}
}
}
}
// There are five possible hostnames that also can be giphy urls: media.giphy.com and media0-4.giphy.com
// These can include (presumably) a tracking id in the path name, so we have to check for that as well
if (giphyRegex.test(urlp.hostname)) {
// We can link directly to the gif, if its a proper link
const [_, media, trackingOrId, idOrFilename, filename] =
urlp.pathname.split('/')
if (media === 'media') {
if (idOrFilename && gifFilenameRegex.test(idOrFilename)) {
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${trackingOrId}`,
playerUri: `https://i.giphy.com/media/${trackingOrId}/200.webp`,
}
} else if (filename && gifFilenameRegex.test(filename)) {
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${idOrFilename}`,
playerUri: `https://i.giphy.com/media/${idOrFilename}/200.webp`,
}
}
}
}
// Finally, we should see if it is a link to i.giphy.com. These links don't necessarily end in .gif but can also
// be .webp
if (urlp.hostname === 'i.giphy.com' || urlp.hostname === 'www.i.giphy.com') {
const [_, mediaOrFilename, filename] = urlp.pathname.split('/')
if (mediaOrFilename === 'media' && filename) {
const gifId = filename.split('.')[0]
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${gifId}`,
playerUri: `https://i.giphy.com/media/${gifId}/200.webp`,
}
} else if (mediaOrFilename) {
const gifId = mediaOrFilename.split('.')[0]
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${gifId}`,
playerUri: `https://i.giphy.com/media/${
mediaOrFilename.split('.')[0]
}/200.webp`,
}
}
}
const tenorGif = parseTenorGif(urlp)
if (tenorGif.success) {
const {playerUri, dimensions} = tenorGif
return {
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri,
dimensions,
}
}
}
export function getGifDims(
originalHeight: number,
originalWidth: number,
viewWidth: number,
) {
const scaledHeight = (originalHeight / originalWidth) * viewWidth
return {
height: scaledHeight > 250 ? 250 : scaledHeight,
width: (250 / scaledHeight) * viewWidth,
}
}
export function getGiphyMetaUri(url: URL) {
if (giphyRegex.test(url.hostname) || url.hostname === 'i.giphy.com') {
const params = parseEmbedPlayerFromUrl(url.toString())
if (params && params.type === 'giphy_gif') {
return params.metaUri
}
}
}
export function parseTenorGif(urlp: URL):
| {success: false}
| {
success: true
playerUri: string
dimensions: {height: number; width: number}
} {
if (urlp.hostname !== 'media.tenor.com') {
return {success: false}
}
let [_, id, filename] = urlp.pathname.split('/')
if (!id || !filename) {
return {success: false}
}
if (!id.includes('AAAAC')) {
return {success: false}
}
const h = urlp.searchParams.get('hh')
const w = urlp.searchParams.get('ww')
if (!h || !w) {
return {success: false}
}
const dimensions = {
height: Number(h),
width: Number(w),
}
// if (isWeb) {
// id = id.replace('AAAAC', 'AAAP3')
// filename = filename.replace('.gif', '.webm')
// } else {
id = id.replace('AAAAC', 'AAAAM')
// }
return {
success: true,
playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
dimensions,
}
}
export function isTenorGifUri(url: URL | string) {
try {
return parseTenorGif(typeof url === 'string' ? new URL(url) : url).success
} catch {
// Invalid URL
return false
}
}
+40
View File
@@ -0,0 +1,40 @@
import {ParsedQs} from 'qs'
import {getWidthWithMat} from './frameMatting.js'
export const DEFAULT_WIDTH = 360
/**
* Options parsed from the query string, passed to the components themselves.
*/
export type DisplayOptions = {
mat: boolean
}
export function parseDisplayOptionsFromQuery(query: ParsedQs): DisplayOptions {
const opts: DisplayOptions = {
mat: false,
}
;['mat'].forEach(key => {
const val = query[key]
if (val === 'true' || val === '1') {
opts[key] = true
} else {
opts[key] = false
}
})
return opts
}
/**
* Options computed from the parsed query options, passed to the render
* function.
*/
export function getRenderOptions(options: DisplayOptions) {
return {
// must match width + padding of Post.tsx
width: options.mat ? getWidthWithMat(DEFAULT_WIDTH) : DEFAULT_WIDTH,
}
}
+7
View File
@@ -0,0 +1,7 @@
export function isInvalidHandle(handle: string): boolean {
return handle === 'handle.invalid'
}
export function sanitizeHandle(handle: string, prefix = ''): string {
return isInvalidHandle(handle) ? '⚠Invalid Handle' : `${prefix}${handle}`
}
+13
View File
@@ -0,0 +1,13 @@
export const BSKY_SERVICE = 'https://bsky.social'
export function toNiceDomain(url: string): string {
try {
const urlp = new URL(url)
if (`https://${urlp.host}` === BSKY_SERVICE) {
return 'Bluesky Social'
}
return urlp.host ? urlp.host : url
} catch (e) {
return url
}
}
+16
View File
@@ -0,0 +1,16 @@
export function toShortUrl(url: string): string {
try {
const urlp = new URL(url)
if (urlp.protocol !== 'http:' && urlp.protocol !== 'https:') {
return url
}
const path =
(urlp.pathname === '/' ? '' : urlp.pathname) + urlp.search + urlp.hash
if (path.length > 15) {
return urlp.host + path.slice(0, 13) + '...'
}
return urlp.host + path
} catch (e) {
return url
}
}
+33
View File
@@ -0,0 +1,33 @@
import {useMemo} from 'react'
import * as bsky from '../types/bsky/index.js'
export type VerificationState = {
role: 'default' | 'verifier'
isVerified: boolean
}
export function getVerificationState({
profile,
}: {
profile?: bsky.profile.AnyProfileView
}): VerificationState {
if (!profile || !profile.verification) {
return {
role: 'default',
isVerified: false,
}
}
const {verifiedStatus, trustedVerifierStatus} = profile.verification
const isVerifiedUser = ['valid', 'invalid'].includes(verifiedStatus)
const isVerifierUser = ['valid', 'invalid'].includes(trustedVerifierStatus)
const isVerified =
(isVerifiedUser && verifiedStatus === 'valid') ||
(isVerifierUser && trustedVerifierStatus === 'valid')
return {
role: isVerifierUser ? 'verifier' : 'default',
isVerified,
}
}
@@ -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],
}
}
+142
View File
@@ -0,0 +1,142 @@
<!doctype html>
<html>
<head>
<link
rel="stylesheet"
href="https://unpkg.com/svbstrate@5.1.0/svbstrate.css" />
<style>
body {
padding: 2rem;
}
section {
margin-bottom: 4rem;
}
h1 {
margin-bottom: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 2rem;
}
.item {
text-align: center;
}
.item img {
width: 100%;
max-width: 400px;
display: block;
margin: 0 auto 0.5rem;
transition: opacity 0.2s ease-in-out;
}
</style>
</head>
<body class="p12">
<!-- Base embeds -->
<section>
<h1>Base embeds</h1>
<div class="grid">
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkch7rnmqs23?mat=1"><div>Image</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchac5lbs23?mat=1"><div>Image w/ label</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchkcokw22h?mat=1"><div>Image x2</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkjhqtunc22v?mat=1"><div>Image x3</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkjhsgb4vk2z?mat=1"><div>Image x4</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchd5laes23?mat=1"><div>Video</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchdnqmzc23?mat=1"><div>Video w/ label</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchef5c2c23?mat=1"><div>GIF</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchgiinq22h?mat=1"><div>List</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchgwpy222h?mat=1"><div>List w/ label</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchhfft722h?mat=1"><div>List (takendown)</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchj6t27k2h?mat=1"><div>Feed</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkjid3khps2r?mat=1"><div>Feed w/ label</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchlshes22h?mat=1"><div>Link</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchm7qo622h?mat=1"><div>Link w/ label</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchnlwlbs2h?mat=1"><div>Starter pack</div></div>
<div class="item"><img src="http://localhost:3000/profile/techcrunch.com/post/3lcnpwhz4d22j?mat=1"><div>Starter pack w/ signups</div></div>
</div>
</section>
<!-- Quote posts -->
<section>
<h1>Quote posts</h1>
<div class="grid">
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchp7oaq22h?mat=1"><div>Image</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchpijwu22h?mat=1"><div>Image w/ label</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchpvu6is2h?mat=1"><div>Video</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchq5dfrk2h?mat=1"><div>Video w/ label</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkjip5qduk2l?mat=1"><div>GIF</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchqdm3nk2h?mat=1"><div>List</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchqmp2722h?mat=1"><div>List (takendown)</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchqrrigs2h?mat=1"><div>Feed</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchqyf6u22h?mat=1"><div>Link</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchremdhc2h?mat=1"><div>Link w/ label</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchrlw4vk2h?mat=1"><div>Starter pack</div></div>
</div>
</section>
<!-- Quote post states -->
<section>
<h1>Quote post states</h1>
<div class="grid">
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkcieaeil22a?mat=1"><div>Deleted</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkciefiepk2a?mat=1"><div>Detached quoted post</div></div>
<div class="item"><img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkcikkheys2c?mat=1"><div>Blocked</div></div>
</div>
</section>
<!-- Default avatars -->
<section>
<h1>Default avatars</h1>
<div class="grid">
<div class="item"><img src="http://localhost:3000/profile/bleh-5.bsky.social/post/3lr6qski2i22o?mat=1" /><div>Default avatar</div></div>
<div class="item"><img src="http://localhost:3000/profile/bleh-5.bsky.social/post/3lr6qrk74rc2o?mat=1" /><div>Default avi quote</div></div>
<div class="item"><img src="http://localhost:3000/profile/bleh-5.bsky.social/post/3lr6qsegoy22o?mat=1" /><div>Default avi list</div></div>
<div class="item"><img src="http://localhost:3000/profile/bleh-5.bsky.social/post/3lr6qy3dpu22o?mat=1" /><div>Default avi feed</div></div>
</div>
</section>
<!-- Verification -->
<section>
<h1>Verification</h1>
<div class="grid">
<div class="item"><img src="http://localhost:3000/profile/wired.com/post/3lr7im23gjr2f?mat=1" /><div>Trusted verifier</div></div>
<div class="item"><img src="http://localhost:3000/profile/bastardsheep.com/post/3lr7jf37gs42v?mat=1" /><div>Trusted verifier quoted</div></div>
<div class="item"><img src="http://localhost:3000/profile/julianchokkattu.bsky.social/post/3lr7jbcxpsc2u?mat=1" /><div>Verified</div></div>
<div class="item"><img src="http://localhost:3000/profile/retr0.id/post/3locfcr4tv22c?mat=1" /><div>Verified quoted</div></div>
</div>
</section>
<!-- Other languages -->
<section>
<h1>Other languages</h1>
<div class="grid">
<div class="item"><img src="http://localhost:3000/profile/miyanushi.bsky.social/post/3lrqt3xxmuk2a?mat=1"><div>Japanese</div></div>
<div class="item"><img src="http://localhost:3000/profile/amir.blue/post/3lbad2u6zg22a?mat=1"><div>Hebrew</div></div>
</div>
</section>
<!-- Misc -->
<section>
<h1>Misc</h1>
<div class="grid">
<div class="item"><img src="http://localhost:3000/profile/xblock.aendra.dev/post/3ljo3ne5cxk2h?mat=1"><div>Post from labeler</div></div>
<div class="item"><img src="http://localhost:3000/profile/hailey5.bsky.social/post/3kl4psn4zbk2q?mat=1"><div>Long name</div></div>
<div class="item"><img src="http://localhost:3000/profile/deepfates.com.deepfates.com.deepfates.com.deepfates.com.deepfates.com/post/3jvcrxfpuhy2i?mat=1"><div>Long handle</div></div>
</div>
</section>
</body>
<!-- reload image on click -->
<script>
document.querySelectorAll('.item img').forEach(img => {
img.onload = () => img.style.opacity = 1;
img.addEventListener('click', () => {
img.style.opacity = 0.5;
const url = new URL(img.src);
url.searchParams.set('t', Date.now()); // bust the cache
img.src = url.toString();
});
});
</script>
</html>
+2 -1
View File
@@ -5,7 +5,8 @@
"esModuleInterop": true,
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"outDir": "dist"
"outDir": "dist",
"skipLibCheck": true
},
"include": ["./src/index.ts", "./src/bin.ts"]
}
+589 -42
View File
@@ -2,63 +2,72 @@
# yarn lockfile v1
"@atproto/api@0.12.19-next.0":
version "0.12.19-next.0"
resolved "https://registry.npmjs.org/@atproto/api/-/api-0.12.19-next.0.tgz"
integrity sha512-wyWr4uIabTgDTBY99y3QyrFxcIx1Mh4DkURgSv8sd/b+w0lfrZAJh0Gg9BXdg/iIjcf/M2lCTL04r0vASfkMVg==
"@atproto/api@^0.15.14":
version "0.15.14"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.14.tgz#41ff6ce2e7603119a005b7b5ce8e64551ec84879"
integrity sha512-FHEMAdscG+r2OFcZUIzPyTDpwzRAyinRsIIaTcuqe0MgZWF4CEGNAKPos0IbecBzMxTOzUHE18dQDKhoXMdgvg==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.0"
"@atproto/syntax" "^0.3.0"
"@atproto/xrpc" "^0.5.0"
"@atproto/common-web" "^0.4.2"
"@atproto/lexicon" "^0.4.11"
"@atproto/syntax" "^0.4.0"
"@atproto/xrpc" "^0.7.0"
await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
zod "^3.23.8"
"@atproto/common-web@^0.3.0":
version "0.3.0"
resolved "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.3.0.tgz"
integrity sha512-67VnV6JJyX+ZWyjV7xFQMypAgDmjVaR9ZCuU/QW+mqlqI7fex2uL4Fv+7/jHadgzhuJHVd6OHOvNn0wR5WZYtA==
"@atproto/common-web@^0.4.2":
version "0.4.2"
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.2.tgz#6e3add6939da93d3dfbc8f87e26dc4f57fad7259"
integrity sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw==
dependencies:
graphemer "^1.4.0"
multiformats "^9.9.0"
uint8arrays "3.0.0"
zod "^3.21.4"
zod "^3.23.8"
"@atproto/common@^0.4.0":
version "0.4.0"
resolved "https://registry.npmjs.org/@atproto/common/-/common-0.4.0.tgz"
integrity sha512-yOXuPlCjT/OK9j+neIGYn9wkxx/AlxQSucysAF0xgwu0Ji8jAtKBf9Jv6R5ObYAjAD/kVUvEYumle+Yq/R9/7g==
"@atproto/common@^0.4.11":
version "0.4.11"
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.11.tgz#9291b7c26f8b3507e280f7ecbdf1695ab5ea62f6"
integrity sha512-Knv0viYXNMfCdIE7jLUiWJKnnMfEwg+vz2epJQi8WOjqtqCFb3W/3Jn72ZiuovIfpdm13MaOiny6w2NErUQC6g==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/common-web" "^0.4.2"
"@ipld/dag-cbor" "^7.0.3"
cbor-x "^1.5.1"
iso-datestring-validator "^2.2.2"
multiformats "^9.9.0"
pino "^8.15.0"
pino "^8.21.0"
"@atproto/lexicon@^0.4.0":
version "0.4.0"
resolved "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.0.tgz"
integrity sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ==
"@atproto/lexicon@^0.4.11":
version "0.4.11"
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.11.tgz#d5d09be1faf1d28d1e57051dab4064101f8b1617"
integrity sha512-btefdnvNz2Ao2I+qbmj0F06HC8IlrM/IBz6qOBS50r0S6uDf5tOO+Mv2tSVdimFkdzyDdLtBI1sV36ONxz2cOw==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/syntax" "^0.3.0"
"@atproto/common-web" "^0.4.2"
"@atproto/syntax" "^0.4.0"
iso-datestring-validator "^2.2.2"
multiformats "^9.9.0"
zod "^3.21.4"
zod "^3.23.8"
"@atproto/syntax@^0.3.0":
version "0.3.0"
resolved "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.0.tgz"
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
"@atproto/syntax@^0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.0.tgz#bec71552087bb24c208a06ef418c0040b65542f2"
integrity sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA==
"@atproto/xrpc@^0.5.0":
version "0.5.0"
resolved "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.5.0.tgz"
integrity sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog==
"@atproto/xrpc@^0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.0.tgz#7d1e497d682431fecd7085d7482e83d8a33821b0"
integrity sha512-SfhP9dGx2qclaScFDb58Jnrmim5nk4geZXCqg6sB0I/KZhZEkr9iIx1hLCp+sxkIfEsmEJjeWO4B0rjUIJW5cw==
dependencies:
"@atproto/lexicon" "^0.4.0"
zod "^3.21.4"
"@atproto/lexicon" "^0.4.11"
zod "^3.23.8"
"@bsky.app/alf@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@bsky.app/alf/-/alf-0.1.0.tgz#7fcd82fbb961a3cb675892d1d6568105ea879492"
integrity sha512-uw3ga4qfDA+5BijLk1FAJQVBXHIQIwROfLuzvXBTM4oY5gd74cMpvsftvFdwlm7+U+jqsKDqahTAbOM1DXCUiw==
dependencies:
react-responsive "^10.0.1"
"@cbor-extract/cbor-extract-darwin-arm64@2.2.0":
version "2.2.0"
@@ -105,6 +114,93 @@
cborg "^1.6.0"
multiformats "^9.5.4"
"@jimp/bmp@^0.22.12":
version "0.22.12"
resolved "https://registry.yarnpkg.com/@jimp/bmp/-/bmp-0.22.12.tgz#0316044dc7b1a90274aef266d50349347fb864d4"
integrity sha512-aeI64HD0npropd+AR76MCcvvRaa+Qck6loCOS03CkkxGHN5/r336qTM5HPUdHKMDOGzqknuVPA8+kK1t03z12g==
dependencies:
"@jimp/utils" "^0.22.12"
bmp-js "^0.1.0"
"@jimp/core@^0.22.12":
version "0.22.12"
resolved "https://registry.yarnpkg.com/@jimp/core/-/core-0.22.12.tgz#70785ea7d10b138fb65bcfe9f712826f00a10e1d"
integrity sha512-l0RR0dOPyzMKfjUW1uebzueFEDtCOj9fN6pyTYWWOM/VS4BciXQ1VVrJs8pO3kycGYZxncRKhCoygbNr8eEZQA==
dependencies:
"@jimp/utils" "^0.22.12"
any-base "^1.1.0"
buffer "^5.2.0"
exif-parser "^0.1.12"
file-type "^16.5.4"
isomorphic-fetch "^3.0.0"
pixelmatch "^4.0.2"
tinycolor2 "^1.6.0"
"@jimp/custom@^0.22.12":
version "0.22.12"
resolved "https://registry.yarnpkg.com/@jimp/custom/-/custom-0.22.12.tgz#236f2a3f016b533c50869ff22ad1ac00dd0c36be"
integrity sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==
dependencies:
"@jimp/core" "^0.22.12"
"@jimp/gif@^0.22.12":
version "0.22.12"
resolved "https://registry.yarnpkg.com/@jimp/gif/-/gif-0.22.12.tgz#6caccb45df497fb971b7a88690345596e22163c0"
integrity sha512-y6BFTJgch9mbor2H234VSjd9iwAhaNf/t3US5qpYIs0TSbAvM02Fbc28IaDETj9+4YB4676sz4RcN/zwhfu1pg==
dependencies:
"@jimp/utils" "^0.22.12"
gifwrap "^0.10.1"
omggif "^1.0.9"
"@jimp/jpeg@^0.22.12":
version "0.22.12"
resolved "https://registry.yarnpkg.com/@jimp/jpeg/-/jpeg-0.22.12.tgz#b5c74a5aac9826245311370dda8c71a1fcca05ed"
integrity sha512-Rq26XC/uQWaQKyb/5lksCTCxXhtY01NJeBN+dQv5yNYedN0i7iYu+fXEoRsfaJ8xZzjoANH8sns7rVP4GE7d/Q==
dependencies:
"@jimp/utils" "^0.22.12"
jpeg-js "^0.4.4"
"@jimp/plugin-resize@^0.22.12":
version "0.22.12"
resolved "https://registry.yarnpkg.com/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz#f92acbf73beb97dd1fe93b166ef367a323b81e81"
integrity sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==
dependencies:
"@jimp/utils" "^0.22.12"
"@jimp/png@^0.22.12":
version "0.22.12"
resolved "https://registry.yarnpkg.com/@jimp/png/-/png-0.22.12.tgz#e033586caf38d9c9d33808e92eb87c4d7f0aa1eb"
integrity sha512-Mrp6dr3UTn+aLK8ty/dSKELz+Otdz1v4aAXzV5q53UDD2rbB5joKVJ/ChY310B+eRzNxIovbUF1KVrUsYdE8Hg==
dependencies:
"@jimp/utils" "^0.22.12"
pngjs "^6.0.0"
"@jimp/tiff@^0.22.12":
version "0.22.12"
resolved "https://registry.yarnpkg.com/@jimp/tiff/-/tiff-0.22.12.tgz#67cac3f2ded6fde3ef631fbf74bea0fa53800123"
integrity sha512-E1LtMh4RyJsoCAfAkBRVSYyZDTtLq9p9LUiiYP0vPtXyxX4BiYBUYihTLSBlCQg5nF2e4OpQg7SPrLdJ66u7jg==
dependencies:
utif2 "^4.0.1"
"@jimp/types@^0.22.12":
version "0.22.12"
resolved "https://registry.yarnpkg.com/@jimp/types/-/types-0.22.12.tgz#6f83761ba171cb8cd5998fa66a5cbfb0b22d3d8c"
integrity sha512-wwKYzRdElE1MBXFREvCto5s699izFHNVvALUv79GXNbsOVqlwlOxlWJ8DuyOGIXoLP4JW/m30YyuTtfUJgMRMA==
dependencies:
"@jimp/bmp" "^0.22.12"
"@jimp/gif" "^0.22.12"
"@jimp/jpeg" "^0.22.12"
"@jimp/png" "^0.22.12"
"@jimp/tiff" "^0.22.12"
timm "^1.6.1"
"@jimp/utils@^0.22.12":
version "0.22.12"
resolved "https://registry.yarnpkg.com/@jimp/utils/-/utils-0.22.12.tgz#8ffaed8f2dc2962539ccaf14727ac60793c7a537"
integrity sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q==
dependencies:
regenerator-runtime "^0.13.3"
"@jridgewell/resolve-uri@^3.0.3":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6"
@@ -209,6 +305,11 @@
fflate "^0.7.3"
string.prototype.codepointat "^0.2.1"
"@tokenizer/token@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276"
integrity sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==
"@tsconfig/node10@^1.0.7":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"
@@ -229,6 +330,18 @@
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9"
integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==
"@types/node@16.9.1":
version "16.9.1"
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.1.tgz#0611b37db4246c937feef529ddcc018cf8e35708"
integrity sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==
"@types/node@^18.15.3":
version "18.19.80"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.80.tgz#6d6008e8920dddcd23f9dd33da24684ef57d487c"
integrity sha512-kEWeMwMeIvxYkeg1gTc01awpwLbfMRZXdIhwRcakd/KlK53jmRC26LqcbIt7fnAQTu5GzlnWmzA3H6+l1u6xxQ==
dependencies:
undici-types "~5.26.4"
"@types/node@^20.14.3":
version "20.14.3"
resolved "https://registry.npmjs.org/@types/node/-/node-20.14.3.tgz"
@@ -236,6 +349,92 @@
dependencies:
undici-types "~5.26.4"
"@vibrant/color@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@vibrant/color/-/color-4.0.0.tgz#f804da47caf0072bc9bafddd94aa42650b0d1e25"
integrity sha512-S9ItdqS1135wTXoIIqAJu8df9dqlOo6Boc5Y4MGsBTu9UmUOvOwfj5b4Ga6S5yrLAKmKYIactkz7zYJdMddkig==
"@vibrant/core@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@vibrant/core/-/core-4.0.0.tgz#a13a56b223c7f12543773b8d8ba2263096c6f5ce"
integrity sha512-fqlVRUTDjEws9VNKvI3cDXM4wUT7fMFS+cVqEjJk3im+R5EvjJzPF6OAbNhfPzW04NvHNE555eY9FfhYuX3PRw==
dependencies:
"@vibrant/color" "^4.0.0"
"@vibrant/generator" "^4.0.0"
"@vibrant/image" "^4.0.0"
"@vibrant/quantizer" "^4.0.0"
"@vibrant/worker" "^4.0.0"
"@vibrant/generator-default@^4.0.3":
version "4.0.3"
resolved "https://registry.yarnpkg.com/@vibrant/generator-default/-/generator-default-4.0.3.tgz#2634a4c0b17698515666bed993100d6b85e9eef9"
integrity sha512-HZlfp19sDokODEkZF4p70QceARHgjP3a1Dmxg+dlblYMJM98jPq+azA0fzqKNR7R17JJNHxexpJEepEsNlG0gw==
dependencies:
"@vibrant/color" "^4.0.0"
"@vibrant/generator" "^4.0.0"
"@vibrant/generator@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@vibrant/generator/-/generator-4.0.0.tgz#2fad5054c76695750d32c88056ab0bd823733c3e"
integrity sha512-CqKAjmgHVDXJVo3Q5+9pUJOvksR7cN3bzx/6MbURYh7lA4rhsIewkUK155M6q0vfcUN3ETi/eTneCi0tLuM2Sg==
dependencies:
"@vibrant/color" "^4.0.0"
"@vibrant/types" "^4.0.0"
"@vibrant/image-browser@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@vibrant/image-browser/-/image-browser-4.0.0.tgz#9f62901ebbbf147b7c00aae84b5965479731f1b7"
integrity sha512-mXckzvJWiP575Y/wNtP87W/TPgyJoGlPBjW4E9YmNS6n4Jb6RqyHQA0ZVulqDslOxjSsihDzY7gpAORRclaoLg==
dependencies:
"@vibrant/image" "^4.0.0"
"@vibrant/image-node@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@vibrant/image-node/-/image-node-4.0.0.tgz#3d60221f97215eebd5a7d6c34d563b7992da1e94"
integrity sha512-m7yfnQtmo2y8z+tOjRFBx6q/qGnhl/ax2uCaj4TBkm4TtXfR4Dsn90wT6OWXmCFFzxIKHXKKEBShkxR+4RHseA==
dependencies:
"@jimp/custom" "^0.22.12"
"@jimp/plugin-resize" "^0.22.12"
"@jimp/types" "^0.22.12"
"@vibrant/image" "^4.0.0"
"@vibrant/image@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@vibrant/image/-/image-4.0.0.tgz#2c16047a760d5969c59c5686981a972b0fb37c4d"
integrity sha512-Asv/7R/L701norosgvbjOVkodFiwcFihkXixA/gbAd6C+5GCts1Wm1NPk14FNKnM7eKkfAN+0wwPkdOH+PY/YA==
dependencies:
"@vibrant/color" "^4.0.0"
"@vibrant/quantizer-mmcq@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@vibrant/quantizer-mmcq/-/quantizer-mmcq-4.0.0.tgz#b82469653d21827233dbc243b7cebc3d4b761cd9"
integrity sha512-TZqNiRoGGyCP8fH1XE6rvhFwLNv9D8MP1Xhz3K8tsuUweC6buWax3qLfrfEnkhtQnPJHaqvTfTOlIIXVMfRpow==
dependencies:
"@vibrant/color" "^4.0.0"
"@vibrant/image" "^4.0.0"
"@vibrant/quantizer" "^4.0.0"
"@vibrant/quantizer@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@vibrant/quantizer/-/quantizer-4.0.0.tgz#48cb29feca2cea98c1129ce46f85995a8e206462"
integrity sha512-YDGxmCv/RvHFtZghDlVRwH5GMxdGGozWS1JpUOUt73/F5zAKGiiier8F31K1npIXARn6/Gspvg/Rbg7qqyEr2A==
dependencies:
"@vibrant/color" "^4.0.0"
"@vibrant/image" "^4.0.0"
"@vibrant/types" "^4.0.0"
"@vibrant/types@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@vibrant/types/-/types-4.0.0.tgz#8c5a777d827d5df986644abe5c05d16b4114012f"
integrity sha512-tA5TAbuROXcPkt+PWjmGfoaiEXyySVaNnCZovf6vXhCbMdrTTCQXvNCde2geiVl6YwtuU/Qrj9iZxS5jZ6yVIw==
"@vibrant/worker@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@vibrant/worker/-/worker-4.0.0.tgz#ca60568f5b607af5786be9ce625efc397087fbdb"
integrity sha512-nSaZZwWQKOgN/nPYUAIRF0/uoa7KpK91A+gjLmZZDgfN1enqxaiihmn+75ayNadW0c6cxAEpEFEHTONR5u9tMw==
dependencies:
"@vibrant/types" "^4.0.0"
abort-controller@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz"
@@ -263,6 +462,11 @@ acorn@^8.11.0, acorn@^8.4.1:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.0.tgz#1627bfa2e058148036133b8d9b51a700663c294c"
integrity sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==
any-base@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/any-base/-/any-base-1.1.0.tgz#ae101a62bc08a597b4c9ab5b7089d456630549fe"
integrity sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==
arg@^4.1.0:
version "4.1.3"
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
@@ -278,6 +482,11 @@ atomic-sleep@^1.0.0:
resolved "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz"
integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==
await-lock@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef"
integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==
base64-js@0.0.8:
version "0.0.8"
resolved "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz"
@@ -288,6 +497,11 @@ base64-js@^1.3.1:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
bmp-js@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/bmp-js/-/bmp-js-0.1.0.tgz#e05a63f796a6c1ff25f4771ec7adadc148c07233"
integrity sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==
body-parser@1.20.2:
version "1.20.2"
resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz"
@@ -311,6 +525,14 @@ boolean@^3.1.4:
resolved "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz"
integrity sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==
buffer@^5.2.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
dependencies:
base64-js "^1.3.1"
ieee754 "^1.1.13"
buffer@^6.0.3:
version "6.0.3"
resolved "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz"
@@ -371,6 +593,11 @@ color-name@^1.1.4:
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
colorette@^2.0.7:
version "2.0.20"
resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a"
integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==
content-disposition@0.5.4:
version "0.5.4"
resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz"
@@ -413,6 +640,11 @@ css-color-keywords@^1.0.0:
resolved "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz"
integrity sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==
css-mediaquery@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/css-mediaquery/-/css-mediaquery-0.1.2.tgz#6a2c37344928618631c54bd33cedd301da18bea0"
integrity sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==
css-to-react-native@^3.0.0:
version "3.2.0"
resolved "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz"
@@ -422,6 +654,11 @@ css-to-react-native@^3.0.0:
css-color-keywords "^1.0.0"
postcss-value-parser "^4.0.2"
dateformat@^4.6.3:
version "4.6.3"
resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-4.6.3.tgz#556fa6497e5217fedb78821424f8a1c22fa3f4b5"
integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==
debug@2.6.9:
version "2.6.9"
resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz"
@@ -478,6 +715,13 @@ encodeurl@~1.0.2:
resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz"
integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
end-of-stream@^1.1.0:
version "1.4.4"
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
dependencies:
once "^1.4.0"
es-define-property@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz"
@@ -510,6 +754,11 @@ events@^3.3.0:
resolved "https://registry.npmjs.org/events/-/events-3.3.0.tgz"
integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
exif-parser@^0.1.12:
version "0.1.12"
resolved "https://registry.yarnpkg.com/exif-parser/-/exif-parser-0.1.12.tgz#58a9d2d72c02c1f6f02a0ef4a9166272b7760922"
integrity sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==
express@^4.19.2:
version "4.19.2"
resolved "https://registry.npmjs.org/express/-/express-4.19.2.tgz"
@@ -547,6 +796,11 @@ express@^4.19.2:
utils-merge "1.0.1"
vary "~1.1.2"
fast-copy@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/fast-copy/-/fast-copy-3.0.2.tgz#59c68f59ccbcac82050ba992e0d5c389097c9d35"
integrity sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==
fast-printf@^1.6.9:
version "1.6.9"
resolved "https://registry.npmjs.org/fast-printf/-/fast-printf-1.6.9.tgz"
@@ -559,11 +813,25 @@ fast-redact@^3.1.1:
resolved "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz"
integrity sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==
fast-safe-stringify@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884"
integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==
fflate@^0.7.3:
version "0.7.4"
resolved "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz"
integrity sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==
file-type@^16.5.4:
version "16.5.4"
resolved "https://registry.yarnpkg.com/file-type/-/file-type-16.5.4.tgz#474fb4f704bee427681f98dd390058a172a6c2fd"
integrity sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==
dependencies:
readable-web-to-node-stream "^3.0.0"
strtok3 "^6.2.4"
token-types "^4.1.1"
finalhandler@1.2.0:
version "1.2.0"
resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz"
@@ -612,6 +880,14 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.4:
has-symbols "^1.0.3"
hasown "^2.0.0"
gifwrap@^0.10.1:
version "0.10.1"
resolved "https://registry.yarnpkg.com/gifwrap/-/gifwrap-0.10.1.tgz#9ed46a5d51913b482d4221ce9c727080260b681e"
integrity sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==
dependencies:
image-q "^4.0.0"
omggif "^1.0.10"
gopd@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz"
@@ -653,6 +929,11 @@ hasown@^2.0.0:
dependencies:
function-bind "^1.1.2"
help-me@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/help-me/-/help-me-5.0.0.tgz#b1ebe63b967b74060027c2ac61f9be12d354a6f6"
integrity sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==
hex-rgb@^4.1.0:
version "4.3.0"
resolved "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz"
@@ -679,6 +960,11 @@ http-terminator@^3.2.0:
roarr "^7.0.4"
type-fest "^2.3.3"
hyphenate-style-name@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz#1797bf50369588b47b72ca6d5e65374607cf4436"
integrity sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==
iconv-lite@0.4.24:
version "0.4.24"
resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz"
@@ -686,11 +972,18 @@ iconv-lite@0.4.24:
dependencies:
safer-buffer ">= 2.1.2 < 3"
ieee754@^1.2.1:
ieee754@^1.1.13, ieee754@^1.2.1:
version "1.2.1"
resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
image-q@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/image-q/-/image-q-4.0.0.tgz#31e075be7bae3c1f42a85c469b4732c358981776"
integrity sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==
dependencies:
"@types/node" "16.9.1"
inherits@2.0.4:
version "2.0.4"
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
@@ -706,6 +999,24 @@ iso-datestring-validator@^2.2.2:
resolved "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz"
integrity sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==
isomorphic-fetch@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz#0267b005049046d2421207215d45d6a262b8b8b4"
integrity sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==
dependencies:
node-fetch "^2.6.1"
whatwg-fetch "^3.4.1"
joycon@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03"
integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==
jpeg-js@^0.4.4:
version "0.4.4"
resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.4.4.tgz#a9f1c6f1f9f0fa80cdb3484ed9635054d28936aa"
integrity sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==
"js-tokens@^3.0.0 || ^4.0.0":
version "4.0.0"
resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz"
@@ -735,7 +1046,7 @@ linebreak@^1.1.0:
base64-js "0.0.8"
unicode-trie "^2.0.0"
loose-envify@^1.1.0:
loose-envify@^1.1.0, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -747,6 +1058,13 @@ make-error@^1.1.1:
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
matchmediaquery@^0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/matchmediaquery/-/matchmediaquery-0.4.2.tgz#22582bd4ae63ad9f54c53001bba80cbed0f7eafa"
integrity sha512-wrZpoT50ehYOudhDjt/YvUJc6eUzcdFPdmbizfgvswCKNHD1/OBOHYJpHie+HXpu6bSkEGieFMYk6VuutaiRfA==
dependencies:
css-mediaquery "^0.1.2"
media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz"
@@ -779,6 +1097,11 @@ mime@1.6.0:
resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz"
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
minimist@^1.2.6:
version "1.2.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
ms@2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
@@ -799,6 +1122,13 @@ negotiator@0.6.3:
resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz"
integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
node-fetch@^2.6.1:
version "2.7.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
dependencies:
whatwg-url "^5.0.0"
node-gyp-build-optional-packages@5.1.1:
version "5.1.1"
resolved "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz"
@@ -806,11 +1136,33 @@ node-gyp-build-optional-packages@5.1.1:
dependencies:
detect-libc "^2.0.1"
node-vibrant@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/node-vibrant/-/node-vibrant-4.0.3.tgz#0737480f2eaceee6336915388541d04f6af308c7"
integrity sha512-kzoIuJK90BH/k65Avt077JCX4Nhqz1LNc8cIOm2rnYEvFdJIYd8b3SQwU1MTpzcHtr8z8jxkl1qdaCfbP3olFg==
dependencies:
"@types/node" "^18.15.3"
"@vibrant/core" "^4.0.0"
"@vibrant/generator-default" "^4.0.3"
"@vibrant/image-browser" "^4.0.0"
"@vibrant/image-node" "^4.0.0"
"@vibrant/quantizer-mmcq" "^4.0.0"
object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
object-inspect@^1.13.1:
version "1.13.1"
resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz"
integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==
omggif@^1.0.10, omggif@^1.0.9:
version "1.0.10"
resolved "https://registry.yarnpkg.com/omggif/-/omggif-1.0.10.tgz#ddaaf90d4a42f532e9e7cb3a95ecdd47f17c7b19"
integrity sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==
on-exit-leak-free@^2.1.0:
version "2.1.2"
resolved "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz"
@@ -823,6 +1175,13 @@ on-finished@2.4.1:
dependencies:
ee-first "1.1.1"
once@^1.3.1, once@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
dependencies:
wrappy "1"
p-finally@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz"
@@ -847,6 +1206,11 @@ pako@^0.2.5:
resolved "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz"
integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==
pako@^1.0.11:
version "1.0.11"
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
parse-css-color@^0.2.1:
version "0.2.1"
resolved "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz"
@@ -865,7 +1229,12 @@ path-to-regexp@0.1.7:
resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz"
integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==
pino-abstract-transport@^1.2.0:
peek-readable@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-4.1.0.tgz#4ece1111bf5c2ad8867c314c81356847e8a62e72"
integrity sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==
pino-abstract-transport@^1.0.0, pino-abstract-transport@^1.2.0:
version "1.2.0"
resolved "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz"
integrity sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==
@@ -873,6 +1242,26 @@ pino-abstract-transport@^1.2.0:
readable-stream "^4.0.0"
split2 "^4.0.0"
pino-pretty@^11.2.2:
version "11.2.2"
resolved "https://registry.yarnpkg.com/pino-pretty/-/pino-pretty-11.2.2.tgz#5e8ec69b31e90eb187715af07b1d29a544e60d39"
integrity sha512-2FnyGir8nAJAqD3srROdrF1J5BIcMT4nwj7hHSc60El6Uxlym00UbCCd8pYIterstVBFlMyF1yFV8XdGIPbj4A==
dependencies:
colorette "^2.0.7"
dateformat "^4.6.3"
fast-copy "^3.0.2"
fast-safe-stringify "^2.1.1"
help-me "^5.0.0"
joycon "^3.1.1"
minimist "^1.2.6"
on-exit-leak-free "^2.1.0"
pino-abstract-transport "^1.0.0"
pump "^3.0.0"
readable-stream "^4.0.0"
secure-json-parse "^2.4.0"
sonic-boom "^4.0.1"
strip-json-comments "^3.1.1"
pino-std-serializers@^6.0.0:
version "6.2.2"
resolved "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.2.2.tgz"
@@ -883,9 +1272,9 @@ pino-std-serializers@^7.0.0:
resolved "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz"
integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==
pino@^8.15.0:
pino@^8.21.0:
version "8.21.0"
resolved "https://registry.npmjs.org/pino/-/pino-8.21.0.tgz"
resolved "https://registry.yarnpkg.com/pino/-/pino-8.21.0.tgz#e1207f3675a2722940d62da79a7a55a98409f00d"
integrity sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q==
dependencies:
atomic-sleep "^1.0.0"
@@ -917,11 +1306,33 @@ pino@^9.2.0:
sonic-boom "^4.0.1"
thread-stream "^3.0.0"
pixelmatch@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-4.0.2.tgz#8f47dcec5011b477b67db03c243bc1f3085e8854"
integrity sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA==
dependencies:
pngjs "^3.0.0"
pngjs@^3.0.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f"
integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==
pngjs@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-6.0.0.tgz#ca9e5d2aa48db0228a52c419c3308e87720da821"
integrity sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==
postcss-value-parser@^4.0.2, postcss-value-parser@^4.2.0:
version "4.2.0"
resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
prettier@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.5.3.tgz#4fc2ce0d657e7a02e602549f053b239cb7dfe1b5"
integrity sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==
process-warning@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz"
@@ -932,6 +1343,15 @@ process@^0.11.10:
resolved "https://registry.npmjs.org/process/-/process-0.11.10.tgz"
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
prop-types@^15.6.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
dependencies:
loose-envify "^1.4.0"
object-assign "^4.1.1"
react-is "^16.13.1"
proxy-addr@~2.0.7:
version "2.0.7"
resolved "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz"
@@ -940,6 +1360,14 @@ proxy-addr@~2.0.7:
forwarded "0.2.0"
ipaddr.js "1.9.1"
pump@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==
dependencies:
end-of-stream "^1.1.0"
once "^1.3.1"
qs@6.11.0:
version "6.11.0"
resolved "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz"
@@ -967,6 +1395,21 @@ raw-body@2.5.2:
iconv-lite "0.4.24"
unpipe "1.0.0"
react-is@^16.13.1:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-responsive@^10.0.1:
version "10.0.1"
resolved "https://registry.yarnpkg.com/react-responsive/-/react-responsive-10.0.1.tgz#293d4d2562da93409861216f0110d146c5676eb3"
integrity sha512-OM5/cRvbtUWEX8le8RCT8scA8y2OPtb0Q/IViEyCEM5FBN8lRrkUOZnu87I88A6njxDldvxG+rLBxWiA7/UM9g==
dependencies:
hyphenate-style-name "^1.0.0"
matchmediaquery "^0.4.2"
prop-types "^15.6.1"
shallow-equal "^3.1.0"
react@^18.3.1:
version "18.3.1"
resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz"
@@ -985,11 +1428,34 @@ readable-stream@^4.0.0:
process "^0.11.10"
string_decoder "^1.3.0"
readable-stream@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.7.0.tgz#cedbd8a1146c13dfff8dab14068028d58c15ac91"
integrity sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==
dependencies:
abort-controller "^3.0.0"
buffer "^6.0.3"
events "^3.3.0"
process "^0.11.10"
string_decoder "^1.3.0"
readable-web-to-node-stream@^3.0.0:
version "3.0.4"
resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz#392ba37707af5bf62d725c36c1b5d6ef4119eefc"
integrity sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==
dependencies:
readable-stream "^4.7.0"
real-require@^0.2.0:
version "0.2.0"
resolved "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz"
integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==
regenerator-runtime@^0.13.3:
version "0.13.11"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
roarr@^7.0.4:
version "7.21.1"
resolved "https://registry.npmjs.org/roarr/-/roarr-7.21.1.tgz"
@@ -1030,6 +1496,11 @@ satori@^0.10.13:
postcss-value-parser "^4.2.0"
yoga-wasm-web "^0.3.3"
secure-json-parse@^2.4.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.7.0.tgz#5a5f9cd6ae47df23dba3151edd06855d47e09862"
integrity sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==
semver-compare@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz"
@@ -1081,6 +1552,11 @@ setprototypeof@1.2.0:
resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz"
integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
shallow-equal@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-3.1.0.tgz#e7a54bac629c7f248eff6c2f5b63122ba4320bec"
integrity sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==
side-channel@^1.0.4:
version "1.0.6"
resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz"
@@ -1127,6 +1603,19 @@ string_decoder@^1.3.0:
dependencies:
safe-buffer "~5.2.0"
strip-json-comments@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
strtok3@^6.2.4:
version "6.3.0"
resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-6.3.0.tgz#358b80ffe6d5d5620e19a073aa78ce947a90f9a0"
integrity sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==
dependencies:
"@tokenizer/token" "^0.3.0"
peek-readable "^4.1.0"
thread-stream@^2.6.0:
version "2.7.0"
resolved "https://registry.npmjs.org/thread-stream/-/thread-stream-2.7.0.tgz"
@@ -1141,11 +1630,21 @@ thread-stream@^3.0.0:
dependencies:
real-require "^0.2.0"
timm@^1.6.1:
version "1.7.1"
resolved "https://registry.yarnpkg.com/timm/-/timm-1.7.1.tgz#96bab60c7d45b5a10a8a4d0f0117c6b7e5aff76f"
integrity sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==
tiny-inflate@^1.0.0:
version "1.0.3"
resolved "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz"
integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==
tinycolor2@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e"
integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==
tlds@^1.234.0:
version "1.252.0"
resolved "https://registry.npmjs.org/tlds/-/tlds-1.252.0.tgz"
@@ -1156,6 +1655,19 @@ toidentifier@1.0.1:
resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz"
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
token-types@^4.1.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/token-types/-/token-types-4.2.1.tgz#0f897f03665846982806e138977dbe72d44df753"
integrity sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==
dependencies:
"@tokenizer/token" "^0.3.0"
ieee754 "^1.2.1"
tr46@~0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
ts-node@^10.9.2:
version "10.9.2"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f"
@@ -1238,6 +1750,13 @@ unpipe@1.0.0, unpipe@~1.0.0:
resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
utif2@^4.0.1:
version "4.1.0"
resolved "https://registry.yarnpkg.com/utif2/-/utif2-4.1.0.tgz#e768d37bd619b995d56d9780b5d2b4611a3d932b"
integrity sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==
dependencies:
pako "^1.0.11"
utils-merge@1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz"
@@ -1253,6 +1772,34 @@ vary@~1.1.2:
resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz"
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
webidl-conversions@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
whatwg-fetch@^3.4.1:
version "3.6.20"
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz#580ce6d791facec91d37c72890995a0b48d31c70"
integrity sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==
whatwg-mimetype@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a"
integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==
whatwg-url@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
dependencies:
tr46 "~0.0.3"
webidl-conversions "^3.0.0"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
yn@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
@@ -1263,7 +1810,7 @@ yoga-wasm-web@^0.3.3:
resolved "https://registry.npmjs.org/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz"
integrity sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==
zod@^3.21.4:
zod@^3.23.8:
version "3.23.8"
resolved "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==