diff --git a/bskyogcard/src/assets/fonts/Inter-Bold.ttf b/bskyogcard/src/assets/fonts/Inter-Bold.ttf index fe23eeb9c9..9fb9b751e5 100644 Binary files a/bskyogcard/src/assets/fonts/Inter-Bold.ttf and b/bskyogcard/src/assets/fonts/Inter-Bold.ttf differ diff --git a/bskyogcard/src/assets/fonts/Inter-Regular.ttf b/bskyogcard/src/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000000..b7aaca8de1 Binary files /dev/null and b/bskyogcard/src/assets/fonts/Inter-Regular.ttf differ diff --git a/bskyogcard/src/assets/fonts/Inter-SemiBold.ttf b/bskyogcard/src/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000000..47f8ab1d68 Binary files /dev/null and b/bskyogcard/src/assets/fonts/Inter-SemiBold.ttf differ diff --git a/bskyogcard/src/components/ChatInvite.tsx b/bskyogcard/src/components/ChatInvite.tsx new file mode 100644 index 0000000000..4a64c9a103 --- /dev/null +++ b/bskyogcard/src/components/ChatInvite.tsx @@ -0,0 +1,523 @@ +/* eslint-disable bsky-internal/avoid-unwrapped-text */ +import {ChatBskyGroupDefs} from '@atproto/api' + +import {Img} from './Img.js' + +export const CHAT_INVITE_HEIGHT = 630 +export const CHAT_INVITE_WIDTH = 1200 + +const PADDING = 64 + +export function ChatInvite({ + preview, + ownerImage, +}: { + preview: ChatBskyGroupDefs.JoinLinkPreviewView + ownerImage: Buffer | null +}) { + return ( +
+ +
+ {/* Avatar */} +
+ {ownerImage && } +
+ + {/* Spacer pushes the text block to the bottom-left */} +
+ + {/* Group Chat row */} +
+ + Group Chat +
+ + {/* Chat name */} +
+ {preview.name} + {/*{(preview.name + ' ').repeat(200)}*/} +
+ + {/* By @handle */} +
+ By @{preview.owner.handle} +
+
+
+ ) +} + +function ChatIcon(props: React.SVGProps) { + return ( + + + + + + + ) +} + +function Background(props: React.SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/bskyogcard/src/components/StarterPack.tsx b/bskyogcard/src/components/StarterPack.tsx index 82bfe4d60e..be4627f707 100644 --- a/bskyogcard/src/components/StarterPack.tsx +++ b/bskyogcard/src/components/StarterPack.tsx @@ -57,6 +57,7 @@ export function StarterPack(props: { backgroundColor: 'black', color: 'white', fontFamily: 'Inter', + fontWeight: 700, }}> {/* image tiles */}
{ metricsPort: envInt('CARD_METRICS_PORT'), version: envStr('CARD_VERSION'), appviewUrl: envStr('CARD_APPVIEW_URL'), + chatUrl: envStr('CARD_CHAT_URL'), originVerify: envStr('CARD_ORIGIN_VERIFY'), } } @@ -36,6 +39,7 @@ export const envToCfg = (env: Environment): Config => { metricsPort: env.metricsPort ?? 3001, version: env.version, appviewUrl: env.appviewUrl ?? 'https://api.bsky.app', + chatUrl: env.chatUrl ?? 'https://api.bsky.chat', originVerify: env.originVerify, } return { diff --git a/bskyogcard/src/context.ts b/bskyogcard/src/context.ts index 3e58a369ef..fef1d7b8ac 100644 --- a/bskyogcard/src/context.ts +++ b/bskyogcard/src/context.ts @@ -3,6 +3,7 @@ import * as path from 'node:path' import {fileURLToPath} from 'node:url' import {AtpAgent} from '@atproto/api' +import {type FontWeight} from 'satori' import {type Config} from './config.js' @@ -11,13 +12,15 @@ const __DIRNAME = path.dirname(fileURLToPath(import.meta.url)) export type AppContextOptions = { cfg: Config appviewAgent: AtpAgent - fonts: {name: string; data: Buffer}[] + chatAgent: AtpAgent + fonts: {name: string; data: Buffer; weight?: FontWeight}[] } export class AppContext { cfg: Config appviewAgent: AtpAgent - fonts: {name: string; data: Buffer}[] + chatAgent: AtpAgent + fonts: {name: string; data: Buffer; weight?: FontWeight}[] abortController = new AbortController() private opts: AppContextOptions @@ -26,24 +29,40 @@ export class AppContext { this.opts = opts this.cfg = opts.cfg this.appviewAgent = opts.appviewAgent + this.chatAgent = opts.chatAgent this.fonts = opts.fonts } static async fromConfig(cfg: Config, overrides?: Partial) { const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl}) + const chatAgent = new AtpAgent({service: cfg.service.chatUrl}) 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)), + weight: getWeight(file), } }) return new AppContext({ cfg, appviewAgent, + chatAgent, fonts, ...overrides, }) } } + +function getWeight(file: string): FontWeight { + switch (path.basename(file, path.extname(file))) { + case 'Inter-Regular': + return 400 + case 'Inter-SemiBold': + return 600 + case 'Inter-Bold': + default: + return 700 + } +} diff --git a/bskyogcard/src/routes/chat-invite.tsx b/bskyogcard/src/routes/chat-invite.tsx new file mode 100644 index 0000000000..e3267cc0d0 --- /dev/null +++ b/bskyogcard/src/routes/chat-invite.tsx @@ -0,0 +1,82 @@ +import assert from 'node:assert' + +import {type ChatBskyGroupDefs} from '@atproto/api' +import resvg from '@resvg/resvg-js' +import {type Express} from 'express' +import satori from 'satori' + +import { + CHAT_INVITE_HEIGHT, + CHAT_INVITE_WIDTH, + ChatInvite, +} from '../components/ChatInvite.js' +import {type AppContext} from '../context.js' +import {httpLogger} from '../logger.js' +import {loadEmojiAsSvg} from '../util.js' +import { + getImage, + handler, + hideAvatarLabels, + originVerifyMiddleware, +} from './util.js' + +export default function (ctx: AppContext, app: Express) { + return app.get( + '/chat-invite/:code', + originVerifyMiddleware(ctx), + handler(async (req, res) => { + const {code} = req.params + let preview: ChatBskyGroupDefs.JoinLinkPreviewView + try { + const result = await ctx.chatAgent.chat.bsky.group.getJoinLinkPreviews({ + codes: [code], + }) + const found = result.data.joinLinkPreviews[0] + if (!found) { + return res.status(404).end('not found') + } + preview = found + } catch (err) { + httpLogger.warn({err, code}, 'could not fetch chat invite preview') + return res.status(404).end('not found') + } + + // Load the owner's avatar (if any, and not labeled). + let ownerImage: Buffer | null = null + const owner = preview.owner + const ownerHasSafeAvatar = + !!owner.avatar && !owner.labels?.some(l => hideAvatarLabels.has(l.val)) + if (ownerHasSafeAvatar) { + try { + assert(owner.avatar) + ownerImage = await getImage(owner.avatar) + } catch (err) { + httpLogger.warn( + {err, code, did: owner.did}, + 'could not fetch owner image', + ) + } + } + + const svg = await satori( + , + { + fonts: ctx.fonts, + height: CHAT_INVITE_HEIGHT, + width: CHAT_INVITE_WIDTH, + loadAdditionalAsset: async (lang, text) => { + if (lang === 'emoji') { + return (await loadEmojiAsSvg(text)) ?? '' + } + return '' + }, + }, + ) + const output = await resvg.renderAsync(svg) + res.statusCode = 200 + res.setHeader('content-type', 'image/png') + res.setHeader('cdn-tag', owner.did) + return res.end(output.asPng()) + }), + ) +} diff --git a/bskyogcard/src/routes/index.ts b/bskyogcard/src/routes/index.ts index 112c6a3548..5c2a26e989 100644 --- a/bskyogcard/src/routes/index.ts +++ b/bskyogcard/src/routes/index.ts @@ -2,6 +2,7 @@ import {type Express} from 'express' import {type AppContext} from '../context.js' import {default as avatarBubbles} from './avatar-bubbles.js' +import {default as chatInvite} from './chat-invite.js' import {default as health} from './health.js' import {default as starterPack} from './starter-pack.js' @@ -10,6 +11,7 @@ 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 = chatInvite(ctx, app) // GET /chat-invite/:code app = avatarBubbles(ctx, app) // GET /avatar-bubbles?dids=... return app }