diff options
74 files changed, 5610 insertions, 769 deletions
diff --git a/.github/workflows/build-and-push-bskyweb-aws.yaml b/.github/workflows/build-and-push-bskyweb-aws.yaml index 6eb9485b1..bcd759b0c 100644 --- a/.github/workflows/build-and-push-bskyweb-aws.yaml +++ b/.github/workflows/build-and-push-bskyweb-aws.yaml @@ -4,6 +4,7 @@ on: push: branches: - main + - divy/bskylink env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} diff --git a/.github/workflows/build-and-push-link-aws.yaml b/.github/workflows/build-and-push-link-aws.yaml new file mode 100644 index 000000000..f91af4877 --- /dev/null +++ b/.github/workflows/build-and-push-link-aws.yaml @@ -0,0 +1,55 @@ +name: build-and-push-link-aws +on: + push: + branches: + - divy/bskylink + +env: + REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} + USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} + PASSWORD: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_PASSWORD }} + IMAGE_NAME: bskylink + +jobs: + link-container-aws: + if: github.repository == 'bluesky-social/social-app' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v1 + + - name: Log into registry ${{ env.REGISTRY }} + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ env.USERNAME}} + password: ${{ env.PASSWORD }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v4 + with: + images: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,enable=true,priority=100,prefix=,suffix=,format=long + + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v4 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + file: ./Dockerfile.bskylink + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/build-and-push-ogcard-aws.yaml b/.github/workflows/build-and-push-ogcard-aws.yaml new file mode 100644 index 000000000..5d6ff041d --- /dev/null +++ b/.github/workflows/build-and-push-ogcard-aws.yaml @@ -0,0 +1,55 @@ +name: build-and-push-ogcard-aws +on: + push: + branches: + - divy/bskycard + +env: + REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} + USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} + PASSWORD: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_PASSWORD }} + IMAGE_NAME: bskyogcard + +jobs: + ogcard-container-aws: + if: github.repository == 'bluesky-social/social-app' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v1 + + - name: Log into registry ${{ env.REGISTRY }} + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ env.USERNAME}} + password: ${{ env.PASSWORD }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v4 + with: + images: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,enable=true,priority=100,prefix=,suffix=,format=long + + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v4 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + file: ./Dockerfile.bskyogcard + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile.bskylink b/Dockerfile.bskylink new file mode 100644 index 000000000..acde23225 --- /dev/null +++ b/Dockerfile.bskylink @@ -0,0 +1,41 @@ +FROM node:20.11-alpine3.18 as build + +# Move files into the image and install +WORKDIR /app + +COPY ./bskylink/package.json ./ +COPY ./bskylink/yarn.lock ./ +RUN yarn install --frozen-lockfile + +COPY ./bskylink ./ + +# build then prune dev deps +RUN yarn build +RUN yarn install --production --ignore-scripts --prefer-offline + +# Uses assets from build stage to reduce build size +FROM node:20.11-alpine3.18 + +RUN apk add --update dumb-init + +# Avoid zombie processes, handle signal forwarding +ENTRYPOINT ["dumb-init", "--"] + +WORKDIR /app +COPY --from=build /app /app +RUN mkdir /app/data && chown node /app/data + +VOLUME /app/data +EXPOSE 3000 +ENV LINK_PORT=3000 +ENV NODE_ENV=production +# potential perf issues w/ io_uring on this version of node +ENV UV_USE_IO_URING=0 + +# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user +USER node +CMD ["node", "--heapsnapshot-signal=SIGUSR2", "--enable-source-maps", "dist/bin.js"] + +LABEL org.opencontainers.image.source=https://github.com/bluesky-social/social-app +LABEL org.opencontainers.image.description="Bsky Link Service" +LABEL org.opencontainers.image.licenses=UNLICENSED diff --git a/Dockerfile.bskyogcard b/Dockerfile.bskyogcard new file mode 100644 index 000000000..aa68add59 --- /dev/null +++ b/Dockerfile.bskyogcard @@ -0,0 +1,41 @@ +FROM node:20.11-alpine3.18 as build + +# Move files into the image and install +WORKDIR /app + +COPY ./bskyogcard/package.json ./ +COPY ./bskyogcard/yarn.lock ./ +RUN yarn install --frozen-lockfile + +COPY ./bskyogcard ./ + +# build then prune dev deps +RUN yarn build +RUN yarn install --production --ignore-scripts --prefer-offline + +# Uses assets from build stage to reduce build size +FROM node:20.11-alpine3.18 + +RUN apk add --update dumb-init + +# Avoid zombie processes, handle signal forwarding +ENTRYPOINT ["dumb-init", "--"] + +WORKDIR /app +COPY --from=build /app /app +RUN mkdir /app/data && chown node /app/data + +VOLUME /app/data +EXPOSE 3000 +ENV CARD_PORT=3000 +ENV NODE_ENV=production +# potential perf issues w/ io_uring on this version of node +ENV UV_USE_IO_URING=0 + +# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user +USER node +CMD ["node", "--heapsnapshot-signal=SIGUSR2", "--enable-source-maps", "dist/bin.js"] + +LABEL org.opencontainers.image.source=https://github.com/bluesky-social/social-app +LABEL org.opencontainers.image.description="Bsky Card Service" +LABEL org.opencontainers.image.licenses=UNLICENSED diff --git a/bskylink/package.json b/bskylink/package.json new file mode 100644 index 000000000..5fdee206b --- /dev/null +++ b/bskylink/package.json @@ -0,0 +1,26 @@ +{ + "name": "bskylink", + "version": "0.0.0", + "type": "module", + "main": "index.ts", + "scripts": { + "test": "./tests/infra/with-test-db.sh node --loader ts-node/esm --test ./tests/index.ts", + "build": "tsc" + }, + "dependencies": { + "@atproto/common": "^0.4.0", + "body-parser": "^1.20.2", + "cors": "^2.8.5", + "express": "^4.19.2", + "http-terminator": "^3.2.0", + "kysely": "^0.27.3", + "pg": "^8.12.0", + "pino": "^9.2.0", + "uint8arrays": "^5.1.0" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/pg": "^8.11.6", + "typescript": "^5.4.5" + } +} diff --git a/bskylink/src/bin.ts b/bskylink/src/bin.ts new file mode 100644 index 000000000..17f068841 --- /dev/null +++ b/bskylink/src/bin.ts @@ -0,0 +1,24 @@ +import {Database, envToCfg, httpLogger, LinkService, readEnv} from './index.js' + +async function main() { + const env = readEnv() + const cfg = envToCfg(env) + if (cfg.db.migrationUrl) { + const migrateDb = Database.postgres({ + url: cfg.db.migrationUrl, + schema: cfg.db.schema, + }) + await migrateDb.migrateToLatestOrThrow() + await migrateDb.close() + } + const link = await LinkService.create(cfg) + await link.start() + httpLogger.info('link service is running') + process.on('SIGTERM', async () => { + httpLogger.info('link service is stopping') + await link.destroy() + httpLogger.info('link service is stopped') + }) +} + +main() diff --git a/bskylink/src/config.ts b/bskylink/src/config.ts new file mode 100644 index 000000000..ce409cccc --- /dev/null +++ b/bskylink/src/config.ts @@ -0,0 +1,82 @@ +import {envInt, envList, envStr} from '@atproto/common' + +export type Config = { + service: ServiceConfig + db: DbConfig +} + +export type ServiceConfig = { + port: number + version?: string + hostnames: string[] + appHostname: string +} + +export type DbConfig = { + url: string + migrationUrl?: string + pool: DbPoolConfig + schema?: string +} + +export type DbPoolConfig = { + size: number + maxUses: number + idleTimeoutMs: number +} + +export type Environment = { + port?: number + version?: string + hostnames: string[] + appHostname?: string + dbPostgresUrl?: string + dbPostgresMigrationUrl?: string + dbPostgresSchema?: string + dbPostgresPoolSize?: number + dbPostgresPoolMaxUses?: number + dbPostgresPoolIdleTimeoutMs?: number +} + +export const readEnv = (): Environment => { + return { + port: envInt('LINK_PORT'), + version: envStr('LINK_VERSION'), + hostnames: envList('LINK_HOSTNAMES'), + appHostname: envStr('LINK_APP_HOSTNAME'), + dbPostgresUrl: envStr('LINK_DB_POSTGRES_URL'), + dbPostgresMigrationUrl: envStr('LINK_DB_POSTGRES_MIGRATION_URL'), + dbPostgresSchema: envStr('LINK_DB_POSTGRES_SCHEMA'), + dbPostgresPoolSize: envInt('LINK_DB_POSTGRES_POOL_SIZE'), + dbPostgresPoolMaxUses: envInt('LINK_DB_POSTGRES_POOL_MAX_USES'), + dbPostgresPoolIdleTimeoutMs: envInt( + 'LINK_DB_POSTGRES_POOL_IDLE_TIMEOUT_MS', + ), + } +} + +export const envToCfg = (env: Environment): Config => { + const serviceCfg: ServiceConfig = { + port: env.port ?? 3000, + version: env.version, + hostnames: env.hostnames, + appHostname: env.appHostname || 'bsky.app', + } + if (!env.dbPostgresUrl) { + throw new Error('Must configure postgres url (LINK_DB_POSTGRES_URL)') + } + const dbCfg: DbConfig = { + url: env.dbPostgresUrl, + migrationUrl: env.dbPostgresMigrationUrl, + schema: env.dbPostgresSchema, + pool: { + idleTimeoutMs: env.dbPostgresPoolIdleTimeoutMs ?? 10000, + maxUses: env.dbPostgresPoolMaxUses ?? Infinity, + size: env.dbPostgresPoolSize ?? 10, + }, + } + return { + service: serviceCfg, + db: dbCfg, + } +} diff --git a/bskylink/src/context.ts b/bskylink/src/context.ts new file mode 100644 index 000000000..7e6f2f34e --- /dev/null +++ b/bskylink/src/context.ts @@ -0,0 +1,33 @@ +import {Config} from './config.js' +import Database from './db/index.js' + +export type AppContextOptions = { + cfg: Config + db: Database +} + +export class AppContext { + cfg: Config + db: Database + abortController = new AbortController() + + constructor(private opts: AppContextOptions) { + this.cfg = this.opts.cfg + this.db = this.opts.db + } + + static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) { + const db = Database.postgres({ + url: cfg.db.url, + schema: cfg.db.schema, + poolSize: cfg.db.pool.size, + poolMaxUses: cfg.db.pool.maxUses, + poolIdleTimeoutMs: cfg.db.pool.idleTimeoutMs, + }) + return new AppContext({ + cfg, + db, + ...overrides, + }) + } +} diff --git a/bskylink/src/db/index.ts b/bskylink/src/db/index.ts new file mode 100644 index 000000000..5f201cc07 --- /dev/null +++ b/bskylink/src/db/index.ts @@ -0,0 +1,174 @@ +import assert from 'assert' +import { + Kysely, + KyselyPlugin, + Migrator, + PluginTransformQueryArgs, + PluginTransformResultArgs, + PostgresDialect, + QueryResult, + RootOperationNode, + UnknownRow, +} from 'kysely' +import {default as Pg} from 'pg' + +import {dbLogger as log} from '../logger.js' +import {default as migrations} from './migrations/index.js' +import {DbMigrationProvider} from './migrations/provider.js' +import {DbSchema} from './schema.js' + +export class Database { + migrator: Migrator + destroyed = false + + constructor(public db: Kysely<DbSchema>, public cfg: PgConfig) { + this.migrator = new Migrator({ + db, + migrationTableSchema: cfg.schema, + provider: new DbMigrationProvider(migrations), + }) + } + + static postgres(opts: PgOptions): Database { + const {schema, url, txLockNonce} = opts + const pool = + opts.pool ?? + new Pg.Pool({ + connectionString: url, + max: opts.poolSize, + maxUses: opts.poolMaxUses, + idleTimeoutMillis: opts.poolIdleTimeoutMs, + }) + + // Select count(*) and other pg bigints as js integer + Pg.types.setTypeParser(Pg.types.builtins.INT8, n => parseInt(n, 10)) + + // Setup schema usage, primarily for test parallelism (each test suite runs in its own pg schema) + if (schema && !/^[a-z_]+$/i.test(schema)) { + throw new Error(`Postgres schema must only contain [A-Za-z_]: ${schema}`) + } + + pool.on('error', onPoolError) + + const db = new Kysely<DbSchema>({ + dialect: new PostgresDialect({pool}), + }) + + return new Database(db, { + pool, + schema, + url, + txLockNonce, + }) + } + + async transaction<T>(fn: (db: Database) => Promise<T>): Promise<T> { + const leakyTxPlugin = new LeakyTxPlugin() + return this.db + .withPlugin(leakyTxPlugin) + .transaction() + .execute(txn => { + const dbTxn = new Database(txn, this.cfg) + return fn(dbTxn) + .catch(async err => { + leakyTxPlugin.endTx() + // ensure that all in-flight queries are flushed & the connection is open + await dbTxn.db.getExecutor().provideConnection(async () => {}) + throw err + }) + .finally(() => leakyTxPlugin.endTx()) + }) + } + + get schema(): string | undefined { + return this.cfg.schema + } + + get isTransaction() { + return this.db.isTransaction + } + + assertTransaction() { + assert(this.isTransaction, 'Transaction required') + } + + assertNotTransaction() { + assert(!this.isTransaction, 'Cannot be in a transaction') + } + + async close(): Promise<void> { + if (this.destroyed) return + await this.db.destroy() + this.destroyed = true + } + + async migrateToOrThrow(migration: string) { + if (this.schema) { + await this.db.schema.createSchema(this.schema).ifNotExists().execute() + } + const {error, results} = await this.migrator.migrateTo(migration) + if (error) { + throw error + } + if (!results) { + throw new Error('An unknown failure occurred while migrating') + } + return results + } + + async migrateToLatestOrThrow() { + if (this.schema) { + await this.db.schema.createSchema(this.schema).ifNotExists().execute() + } + const {error, results} = await this.migrator.migrateToLatest() + if (error) { + throw error + } + if (!results) { + throw new Error('An unknown failure occurred while migrating') + } + return results + } +} + +export default Database + +export type PgConfig = { + pool: Pg.Pool + url: string + schema?: string + txLockNonce?: string +} + +type PgOptions = { + url: string + pool?: Pg.Pool + schema?: string + poolSize?: number + poolMaxUses?: number + poolIdleTimeoutMs?: number + txLockNonce?: string +} + +class LeakyTxPlugin implements KyselyPlugin { + private txOver = false + + endTx() { + this.txOver = true + } + + transformQuery(args: PluginTransformQueryArgs): RootOperationNode { + if (this.txOver) { + throw new Error('tx already failed') + } + return args.node + } + + async transformResult( + args: PluginTransformResultArgs, + ): Promise<QueryResult<UnknownRow>> { + return args.result + } +} + +const onPoolError = (err: Error) => log.error({err}, 'db pool error') diff --git a/bskylink/src/db/migrations/001-init.ts b/bskylink/src/db/migrations/001-init.ts new file mode 100644 index 000000000..fe3bcf186 --- /dev/null +++ b/bskylink/src/db/migrations/001-init.ts @@ -0,0 +1,15 @@ +import {Kysely} from 'kysely' + +export async function up(db: Kysely<unknown>): Promise<void> { + await db.schema + .createTable('link') + .addColumn('id', 'varchar', col => col.primaryKey()) + .addColumn('type', 'smallint', col => col.notNull()) // integer enum: 1->starterpack + .addColumn('path', 'varchar', col => col.notNull()) + .addUniqueConstraint('link_path_unique', ['path']) + .execute() +} + +export async function down(db: Kysely<unknown>): Promise<void> { + await db.schema.dropTable('link').execute() +} diff --git a/bskylink/src/db/migrations/index.ts b/bskylink/src/db/migrations/index.ts new file mode 100644 index 000000000..05e4de937 --- /dev/null +++ b/bskylink/src/db/migrations/index.ts @@ -0,0 +1,5 @@ +import * as init from './001-init.js' + +export default { + '001': init, +} diff --git a/bskylink/src/db/migrations/provider.ts b/bskylink/src/db/migrations/provider.ts new file mode 100644 index 000000000..bef93a48f --- /dev/null +++ b/bskylink/src/db/migrations/provider.ts @@ -0,0 +1,8 @@ +import {Migration, MigrationProvider} from 'kysely' + +export class DbMigrationProvider implements MigrationProvider { + constructor(private migrations: Record<string, Migration>) {} + async getMigrations(): Promise<Record<string, Migration>> { + return this.migrations + } +} diff --git a/bskylink/src/db/schema.ts b/bskylink/src/db/schema.ts new file mode 100644 index 000000000..8d97f5800 --- /dev/null +++ b/bskylink/src/db/schema.ts @@ -0,0 +1,17 @@ +import {Selectable} from 'kysely' + +export type DbSchema = { + link: Link +} + +export interface Link { + id: string + type: LinkType + path: string +} + +export enum LinkType { + StarterPack = 1, +} + +export type LinkEntry = Selectable<Link> diff --git a/bskylink/src/index.ts b/bskylink/src/index.ts new file mode 100644 index 000000000..ca425eee8 --- /dev/null +++ b/bskylink/src/index.ts @@ -0,0 +1,45 @@ +import events from 'node:events' +import http from 'node:http' + +import cors from 'cors' +import express from 'express' +import {createHttpTerminator, HttpTerminator} from 'http-terminator' + +import {Config} from './config.js' +import {AppContext} from './context.js' +import {default as routes, errorHandler} from './routes/index.js' + +export * from './config.js' +export * from './db/index.js' +export * from './logger.js' + +export class LinkService { + public server?: http.Server + private terminator?: HttpTerminator + + constructor(public app: express.Application, public ctx: AppContext) {} + + static async create(cfg: Config): Promise<LinkService> { + let app = express() + app.use(cors()) + + const ctx = await AppContext.fromConfig(cfg) + app = routes(ctx, app) + app.use(errorHandler) + + return new LinkService(app, ctx) + } + + async start() { + this.server = this.app.listen(this.ctx.cfg.service.port) + this.server.keepAliveTimeout = 90000 + this.terminator = createHttpTerminator({server: this.server}) + await events.once(this.server, 'listening') + } + + async destroy() { + this.ctx.abortController.abort() + await this.terminator?.terminate() + await this.ctx.db.close() + } +} diff --git a/bskylink/src/logger.ts b/bskylink/src/logger.ts new file mode 100644 index 000000000..25bb590a1 --- /dev/null +++ b/bskylink/src/logger.ts @@ -0,0 +1,4 @@ +import {subsystemLogger} from '@atproto/common' + +export const httpLogger = subsystemLogger('bskylink') +export const dbLogger = subsystemLogger('bskylink:db') diff --git a/bskylink/src/routes/create.ts b/bskylink/src/routes/create.ts new file mode 100644 index 000000000..db7c3f809 --- /dev/null +++ b/bskylink/src/routes/create.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert' + +import bodyParser from 'body-parser' +import {Express, Request} from 'express' + +import {AppContext} from '../context.js' +import {LinkType} from '../db/schema.js' +import {randomId} from '../util.js' +import {handler} from './util.js' + +export default function (ctx: AppContext, app: Express) { + return app.post( + '/link', + bodyParser.json(), + handler(async (req, res) => { + let path: string + if (typeof req.body?.path === 'string') { + path = req.body.path + } else { + return res.status(400).json({ + error: 'InvalidPath', + message: '"path" parameter is missing or not a string', + }) + } + if (!path.startsWith('/')) { + return res.status(400).json({ + error: 'InvalidPath', + message: + '"path" parameter must be formatted as a path, starting with a "/"', + }) + } + const parts = getPathParts(path) + if (parts.length === 3 && parts[0] === 'start') { + // link pattern: /start/{did}/{rkey} + if (!parts[1].startsWith('did:')) { + // enforce strong links + return res.status(400).json({ + error: 'InvalidPath', + message: + '"path" parameter for starter pack must contain the actor\'s DID', + }) + } + const id = await ensureLink(ctx, LinkType.StarterPack, parts) + return res.json({url: getUrl(ctx, req, id)}) + } + return res.status(400).json({ + error: 'InvalidPath', + message: '"path" parameter does not have a known format', + }) + }), + ) +} + +const ensureLink = async (ctx: AppContext, type: LinkType, parts: string[]) => { + const normalizedPath = normalizedPathFromParts(parts) + const created = await ctx.db.db + .insertInto('link') + .values({ + id: randomId(), + type, + path: normalizedPath, + }) + .onConflict(oc => oc.column('path').doNothing()) + .returningAll() + .executeTakeFirst() + if (created) { + return created.id + } + const found = await ctx.db.db + .selectFrom('link') + .selectAll() + .where('path', '=', normalizedPath) + .executeTakeFirstOrThrow() + return found.id +} + +const getUrl = (ctx: AppContext, req: Request, id: string) => { + if (!ctx.cfg.service.hostnames.length) { + assert(req.headers.host, 'request must be made with host header') + const baseUrl = + req.protocol === 'http' && req.headers.host.startsWith('localhost:') + ? `http://${req.headers.host}` + : `https://${req.headers.host}` + return `${baseUrl}/${id}` + } + const baseUrl = ctx.cfg.service.hostnames.includes(req.headers.host) + ? `https://${req.headers.host}` + : `https://${ctx.cfg.service.hostnames[0]}` + return `${baseUrl}/${id}` +} + +const normalizedPathFromParts = (parts: string[]): string => { + return ( + '/' + + parts + .map(encodeURIComponent) + .map(part => part.replaceAll('%3A', ':')) // preserve colons + .join('/') + ) +} + +const getPathParts = (path: string): string[] => { + if (path === '/') return [] + if (path.endsWith('/')) { + path = path.slice(0, -1) // ignore trailing slash + } + return path + .slice(1) // remove leading slash + .split('/') + .map(decodeURIComponent) +} diff --git a/bskylink/src/routes/health.ts b/bskylink/src/routes/health.ts new file mode 100644 index 000000000..c8a30c59e --- /dev/null +++ b/bskylink/src/routes/health.ts @@ -0,0 +1,20 @@ +import {Express} from 'express' +import {sql} from 'kysely' + +import {AppContext} from '../context.js' +import {handler} from './util.js' + +export default function (ctx: AppContext, app: Express) { + return app.get( + '/_health', + handler(async (_req, res) => { + const {version} = ctx.cfg.service + try { + await sql`select 1`.execute(ctx.db.db) + return res.send({version}) + } catch (err) { + return res.status(503).send({version, error: 'Service Unavailable'}) + } + }), + ) +} diff --git a/bskylink/src/routes/index.ts b/bskylink/src/routes/index.ts new file mode 100644 index 000000000..f60b99bcb --- /dev/null +++ b/bskylink/src/routes/index.ts @@ -0,0 +1,17 @@ +import {Express} from 'express' + +import {AppContext} from '../context.js' +import {default as create} from './create.js' +import {default as health} from './health.js' +import {default as redirect} from './redirect.js' +import {default as siteAssociation} from './siteAssociation.js' + +export * from './util.js' + +export default function (ctx: AppContext, app: Express) { + app = health(ctx, app) // GET /_health + app = siteAssociation(ctx, app) // GET /.well-known/apple-app-site-association + app = create(ctx, app) // POST /link + app = redirect(ctx, app) // GET /:linkId (should go last due to permissive matching) + return app +} diff --git a/bskylink/src/routes/redirect.ts b/bskylink/src/routes/redirect.ts new file mode 100644 index 000000000..7791ea815 --- /dev/null +++ b/bskylink/src/routes/redirect.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert' + +import {DAY, SECOND} from '@atproto/common' +import {Express} from 'express' + +import {AppContext} from '../context.js' +import {handler} from './util.js' + +export default function (ctx: AppContext, app: Express) { + return app.get( + '/:linkId', + handler(async (req, res) => { + const linkId = req.params.linkId + assert( + typeof linkId === 'string', + 'express guarantees id parameter is a string', + ) + const found = await ctx.db.db + .selectFrom('link') + .selectAll() + .where('id', '=', linkId) + .executeTakeFirst() + if (!found) { + // potentially broken or mistyped link— send user to the app + res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`) + res.setHeader('Cache-Control', 'no-store') + return res.status(302).end() + } + // build url from original url in order to preserve query params + const url = new URL( + req.originalUrl, + `https://${ctx.cfg.service.appHostname}`, + ) + url.pathname = found.path + res.setHeader('Location', url.href) + res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`) + return res.status(301).end() + }), + ) +} diff --git a/bskylink/src/routes/siteAssociation.ts b/bskylink/src/routes/siteAssociation.ts new file mode 100644 index 000000000..ae3b42e30 --- /dev/null +++ b/bskylink/src/routes/siteAssociation.ts @@ -0,0 +1,13 @@ +import {Express} from 'express' + +import {AppContext} from '../context.js' + +export default function (ctx: AppContext, app: Express) { + return app.get('/.well-known/apple-app-site-association', (req, res) => { + res.json({ + appclips: { + apps: ['B3LX46C5HS.xyz.blueskyweb.app.AppClip'], + }, + }) + }) +} diff --git a/bskylink/src/routes/util.ts b/bskylink/src/routes/util.ts new file mode 100644 index 000000000..bcac64b01 --- /dev/null +++ b/bskylink/src/routes/util.ts @@ -0,0 +1,23 @@ +import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express' + +import {httpLogger} from '../logger.js' + +export type Handler = (req: Request, res: Response) => Awaited<void> + +export const handler = (runHandler: Handler): RequestHandler => { + return async (req, res, next) => { + try { + await runHandler(req, res) + } catch (err) { + next(err) + } + } +} + +export const errorHandler: ErrorRequestHandler = (err, _req, res, next) => { + httpLogger.error({err}, 'request error') + if (res.headersSent) { + return next(err) + } + return res.status(500).end('server error') +} diff --git a/bskylink/src/util.ts b/bskylink/src/util.ts new file mode 100644 index 000000000..0b57dd5c5 --- /dev/null +++ b/bskylink/src/util.ts @@ -0,0 +1,8 @@ +import {randomBytes} from 'node:crypto' + +import {toString} from 'uint8arrays' + +// 40bit random id of 5-7 characters +export const randomId = () => { + return toString(randomBytes(5), 'base58btc') +} diff --git a/bskylink/tests/index.ts b/bskylink/tests/index.ts new file mode 100644 index 000000000..51449c21b --- /dev/null +++ b/bskylink/tests/index.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert' +import {AddressInfo} from 'node:net' +import {after, before, describe, it} from 'node:test' + +import {Database, envToCfg, LinkService, readEnv} from '../src/index.js' + +describe('link service', async () => { + let linkService: LinkService + let baseUrl: string + before(async () => { + const env = readEnv() + const cfg = envToCfg({ + ...env, + hostnames: ['test.bsky.link'], + appHostname: 'test.bsky.app', + dbPostgresSchema: 'link_test', + dbPostgresUrl: process.env.DB_POSTGRES_URL, + }) + const migrateDb = Database.postgres({ + url: cfg.db.url, + schema: cfg.db.schema, + }) + await migrateDb.migrateToLatestOrThrow() + await migrateDb.close() + linkService = await LinkService.create(cfg) + await linkService.start() + const {port} = linkService.server?.address() as AddressInfo + baseUrl = `http://localhost:${port}` + }) + + after(async () => { + await linkService?.destroy() + }) + + it('creates a starter pack link', async () => { + const link = await getLink('/start/did:example:alice/xxx') + const url = new URL(link) + assert.strictEqual(url.origin, 'https://test.bsky.link') + assert.match(url.pathname, /^\/[a-z0-9]+$/i) + }) + + it('normalizes input paths and provides same link each time.', async () => { + const link1 = await getLink('/start/did%3Aexample%3Abob/yyy') + const link2 = await getLink('/start/did:example:bob/yyy/') + assert.strictEqual(link1, link2) + }) + + it('serves permanent redirect, preserving query params.', async () => { + const link = await getLink('/start/did:example:carol/zzz/') + const [status, location] = await getRedirect(`${link}?a=b`) + assert.strictEqual(status, 301) + const locationUrl = new URL(location) + assert.strictEqual( + locationUrl.pathname + locationUrl.search, + '/start/did:example:carol/zzz?a=b', + ) + }) + + async function getRedirect(link: string): Promise<[number, string]> { + const url = new URL(link) + const base = new URL(baseUrl) + url.protocol = base.protocol + url.host = base.host + const res = await fetch(url, {redirect: 'manual'}) + await res.arrayBuffer() // drain + assert( + res.status === 301 || res.status === 303, + 'response was not a redirect', + ) + return [res.status, res.headers.get('location') ?? ''] + } + + async function getLink(path: string): Promise<string> { + const res = await fetch(new URL('/link', baseUrl), { + method: 'post', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({path}), + }) + assert.strictEqual(res.status, 200) + const payload = await res.json() + assert(typeof payload.url === 'string') + return payload.url + } +}) diff --git a/bskylink/tests/infra/_common.sh b/bskylink/tests/infra/_common.sh new file mode 100755 index 000000000..1587f5c70 --- /dev/null +++ b/bskylink/tests/infra/_common.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env sh + +# Exit if any command fails +set -e + +get_container_id() { + local compose_file=$1 + local service=$2 + if [ -z "${compose_file}" ] || [ -z "${service}" ]; then + echo "usage: get_container_id <compose_file> <service>" + exit 1 + fi + + # first line of jq normalizes for docker compose breaking change, see docker/compose#10958 + docker compose --file $compose_file ps --format json --status running \ + | jq -sc '.[] | if type=="array" then .[] else . end' | jq -s \ + | jq -r '.[]? | select(.Service == "'${service}'") | .ID' +} + +# Exports all environment variables +export_env() { + export_pg_env +} + +# Exports postgres environment variables +export_pg_env() { + # Based on creds in compose.yaml + export PGPORT=5433 + export PGHOST=localhost + export PGUSER=pg + export PGPASSWORD=password + export PGDATABASE=postgres + export DB_POSTGRES_URL="postgresql://pg:password@127.0.0.1:5433/postgres" +} + + +pg_clear() { + local pg_uri=$1 + + for schema_name in `psql "${pg_uri}" -c "SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT LIKE 'pg_%' AND schema_name NOT LIKE 'information_schema';" -t`; do + psql "${pg_uri}" -c "DROP SCHEMA \"${schema_name}\" CASCADE;" + done +} + +pg_init() { + local pg_uri=$1 + + psql "${pg_uri}" -c "CREATE SCHEMA IF NOT EXISTS \"public\";" +} + +main_native() { + local services=${SERVICES} + local postgres_url_env_var=`[[ $services == *"db_test"* ]] && echo "DB_TEST_POSTGRES_URL" || echo "DB_POSTGRES_URL"` + + postgres_url="${!postgres_url_env_var}" + + if [ -n "${postgres_url}" ]; then + echo "Using ${postgres_url_env_var} (${postgres_url}) to connect to postgres." + pg_init "${postgres_url}" + else + echo "Postgres connection string missing did you set ${postgres_url_env_var}?" + exit 1 + fi + + cleanup() { + local services=$@ + + if [ -n "${postgres_url}" ] && [[ $services == *"db_test"* ]]; then + pg_clear "${postgres_url}" &> /dev/null + fi + } + + # trap SIGINT and performs cleanup + trap "on_sigint ${services}" INT + on_sigint() { + cleanup $@ + exit $? + } + + # Run the arguments as a command + DB_POSTGRES_URL="${postgres_url}" \ + "$@" + code=$? + + cleanup ${services} + + exit ${code} +} + +main_docker() { + # Expect a SERVICES env var to be set with the docker service names + local services=${SERVICES} + + dir=$(dirname $0) + compose_file="${dir}/docker-compose.yaml" + + # whether this particular script started the container(s) + started_container=false + + # performs cleanup as necessary, i.e. taking down containers + # if this script started them + cleanup() { + local services=$@ + echo # newline + if $started_container; then + docker compose --file $compose_file rm --force --stop --volumes ${services} + fi + } + + # trap SIGINT and performs cleanup + trap "on_sigint ${services}" INT + on_sigint() { + cleanup $@ + exit $? + } + + # check if all services are running already + not_running=false + for service in $services; do + container_id=$(get_container_id $compose_file $service) + if [ -z $container_id ]; then + not_running=true + break + fi + done + + # if any are missing, recreate all services + if $not_running; then + started_container=true + docker compose --file $compose_file up --wait --force-recreate ${services} + else + echo "all services ${services} are already running" + fi + + # do not exit when following commands fail, so we can intercept exit code & tear down docker + set +e + + # setup environment variables and run args + export_env + "$@" + # save return code for later + code=$? + + # performs cleanup as necessary + cleanup ${services} + exit ${code} +} + +# Main entry point +main() { + if ! docker ps >/dev/null 2>&1; then + echo "Docker unavailable. Running on host." + main_native $@ + else + main_docker $@ + fi +} diff --git a/bskylink/tests/infra/docker-compose.yaml b/bskylink/tests/infra/docker-compose.yaml new file mode 100644 index 000000000..4bc939e01 --- /dev/null +++ b/bskylink/tests/infra/docker-compose.yaml @@ -0,0 +1,27 @@ +version: '3.8' +services: + # An ephermerally-stored postgres database for single-use test runs + db_test: &db_test + image: postgres:14.11-alpine + environment: + - POSTGRES_USER=pg + - POSTGRES_PASSWORD=password + ports: + - '5433:5432' + # Healthcheck ensures db is queryable when `docker-compose up --wait` completes + healthcheck: + test: 'pg_isready -U pg' + interval: 500ms + timeout: 10s + retries: 20 + # A persistently-stored postgres database + db: + <<: *db_test + ports: + - '5432:5432' + healthcheck: + disable: true + volumes: + - link_db:/var/lib/postgresql/data +volumes: + link_db: diff --git a/bskylink/tests/infra/with-test-db.sh b/bskylink/tests/infra/with-test-db.sh new file mode 100755 index 000000000..cc083491a --- /dev/null +++ b/bskylink/tests/infra/with-test-db.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env sh + +# Example usage: +# ./with-test-db.sh psql postgresql://pg:password@localhost:5433/postgres -c 'select 1;' + +dir=$(dirname $0) +. ${dir}/_common.sh + +SERVICES="db_test" main "$@" diff --git a/bskylink/tsconfig.json b/bskylink/tsconfig.json new file mode 100644 index 000000000..3c382acc4 --- /dev/null +++ b/bskylink/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "esModuleInterop": true, + "moduleResolution": "NodeNext", + "outDir": "dist", + "lib": ["ES2021.String"] + }, + "include": ["./src/index.ts", "./src/bin.ts"] + } diff --git a/bskylink/yarn.lock b/bskylink/yarn.lock new file mode 100644 index 000000000..d2fa31456 --- /dev/null +++ b/bskylink/yarn.lock @@ -0,0 +1,1027 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@atproto/common-web@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.3.0.tgz#36da8c2c31d8cf8a140c3c8f03223319bf4430bb" + integrity sha512-67VnV6JJyX+ZWyjV7xFQMypAgDmjVaR9ZCuU/QW+mqlqI7fex2uL4Fv+7/jHadgzhuJHVd6OHOvNn0wR5WZYtA== + dependencies: + graphemer "^1.4.0" + multiformats "^9.9.0" + uint8arrays "3.0.0" + zod "^3.21.4" + +"@atproto/common@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.0.tgz#d77696c7eb545426df727837d9ee333b429fe7ef" + integrity sha512-yOXuPlCjT/OK9j+neIGYn9wkxx/AlxQSucysAF0xgwu0Ji8jAtKBf9Jv6R5ObYAjAD/kVUvEYumle+Yq/R9/7g== + dependencies: + "@atproto/common-web" "^0.3.0" + "@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" + +"@cbor-extract/cbor-extract-darwin-arm64@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz#8d65cb861a99622e1b4a268e2d522d2ec6137338" + integrity sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w== + +"@cbor-extract/cbor-extract-darwin-x64@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.0.tgz#9fbec199c888c5ec485a1839f4fad0485ab6c40a" + integrity sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w== + +"@cbor-extract/cbor-extract-linux-arm64@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.0.tgz#bf77e0db4a1d2200a5aa072e02210d5043e953ae" + integrity sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ== + +"@cbor-extract/cbor-extract-linux-arm@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.0.tgz#491335037eb8533ed8e21b139c59f6df04e39709" + integrity sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q== + +"@cbor-extract/cbor-extract-linux-x64@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.0.tgz#672574485ccd24759bf8fb8eab9dbca517d35b97" + integrity sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw== + +"@cbor-extract/cbor-extract-win32-x64@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz#4b3f07af047f984c082de34b116e765cb9af975f" + integrity sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w== + +"@ipld/dag-cbor@^7.0.3": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@ipld/dag-cbor/-/dag-cbor-7.0.3.tgz#aa31b28afb11a807c3d627828a344e5521ac4a1e" + integrity sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA== + dependencies: + cborg "^1.6.0" + multiformats "^9.5.4" + +"@types/cors@^2.8.17": + version "2.8.17" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b" + integrity sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA== + dependencies: + "@types/node" "*" + +"@types/node@*": + version "20.14.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.2.tgz#a5f4d2bcb4b6a87bffcaa717718c5a0f208f4a18" + integrity sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q== + dependencies: + undici-types "~5.26.4" + +"@types/pg@^8.11.6": + version "8.11.6" + resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.11.6.tgz#a2d0fb0a14b53951a17df5197401569fb9c0c54b" + integrity sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ== + dependencies: + "@types/node" "*" + pg-protocol "*" + pg-types "^4.0.1" + +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + +accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + +atomic-sleep@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" + integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== + +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +body-parser@1.20.2, body-parser@^1.20.2: + version "1.20.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" + integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== + dependencies: + bytes "3.1.2" + content-type "~1.0.5" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.11.0" + raw-body "2.5.2" + type-is "~1.6.18" + unpipe "1.0.0" + +boolean@^3.1.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/boolean/-/boolean-3.2.0.tgz#9e5294af4e98314494cbb17979fa54ca159f116b" + integrity sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw== + +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + +cbor-extract@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cbor-extract/-/cbor-extract-2.2.0.tgz#cee78e630cbeae3918d1e2e58e0cebaf3a3be840" + integrity sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA== + dependencies: + node-gyp-build-optional-packages "5.1.1" + optionalDependencies: + "@cbor-extract/cbor-extract-darwin-arm64" "2.2.0" + "@cbor-extract/cbor-extract-darwin-x64" "2.2.0" + "@cbor-extract/cbor-extract-linux-arm" "2.2.0" + "@cbor-extract/cbor-extract-linux-arm64" "2.2.0" + "@cbor-extract/cbor-extract-linux-x64" "2.2.0" + "@cbor-extract/cbor-extract-win32-x64" "2.2.0" + +cbor-x@^1.5.1: + version "1.5.9" + resolved "https://registry.yarnpkg.com/cbor-x/-/cbor-x-1.5.9.tgz#ed6b2afcd7884bdd697674bfb7332c1473a13ecf" + integrity sha512-OEI5rEu3MeR0WWNUXuIGkxmbXVhABP+VtgAXzm48c9ulkrsvxshjjk94XSOGphyAKeNGLPfAxxzEtgQ6rEVpYQ== + optionalDependencies: + cbor-extract "^2.2.0" + +cborg@^1.6.0: + version "1.10.2" + resolved "https://registry.yarnpkg.com/cborg/-/cborg-1.10.2.tgz#83cd581b55b3574c816f82696307c7512db759a1" + integrity sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug== + +content-disposition@0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@~1.0.4, content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== + +cookie@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" + integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== + +cors@^2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + +debug@2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +delay@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/delay/-/delay-5.0.0.tgz#137045ef1b96e5071060dd5be60bf9334436bd1d" + integrity sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw== + +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +detect-libc@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" + integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + +express@^4.19.2: + version "4.19.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" + integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "1.20.2" + content-disposition "0.5.4" + content-type "~1.0.4" + cookie "0.6.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "2.0.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.2.0" + fresh "0.5.2" + http-errors "2.0.0" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "2.4.1" + parseurl "~1.3.3" + path-to-regexp "0.1.7" + proxy-addr "~2.0.7" + qs "6.11.0" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "0.18.0" + serve-static "1.15.0" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + +fast-printf@^1.6.9: + version "1.6.9" + resolved "https://registry.yarnpkg.com/fast-printf/-/fast-printf-1.6.9.tgz#212f56570d2dc8ccdd057ee93d50dd414d07d676" + integrity sha512-FChq8hbz65WMj4rstcQsFB0O7Cy++nmbNfLYnD9cYv2cRn8EG6k/MGn9kO/tjO66t09DLDugj3yL+V2o6Qftrg== + dependencies: + boolean "^3.1.4" + +fast-redact@^3.1.1: + version "3.5.0" + resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.5.0.tgz#e9ea02f7e57d0cd8438180083e93077e496285e4" + integrity sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A== + +finalhandler@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" + integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "2.4.1" + parseurl "~1.3.3" + statuses "2.0.1" + unpipe "~1.0.0" + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + +has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== + +has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +hasown@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +http-terminator@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/http-terminator/-/http-terminator-3.2.0.tgz#bc158d2694b733ca4fbf22a35065a81a609fb3e9" + integrity sha512-JLjck1EzPaWjsmIf8bziM3p9fgR1Y3JoUKAkyYEbZmFrIvJM6I8vVJfBGWlEtV9IWOvzNnaTtjuwZeBY2kwB4g== + dependencies: + delay "^5.0.0" + p-wait-for "^3.2.0" + roarr "^7.0.4" + type-fest "^2.3.3" + +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +inherits@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +iso-datestring-validator@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz#2daa80d2900b7a954f9f731d42f96ee0c19a6895" + integrity sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA== + +kysely@^0.27.3: + version "0.27.3" + resolved "https://registry.yarnpkg.com/kysely/-/kysely-0.27.3.tgz#6cc6c757040500b43c4ac596cdbb12be400ee276" + integrity sha512-lG03Ru+XyOJFsjH3OMY6R/9U38IjDPfnOfDgO3ynhbDr+Dz8fak+X6L62vqu3iybQnj+lG84OttBuU9KY3L9kA== + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +merge-descriptors@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" + integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== + +methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +ms@2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +multiformats@^13.0.0: + version "13.1.1" + resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-13.1.1.tgz#b22ce4df26330d2cf0d69f5bdcbc9a787095a6e5" + integrity sha512-JiptvwMmlxlzIlLLwhCi/srf/nk409UL0eUBr0kioRJq15hqqKyg68iftrBvhCRjR6Rw4fkNnSc4ZJXJDuta/Q== + +multiformats@^9.4.2, multiformats@^9.5.4, multiformats@^9.9.0: + version "9.9.0" + resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-9.9.0.tgz#c68354e7d21037a8f1f8833c8ccd68618e8f1d37" + integrity sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg== + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +node-gyp-build-optional-packages@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz#52b143b9dd77b7669073cbfe39e3f4118bfc603c" + integrity sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw== + dependencies: + detect-libc "^2.0.1" + +object-assign@^4: + 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.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" + integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== + +obuf@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" + integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== + +on-exit-leak-free@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8" + integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA== + +on-finished@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow== + +p-timeout@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" + integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== + dependencies: + p-finally "^1.0.0" + +p-wait-for@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-wait-for/-/p-wait-for-3.2.0.tgz#640429bcabf3b0dd9f492c31539c5718cb6a3f1f" + integrity sha512-wpgERjNkLrBiFmkMEjuZJEWKKDrNfHCKA1OhyN1wg1FrLkULbviEy6py1AyJUgZ72YWFbZ38FIpnqvVqAlDUwA== + dependencies: + p-timeout "^3.0.0" + +parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +path-to-regexp@0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" + integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== + +pg-cloudflare@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz#e6d5833015b170e23ae819e8c5d7eaedb472ca98" + integrity sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q== + +pg-connection-string@^2.6.4: + version "2.6.4" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.4.tgz#f543862adfa49fa4e14bc8a8892d2a84d754246d" + integrity sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA== + +pg-int8@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" + integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== + +pg-numeric@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pg-numeric/-/pg-numeric-1.0.2.tgz#816d9a44026086ae8ae74839acd6a09b0636aa3a" + integrity sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw== + +pg-pool@^3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.6.2.tgz#3a592370b8ae3f02a7c8130d245bc02fa2c5f3f2" + integrity sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg== + +pg-protocol@*, pg-protocol@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.6.1.tgz#21333e6d83b01faaebfe7a33a7ad6bfd9ed38cb3" + integrity sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg== + +pg-types@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" + integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== + dependencies: + pg-int8 "1.0.1" + postgres-array "~2.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.4" + postgres-interval "^1.1.0" + +pg-types@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-4.0.2.tgz#399209a57c326f162461faa870145bb0f918b76d" + integrity sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng== + dependencies: + pg-int8 "1.0.1" + pg-numeric "1.0.2" + postgres-array "~3.0.1" + postgres-bytea "~3.0.0" + postgres-date "~2.1.0" + postgres-interval "^3.0.0" + postgres-range "^1.1.1" + +pg@^8.12.0: + version "8.12.0" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.12.0.tgz#9341724db571022490b657908f65aee8db91df79" + integrity sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ== + dependencies: + pg-connection-string "^2.6.4" + pg-pool "^3.6.2" + pg-protocol "^1.6.1" + pg-types "^2.1.0" + pgpass "1.x" + optionalDependencies: + pg-cloudflare "^1.1.1" + +pgpass@1.x: + version "1.0.5" + resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d" + integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug== + dependencies: + split2 "^4.1.0" + +pino-abstract-transport@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz#97f9f2631931e242da531b5c66d3079c12c9d1b5" + integrity sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q== + dependencies: + readable-stream "^4.0.0" + split2 "^4.0.0" + +pino-std-serializers@^6.0.0: + version "6.2.2" + resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-6.2.2.tgz#d9a9b5f2b9a402486a5fc4db0a737570a860aab3" + integrity sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA== + +pino-std-serializers@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz#7c625038b13718dbbd84ab446bd673dc52259e3b" + integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== + +pino@^8.15.0: + version "8.21.0" + resolved "https://registry.yarnpkg.com/pino/-/pino-8.21.0.tgz#e1207f3675a2722940d62da79a7a55a98409f00d" + integrity sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q== + dependencies: + atomic-sleep "^1.0.0" + fast-redact "^3.1.1" + on-exit-leak-free "^2.1.0" + pino-abstract-transport "^1.2.0" + pino-std-serializers "^6.0.0" + process-warning "^3.0.0" + quick-format-unescaped "^4.0.3" + real-require "^0.2.0" + safe-stable-stringify "^2.3.1" + sonic-boom "^3.7.0" + thread-stream "^2.6.0" + +pino@^9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/pino/-/pino-9.2.0.tgz#e77a9516f3a3e5550d9b76d9f65ac6118ef02bdd" + integrity sha512-g3/hpwfujK5a4oVbaefoJxezLzsDgLcNJeITvC6yrfwYeT9la+edCK42j5QpEQSQCZgTKapXvnQIdgZwvRaZug== + dependencies: + atomic-sleep "^1.0.0" + fast-redact "^3.1.1" + on-exit-leak-free "^2.1.0" + pino-abstract-transport "^1.2.0" + pino-std-serializers "^7.0.0" + process-warning "^3.0.0" + quick-format-unescaped "^4.0.3" + real-require "^0.2.0" + safe-stable-stringify "^2.3.1" + sonic-boom "^4.0.1" + thread-stream "^3.0.0" + +postgres-array@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" + integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== + +postgres-array@~3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-3.0.2.tgz#68d6182cb0f7f152a7e60dc6a6889ed74b0a5f98" + integrity sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog== + +postgres-bytea@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" + integrity sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w== + +postgres-bytea@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-3.0.0.tgz#9048dc461ac7ba70a6a42d109221619ecd1cb089" + integrity sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw== + dependencies: + obuf "~1.1.2" + +postgres-date@~1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" + integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== + +postgres-date@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-2.1.0.tgz#b85d3c1fb6fb3c6c8db1e9942a13a3bf625189d0" + integrity sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA== + +postgres-interval@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" + integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== + dependencies: + xtend "^4.0.0" + +postgres-interval@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-3.0.0.tgz#baf7a8b3ebab19b7f38f07566c7aab0962f0c86a" + integrity sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw== + +postgres-range@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/postgres-range/-/postgres-range-1.1.4.tgz#a59c5f9520909bcec5e63e8cf913a92e4c952863" + integrity sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w== + +process-warning@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-3.0.0.tgz#96e5b88884187a1dce6f5c3166d611132058710b" + integrity sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ== + +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + +proxy-addr@~2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +qs@6.11.0: + version "6.11.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" + integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== + dependencies: + side-channel "^1.0.4" + +quick-format-unescaped@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" + integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== + +range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + +readable-stream@^4.0.0: + version "4.5.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.5.2.tgz#9e7fc4c45099baeed934bff6eb97ba6cf2729e09" + integrity sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g== + dependencies: + abort-controller "^3.0.0" + buffer "^6.0.3" + events "^3.3.0" + process "^0.11.10" + string_decoder "^1.3.0" + +real-require@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" + integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== + +roarr@^7.0.4: + version "7.21.1" + resolved "https://registry.yarnpkg.com/roarr/-/roarr-7.21.1.tgz#fd6452ca822a65f736c35e5372f04ee9f2ca3851" + integrity sha512-3niqt5bXFY1InKU8HKWqqYTYjtrBaxBMnXELXCXUYgtNYGUtZM5rB46HIC430AyacL95iEniGf7RgqsesykLmQ== + dependencies: + fast-printf "^1.6.9" + safe-stable-stringify "^2.4.3" + semver-compare "^1.0.0" + +safe-buffer@5.2.1, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-stable-stringify@^2.3.1, safe-stable-stringify@^2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz#138c84b6f6edb3db5f8ef3ef7115b8f55ccbf886" + integrity sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g== + +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +semver-compare@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" + integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== + +send@0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" + integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + +serve-static@1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" + integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.18.0" + +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +side-channel@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + +sonic-boom@^3.7.0: + version "3.8.1" + resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-3.8.1.tgz#d5ba8c4e26d6176c9a1d14d549d9ff579a163422" + integrity sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg== + dependencies: + atomic-sleep "^1.0.0" + +sonic-boom@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-4.0.1.tgz#515b7cef2c9290cb362c4536388ddeece07aed30" + integrity sha512-hTSD/6JMLyT4r9zeof6UtuBDpjJ9sO08/nmS5djaA9eozT9oOlNdpXSnzcgj4FTqpk3nkLrs61l4gip9r1HCrQ== + dependencies: + atomic-sleep "^1.0.0" + +split2@^4.0.0, split2@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" + integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== + +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +string_decoder@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +thread-stream@^2.6.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-2.7.0.tgz#d8a8e1b3fd538a6cca8ce69dbe5d3d097b601e11" + integrity sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw== + dependencies: + real-require "^0.2.0" + +thread-stream@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-3.1.0.tgz#4b2ef252a7c215064507d4ef70c05a5e2d34c4f1" + integrity sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A== + dependencies: + real-require "^0.2.0" + +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +type-fest@^2.3.3: + version "2.19.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" + integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== + +type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +typescript@^5.4.5: + version "5.4.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" + integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== + +uint8arrays@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/uint8arrays/-/uint8arrays-3.0.0.tgz#260869efb8422418b6f04e3fac73a3908175c63b" + integrity sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA== + dependencies: + multiformats "^9.4.2" + +uint8arrays@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/uint8arrays/-/uint8arrays-5.1.0.tgz#14047c9bdf825d025b7391299436e5e50e7270f1" + integrity sha512-vA6nFepEmlSKkMBnLBaUMVvAC4G3CTmO58C12y4sq6WPDOR7mOFYOi7GlrQ4djeSbP6JG9Pv9tJDM97PedRSww== + dependencies: + multiformats "^13.0.0" + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +vary@^1, vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +zod@^3.21.4: + version "3.23.8" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== diff --git a/bskyogcard/package.json b/bskyogcard/package.json new file mode 100644 index 000000000..3be1337fc --- /dev/null +++ b/bskyogcard/package.json @@ -0,0 +1,24 @@ +{ + "name": "bskyogcard", + "version": "0.0.0", + "type": "module", + "main": "src/index.ts", + "scripts": { + "start": "node --loader ts-node/esm ./src/bin.ts", + "build": "tsc && cp -r src/assets dist/assets" + }, + "dependencies": { + "@atproto/api": "0.12.19-next.0", + "@atproto/common": "^0.4.0", + "@resvg/resvg-js": "^2.6.2", + "express": "^4.19.2", + "http-terminator": "^3.2.0", + "pino": "^9.2.0", + "react": "^18.3.1", + "satori": "^0.10.13" + }, + "devDependencies": { + "@types/node": "^20.14.3", + "typescript": "^5.4.5" + } +} diff --git a/bskyogcard/src/assets/Inter-Bold.ttf b/bskyogcard/src/assets/Inter-Bold.ttf new file mode 100644 index 000000000..fe23eeb9c --- /dev/null +++ b/bskyogcard/src/assets/Inter-Bold.ttf Binary files differdiff --git a/bskyogcard/src/bin.ts b/bskyogcard/src/bin.ts new file mode 100644 index 000000000..ff550809d --- /dev/null +++ b/bskyogcard/src/bin.ts @@ -0,0 +1,48 @@ +import cluster, {Worker} from 'node:cluster' + +import {envInt} from '@atproto/common' + +import {CardService, envToCfg, httpLogger, readEnv} from './index.js' + +async function main() { + const env = readEnv() + const cfg = envToCfg(env) + const card = await CardService.create(cfg) + await card.start() + httpLogger.info('card service is running') + process.on('SIGTERM', async () => { + httpLogger.info('card service is stopping') + await card.destroy() + httpLogger.info('card service is stopped') + if (cluster.isWorker) process.exit(0) + }) +} + +const workerCount = envInt('CARD_CLUSTER_WORKER_COUNT') + +if (workerCount) { + if (cluster.isPrimary) { + httpLogger.info(`primary ${process.pid} is running`) + const workers = new Set<Worker>() + for (let i = 0; i < workerCount; ++i) { + workers.add(cluster.fork()) + } + let teardown = false + cluster.on('exit', worker => { + workers.delete(worker) + if (!teardown) { + workers.add(cluster.fork()) // restart on crash + } + }) + process.on('SIGTERM', () => { + teardown = true + httpLogger.info('disconnecting workers') + workers.forEach(w => w.kill('SIGTERM')) + }) + } else { + httpLogger.info(`worker ${process.pid} is running`) + main() + } +} else { + main() // non-clustering +} diff --git a/bskyogcard/src/components/Butterfly.tsx b/bskyogcard/src/components/Butterfly.tsx new file mode 100644 index 000000000..5a4124975 --- /dev/null +++ b/bskyogcard/src/components/Butterfly.tsx @@ -0,0 +1,16 @@ +import React from 'react' + +export function Butterfly(props: React.SVGAttributes<SVGSVGElement>) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 568 501" + {...props}> + <path + fill="currentColor" + d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z" + /> + </svg> + ) +} diff --git a/bskyogcard/src/components/Img.tsx b/bskyogcard/src/components/Img.tsx new file mode 100644 index 000000000..dac223180 --- /dev/null +++ b/bskyogcard/src/components/Img.tsx @@ -0,0 +1,10 @@ +import React from 'react' + +export function Img( + props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer}, +) { + const {src, ...others} = props + return ( + <img {...others} src={`data:image/jpeg;base64,${src.toString('base64')}`} /> + ) +} diff --git a/bskyogcard/src/components/StarterPack.tsx b/bskyogcard/src/components/StarterPack.tsx new file mode 100644 index 000000000..f73442190 --- /dev/null +++ b/bskyogcard/src/components/StarterPack.tsx @@ -0,0 +1,149 @@ +/* eslint-disable bsky-internal/avoid-unwrapped-text */ +import React from 'react' +import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' + +import {Butterfly} from './Butterfly.js' +import {Img} from './Img.js' + +export const STARTERPACK_HEIGHT = 630 +export const STARTERPACK_WIDTH = 1200 +export const TILE_SIZE = STARTERPACK_HEIGHT / 3 + +const GRADIENT_TOP = '#0A7AFF' +const GRADIENT_BOTTOM = '#59B9FF' +const IMAGE_STROKE = '#359CFF' + +export function StarterPack(props: { + starterPack: AppBskyGraphDefs.StarterPackView + images: Map<string, Buffer> +}) { + const {starterPack, images} = props + const record = AppBskyGraphStarterpack.isRecord(starterPack.record) + ? starterPack.record + : null + const imagesArray = [...images.values()] + const imageOfCreator = images.get(starterPack.creator.did) + const imagesExceptCreator = [...images.entries()] + .filter(([did]) => did !== starterPack.creator.did) + .map(([, image]) => image) + const imagesAcross: Buffer[] = [] + if (imageOfCreator) { + if (imagesExceptCreator.length >= 6) { + imagesAcross.push(...imagesExceptCreator.slice(0, 3)) + imagesAcross.push(imageOfCreator) + imagesAcross.push(...imagesExceptCreator.slice(3, 6)) + } else { + const firstHalf = Math.floor(imagesExceptCreator.length / 2) + imagesAcross.push(...imagesExceptCreator.slice(0, firstHalf)) + imagesAcross.push(imageOfCreator) + imagesAcross.push( + ...imagesExceptCreator.slice(firstHalf, imagesExceptCreator.length), + ) + } + } else { + imagesAcross.push(...imagesExceptCreator.slice(0, 7)) + } + return ( + <div + style={{ + display: 'flex', + justifyContent: 'center', + width: STARTERPACK_WIDTH, + height: STARTERPACK_HEIGHT, + backgroundColor: 'black', + color: 'white', + fontFamily: 'Inter', + }}> + {/* image tiles */} + <div + style={{ + display: 'flex', + flexWrap: 'wrap', + alignItems: 'stretch', + width: TILE_SIZE * 6, + height: TILE_SIZE * 3, + }}> + {[...Array(18)].map((_, i) => { + const image = imagesArray.at(i % imagesArray.length) + return ( + <div + key={i} + style={{ + display: 'flex', + height: TILE_SIZE, + width: TILE_SIZE, + }}> + {image && <Img height="100%" width="100%" src={image} />} + </div> + ) + })} + {/* background overlay */} + <div + style={{ + display: 'flex', + width: '100%', + height: '100%', + position: 'absolute', + backgroundImage: `linear-gradient(to bottom, ${GRADIENT_TOP}, ${GRADIENT_BOTTOM})`, + opacity: 0.9, + }} + /> + </div> + {/* foreground text & images */} + <div + style={{ + display: 'flex', + alignItems: 'center', + flexDirection: 'column', + width: '100%', + height: '100%', + position: 'absolute', + color: 'white', + }}> + <div + style={{ + color: 'white', + padding: 60, + fontSize: 40, + }}> + JOIN THE CONVERSATION + </div> + <div style={{display: 'flex'}}> + {imagesAcross.map((image, i) => { + return ( + <div + key={i} + style={{ + display: 'flex', + height: 172 + 15 * 2, + width: 172 + 15 * 2, + margin: -15, + border: `15px solid ${IMAGE_STROKE}`, + borderRadius: '50%', + overflow: 'hidden', + }}> + <Img height="100%" width="100%" src={image} /> + </div> + ) + })} + </div> + <div + style={{ + padding: '75px 30px 0px', + fontSize: 65, + }}> + {record?.name || 'Starter Pack'} + </div> + <div + style={{ + display: 'flex', + fontSize: 40, + justifyContent: 'center', + padding: '30px 30px 10px', + }}> + on <Butterfly width="65" style={{margin: '-7px 10px 0'}} /> Bluesky + </div> + </div> + </div> + ) +} diff --git a/bskyogcard/src/config.ts b/bskyogcard/src/config.ts new file mode 100644 index 000000000..fafa18e74 --- /dev/null +++ b/bskyogcard/src/config.ts @@ -0,0 +1,40 @@ +import {envInt, envStr} from '@atproto/common' + +export type Config = { + service: ServiceConfig +} + +export type ServiceConfig = { + port: number + version?: string + appviewUrl: string + originVerify?: string +} + +export type Environment = { + port?: number + version?: string + appviewUrl?: string + originVerify?: string +} + +export const readEnv = (): Environment => { + return { + port: envInt('CARD_PORT'), + version: envStr('CARD_VERSION'), + appviewUrl: envStr('CARD_APPVIEW_URL'), + originVerify: envStr('CARD_ORIGIN_VERIFY'), + } +} + +export const envToCfg = (env: Environment): Config => { + const serviceCfg: ServiceConfig = { + port: env.port ?? 3000, + version: env.version, + appviewUrl: env.appviewUrl ?? 'https://api.bsky.app', + originVerify: env.originVerify, + } + return { + service: serviceCfg, + } +} diff --git a/bskyogcard/src/context.ts b/bskyogcard/src/context.ts new file mode 100644 index 000000000..f92651caf --- /dev/null +++ b/bskyogcard/src/context.ts @@ -0,0 +1,44 @@ +import {readFileSync} from 'node:fs' + +import {AtpAgent} from '@atproto/api' +import * as path from 'path' +import {fileURLToPath} from 'url' + +import {Config} from './config.js' + +const __DIRNAME = path.dirname(fileURLToPath(import.meta.url)) + +export type AppContextOptions = { + cfg: Config + appviewAgent: AtpAgent + fonts: {name: string; data: Buffer}[] +} + +export class AppContext { + cfg: Config + appviewAgent: AtpAgent + fonts: {name: string; data: Buffer}[] + abortController = new AbortController() + + constructor(private opts: AppContextOptions) { + this.cfg = this.opts.cfg + this.appviewAgent = this.opts.appviewAgent + this.fonts = this.opts.fonts + } + + static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) { + const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl}) + const fonts = [ + { + name: 'Inter', + data: readFileSync(path.join(__DIRNAME, 'assets', 'Inter-Bold.ttf')), + }, + ] + return new AppContext({ + cfg, + appviewAgent, + fonts, + ...overrides, + }) + } +} diff --git a/bskyogcard/src/index.ts b/bskyogcard/src/index.ts new file mode 100644 index 000000000..ef8d48494 --- /dev/null +++ b/bskyogcard/src/index.ts @@ -0,0 +1,41 @@ +import events from 'node:events' +import http from 'node:http' + +import express from 'express' +import {createHttpTerminator, HttpTerminator} from 'http-terminator' + +import {Config} from './config.js' +import {AppContext} from './context.js' +import {default as routes, errorHandler} from './routes/index.js' + +export * from './config.js' +export * from './logger.js' + +export class CardService { + public server?: http.Server + private terminator?: HttpTerminator + + constructor(public app: express.Application, public ctx: AppContext) {} + + static async create(cfg: Config): Promise<CardService> { + let app = express() + + const ctx = await AppContext.fromConfig(cfg) + app = routes(ctx, app) + app.use(errorHandler) + + return new CardService(app, ctx) + } + + async start() { + this.server = this.app.listen(this.ctx.cfg.service.port) + this.server.keepAliveTimeout = 90000 + this.terminator = createHttpTerminator({server: this.server}) + await events.once(this.server, 'listening') + } + + async destroy() { + this.ctx.abortController.abort() + await this.terminator?.terminate() + } +} diff --git a/bskyogcard/src/logger.ts b/bskyogcard/src/logger.ts new file mode 100644 index 000000000..04b5d9046 --- /dev/null +++ b/bskyogcard/src/logger.ts @@ -0,0 +1,3 @@ +import {subsystemLogger} from '@atproto/common' + +export const httpLogger = subsystemLogger('bskyogcard') diff --git a/bskyogcard/src/routes/health.ts b/bskyogcard/src/routes/health.ts new file mode 100644 index 000000000..0cc69515e --- /dev/null +++ b/bskyogcard/src/routes/health.ts @@ -0,0 +1,14 @@ +import {Express} from 'express' + +import {AppContext} from '../context.js' +import {handler} from './util.js' + +export default function (ctx: AppContext, app: Express) { + return app.get( + '/_health', + handler(async (_req, res) => { + const {version} = ctx.cfg.service + return res.send({version}) + }), + ) +} diff --git a/bskyogcard/src/routes/index.ts b/bskyogcard/src/routes/index.ts new file mode 100644 index 000000000..0c40f89d3 --- /dev/null +++ b/bskyogcard/src/routes/index.ts @@ -0,0 +1,13 @@ +import {Express} from 'express' + +import {AppContext} from '../context.js' +import {default as health} from './health.js' +import {default as starterPack} from './starter-pack.js' + +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 + return app +} diff --git a/bskyogcard/src/routes/starter-pack.tsx b/bskyogcard/src/routes/starter-pack.tsx new file mode 100644 index 000000000..cb3a55327 --- /dev/null +++ b/bskyogcard/src/routes/starter-pack.tsx @@ -0,0 +1,102 @@ +import assert from 'node:assert' + +import React from 'react' +import {AppBskyGraphDefs, AtUri} from '@atproto/api' +import resvg from '@resvg/resvg-js' +import {Express} from 'express' +import satori from 'satori' + +import { + StarterPack, + STARTERPACK_HEIGHT, + STARTERPACK_WIDTH, +} from '../components/StarterPack.js' +import {AppContext} from '../context.js' +import {httpLogger} from '../logger.js' +import {handler, originVerifyMiddleware} from './util.js' + +export default function (ctx: AppContext, app: Express) { + return app.get( + '/start/:actor/:rkey', + originVerifyMiddleware(ctx), + handler(async (req, res) => { + const {actor, rkey} = req.params + const uri = AtUri.make(actor, 'app.bsky.graph.starterpack', rkey) + let starterPack: AppBskyGraphDefs.StarterPackView + try { + const result = await ctx.appviewAgent.api.app.bsky.graph.getStarterPack( + {starterPack: uri.toString()}, + ) + starterPack = result.data.starterPack + } catch (err) { + httpLogger.warn( + {err, uri: uri.toString()}, + 'could not fetch starter pack', + ) + return res.status(404).end('not found') + } + const imageEntries = await Promise.all( + [starterPack.creator] + .concat(starterPack.listItemsSample.map(li => li.subject)) + // has avatar + .filter(p => p.avatar) + // no sensitive labels + .filter(p => !p.labels.some(l => hideAvatarLabels.has(l.val))) + .map(async p => { + try { + assert(p.avatar) + const image = await getImage(p.avatar) + return [p.did, image] as const + } catch (err) { + httpLogger.warn( + {err, uri: uri.toString(), did: p.did}, + 'could not fetch image', + ) + return [p.did, null] as const + } + }), + ) + const images = new Map( + imageEntries.filter(([_, image]) => image !== null).slice(0, 7), + ) + const svg = await satori( + <StarterPack starterPack={starterPack} images={images} />, + { + fonts: ctx.fonts, + height: STARTERPACK_HEIGHT, + width: STARTERPACK_WIDTH, + }, + ) + const output = await resvg.renderAsync(svg) + res.statusCode = 200 + res.setHeader('content-type', 'image/png') + res.setHeader('cdn-tag', [...images.keys()].join(',')) + return res.end(output.asPng()) + }), + ) +} + +async function getImage(url: string) { + const response = await fetch(url) + const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded + if (response.status !== 200) return null + return Buffer.from(arrayBuf) +} + +const hideAvatarLabels = new Set([ + '!hide', + '!warn', + 'porn', + 'sexual', + 'nudity', + 'sexual-figurative', + 'graphic-media', + 'self-harm', + 'sensitive', + 'security', + 'impersonation', + 'scam', + 'spam', + 'misleading', + 'inauthentic', +]) diff --git a/bskyogcard/src/routes/util.ts b/bskyogcard/src/routes/util.ts new file mode 100644 index 000000000..718ed592a --- /dev/null +++ b/bskyogcard/src/routes/util.ts @@ -0,0 +1,36 @@ +import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express' + +import {AppContext} from '../context.js' +import {httpLogger} from '../logger.js' + +export type Handler = (req: Request, res: Response) => Awaited<void> + +export const handler = (runHandler: Handler): RequestHandler => { + return async (req, res, next) => { + try { + await runHandler(req, res) + } catch (err) { + next(err) + } + } +} + +export function originVerifyMiddleware(ctx: AppContext): RequestHandler { + const {originVerify} = ctx.cfg.service + if (!originVerify) return (_req, _res, next) => next() + return (req, res, next) => { + const verifyHeader = req.headers['x-origin-verify'] + if (verifyHeader !== originVerify) { + return res.status(404).end('not found') + } + next() + } +} + +export const errorHandler: ErrorRequestHandler = (err, req, res, next) => { + httpLogger.error({err}, 'request error') + if (res.headersSent) { + return next(err) + } + return res.status(500).end('server error') +} diff --git a/bskyogcard/tsconfig.json b/bskyogcard/tsconfig.json new file mode 100644 index 000000000..a5c3beecb --- /dev/null +++ b/bskyogcard/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "esModuleInterop": true, + "moduleResolution": "NodeNext", + "jsx": "react-jsx", + "outDir": "dist" + }, + "include": ["./src/index.ts", "./src/bin.ts"] +} diff --git a/bskyogcard/yarn.lock b/bskyogcard/yarn.lock new file mode 100644 index 000000000..0403efb84 --- /dev/null +++ b/bskyogcard/yarn.lock @@ -0,0 +1,1113 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@atproto/api@0.12.19-next.0": + version "0.12.19-next.0" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.19-next.0.tgz#9592476cbdba8482d0fd8d65e20275c95d6d5fd4" + integrity sha512-wyWr4uIabTgDTBY99y3QyrFxcIx1Mh4DkURgSv8sd/b+w0lfrZAJh0Gg9BXdg/iIjcf/M2lCTL04r0vASfkMVg== + dependencies: + "@atproto/common-web" "^0.3.0" + "@atproto/lexicon" "^0.4.0" + "@atproto/syntax" "^0.3.0" + "@atproto/xrpc" "^0.5.0" + multiformats "^9.9.0" + tlds "^1.234.0" + +"@atproto/common-web@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.3.0.tgz#36da8c2c31d8cf8a140c3c8f03223319bf4430bb" + integrity sha512-67VnV6JJyX+ZWyjV7xFQMypAgDmjVaR9ZCuU/QW+mqlqI7fex2uL4Fv+7/jHadgzhuJHVd6OHOvNn0wR5WZYtA== + dependencies: + graphemer "^1.4.0" + multiformats "^9.9.0" + uint8arrays "3.0.0" + zod "^3.21.4" + +"@atproto/common@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.0.tgz#d77696c7eb545426df727837d9ee333b429fe7ef" + integrity sha512-yOXuPlCjT/OK9j+neIGYn9wkxx/AlxQSucysAF0xgwu0Ji8jAtKBf9Jv6R5ObYAjAD/kVUvEYumle+Yq/R9/7g== + dependencies: + "@atproto/common-web" "^0.3.0" + "@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" + +"@atproto/lexicon@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.0.tgz#63e8829945d80c25524882caa8ed27b1151cc576" + integrity sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ== + dependencies: + "@atproto/common-web" "^0.3.0" + "@atproto/syntax" "^0.3.0" + iso-datestring-validator "^2.2.2" + multiformats "^9.9.0" + zod "^3.21.4" + +"@atproto/syntax@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3" + integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA== + +"@atproto/xrpc@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.5.0.tgz#dacbfd8f7b13f0ab5bd56f8fdd4b460e132a6032" + integrity sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog== + dependencies: + "@atproto/lexicon" "^0.4.0" + zod "^3.21.4" + +"@cbor-extract/cbor-extract-darwin-arm64@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz#8d65cb861a99622e1b4a268e2d522d2ec6137338" + integrity sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w== + +"@cbor-extract/cbor-extract-darwin-x64@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.0.tgz#9fbec199c888c5ec485a1839f4fad0485ab6c40a" + integrity sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w== + +"@cbor-extract/cbor-extract-linux-arm64@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.0.tgz#bf77e0db4a1d2200a5aa072e02210d5043e953ae" + integrity sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ== + +"@cbor-extract/cbor-extract-linux-arm@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.0.tgz#491335037eb8533ed8e21b139c59f6df04e39709" + integrity sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q== + +"@cbor-extract/cbor-extract-linux-x64@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.0.tgz#672574485ccd24759bf8fb8eab9dbca517d35b97" + integrity sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw== + +"@cbor-extract/cbor-extract-win32-x64@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz#4b3f07af047f984c082de34b116e765cb9af975f" + integrity sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w== + +"@ipld/dag-cbor@^7.0.3": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@ipld/dag-cbor/-/dag-cbor-7.0.3.tgz#aa31b28afb11a807c3d627828a344e5521ac4a1e" + integrity sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA== + dependencies: + cborg "^1.6.0" + multiformats "^9.5.4" + +"@resvg/resvg-js-android-arm-eabi@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz#e761e0b688127db64879f455178c92468a9aeabe" + integrity sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA== + +"@resvg/resvg-js-android-arm64@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz#b8cb564d7f6b3f37d9b43129f5dc5fe171e249e4" + integrity sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ== + +"@resvg/resvg-js-darwin-arm64@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz#49bd3faeda5c49f53302d970e6e79d006de18e7d" + integrity sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A== + +"@resvg/resvg-js-darwin-x64@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz#e1344173aa27bfb4d880ab576d1acf1c1648faca" + integrity sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw== + +"@resvg/resvg-js-linux-arm-gnueabihf@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz#34c445eba45efd68f6130b2ab426d76a7424253d" + integrity sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw== + +"@resvg/resvg-js-linux-arm64-gnu@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz#30da47087dd8153182198b94fe9f8d994890dae5" + integrity sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg== + +"@resvg/resvg-js-linux-arm64-musl@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz#5d75b8ff5c83103729c1ca3779987302753c50d4" + integrity sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg== + +"@resvg/resvg-js-linux-x64-gnu@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz#411abedfaee5edc57cbb7701736cecba522e26f3" + integrity sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw== + +"@resvg/resvg-js-linux-x64-musl@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz#fe4984038f0372f279e3ff570b72934dd7eb2a5c" + integrity sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ== + +"@resvg/resvg-js-win32-arm64-msvc@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz#d3a053cf7ff687087a2106330c0fdaae706254d1" + integrity sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ== + +"@resvg/resvg-js-win32-ia32-msvc@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz#7cdda1ce29ef7209e28191d917fa5bef0624a4ad" + integrity sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w== + +"@resvg/resvg-js-win32-x64-msvc@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz#cb0ad04525d65f3def4c8d346157a57976d5b388" + integrity sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ== + +"@resvg/resvg-js@^2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@resvg/resvg-js/-/resvg-js-2.6.2.tgz#3e92a907d88d879256c585347c5b21a7f3bb5b46" + integrity sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q== + optionalDependencies: + "@resvg/resvg-js-android-arm-eabi" "2.6.2" + "@resvg/resvg-js-android-arm64" "2.6.2" + "@resvg/resvg-js-darwin-arm64" "2.6.2" + "@resvg/resvg-js-darwin-x64" "2.6.2" + "@resvg/resvg-js-linux-arm-gnueabihf" "2.6.2" + "@resvg/resvg-js-linux-arm64-gnu" "2.6.2" + "@resvg/resvg-js-linux-arm64-musl" "2.6.2" + "@resvg/resvg-js-linux-x64-gnu" "2.6.2" + "@resvg/resvg-js-linux-x64-musl" "2.6.2" + "@resvg/resvg-js-win32-arm64-msvc" "2.6.2" + "@resvg/resvg-js-win32-ia32-msvc" "2.6.2" + "@resvg/resvg-js-win32-x64-msvc" "2.6.2" + +"@shuding/opentype.js@1.4.0-beta.0": + version "1.4.0-beta.0" + resolved "https://registry.yarnpkg.com/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz#5d1e7e9e056f546aad41df1c5043f8f85d39e24b" + integrity sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA== + dependencies: + fflate "^0.7.3" + string.prototype.codepointat "^0.2.1" + +"@types/node@^20.14.3": + version "20.14.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.3.tgz#7a9a5d009b0861e7f337166dc435dbfd758db92d" + integrity sha512-Nuzqa6WAxeGnve6SXqiPAM9rA++VQs+iLZ1DDd56y0gdvygSZlQvZuvdFPR3yLqkVxPu4WrO02iDEyH1g+wazw== + dependencies: + undici-types "~5.26.4" + +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + +accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + +atomic-sleep@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" + integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== + +base64-js@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978" + integrity sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw== + +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +body-parser@1.20.2: + version "1.20.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" + integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== + dependencies: + bytes "3.1.2" + content-type "~1.0.5" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.11.0" + raw-body "2.5.2" + type-is "~1.6.18" + unpipe "1.0.0" + +boolean@^3.1.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/boolean/-/boolean-3.2.0.tgz#9e5294af4e98314494cbb17979fa54ca159f116b" + integrity sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw== + +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + +camelize@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.1.tgz#89b7e16884056331a35d6b5ad064332c91daa6c3" + integrity sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ== + +cbor-extract@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cbor-extract/-/cbor-extract-2.2.0.tgz#cee78e630cbeae3918d1e2e58e0cebaf3a3be840" + integrity sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA== + dependencies: + node-gyp-build-optional-packages "5.1.1" + optionalDependencies: + "@cbor-extract/cbor-extract-darwin-arm64" "2.2.0" + "@cbor-extract/cbor-extract-darwin-x64" "2.2.0" + "@cbor-extract/cbor-extract-linux-arm" "2.2.0" + "@cbor-extract/cbor-extract-linux-arm64" "2.2.0" + "@cbor-extract/cbor-extract-linux-x64" "2.2.0" + "@cbor-extract/cbor-extract-win32-x64" "2.2.0" + +cbor-x@^1.5.1: + version "1.5.9" + resolved "https://registry.yarnpkg.com/cbor-x/-/cbor-x-1.5.9.tgz#ed6b2afcd7884bdd697674bfb7332c1473a13ecf" + integrity sha512-OEI5rEu3MeR0WWNUXuIGkxmbXVhABP+VtgAXzm48c9ulkrsvxshjjk94XSOGphyAKeNGLPfAxxzEtgQ6rEVpYQ== + optionalDependencies: + cbor-extract "^2.2.0" + +cborg@^1.6.0: + version "1.10.2" + resolved "https://registry.yarnpkg.com/cborg/-/cborg-1.10.2.tgz#83cd581b55b3574c816f82696307c7512db759a1" + integrity sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug== + +color-name@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +content-disposition@0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@~1.0.4, content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== + +cookie@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" + integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== + +css-background-parser@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/css-background-parser/-/css-background-parser-0.1.0.tgz#48a17f7fe6d4d4f1bca3177ddf16c5617950741b" + integrity sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA== + +css-box-shadow@1.0.0-3: + version "1.0.0-3" + resolved "https://registry.yarnpkg.com/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz#9eaeb7140947bf5d649fc49a19e4bbaa5f602713" + integrity sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg== + +css-color-keywords@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" + integrity sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg== + +css-to-react-native@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/css-to-react-native/-/css-to-react-native-3.2.0.tgz#cdd8099f71024e149e4f6fe17a7d46ecd55f1e32" + integrity sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ== + dependencies: + camelize "^1.0.0" + css-color-keywords "^1.0.0" + postcss-value-parser "^4.0.2" + +debug@2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +delay@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/delay/-/delay-5.0.0.tgz#137045ef1b96e5071060dd5be60bf9334436bd1d" + integrity sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw== + +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +detect-libc@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" + integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +emoji-regex@^10.2.1: + version "10.3.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.3.0.tgz#76998b9268409eb3dae3de989254d456e70cfe23" + integrity sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw== + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +escape-html@^1.0.3, escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + +express@^4.19.2: + version "4.19.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" + integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "1.20.2" + content-disposition "0.5.4" + content-type "~1.0.4" + cookie "0.6.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "2.0.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.2.0" + fresh "0.5.2" + http-errors "2.0.0" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "2.4.1" + parseurl "~1.3.3" + path-to-regexp "0.1.7" + proxy-addr "~2.0.7" + qs "6.11.0" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "0.18.0" + serve-static "1.15.0" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + +fast-printf@^1.6.9: + version "1.6.9" + resolved "https://registry.yarnpkg.com/fast-printf/-/fast-printf-1.6.9.tgz#212f56570d2dc8ccdd057ee93d50dd414d07d676" + integrity sha512-FChq8hbz65WMj4rstcQsFB0O7Cy++nmbNfLYnD9cYv2cRn8EG6k/MGn9kO/tjO66t09DLDugj3yL+V2o6Qftrg== + dependencies: + boolean "^3.1.4" + +fast-redact@^3.1.1: + version "3.5.0" + resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.5.0.tgz#e9ea02f7e57d0cd8438180083e93077e496285e4" + integrity sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A== + +fflate@^0.7.3: + version "0.7.4" + resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.7.4.tgz#61587e5d958fdabb5a9368a302c25363f4f69f50" + integrity sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw== + +finalhandler@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" + integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "2.4.1" + parseurl "~1.3.3" + statuses "2.0.1" + unpipe "~1.0.0" + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + +has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== + +has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +hasown@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +hex-rgb@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/hex-rgb/-/hex-rgb-4.3.0.tgz#af5e974e83bb2fefe44d55182b004ec818c07776" + integrity sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw== + +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +http-terminator@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/http-terminator/-/http-terminator-3.2.0.tgz#bc158d2694b733ca4fbf22a35065a81a609fb3e9" + integrity sha512-JLjck1EzPaWjsmIf8bziM3p9fgR1Y3JoUKAkyYEbZmFrIvJM6I8vVJfBGWlEtV9IWOvzNnaTtjuwZeBY2kwB4g== + dependencies: + delay "^5.0.0" + p-wait-for "^3.2.0" + roarr "^7.0.4" + type-fest "^2.3.3" + +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +inherits@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +iso-datestring-validator@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz#2daa80d2900b7a954f9f731d42f96ee0c19a6895" + integrity sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA== + +"js-tokens@^3.0.0 || ^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +linebreak@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/linebreak/-/linebreak-1.1.0.tgz#831cf378d98bced381d8ab118f852bd50d81e46b" + integrity sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ== + dependencies: + base64-js "0.0.8" + unicode-trie "^2.0.0" + +loose-envify@^1.1.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +merge-descriptors@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" + integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== + +methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +ms@2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +multiformats@^9.4.2, multiformats@^9.5.4, multiformats@^9.9.0: + version "9.9.0" + resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-9.9.0.tgz#c68354e7d21037a8f1f8833c8ccd68618e8f1d37" + integrity sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg== + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +node-gyp-build-optional-packages@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz#52b143b9dd77b7669073cbfe39e3f4118bfc603c" + integrity sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw== + dependencies: + detect-libc "^2.0.1" + +object-inspect@^1.13.1: + version "1.13.1" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" + integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== + +on-exit-leak-free@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8" + integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA== + +on-finished@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow== + +p-timeout@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" + integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== + dependencies: + p-finally "^1.0.0" + +p-wait-for@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-wait-for/-/p-wait-for-3.2.0.tgz#640429bcabf3b0dd9f492c31539c5718cb6a3f1f" + integrity sha512-wpgERjNkLrBiFmkMEjuZJEWKKDrNfHCKA1OhyN1wg1FrLkULbviEy6py1AyJUgZ72YWFbZ38FIpnqvVqAlDUwA== + dependencies: + p-timeout "^3.0.0" + +pako@^0.2.5: + version "0.2.9" + resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" + integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA== + +parse-css-color@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/parse-css-color/-/parse-css-color-0.2.1.tgz#b687a583f2e42e66ffdfce80a570706966e807c9" + integrity sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg== + dependencies: + color-name "^1.1.4" + hex-rgb "^4.1.0" + +parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +path-to-regexp@0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" + integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== + +pino-abstract-transport@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz#97f9f2631931e242da531b5c66d3079c12c9d1b5" + integrity sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q== + dependencies: + readable-stream "^4.0.0" + split2 "^4.0.0" + +pino-std-serializers@^6.0.0: + version "6.2.2" + resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-6.2.2.tgz#d9a9b5f2b9a402486a5fc4db0a737570a860aab3" + integrity sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA== + +pino-std-serializers@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz#7c625038b13718dbbd84ab446bd673dc52259e3b" + integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== + +pino@^8.15.0: + version "8.21.0" + resolved "https://registry.yarnpkg.com/pino/-/pino-8.21.0.tgz#e1207f3675a2722940d62da79a7a55a98409f00d" + integrity sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q== + dependencies: + atomic-sleep "^1.0.0" + fast-redact "^3.1.1" + on-exit-leak-free "^2.1.0" + pino-abstract-transport "^1.2.0" + pino-std-serializers "^6.0.0" + process-warning "^3.0.0" + quick-format-unescaped "^4.0.3" + real-require "^0.2.0" + safe-stable-stringify "^2.3.1" + sonic-boom "^3.7.0" + thread-stream "^2.6.0" + +pino@^9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/pino/-/pino-9.2.0.tgz#e77a9516f3a3e5550d9b76d9f65ac6118ef02bdd" + integrity sha512-g3/hpwfujK5a4oVbaefoJxezLzsDgLcNJeITvC6yrfwYeT9la+edCK42j5QpEQSQCZgTKapXvnQIdgZwvRaZug== + dependencies: + atomic-sleep "^1.0.0" + fast-redact "^3.1.1" + on-exit-leak-free "^2.1.0" + pino-abstract-transport "^1.2.0" + pino-std-serializers "^7.0.0" + process-warning "^3.0.0" + quick-format-unescaped "^4.0.3" + real-require "^0.2.0" + safe-stable-stringify "^2.3.1" + sonic-boom "^4.0.1" + thread-stream "^3.0.0" + +postcss-value-parser@^4.0.2, postcss-value-parser@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + +process-warning@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-3.0.0.tgz#96e5b88884187a1dce6f5c3166d611132058710b" + integrity sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ== + +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + +proxy-addr@~2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +qs@6.11.0: + version "6.11.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" + integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== + dependencies: + side-channel "^1.0.4" + +quick-format-unescaped@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" + integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== + +range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + +react@^18.3.1: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" + integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== + dependencies: + loose-envify "^1.1.0" + +readable-stream@^4.0.0: + version "4.5.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.5.2.tgz#9e7fc4c45099baeed934bff6eb97ba6cf2729e09" + integrity sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g== + dependencies: + abort-controller "^3.0.0" + buffer "^6.0.3" + events "^3.3.0" + process "^0.11.10" + string_decoder "^1.3.0" + +real-require@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" + integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== + +roarr@^7.0.4: + version "7.21.1" + resolved "https://registry.yarnpkg.com/roarr/-/roarr-7.21.1.tgz#fd6452ca822a65f736c35e5372f04ee9f2ca3851" + integrity sha512-3niqt5bXFY1InKU8HKWqqYTYjtrBaxBMnXELXCXUYgtNYGUtZM5rB46HIC430AyacL95iEniGf7RgqsesykLmQ== + dependencies: + fast-printf "^1.6.9" + safe-stable-stringify "^2.4.3" + semver-compare "^1.0.0" + +safe-buffer@5.2.1, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-stable-stringify@^2.3.1, safe-stable-stringify@^2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz#138c84b6f6edb3db5f8ef3ef7115b8f55ccbf886" + integrity sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g== + +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +satori@^0.10.13: + version "0.10.13" + resolved "https://registry.yarnpkg.com/satori/-/satori-0.10.13.tgz#658a9920f55268d2002819387a80a0b6d4bdc262" + integrity sha512-klCwkVYMQ/ZN5inJLHzrUmGwoRfsdP7idB5hfpJ1jfiJk1ErDitK8Hkc6Kll1+Ox2WtqEuGecSZLnmup3CGzvQ== + dependencies: + "@shuding/opentype.js" "1.4.0-beta.0" + css-background-parser "^0.1.0" + css-box-shadow "1.0.0-3" + css-to-react-native "^3.0.0" + emoji-regex "^10.2.1" + escape-html "^1.0.3" + linebreak "^1.1.0" + parse-css-color "^0.2.1" + postcss-value-parser "^4.2.0" + yoga-wasm-web "^0.3.3" + +semver-compare@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" + integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== + +send@0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" + integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + +serve-static@1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" + integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.18.0" + +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +side-channel@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + +sonic-boom@^3.7.0: + version "3.8.1" + resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-3.8.1.tgz#d5ba8c4e26d6176c9a1d14d549d9ff579a163422" + integrity sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg== + dependencies: + atomic-sleep "^1.0.0" + +sonic-boom@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-4.0.1.tgz#515b7cef2c9290cb362c4536388ddeece07aed30" + integrity sha512-hTSD/6JMLyT4r9zeof6UtuBDpjJ9sO08/nmS5djaA9eozT9oOlNdpXSnzcgj4FTqpk3nkLrs61l4gip9r1HCrQ== + dependencies: + atomic-sleep "^1.0.0" + +split2@^4.0.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" + integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== + +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +string.prototype.codepointat@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz#004ad44c8afc727527b108cd462b4d971cd469bc" + integrity sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg== + +string_decoder@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +thread-stream@^2.6.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-2.7.0.tgz#d8a8e1b3fd538a6cca8ce69dbe5d3d097b601e11" + integrity sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw== + dependencies: + real-require "^0.2.0" + +thread-stream@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-3.1.0.tgz#4b2ef252a7c215064507d4ef70c05a5e2d34c4f1" + integrity sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A== + dependencies: + real-require "^0.2.0" + +tiny-inflate@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" + integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw== + +tlds@^1.234.0: + version "1.252.0" + resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.252.0.tgz#71d9617f4ef4cc7347843bee72428e71b8b0f419" + integrity sha512-GA16+8HXvqtfEnw/DTcwB0UU354QE1n3+wh08oFjr6Znl7ZLAeUgYzCcK+/CCrOyE0vnHR8/pu3XXG3vDijXpQ== + +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +type-fest@^2.3.3: + version "2.19.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" + integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== + +type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +typescript@^5.4.5: + version "5.4.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" + integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== + +uint8arrays@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/uint8arrays/-/uint8arrays-3.0.0.tgz#260869efb8422418b6f04e3fac73a3908175c63b" + integrity sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA== + dependencies: + multiformats "^9.4.2" + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + +unicode-trie@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unicode-trie/-/unicode-trie-2.0.0.tgz#8fd8845696e2e14a8b67d78fa9e0dd2cad62fec8" + integrity sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ== + dependencies: + pako "^0.2.5" + tiny-inflate "^1.0.0" + +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +yoga-wasm-web@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz#eb8e9fcb18e5e651994732f19a220cb885d932ba" + integrity sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA== + +zod@^3.21.4: + version "3.23.8" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== diff --git a/bskyweb/cmd/bskyweb/main.go b/bskyweb/cmd/bskyweb/main.go index 5185ff573..49629e3f2 100644 --- a/bskyweb/cmd/bskyweb/main.go +++ b/bskyweb/cmd/bskyweb/main.go @@ -35,7 +35,7 @@ func run(args []string) { Flags: []cli.Flag{ &cli.StringFlag{ Name: "appview-host", - Usage: "method, hostname, and port of PDS instance", + Usage: "scheme, hostname, and port of PDS instance", Value: "http://localhost:2584", // retain old PDS env var for easy transition EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"}, @@ -47,6 +47,13 @@ func run(args []string) { Value: ":8100", EnvVars: []string{"HTTP_ADDRESS"}, }, + &cli.StringFlag{ + Name: "link-host", + Usage: "scheme, hostname, and port of link service", + Required: false, + Value: "", + EnvVars: []string{"LINK_HOST"}, + }, &cli.BoolFlag{ Name: "debug", Usage: "Enable debug mode", diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index bb81e780f..6d32e0e21 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -6,6 +6,7 @@ import ( "fmt" "io/fs" "net/http" + "net/url" "os" "os/signal" "strings" @@ -36,6 +37,7 @@ func serve(cctx *cli.Context) error { debug := cctx.Bool("debug") httpAddress := cctx.String("http-address") appviewHost := cctx.String("appview-host") + linkHost := cctx.String("link-host") // Echo e := echo.New() @@ -221,6 +223,14 @@ func serve(cctx *cli.Context) error { e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric) e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric) + if linkHost != "" { + linkUrl, err := url.Parse(linkHost) + if err != nil { + return err + } + e.Group("/:linkId", server.LinkProxyMiddleware(linkUrl)) + } + // Start the server. log.Infof("starting server address=%s", httpAddress) go func() { @@ -292,6 +302,30 @@ func (srv *Server) Download(c echo.Context) error { return c.Redirect(http.StatusFound, "/") } +// Handler for proxying top-level paths to link service, which ends up serving a redirect +func (srv *Server) LinkProxyMiddleware(url *url.URL) echo.MiddlewareFunc { + return middleware.ProxyWithConfig( + middleware.ProxyConfig{ + Balancer: middleware.NewRoundRobinBalancer( + []*middleware.ProxyTarget{{URL: url}}, + ), + Skipper: func(c echo.Context) bool { + req := c.Request() + if req.Method == "GET" && + strings.LastIndex(strings.TrimRight(req.URL.Path, "/"), "/") == 0 && // top-level path + !strings.HasPrefix(req.URL.Path, "/_") { // e.g. /_health endpoint + return false + } + return true + }, + RetryCount: 2, + ErrorHandler: func(c echo.Context, err error) error { + return c.Redirect(302, "/") + }, + }, + ) +} + // handler for endpoint that have no specific server-side handling func (srv *Server) WebGeneric(c echo.Context) error { data := pongo2.Context{} diff --git a/src/App.native.tsx b/src/App.native.tsx index 18461fdd0..4c73d8752 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -24,6 +24,7 @@ import { import {s} from '#/lib/styles' import {ThemeProvider} from '#/lib/ThemeContext' import {logger} from '#/logger' +import {Provider as A11yProvider} from '#/state/a11y' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' import {Provider as InvitesStateProvider} from '#/state/invites' @@ -152,27 +153,29 @@ function App() { * that is set up in the InnerApp component above. */ return ( - <KeyboardProvider enabled={false} statusBarTranslucent={true}> - <SessionProvider> - <ShellStateProvider> - <PrefsStateProvider> - <InvitesStateProvider> - <ModalStateProvider> - <DialogStateProvider> - <LightboxStateProvider> - <I18nProvider> - <PortalProvider> - <InnerApp /> - </PortalProvider> - </I18nProvider> - </LightboxStateProvider> - </DialogStateProvider> - </ModalStateProvider> - </InvitesStateProvider> - </PrefsStateProvider> - </ShellStateProvider> - </SessionProvider> - </KeyboardProvider> + <A11yProvider> + <KeyboardProvider enabled={false} statusBarTranslucent={true}> + <SessionProvider> + <ShellStateProvider> + <PrefsStateProvider> + <InvitesStateProvider> + <ModalStateProvider> + <DialogStateProvider> + <LightboxStateProvider> + <I18nProvider> + <PortalProvider> + <InnerApp /> + </PortalProvider> + </I18nProvider> + </LightboxStateProvider> + </DialogStateProvider> + </ModalStateProvider> + </InvitesStateProvider> + </PrefsStateProvider> + </ShellStateProvider> + </SessionProvider> + </KeyboardProvider> + </A11yProvider> ) } diff --git a/src/App.web.tsx b/src/App.web.tsx index 6af3c7d6f..00939c9eb 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -13,6 +13,7 @@ import {QueryProvider} from '#/lib/react-query' import {Provider as StatsigProvider} from '#/lib/statsig/statsig' import {ThemeProvider} from '#/lib/ThemeContext' import {logger} from '#/logger' +import {Provider as A11yProvider} from '#/state/a11y' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' import {Provider as InvitesStateProvider} from '#/state/invites' @@ -135,25 +136,27 @@ function App() { * that is set up in the InnerApp component above. */ return ( - <SessionProvider> - <ShellStateProvider> - <PrefsStateProvider> - <InvitesStateProvider> - <ModalStateProvider> - <DialogStateProvider> - <LightboxStateProvider> - <I18nProvider> - <PortalProvider> - <InnerApp /> - </PortalProvider> - </I18nProvider> - </LightboxStateProvider> - </DialogStateProvider> - </ModalStateProvider> - </InvitesStateProvider> - </PrefsStateProvider> - </ShellStateProvider> - </SessionProvider> + <A11yProvider> + <SessionProvider> + <ShellStateProvider> + <PrefsStateProvider> + <InvitesStateProvider> + <ModalStateProvider> + <DialogStateProvider> + <LightboxStateProvider> + <I18nProvider> + <PortalProvider> + <InnerApp /> + </PortalProvider> + </I18nProvider> + </LightboxStateProvider> + </DialogStateProvider> + </ModalStateProvider> + </InvitesStateProvider> + </PrefsStateProvider> + </ShellStateProvider> + </SessionProvider> + </A11yProvider> ) } diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 5d4ba0e3f..f2b7cd911 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -312,7 +312,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => MessagesSettingsScreen} options={{title: title(msg`Chat settings`), requireAuth: true}} /> - <Stack.Screen name="Feeds" getComponent={() => FeedsScreen} /> + <Stack.Screen + name="Feeds" + getComponent={() => FeedsScreen} + options={{title: title(msg`Feeds`)}} + /> </> ) } diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx index 94d97cb62..7f3cb88ff 100644 --- a/src/components/FeedCard.tsx +++ b/src/components/FeedCard.tsx @@ -1,8 +1,14 @@ import React from 'react' import {GestureResponderEvent, View} from 'react-native' -import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api' +import { + AppBskyActorDefs, + AppBskyFeedDefs, + AppBskyGraphDefs, + AtUri, +} from '@atproto/api' import {msg, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' import {logger} from '#/logger' import { @@ -11,6 +17,7 @@ import { useRemoveFeedMutation, } from '#/state/queries/preferences' import {sanitizeHandle} from 'lib/strings/handles' +import {precacheFeedFromGeneratorView, precacheList} from 'state/queries/feed' import {useSession} from 'state/session' import {UserAvatar} from '#/view/com/util/UserAvatar' import * as Toast from 'view/com/util/Toast' @@ -20,41 +27,72 @@ import {Button, ButtonIcon} from '#/components/Button' import {useRichText} from '#/components/hooks/useRichText' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import {Link as InternalLink} from '#/components/Link' +import {Link as InternalLink, LinkProps} from '#/components/Link' import {Loader} from '#/components/Loader' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' -export function Default({feed}: {feed: AppBskyFeedDefs.GeneratorView}) { +type Props = + | { + type: 'feed' + view: AppBskyFeedDefs.GeneratorView + } + | { + type: 'list' + view: AppBskyGraphDefs.ListView + } + +export function Default(props: Props) { + const {type, view} = props + const displayName = type === 'feed' ? view.displayName : view.name + const purpose = type === 'list' ? view.purpose : undefined return ( - <Link feed={feed}> + <Link label={displayName} {...props}> <Outer> <Header> - <Avatar src={feed.avatar} /> - <TitleAndByline title={feed.displayName} creator={feed.creator} /> - <Action uri={feed.uri} pin /> + <Avatar src={view.avatar} /> + <TitleAndByline + title={displayName} + creator={view.creator} + type={type} + purpose={purpose} + /> + <Action uri={view.uri} pin type={type} purpose={purpose} /> </Header> - <Description description={feed.description} /> - <Likes count={feed.likeCount || 0} /> + <Description description={view.description} /> + {type === 'feed' && <Likes count={view.likeCount || 0} />} </Outer> </Link> ) } export function Link({ + type, + view, + label, children, - feed, -}: { - children: React.ReactElement - feed: AppBskyFeedDefs.GeneratorView -}) { +}: Props & Omit<LinkProps, 'to'>) { + const queryClient = useQueryClient() + const href = React.useMemo(() => { - const urip = new AtUri(feed.uri) - const handleOrDid = feed.creator.handle || feed.creator.did - return `/profile/${handleOrDid}/feed/${urip.rkey}` - }, [feed]) - return <InternalLink to={href}>{children}</InternalLink> + return createProfileFeedHref({feed: view}) + }, [view]) + + return ( + <InternalLink + to={href} + label={label} + onPress={() => { + if (type === 'feed') { + precacheFeedFromGeneratorView(queryClient, view) + } else { + precacheList(queryClient, view) + } + }}> + {children} + </InternalLink> + ) } export function Outer({children}: {children: React.ReactNode}) { @@ -62,34 +100,100 @@ export function Outer({children}: {children: React.ReactNode}) { } export function Header({children}: {children: React.ReactNode}) { - return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View> + return ( + <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_md]}> + {children} + </View> + ) +} + +export type AvatarProps = {src: string | undefined; size?: number} + +export function Avatar({src, size = 40}: AvatarProps) { + return <UserAvatar type="algo" size={size} avatar={src} /> } -export function Avatar({src}: {src: string | undefined}) { - return <UserAvatar type="algo" size={40} avatar={src} /> +export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) { + const t = useTheme() + return ( + <View + style={[ + t.atoms.bg_contrast_25, + { + width: size, + height: size, + borderRadius: 8, + }, + ]} + /> + ) } export function TitleAndByline({ title, creator, + type, + purpose, }: { title: string - creator: AppBskyActorDefs.ProfileViewBasic + creator?: AppBskyActorDefs.ProfileViewBasic + type: 'feed' | 'list' + purpose?: AppBskyGraphDefs.ListView['purpose'] }) { const t = useTheme() return ( <View style={[a.flex_1]}> - <Text - style={[a.text_md, a.font_bold, a.flex_1, a.leading_snug]} - numberOfLines={1}> + <Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}> {title} </Text> - <Text - style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]} - numberOfLines={1}> - <Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans> - </Text> + {creator && ( + <Text + style={[a.leading_snug, t.atoms.text_contrast_medium]} + numberOfLines={1}> + {type === 'list' && purpose === 'app.bsky.graph.defs#curatelist' ? ( + <Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans> + ) : type === 'list' && purpose === 'app.bsky.graph.defs#modlist' ? ( + <Trans> + Moderation list by {sanitizeHandle(creator.handle, '@')} + </Trans> + ) : ( + <Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans> + )} + </Text> + )} + </View> + ) +} + +export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) { + const t = useTheme() + + return ( + <View style={[a.flex_1, a.gap_xs]}> + <View + style={[ + a.rounded_xs, + t.atoms.bg_contrast_50, + { + width: '60%', + height: 14, + }, + ]} + /> + + {creator && ( + <View + style={[ + a.rounded_xs, + t.atoms.bg_contrast_25, + { + width: '40%', + height: 10, + }, + ]} + /> + )} </View> ) } @@ -116,13 +220,31 @@ export function Likes({count}: {count: number}) { ) } -export function Action({uri, pin}: {uri: string; pin?: boolean}) { +export function Action({ + uri, + pin, + type, + purpose, +}: { + uri: string + pin?: boolean + type: 'feed' | 'list' + purpose?: AppBskyGraphDefs.ListView['purpose'] +}) { const {hasSession} = useSession() - if (!hasSession) return null - return <ActionInner uri={uri} pin={pin} /> + if (!hasSession || purpose !== 'app.bsky.graph.defs#curatelist') return null + return <ActionInner uri={uri} pin={pin} type={type} /> } -function ActionInner({uri, pin}: {uri: string; pin?: boolean}) { +function ActionInner({ + uri, + pin, + type, +}: { + uri: string + pin?: boolean + type: 'feed' | 'list' +}) { const {_} = useLingui() const {data: preferences} = usePreferencesQuery() const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} = @@ -130,9 +252,7 @@ function ActionInner({uri, pin}: {uri: string; pin?: boolean}) { const {isPending: isRemovePending, mutateAsync: removeFeed} = useRemoveFeedMutation() const savedFeedConfig = React.useMemo(() => { - return preferences?.savedFeeds?.find( - feed => feed.type === 'feed' && feed.value === uri, - ) + return preferences?.savedFeeds?.find(feed => feed.value === uri) }, [preferences?.savedFeeds, uri]) const removePromptControl = Prompt.usePromptControl() const isPending = isAddSavedFeedPending || isRemovePending @@ -148,7 +268,7 @@ function ActionInner({uri, pin}: {uri: string; pin?: boolean}) { } else { await saveFeeds([ { - type: 'feed', + type, value: uri, pinned: pin || false, }, @@ -160,7 +280,7 @@ function ActionInner({uri, pin}: {uri: string; pin?: boolean}) { Toast.show(_(msg`Failed to update feeds`)) } }, - [_, pin, saveFeeds, removeFeed, uri, savedFeedConfig], + [_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type], ) const onPrompRemoveFeed = React.useCallback( @@ -203,3 +323,16 @@ function ActionInner({uri, pin}: {uri: string; pin?: boolean}) { </> ) } + +export function createProfileFeedHref({ + feed, +}: { + feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView +}) { + const urip = new AtUri(feed.uri) + const type = urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'list' + const handleOrDid = feed.creator.handle || feed.creator.did + return `/profile/${handleOrDid}/${type === 'feed' ? 'feed' : 'lists'}/${ + urip.rkey + }` +} diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index 319eccfa4..4db9c4f8e 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -64,7 +64,7 @@ export function ProfileHoverCard(props: ProfileHoverCardProps) { return props.children } else { return ( - <View onPointerMove={onPointerMove}> + <View onPointerMove={onPointerMove} style={[a.flex_shrink]}> <ProfileHoverCardInner {...props} /> </View> ) diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 0d77ec8a3..2e8cedb54 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -73,6 +73,22 @@ export type LogEvents = { feedType: string reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest' } + 'discover:showMore': { + feedContext: string + } + 'discover:showLess': { + feedContext: string + } + 'discover:clickthrough:sampled': { + count: number + } + 'discover:engaged:sampled': { + count: number + } + 'discover:seen:sampled': { + count: number + } + 'composer:gif:open': {} 'composer:gif:select': {} diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 6e460dc60..46ef934ef 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -1,5 +1,6 @@ export type Gate = // Keep this alphabetic please. + | 'debug_show_feedcontext' | 'native_pwi_disabled' | 'request_notifications_permission_after_onboarding_v2' | 'show_avi_follow_button' diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx index b5a239c3a..94a1e63d0 100644 --- a/src/lib/statsig/statsig.tsx +++ b/src/lib/statsig/statsig.tsx @@ -115,6 +115,9 @@ const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([ 'home:feedDisplayed:sampled', 'feed:endReached:sampled', 'feed:refresh:sampled', + 'discover:clickthrough:sampled', + 'discover:engaged:sampled', + 'discover:seen:sampled', ]) const isDownsampledSession = Math.random() < 0.9 // 90% likely diff --git a/src/locale/locales/it/messages.po b/src/locale/locales/it/messages.po index e2c2bdb3a..59660ca2e 100644 --- a/src/locale/locales/it/messages.po +++ b/src/locale/locales/it/messages.po @@ -165,7 +165,7 @@ msgstr "" #~ msgstr "<0>{following} </0><1>following</1>" #~ msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>" -#~ msgstr "<0>Scegli i tuoi</0><1>feeds</1><2>consigliati</2>" +#~ msgstr "<0>Scegli i tuoi</0><1>feed/1><2>consigliati</2>" #~ msgid "<0>Follow some</0><1>Recommended</1><2>Users</2>" #~ msgstr "<0>Segui alcuni</0><1>utenti</1><2>consigliati</2>" @@ -356,7 +356,7 @@ msgstr "Aggiunto alla lista" #: src/view/com/feeds/FeedSourceCard.tsx:126 msgid "Added to my feeds" -msgstr "Aggiunto ai miei feeds" +msgstr "Aggiunto ai miei feed" #: src/view/screens/PreferencesFollowingFeed.tsx:172 msgid "Adjust the number of likes a reply must have to be shown in your feed." @@ -395,7 +395,7 @@ msgstr "" #: src/screens/Messages/Settings.tsx:62 #: src/screens/Messages/Settings.tsx:65 msgid "Allow new messages from" -msgstr "" +msgstr "Consenti nuovi messaggi da" #: src/screens/Login/ForgotPasswordForm.tsx:178 #: src/view/com/modals/ChangePassword.tsx:171 @@ -936,12 +936,12 @@ msgstr "Conversazione silenziata" #: src/screens/Messages/List/index.tsx:88 #: src/view/screens/Settings/index.tsx:638 msgid "Chat settings" -msgstr "" +msgstr "Impostazioni messaggi" #: src/screens/Messages/Settings.tsx:59 #: src/view/screens/Settings/index.tsx:647 msgid "Chat Settings" -msgstr "" +msgstr "Impostazioni messaggi" #: src/components/dms/ConvoMenu.tsx:84 msgid "Chat unmuted" @@ -1043,7 +1043,7 @@ msgstr "" #: src/screens/Feeds/NoFollowingFeed.tsx:46 #~ msgid "Click here to add one." -#~ msgstr "" +#~ msgstr "Clicca qui per aggiungerne uno." #: src/components/TagMenu/index.web.tsx:138 msgid "Click here to open tag menu for {tag}" @@ -1665,14 +1665,14 @@ msgstr "Scoraggia le app dal mostrare il mio account agli utenti disconnessi" #: src/view/com/posts/FollowingEmptyState.tsx:70 #: src/view/com/posts/FollowingEndOfFeed.tsx:71 msgid "Discover new custom feeds" -msgstr "Scopri nuovi feeds personalizzati" +msgstr "Scopri nuovi feed personalizzati" #~ msgid "Discover new feeds" -#~ msgstr "Scopri nuovi feeds" +#~ msgstr "Scopri nuovi feed" #: src/view/screens/Feeds.tsx:794 msgid "Discover New Feeds" -msgstr "Scopri nuovi feeds" +msgstr "Scopri nuovi feed" #: src/view/com/modals/EditProfile.tsx:193 msgid "Display name" @@ -1831,7 +1831,7 @@ msgstr "Modifica l'elenco di moderazione" #: src/view/screens/Feeds.tsx:469 #: src/view/screens/SavedFeeds.tsx:93 msgid "Edit My Feeds" -msgstr "Modifica i miei feeds" +msgstr "Modifica i miei feed" #: src/view/com/modals/EditProfile.tsx:153 msgid "Edit my profile" @@ -1850,7 +1850,7 @@ msgstr "Modifica il Profilo" #: src/view/com/home/HomeHeaderLayout.web.tsx:76 #: src/view/screens/Feeds.tsx:416 #~ msgid "Edit Saved Feeds" -#~ msgstr "Modifica i feeds memorizzati" +#~ msgstr "Modifica i feed memorizzati" #: src/view/com/modals/CreateOrEditList.tsx:234 msgid "Edit User List" @@ -1927,7 +1927,7 @@ msgstr "Attiva il contenuto per adulti" #: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:78 #: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:79 #~ msgid "Enable adult content in your feeds" -#~ msgstr "Abilita i contenuti per adulti nei tuoi feeds" +#~ msgstr "Abilita i contenuti per adulti nei tuoi feed" #: src/components/dialogs/EmbedConsent.tsx:82 #: src/components/dialogs/EmbedConsent.tsx:89 @@ -2202,7 +2202,7 @@ msgstr "Commenti" #: src/view/shell/Drawer.tsx:493 #: src/view/shell/Drawer.tsx:494 msgid "Feeds" -msgstr "Feeds" +msgstr "Feed" #~ msgid "Feeds are created by users to curate content. Choose some feeds that you find interesting." #~ msgstr "I feed vengono creati dagli utenti per curare i contenuti. Scegli alcuni feed che ritieni interessanti." @@ -2213,7 +2213,7 @@ msgstr "I feed sono algoritmi personalizzati che gli utenti creano con un minimo #: src/screens/Onboarding/StepTopicalFeeds.tsx:80 #~ msgid "Feeds can be topical as well!" -#~ msgstr "I feeds possono anche avere tematiche!" +#~ msgstr "I feed possono anche avere tematiche!" #: src/view/com/modals/ChangeHandle.tsx:475 msgid "File Contents" @@ -3214,7 +3214,7 @@ msgstr "Il messaggio è troppo lungo" #: src/screens/Messages/List/index.tsx:321 msgid "Message settings" -msgstr "Impostazione messaggio" +msgstr "Impostazioni messaggio" #: src/Navigation.tsx:504 #: src/screens/Messages/List/index.tsx:164 @@ -3412,7 +3412,7 @@ msgstr "Il mio Compleanno" #: src/view/screens/Feeds.tsx:768 msgid "My Feeds" -msgstr "I miei Feeds" +msgstr "I miei Feed" #: src/view/shell/desktop/LeftNav.tsx:84 msgid "My Profile" @@ -3424,7 +3424,7 @@ msgstr "I miei feed salvati" #: src/view/screens/Settings/index.tsx:622 msgid "My Saved Feeds" -msgstr "I miei Feeds Salvati" +msgstr "I miei Feed Salvati" #~ msgid "my-server.com" #~ msgstr "my-server.com" @@ -3862,7 +3862,7 @@ msgstr "Apre la fotocamera sul dispositivo" #: src/view/screens/Settings/index.tsx:639 msgid "Opens chat settings" -msgstr "" +msgstr "Apre impostazioni messaggi" #: src/view/com/composer/Prompt.tsx:27 msgid "Opens composer" @@ -4112,7 +4112,7 @@ msgstr "Fissa su Home" #: src/view/screens/SavedFeeds.tsx:103 msgid "Pinned Feeds" -msgstr "Feeds Fissi" +msgstr "Feed Fissi" #: src/view/screens/ProfileList.tsx:289 msgid "Pinned to your feeds" @@ -4380,7 +4380,7 @@ msgstr "Elenchi pubblici e condivisibili di utenti da disattivare o bloccare in #: src/view/screens/Lists.tsx:66 msgid "Public, shareable lists which can drive feeds." -msgstr "Liste pubbliche e condivisibili che possono impulsare i feeds." +msgstr "Liste pubbliche e condivisibili che possono impulsare i feed." #: src/view/com/composer/Composer.tsx:462 msgid "Publish post" @@ -4431,7 +4431,7 @@ msgid "Recent Searches" msgstr "Ricerche recenti" #~ msgid "Recommended Feeds" -#~ msgstr "Feeds consigliati" +#~ msgstr "Feed consigliati" #~ msgid "Recommended Users" #~ msgstr "Utenti consigliati" @@ -4454,7 +4454,7 @@ msgid "Remove" msgstr "Rimuovi" #~ msgid "Remove {0} from my feeds?" -#~ msgstr "Rimuovere {0} dai miei feeds?" +#~ msgstr "Rimuovere {0} dai miei feed?" #: src/view/com/util/AccountDropdownBtn.tsx:22 msgid "Remove account" @@ -4524,14 +4524,14 @@ msgid "Remove repost" msgstr "Rimuovi la ripubblicazione" #~ msgid "Remove this feed from my feeds?" -#~ msgstr "Rimuovere questo feed dai miei feeds?" +#~ msgstr "Rimuovere questo feed dai miei feed?" #: src/view/com/posts/FeedErrorMessage.tsx:210 msgid "Remove this feed from your saved feeds" msgstr "Rimuovi questo feed dai feed salvati" #~ msgid "Remove this feed from your saved feeds?" -#~ msgstr "Elimina questo feed dai feeds salvati?" +#~ msgstr "Elimina questo feed dai feed salvati?" #: src/view/com/modals/ListAddRemoveUsers.tsx:199 #: src/view/com/modals/UserAddRemoveLists.tsx:165 @@ -4540,7 +4540,7 @@ msgstr "Elimina dalla lista" #: src/view/com/feeds/FeedSourceCard.tsx:139 msgid "Removed from my feeds" -msgstr "Rimuovere dai miei feeds" +msgstr "Rimuovere dai miei feed" #: src/view/com/posts/FeedShutdownMsg.tsx:44 #: src/view/screens/ProfileFeed.tsx:191 @@ -5047,7 +5047,7 @@ msgstr "Seleziona il servizio che ospita i tuoi dati." #: src/screens/Onboarding/StepTopicalFeeds.tsx:100 #~ msgid "Select topical feeds to follow from the list below" -#~ msgstr "Seleziona i feeds con temi da seguire dal seguente elenco" +#~ msgstr "Seleziona i feed con temi da seguire dal seguente elenco" #: src/screens/Onboarding/StepModeration/index.tsx:63 #~ msgid "Select what you want to see (or not see), and we’ll handle the rest." @@ -6154,7 +6154,7 @@ msgid "This will delete {0} from your muted words. You can always add it back la msgstr "Questo eliminerà {0} dalle parole disattivate. Puoi sempre aggiungerla nuovamente in seguito." #~ msgid "This will hide this post from your feeds." -#~ msgstr "Questo nasconderà il post dai tuoi feeds." +#~ msgstr "Questo nasconderà il post dai tuoi feed." #: src/view/screens/Settings/index.tsx:594 msgid "Thread preferences" @@ -6783,7 +6783,7 @@ msgstr "Che lingue sono utilizzate in questo post?" #: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:77 msgid "Which languages would you like to see in your algorithmic feeds?" -msgstr "Quali lingue vorresti vedere negli algoritmi dei tuoi feeds?" +msgstr "Quali lingue vorresti vedere negli algoritmi dei tuoi feed?" #: src/components/dms/MessagesNUX.tsx:110 #: src/components/dms/MessagesNUX.tsx:124 @@ -6901,7 +6901,7 @@ msgstr "Puoi modificarlo in qualsiasi momento." #: src/screens/Messages/Settings.tsx:111 msgid "You can continue ongoing conversations regardless of which setting you choose." -msgstr "" +msgstr "Puoi proseguire le conversazioni in corso indipendentemente da quale settaggio scegli." #: src/screens/Login/index.tsx:158 #: src/screens/Login/PasswordUpdatedForm.tsx:33 @@ -6982,7 +6982,7 @@ msgstr "Non hai ancora nessuna conversazione. Avviane una!" #: src/view/com/feeds/ProfileFeedgens.tsx:141 msgid "You have no feeds." -msgstr "Non hai feeds." +msgstr "Non hai feed." #: src/view/com/lists/MyLists.tsx:90 #: src/view/com/lists/ProfileLists.tsx:145 diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index e8dc12ce2..bb41c6331 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-06-05 11:06+0900\n" +"PO-Revision-Date: 2024-06-19 11:10+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -37,10 +37,6 @@ msgstr "{0, plural, other {#個ã®ãƒ©ãƒ™ãƒ«ãŒã“ã®ã‚³ãƒ³ãƒ†ãƒ³ãƒ„ã«é©ç”¨ã•ã msgid "{0, plural, one {# repost} other {# reposts}}" msgstr "{0, plural, other {#回ã®ãƒªãƒã‚¹ãƒˆ}}" -#: src/components/KnownFollowers.tsx:179 -msgid "{0, plural, one {and # other} other {and # others}}" -msgstr "" - #: src/components/ProfileHoverCard/index.web.tsx:376 #: src/screens/Profile/Header/Metrics.tsx:23 msgid "{0, plural, one {follower} other {followers}}" @@ -87,6 +83,26 @@ msgstr "{0}ã®ã‚¢ãƒã‚¿ãƒ¼" msgid "{count, plural, one {Liked by # user} other {Liked by # users}}" msgstr "{count, plural, other {#人ã®ãƒ¦ãƒ¼ã‚¶ãƒ¼ãŒã„ã„ã}}" +#: src/lib/hooks/useTimeAgo.ts:69 +msgid "{diff, plural, one {day} other {days}}" +msgstr "{diff, plural, other {æ—¥}}" + +#: src/lib/hooks/useTimeAgo.ts:64 +msgid "{diff, plural, one {hour} other {hours}}" +msgstr "{diff, plural, other {時間}}" + +#: src/lib/hooks/useTimeAgo.ts:59 +msgid "{diff, plural, one {minute} other {minutes}}" +msgstr "{diff, plural, other {分}}" + +#: src/lib/hooks/useTimeAgo.ts:75 +msgid "{diff, plural, one {month} other {months}}" +msgstr "{diff, plural, other {ヶ月}}" + +#: src/lib/hooks/useTimeAgo.ts:54 +msgid "{diffSeconds, plural, one {second} other {seconds}}" +msgstr "{diffSeconds, plural, other {ç§’}}" + #: src/screens/SignupQueued.tsx:207 msgid "{estimatedTimeHrs, plural, one {hour} other {hours}}" msgstr "{estimatedTimeHrs, plural, other {時間}}" @@ -114,6 +130,10 @@ msgstr "{likeCount, plural, other {#人ã®ãƒ¦ãƒ¼ã‚¶ãƒ¼ãŒã„ã„ã}}" msgid "{numUnreadNotifications} unread" msgstr "{numUnreadNotifications}ä»¶ã®æœªèª" +#: src/components/NewskieDialog.tsx:75 +msgid "{profileName} joined Bluesky {0} ago" +msgstr "{profileName}ã¯Blueskyã«{0}å‰ã«å‚åŠ ã—ã¾ã—ãŸ" + #: src/view/screens/PreferencesFollowingFeed.tsx:67 msgid "{value, plural, =0 {Show all replies} one {Show replies with at least # like} other {Show replies with at least # likes}}" msgstr "{value, plural, =0 {ã™ã¹ã¦ã®è¿”信を表示} other {#個以上ã®ã„ã„ããŒã¤ã„ãŸè¿”信を表示}}" @@ -270,6 +290,10 @@ msgstr "フォãƒãƒ¼ã—ã¦ã„るユーザーã®ã¿ã®ãƒ‡ãƒ•ォルトã®ãƒ•ィー msgid "Add the following DNS record to your domain:" msgstr "次ã®DNSレコードをドメインã«è¿½åŠ ã—ã¦ãã ã•ã„:" +#: src/components/FeedCard.tsx:173 +msgid "Add this feed to your feeds" +msgstr "ã“ã®ãƒ•ィードをã‚ãªãŸã®ãƒ•ィードã«è¿½åŠ ã™ã‚‹" + #: src/view/com/profile/ProfileMenu.tsx:265 #: src/view/com/profile/ProfileMenu.tsx:268 msgid "Add to Lists" @@ -469,6 +493,10 @@ msgstr "ã“ã®ä¼šè©±ã‹ã‚‰é€€å‡ºã—ã¾ã™ã‹ï¼Ÿã‚ãªãŸã®ãƒ¡ãƒƒã‚»ãƒ¼ã‚¸ã¯ã‚ msgid "Are you sure you want to remove {0} from your feeds?" msgstr "ã‚ãªãŸã®ãƒ•ィードã‹ã‚‰{0}を削除ã—ã¦ã‚‚よã‚ã—ã„ã§ã™ã‹ï¼Ÿ" +#: src/components/FeedCard.tsx:190 +msgid "Are you sure you want to remove this from your feeds?" +msgstr "本当ã«ã“ã®ãƒ•ィードをã‚ãªãŸã®ãƒ•ィードã‹ã‚‰å‰Šé™¤ã—ãŸã„ã§ã™ã‹ï¼Ÿ" + #: src/view/com/composer/Composer.tsx:630 msgid "Are you sure you'd like to discard this draft?" msgstr "本当ã«ã“ã®ä¸‹æ›¸ãã‚’ç ´æ£„ã—ã¾ã™ã‹ï¼Ÿ" @@ -1092,7 +1120,7 @@ msgstr "{0}ã¨ã—ã¦ç¶šè¡Œï¼ˆç¾åœ¨ã‚µã‚¤ãƒ³ã‚¤ãƒ³ä¸ï¼‰" #: src/view/com/post-thread/PostThreadLoadMore.tsx:52 msgid "Continue thread..." -msgstr "" +msgstr "スレッドã®ç¶šã…" #: src/screens/Onboarding/StepInterests/index.tsx:250 #: src/screens/Onboarding/StepProfile/index.tsx:266 @@ -1419,6 +1447,10 @@ msgstr "アプリãŒãƒã‚°ã‚¢ã‚¦ãƒˆã—ãŸãƒ¦ãƒ¼ã‚¶ãƒ¼ã«è‡ªåˆ†ã®ã‚¢ã‚«ã‚¦ãƒ³ãƒˆ msgid "Discover new custom feeds" msgstr "æ–°ã—ã„カスタムフィードを見ã¤ã‘ã‚‹" +#: src/view/screens/Search/Explore.tsx:378 +msgid "Discover new feeds" +msgstr "æ–°ã—ã„フィードを探ã™" + #: src/view/screens/Feeds.tsx:794 msgid "Discover New Feeds" msgstr "æ–°ã—ã„フィードを探ã™" @@ -1534,16 +1566,16 @@ msgstr "例:返信ã¨ã—ã¦åºƒå‘Šã‚’繰り返ã—é€ã£ã¦ãるユーザー。 msgid "Each code works once. You'll receive more invite codes periodically." msgstr "ãれãžã‚Œã®ã‚³ãƒ¼ãƒ‰ã¯ä¸€å›žé™ã‚Šæœ‰åйã§ã™ã€‚定期的ã«è¿½åŠ ã®æ‹›å¾…コードをãŠé€ã‚Šã—ã¾ã™ã€‚" -#: src/view/screens/Feeds.tsx:400 -#: src/view/screens/Feeds.tsx:471 -msgid "Edit" -msgstr "" - #: src/view/com/lists/ListMembers.tsx:149 msgctxt "action" msgid "Edit" msgstr "編集" +#: src/view/screens/Feeds.tsx:400 +#: src/view/screens/Feeds.tsx:471 +msgid "Edit" +msgstr "編集" + #: src/view/com/util/UserAvatar.tsx:312 #: src/view/com/util/UserBanner.tsx:92 msgid "Edit avatar" @@ -1583,11 +1615,6 @@ msgstr "プãƒãƒ•ィールを編集" msgid "Edit Profile" msgstr "プãƒãƒ•ィールを編集" -#: src/view/com/home/HomeHeaderLayout.web.tsx:76 -#: src/view/screens/Feeds.tsx:416 -#~ msgid "Edit Saved Feeds" -#~ msgstr "ä¿å˜ã•れãŸãƒ•ィードを編集" - #: src/view/com/modals/CreateOrEditList.tsx:234 msgid "Edit User List" msgstr "ユーザーリストを編集" @@ -1754,6 +1781,10 @@ msgstr "全員" msgid "Everybody can reply" msgstr "誰ã§ã‚‚返信å¯èƒ½" +#: src/view/com/threadgate/WhoCanReply.tsx:129 +msgid "Everybody can reply." +msgstr "誰ã§ã‚‚返信å¯èƒ½ã§ã™ã€‚" + #: src/components/dms/MessagesNUX.tsx:131 #: src/components/dms/MessagesNUX.tsx:134 #: src/screens/Messages/Settings.tsx:75 @@ -1857,6 +1888,11 @@ msgstr "メッセージã®å‰Šé™¤ã«å¤±æ•—ã—ã¾ã—ãŸ" msgid "Failed to delete post, please try again" msgstr "投稿ã®å‰Šé™¤ã«å¤±æ•—ã—ã¾ã—ãŸã€‚ã‚‚ã†ä¸€åº¦ãŠè©¦ã—ãã ã•ã„。" +#: src/view/screens/Search/Explore.tsx:414 +#: src/view/screens/Search/Explore.tsx:438 +msgid "Failed to load feeds preferences" +msgstr "フィードã®è¨å®šã®èªã¿è¾¼ã¿ã«å¤±æ•—ã—ã¾ã—ãŸ" + #: src/components/dialogs/GifSelect.ios.tsx:196 #: src/components/dialogs/GifSelect.tsx:212 msgid "Failed to load GIFs" @@ -1866,6 +1902,15 @@ msgstr "GIFã®èªã¿è¾¼ã¿ã«å¤±æ•—ã—ã¾ã—ãŸ" msgid "Failed to load past messages" msgstr "éŽåŽ»ã®ãƒ¡ãƒƒã‚»ãƒ¼ã‚¸ã®èªã¿è¾¼ã¿ã«å¤±æ•—ã—ã¾ã—ãŸ" +#: src/view/screens/Search/Explore.tsx:407 +#: src/view/screens/Search/Explore.tsx:431 +msgid "Failed to load suggested feeds" +msgstr "ãŠã™ã™ã‚ã®ãƒ•ィードã®èªã¿è¾¼ã¿ã«å¤±æ•—ã—ã¾ã—ãŸ" + +#: src/view/screens/Search/Explore.tsx:367 +msgid "Failed to load suggested follows" +msgstr "ãŠã™ã™ã‚ã®ãƒ•ã‚©ãƒãƒ¼ã®èªã¿è¾¼ã¿ã«å¤±æ•—ã—ã¾ã—ãŸ" + #: src/view/com/lightbox/Lightbox.tsx:84 msgid "Failed to save image: {0}" msgstr "ç”»åƒã®ä¿å˜ã«å¤±æ•—ã—ã¾ã—ãŸï¼š{0}" @@ -1879,6 +1924,14 @@ msgstr "é€ä¿¡ã«å¤±æ•—" msgid "Failed to submit appeal, please try again." msgstr "ç•°è°ç”³ã—ç«‹ã¦ã®é€ä¿¡ã«å¤±æ•—ã—ã¾ã—ãŸã€‚å†åº¦è©¦ã—ã¦ãã ã•ã„。" +#: src/view/com/util/forms/PostDropdownBtn.tsx:180 +msgid "Failed to toggle thread mute, please try again" +msgstr "スレッドã®ãƒŸãƒ¥ãƒ¼ãƒˆã®åˆ‡ã‚Šæ›¿ãˆã«å¤±æ•—ã—ã¾ã—ãŸã€‚å†åº¦è©¦ã—ã¦ãã ã•ã„" + +#: src/components/FeedCard.tsx:153 +msgid "Failed to update feeds" +msgstr "ãƒ•ã‚£ãƒ¼ãƒ‰ã®æ›´æ–°ã«å¤±æ•—ã—ã¾ã—ãŸ" + #: src/components/dms/MessagesNUX.tsx:60 #: src/screens/Messages/Settings.tsx:35 msgid "Failed to update settings" @@ -1914,6 +1967,10 @@ msgstr "フィード" msgid "Feeds are custom algorithms that users build with a little coding expertise. <0/> for more information." msgstr "フィードã¯ãƒ¦ãƒ¼ã‚¶ãƒ¼ãŒãƒ—ãƒã‚°ãƒ©ãƒŸãƒ³ã‚°ã®å°‚門知è˜ã‚’æŒã£ã¦æ§‹ç¯‰ã™ã‚‹ã‚«ã‚¹ã‚¿ãƒ アルゴリズムã§ã™ã€‚詳細ã«ã¤ã„ã¦ã¯ã€<0/>ã‚’å‚ç…§ã—ã¦ãã ã•ã„。" +#: src/components/FeedCard.tsx:150 +msgid "Feeds updated!" +msgstr "フィードを更新ã—ã¾ã—ãŸï¼" + #: src/view/com/modals/ChangeHandle.tsx:475 msgid "File Contents" msgstr "ファイルã®ã‚³ãƒ³ãƒ†ãƒ³ãƒ„" @@ -1996,14 +2053,30 @@ msgstr "アカウントをフォãƒãƒ¼" msgid "Follow Back" msgstr "フォãƒãƒ¼ãƒãƒƒã‚¯" -#: src/components/KnownFollowers.tsx:169 -msgid "Followed by" -msgstr "" +#: src/view/screens/Search/Explore.tsx:332 +msgid "Follow more accounts to get connected to your interests and build your network." +msgstr "ã‚‚ã£ã¨ãŸãã•ã‚“ã®ã‚¢ã‚«ã‚¦ãƒ³ãƒˆã‚’フォãƒãƒ¼ã—ã¦ã€èˆˆå‘³ã‚ã‚‹ã“ã¨ã«ã¤ãªãŒã‚Šã€ãƒãƒƒãƒˆãƒ¯ãƒ¼ã‚¯ã‚’広ã’ã¾ã—ょã†ã€‚" #: src/view/com/profile/ProfileCard.tsx:227 msgid "Followed by {0}" msgstr "{0}ãŒãƒ•ã‚©ãƒãƒ¼ä¸" +#: src/components/KnownFollowers.tsx:192 +msgid "Followed by <0>{0}</0>" +msgstr "<0>{0}</0>ãŒãƒ•ã‚©ãƒãƒ¼ä¸" + +#: src/components/KnownFollowers.tsx:209 +msgid "Followed by <0>{0}</0> and {1, plural, one {# other} other {# others}}" +msgstr "<0>{0}</0>ãŠã‚ˆã³{1, plural, other {ä»–#人}}ãŒãƒ•ã‚©ãƒãƒ¼ä¸" + +#: src/components/KnownFollowers.tsx:181 +msgid "Followed by <0>{0}</0> and <1>{1}</1>" +msgstr "<0>{0}</0>ã¨<1>{1}</1>ãŒãƒ•ã‚©ãƒãƒ¼ä¸" + +#: src/components/KnownFollowers.tsx:168 +msgid "Followed by <0>{0}</0>, <1>{1}</1>, and {2, plural, one {# other} other {# others}}" +msgstr "<0>{0}</0>ã€<1>{1}</1>ãŠã‚ˆã³{2, plural, other {ä»–#人}}ãŒãƒ•ã‚©ãƒãƒ¼ä¸" + #: src/view/com/modals/Threadgate.tsx:99 msgid "Followed users" msgstr "自分ãŒãƒ•ã‚©ãƒãƒ¼ã—ã¦ã„るユーザー" @@ -2023,12 +2096,12 @@ msgstr "フォãƒãƒ¯ãƒ¼" #: src/Navigation.tsx:177 msgid "Followers of @{0} that you know" -msgstr "" +msgstr "ã‚ãªãŸãŒçŸ¥ã£ã¦ã„ã‚‹@{0}ã®ãƒ•ã‚©ãƒãƒ¯ãƒ¼" #: src/screens/Profile/KnownFollowers.tsx:108 #: src/screens/Profile/KnownFollowers.tsx:118 msgid "Followers you know" -msgstr "" +msgstr "ã‚ãªãŸãŒçŸ¥ã£ã¦ã„るフォãƒãƒ¯ãƒ¼" #: src/components/ProfileHoverCard/index.web.tsx:411 #: src/components/ProfileHoverCard/index.web.tsx:422 @@ -2118,6 +2191,10 @@ msgstr "å§‹ã‚ã‚‹" msgid "Get Started" msgstr "é–‹å§‹" +#: src/view/com/util/images/ImageHorzList.tsx:35 +msgid "GIF" +msgstr "GIF" + #: src/screens/Onboarding/StepProfile/index.tsx:225 msgid "Give your profile a face" msgstr "プãƒãƒ•ィールã«é¡”ã‚’ã¤ã‘ã‚‹" @@ -2658,6 +2735,18 @@ msgstr "リスト" msgid "Lists blocking this user:" msgstr "ã“ã®ãƒ¦ãƒ¼ã‚¶ãƒ¼ã‚’ブãƒãƒƒã‚¯ã—ã¦ã„るリスト:" +#: src/view/screens/Search/Explore.tsx:128 +msgid "Load more" +msgstr "ã•らã«èªã¿è¾¼ã‚€" + +#: src/view/screens/Search/Explore.tsx:216 +msgid "Load more suggested feeds" +msgstr "ãŠã™ã™ã‚ã®ãƒ•ィードをã•らã«èªã¿è¾¼ã‚€" + +#: src/view/screens/Search/Explore.tsx:214 +msgid "Load more suggested follows" +msgstr "ãŠã™ã™ã‚ã®ãƒ•ã‚©ãƒãƒ¼ã‚’ã•らã«èªã¿è¾¼ã‚€" + #: src/view/screens/Notifications.tsx:184 msgid "Load new notifications" msgstr "最新ã®é€šçŸ¥ã‚’èªã¿è¾¼ã‚€" @@ -3062,6 +3151,10 @@ msgctxt "action" msgid "New Post" msgstr "æ–°ã—ã„æŠ•ç¨¿" +#: src/components/NewskieDialog.tsx:68 +msgid "New user info dialog" +msgstr "æ–°ã—ã„ãƒ¦ãƒ¼ã‚¶ãƒ¼æƒ…å ±ãƒ€ã‚¤ã‚¢ãƒã‚°" + #: src/view/com/modals/CreateOrEditList.tsx:236 msgid "New User List" msgstr "æ–°ã—ã„ユーザーリスト" @@ -3142,7 +3235,7 @@ msgstr "誰ã‹ã‚‰ã‚‚å—ã‘å–らãªã„" #: src/screens/Profile/Sections/Feed.tsx:59 msgid "No posts yet." -msgstr "" +msgstr "ã¾ã 投稿ãŒã‚りã¾ã›ã‚“。" #: src/view/com/composer/text-input/mobile/Autocomplete.tsx:101 #: src/view/com/composer/text-input/web/Autocomplete.tsx:195 @@ -3236,6 +3329,10 @@ msgstr "通知音" msgid "Notifications" msgstr "通知" +#: src/lib/hooks/useTimeAgo.ts:51 +msgid "now" +msgstr "今" + #: src/components/dms/MessageItem.tsx:175 msgid "Now" msgstr "今" @@ -3274,6 +3371,10 @@ msgstr "OK" msgid "Oldest replies first" msgstr "å¤ã„é †ã«è¿”信を表示" +#: src/lib/hooks/useTimeAgo.ts:81 +msgid "on {str}" +msgstr "{str}" + #: src/view/screens/Settings/index.tsx:256 msgid "Onboarding reset" msgstr "オンボーディングã®ãƒªã‚»ãƒƒãƒˆ" @@ -3449,11 +3550,6 @@ msgstr "モデレーションã®è¨å®šã‚’é–‹ã" msgid "Opens password reset form" msgstr "パスワードリセットã®ãƒ•ォームを開ã" -#: src/view/com/home/HomeHeaderLayout.web.tsx:77 -#: src/view/screens/Feeds.tsx:417 -#~ msgid "Opens screen to edit Saved Feeds" -#~ msgstr "ä¿å˜ã•れãŸãƒ•ィードã®ç·¨é›†ç”»é¢ã‚’é–‹ã" - #: src/view/screens/Settings/index.tsx:617 msgid "Opens screen with all saved feeds" msgstr "ä¿å˜ã•れãŸã™ã¹ã¦ã®ãƒ•ィードã§ç”»é¢ã‚’é–‹ã" @@ -3777,7 +3873,7 @@ msgstr "å†å®Ÿè¡Œã™ã‚‹" #: src/components/KnownFollowers.tsx:111 msgid "Press to view followers of this account that you also follow" -msgstr "" +msgstr "ã‚ãªãŸã‚‚フォãƒãƒ¼ã—ã¦ã„ã‚‹ã“ã®ã‚¢ã‚«ã‚¦ãƒ³ãƒˆã®ãƒ•ã‚©ãƒãƒ¯ãƒ¼ã‚’見る" #: src/view/com/lightbox/Lightbox.web.tsx:150 msgid "Previous image" @@ -3975,7 +4071,7 @@ msgstr "リストã‹ã‚‰å‰Šé™¤ã•れã¾ã—ãŸ" #: src/view/com/feeds/FeedSourceCard.tsx:139 msgid "Removed from my feeds" -msgstr "フィードã‹ã‚‰å‰Šé™¤ã—ã¾ã—ãŸ" +msgstr "マイフィードã‹ã‚‰å‰Šé™¤ã—ã¾ã—ãŸ" #: src/view/com/posts/FeedShutdownMsg.tsx:44 #: src/view/screens/ProfileFeed.tsx:191 @@ -4000,9 +4096,9 @@ msgstr "Discoverã§ç½®ãæ›ãˆã‚‹" msgid "Replies" msgstr "返信" -#: src/view/com/threadgate/WhoCanReply.tsx:98 -msgid "Replies to this thread are disabled" -msgstr "ã“ã®ã‚¹ãƒ¬ãƒƒãƒ‰ã¸ã®è¿”ä¿¡ã¯ã§ãã¾ã›ã‚“" +#: src/view/com/threadgate/WhoCanReply.tsx:131 +msgid "Replies to this thread are disabled." +msgstr "ã“ã®ã‚¹ãƒ¬ãƒƒãƒ‰ã¸ã®è¿”ä¿¡ã¯ã§ãã¾ã›ã‚“。" #: src/view/com/composer/Composer.tsx:475 msgctxt "action" @@ -4019,6 +4115,11 @@ msgctxt "description" msgid "Reply to <0><1/></0>" msgstr "<0><1/></0>ã«è¿”ä¿¡" +#: src/view/com/posts/FeedItem.tsx:437 +msgctxt "description" +msgid "Reply to a blocked post" +msgstr "ブãƒãƒƒã‚¯ã—ãŸæŠ•ç¨¿ã¸ã®è¿”ä¿¡" + #: src/components/dms/MessageMenu.tsx:132 #: src/components/dms/MessagesListBlockedFooter.tsx:77 #: src/components/dms/MessagesListBlockedFooter.tsx:84 @@ -4926,9 +5027,9 @@ msgstr "ã“ã®ãƒ©ãƒ™ãƒ©ãƒ¼ã‚’登録" msgid "Subscribe to this list" msgstr "ã“ã®ãƒªã‚¹ãƒˆã«ç™»éŒ²" -#: src/view/screens/Search/Search.tsx:425 -msgid "Suggested Follows" -msgstr "ãŠã™ã™ã‚ã®ãƒ•ã‚©ãƒãƒ¼" +#: src/view/screens/Search/Explore.tsx:330 +msgid "Suggested accounts" +msgstr "ãŠã™ã™ã‚ã®ã‚¢ã‚«ã‚¦ãƒ³ãƒˆ" #: src/view/com/profile/ProfileHeaderSuggestedFollows.tsx:65 msgid "Suggested for you" @@ -5077,7 +5178,7 @@ msgstr "サービスè¦ç´„ã¯ç§»å‹•ã—ã¾ã—ãŸ" #: src/screens/Settings/components/DeactivateAccountDialog.tsx:86 msgid "There is no time limit for account deactivation, come back any time." -msgstr "アカウントã®ç„¡åŠ¹åŒ–ã«æœŸé™ã¯ã‚りã¾ã›ã‚“。ã„ã¤ã§ã‚‚戻ã£ã¦ã“れã¾ã™ã€‚" +msgstr "アカウントã®ç„¡åŠ¹åŒ–ã«æœŸé™ã¯ã‚りã¾ã›ã‚“。ã„ã¤ã§ã‚‚戻ã£ã¦ã“られã¾ã™ã€‚" #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:115 #: src/view/screens/ProfileFeed.tsx:541 @@ -5217,7 +5318,7 @@ msgstr "ã“ã®ã‚³ãƒ³ãƒ†ãƒ³ãƒ„ã¯Blueskyã®ã‚¢ã‚«ã‚¦ãƒ³ãƒˆãŒãªã„ã¨é–²è¦§ã§ã #: src/screens/Messages/List/ChatListItem.tsx:213 msgid "This conversation is with a deleted or a deactivated account. Press for options." -msgstr "" +msgstr "削除ã‚ã‚‹ã„ã¯ç„¡åŠ¹åŒ–ã•れãŸã‚¢ã‚«ã‚¦ãƒ³ãƒˆã¨ã®ä¼šè©±ã§ã™ã€‚押ã™ã¨é¸æŠžè‚¢ãŒè¡¨ç¤ºã•れã¾ã™ã€‚" #: src/view/screens/Settings/ExportCarDialog.tsx:93 msgid "This feature is in beta. You can read more about repository exports in <0>this blogpost</0>." @@ -5227,12 +5328,6 @@ msgstr "ã“ã®æ©Ÿèƒ½ã¯ãƒ™ãƒ¼ã‚¿ç‰ˆã§ã™ã€‚リãƒã‚¸ãƒˆãƒªã®ã‚¨ã‚¯ã‚¹ãƒãƒ¼ãƒˆ msgid "This feed is currently receiving high traffic and is temporarily unavailable. Please try again later." msgstr "ç¾åœ¨ã“ã®ãƒ•ィードã«ã¯ã‚¢ã‚¯ã‚»ã‚¹ãŒé›†ä¸ã—ã¦ãŠã‚Šã€ä¸€æ™‚çš„ã«ã”利用ã„ãŸã ã‘ã¾ã›ã‚“。時間をãŠã„ã¦ã‚‚ã†ä¸€åº¦ãŠè©¦ã—ãã ã•ã„。" -#: src/screens/Profile/Sections/Feed.tsx:59 -#: src/view/screens/ProfileFeed.tsx:471 -#: src/view/screens/ProfileList.tsx:729 -#~ msgid "This feed is empty!" -#~ msgstr "ã“ã®ãƒ•ィードã¯ç©ºã§ã™ï¼" - #: src/view/com/posts/CustomFeedEmptyState.tsx:37 msgid "This feed is empty! You may need to follow more users or tune your language settings." msgstr "ã“ã®ãƒ•ィードã¯ç©ºã§ã™ï¼ã‚‚ã£ã¨å¤šãã®ãƒ¦ãƒ¼ã‚¶ãƒ¼ã‚’フォãƒãƒ¼ã™ã‚‹ã‹ã€è¨€èªžã®è¨å®šã‚’調整ã™ã‚‹å¿…è¦ãŒã‚ã‚‹ã‹ã‚‚ã—れã¾ã›ã‚“。" @@ -5240,7 +5335,7 @@ msgstr "ã“ã®ãƒ•ィードã¯ç©ºã§ã™ï¼ã‚‚ã£ã¨å¤šãã®ãƒ¦ãƒ¼ã‚¶ãƒ¼ã‚’フォ #: src/view/screens/ProfileFeed.tsx:471 #: src/view/screens/ProfileList.tsx:729 msgid "This feed is empty." -msgstr "" +msgstr "ã“ã®ãƒ•ィードã¯ç©ºã§ã™ã€‚" #: src/view/com/posts/FeedShutdownMsg.tsx:97 msgid "This feed is no longer online. We are showing <0>Discover</0> instead." @@ -5336,6 +5431,10 @@ msgstr "ã“ã®ãƒ¦ãƒ¼ã‚¶ãƒ¼ã¯ãƒ–ãƒãƒƒã‚¯ã—ãŸ<0>{0}</0>リストã«å«ã¾ã‚Œã msgid "This user is included in the <0>{0}</0> list which you have muted." msgstr "ã“ã®ãƒ¦ãƒ¼ã‚¶ãƒ¼ã¯ãƒŸãƒ¥ãƒ¼ãƒˆã—ãŸ<0>{0}</0>リストã«å«ã¾ã‚Œã¦ã„ã¾ã™ã€‚" +#: src/components/NewskieDialog.tsx:50 +msgid "This user is new here. Press for more info about when they joined." +msgstr "æ–°ã—ã„ユーザーã§ã™ã€‚ã“ã“を押ã™ã¨ã„ã¤å‚åŠ ã—ãŸã‹ã®æƒ…å ±ãŒè¡¨ç¤ºã•れã¾ã™ã€‚" + #: src/view/com/profile/ProfileFollows.tsx:87 msgid "This user isn't following anyone." msgstr "ã“ã®ãƒ¦ãƒ¼ã‚¶ãƒ¼ã¯èª°ã‚‚フォãƒãƒ¼ã—ã¦ã„ã¾ã›ã‚“。" @@ -5762,6 +5861,10 @@ msgstr "{0}ã®ã‚¢ãƒã‚¿ãƒ¼ã‚’表示" msgid "View {0}'s profile" msgstr "{0}ã®ãƒ—ãƒãƒ•ィールを表示" +#: src/components/ProfileHoverCard/index.web.tsx:417 +msgid "View blocked user's profile" +msgstr "ブãƒãƒƒã‚¯ä¸ã®ãƒ¦ãƒ¼ã‚¶ãƒ¼ã®ãƒ—ãƒãƒ•ィールを表示" + #: src/view/screens/Log.tsx:52 msgid "View debug entry" msgstr "デãƒãƒƒã‚°ã‚¨ãƒ³ãƒˆãƒªãƒ¼ã‚’表示" @@ -5804,7 +5907,7 @@ msgstr "ã“ã®ãƒ•ィードã«ã„ã„ãã—ãŸãƒ¦ãƒ¼ã‚¶ãƒ¼ã‚’見る" #: src/view/com/home/HomeHeaderLayout.web.tsx:78 #: src/view/com/home/HomeHeaderLayoutMobile.tsx:84 msgid "View your feeds and explore more" -msgstr "" +msgstr "フィードを表示ã—ã€ã•らã«ãƒ•ィードを探ã™" #: src/view/com/modals/LinkWarning.tsx:89 #: src/view/com/modals/LinkWarning.tsx:95 @@ -5891,7 +5994,7 @@ msgstr "大変申ã—訳ã‚りã¾ã›ã‚“ãŒã€æ¤œç´¢ã‚’完了ã§ãã¾ã›ã‚“ã§ã— #: src/view/com/composer/Composer.tsx:318 msgid "We're sorry! The post you are replying to has been deleted." -msgstr "" +msgstr "大変申ã—訳ã‚りã¾ã›ã‚“ï¼è¿”ä¿¡ã—よã†ã¨ã—ã¦ã„る投稿ã¯å‰Šé™¤ã•れã¾ã—ãŸã€‚" #: src/components/Lists.tsx:212 #: src/view/screens/NotFound.tsx:48 @@ -5899,8 +6002,8 @@ msgid "We're sorry! We can't find the page you were looking for." msgstr "大変申ã—訳ã‚りã¾ã›ã‚“ï¼ãпޢã—ã®ãƒšãƒ¼ã‚¸ã¯è¦‹ã¤ã‹ã‚Šã¾ã›ã‚“。" #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:330 -msgid "We're sorry! You can only subscribe to ten labelers, and you've reached your limit of ten." -msgstr "大変申ã—訳ã‚りã¾ã›ã‚“ï¼ãƒ©ãƒ™ãƒ©ãƒ¼ã¯10ã¾ã§ã—ã‹ç™»éŒ²ã§ããšã€ã™ã§ã«ä¸Šé™ã«é”ã—ã¦ã„ã¾ã™ã€‚" +msgid "We're sorry! You can only subscribe to twenty labelers, and you've reached your limit of twenty." +msgstr "大変申ã—訳ã‚りã¾ã›ã‚“ï¼ãƒ©ãƒ™ãƒ©ãƒ¼ã¯20ã¾ã§ã—ã‹ç™»éŒ²ã§ããšã€ã™ã§ã«ä¸Šé™ã«é”ã—ã¦ã„ã¾ã™ã€‚" #: src/screens/Deactivated.tsx:128 msgid "Welcome back!" @@ -6047,7 +6150,7 @@ msgstr "ã‚ãªãŸã¯ã¾ã ã れもフォãƒãƒ¯ãƒ¼ãŒã„ã¾ã›ã‚“。" #: src/screens/Profile/KnownFollowers.tsx:99 msgid "You don't follow any users who follow @{name}." -msgstr "" +msgstr "@{name}をフォãƒãƒ¼ã—ã¦ã„るユーザーを誰もフォãƒãƒ¼ã—ã¦ã„ã¾ã›ã‚“。" #: src/view/com/modals/InviteCodes.tsx:67 msgid "You don't have any invite codes yet! We'll send you some when you've been on Bluesky for a little longer." diff --git a/src/locale/locales/ko/messages.po b/src/locale/locales/ko/messages.po index ea3088188..5f36a7eb7 100644 --- a/src/locale/locales/ko/messages.po +++ b/src/locale/locales/ko/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ko\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-06-08 15:32+0900\n" +"PO-Revision-Date: 2024-06-19 10:55+0900\n" "Last-Translator: quiple\n" "Language-Team: quiple, lens0021, HaruChanHeart, hazzzi, heartade\n" "Plural-Forms: \n" @@ -21,7 +21,7 @@ msgstr "(ìž„ë² ë“œ 콘í…ì¸ í¬í•¨)" msgid "(no email)" msgstr "(ì´ë©”ì¼ ì—†ìŒ)" -#: src/view/com/notifications/FeedItem.tsx:261 +#: src/view/com/notifications/FeedItem.tsx:263 msgid "{0, plural, one {{formattedCount} other} other {{formattedCount} others}}" msgstr "외 {0, plural, other {{formattedCount}}}명" @@ -33,32 +33,29 @@ msgstr "ì´ ê³„ì •ì— {0, plural, other {#}}ê°œì˜ ë¼ë²¨ì´ ì§€ì •ë¨" msgid "{0, plural, one {# label has been placed on this content} other {# labels have been placed on this content}}" msgstr "ì´ ì½˜í…ì¸ ì— {0, plural, other {#}}ê°œì˜ ë¼ë²¨ì´ ì§€ì •ë¨" -#: src/view/com/util/post-ctrls/RepostButton.tsx:65 +#: src/view/com/util/post-ctrls/RepostButton.tsx:66 msgid "{0, plural, one {# repost} other {# reposts}}" msgstr "{0, plural, other {#}}ê°œ" -#: src/components/KnownFollowers.tsx:179 -msgid "{0, plural, one {and # other} other {and # others}}" -msgstr "" - -#: src/components/ProfileHoverCard/index.web.tsx:376 +#: src/components/ProfileHoverCard/index.web.tsx:398 #: src/screens/Profile/Header/Metrics.tsx:23 msgid "{0, plural, one {follower} other {followers}}" msgstr "팔로워" -#: src/components/ProfileHoverCard/index.web.tsx:380 +#: src/components/ProfileHoverCard/index.web.tsx:402 #: src/screens/Profile/Header/Metrics.tsx:27 msgid "{0, plural, one {following} other {following}}" msgstr "팔로우 중" -#: src/view/com/util/post-ctrls/PostCtrls.tsx:252 +#: src/view/com/util/post-ctrls/PostCtrls.tsx:255 msgid "{0, plural, one {Like (# like)} other {Like (# likes)}}" msgstr "좋아요 ({0, plural, other {#}}ê°œ)" -#: src/view/com/post-thread/PostThreadItem.tsx:380 +#: src/view/com/post-thread/PostThreadItem.tsx:382 msgid "{0, plural, one {like} other {likes}}" msgstr "좋아요" +#: src/components/FeedCard.tsx:111 #: src/view/com/feeds/FeedSourceCard.tsx:301 msgid "{0, plural, one {Liked by # user} other {Liked by # users}}" msgstr "{0, plural, other {#}}ëª…ì˜ ì‚¬ìš©ìžê°€ 좋아함" @@ -67,19 +64,19 @@ msgstr "{0, plural, other {#}}ëª…ì˜ ì‚¬ìš©ìžê°€ 좋아함" msgid "{0, plural, one {post} other {posts}}" msgstr "게시물" -#: src/view/com/util/post-ctrls/PostCtrls.tsx:210 +#: src/view/com/util/post-ctrls/PostCtrls.tsx:213 msgid "{0, plural, one {Reply (# reply)} other {Reply (# replies)}}" msgstr "답글 ({0, plural, other {#}}ê°œ)" -#: src/view/com/post-thread/PostThreadItem.tsx:360 +#: src/view/com/post-thread/PostThreadItem.tsx:362 msgid "{0, plural, one {repost} other {reposts}}" msgstr "재게시" -#: src/view/com/util/post-ctrls/PostCtrls.tsx:248 +#: src/view/com/util/post-ctrls/PostCtrls.tsx:251 msgid "{0, plural, one {Unlike (# like)} other {Unlike (# likes)}}" msgstr "좋아요 취소 ({0, plural, other {#}}ê°œ)" -#: src/view/com/util/UserAvatar.tsx:406 +#: src/view/com/util/UserAvatar.tsx:419 msgid "{0}'s avatar" msgstr "{0} ë‹˜ì˜ ì•„ë°”íƒ€" @@ -87,6 +84,26 @@ msgstr "{0} ë‹˜ì˜ ì•„ë°”íƒ€" msgid "{count, plural, one {Liked by # user} other {Liked by # users}}" msgstr "{count, plural, other {#}}ëª…ì˜ ì‚¬ìš©ìžê°€ 좋아함" +#: src/lib/hooks/useTimeAgo.ts:69 +msgid "{diff, plural, one {day} other {days}}" +msgstr "ì¼" + +#: src/lib/hooks/useTimeAgo.ts:64 +msgid "{diff, plural, one {hour} other {hours}}" +msgstr "시간" + +#: src/lib/hooks/useTimeAgo.ts:59 +msgid "{diff, plural, one {minute} other {minutes}}" +msgstr "ë¶„" + +#: src/lib/hooks/useTimeAgo.ts:75 +msgid "{diff, plural, one {month} other {months}}" +msgstr "개월" + +#: src/lib/hooks/useTimeAgo.ts:54 +msgid "{diffSeconds, plural, one {second} other {seconds}}" +msgstr "ì´ˆ" + #: src/screens/SignupQueued.tsx:207 msgid "{estimatedTimeHrs, plural, one {hour} other {hours}}" msgstr "시간" @@ -95,7 +112,7 @@ msgstr "시간" msgid "{estimatedTimeMins, plural, one {minute} other {minutes}}" msgstr "ë¶„" -#: src/components/ProfileHoverCard/index.web.tsx:457 +#: src/components/ProfileHoverCard/index.web.tsx:503 #: src/screens/Profile/Header/Metrics.tsx:50 msgid "{following} following" msgstr "{following} 팔로우 중" @@ -114,11 +131,15 @@ msgstr "{likeCount, plural, other {#}}ëª…ì˜ ì‚¬ìš©ìžê°€ 좋아함" msgid "{numUnreadNotifications} unread" msgstr "{numUnreadNotifications}ê°œ ì½ì§€ 않ìŒ" +#: src/components/NewskieDialog.tsx:75 +msgid "{profileName} joined Bluesky {0} ago" +msgstr "{profileName} ë‹˜ì€ {0} ì „ì— Blueskyì— ê°€ìž…í–ˆìŠµë‹ˆë‹¤." + #: src/view/screens/PreferencesFollowingFeed.tsx:67 msgid "{value, plural, =0 {Show all replies} one {Show replies with at least # like} other {Show replies with at least # likes}}" msgstr "{value, plural, =0 {ëª¨ë“ ë‹µê¸€ 표시} other {좋아요가 #ê°œ ì´ìƒì¸ 답글 표시}}" -#: src/view/com/threadgate/WhoCanReply.tsx:159 +#: src/view/com/threadgate/WhoCanReply.tsx:290 msgid "<0/> members" msgstr "<0/>ì˜ ë©¤ë²„" @@ -134,7 +155,7 @@ msgstr "<0>{0}</0> 팔로우 중" msgid "<0>Not Applicable.</0> This warning is only available for posts with media attached." msgstr "<0>해당 ì—†ìŒ.</0> ì´ ê²½ê³ ëŠ” 미디어가 ì²¨ë¶€ëœ ê²Œì‹œë¬¼ì—ë§Œ ì‚¬ìš©í• ìˆ˜ 있습니다." -#: src/screens/Profile/Header/Handle.tsx:43 +#: src/screens/Profile/Header/Handle.tsx:50 msgid "âš Invalid Handle" msgstr "âš ìž˜ëª»ëœ í•¸ë“¤" @@ -143,7 +164,7 @@ msgid "2FA Confirmation" msgstr "2단계 ì¸ì¦" #: src/view/com/util/ViewHeader.tsx:93 -#: src/view/screens/Search/Search.tsx:715 +#: src/view/screens/Search/Search.tsx:684 msgid "Access navigation links and settings" msgstr "íƒìƒ‰ ë§í¬ ë° ì„¤ì •ìœ¼ë¡œ ì´ë™í•©ë‹ˆë‹¤" @@ -161,7 +182,7 @@ msgid "Accessibility settings" msgstr "ì ‘ê·¼ì„± ì„¤ì •" #: src/Navigation.tsx:296 -#: src/view/screens/AccessibilitySettings.tsx:63 +#: src/view/screens/AccessibilitySettings.tsx:69 msgid "Accessibility Settings" msgstr "ì ‘ê·¼ì„± ì„¤ì •" @@ -171,15 +192,15 @@ msgstr "ì ‘ê·¼ì„± ì„¤ì •" msgid "Account" msgstr "ê³„ì •" -#: src/view/com/profile/ProfileMenu.tsx:142 +#: src/view/com/profile/ProfileMenu.tsx:145 msgid "Account blocked" msgstr "ê³„ì • 차단ë¨" -#: src/view/com/profile/ProfileMenu.tsx:156 +#: src/view/com/profile/ProfileMenu.tsx:159 msgid "Account followed" msgstr "ê³„ì • 팔로우함" -#: src/view/com/profile/ProfileMenu.tsx:116 +#: src/view/com/profile/ProfileMenu.tsx:119 msgid "Account muted" msgstr "ê³„ì • 뮤트ë¨" @@ -200,16 +221,16 @@ msgstr "ê³„ì • 옵션" msgid "Account removed from quick access" msgstr "ë¹ ë¥¸ 액세스ì—서 ê³„ì • ì œê±°" -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:135 -#: src/view/com/profile/ProfileMenu.tsx:131 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:139 +#: src/view/com/profile/ProfileMenu.tsx:134 msgid "Account unblocked" msgstr "ê³„ì • 차단 í•´ì œë¨" -#: src/view/com/profile/ProfileMenu.tsx:169 +#: src/view/com/profile/ProfileMenu.tsx:172 msgid "Account unfollowed" msgstr "ê³„ì • 언팔로우함" -#: src/view/com/profile/ProfileMenu.tsx:105 +#: src/view/com/profile/ProfileMenu.tsx:108 msgid "Account unmuted" msgstr "ê³„ì • 언뮤트ë¨" @@ -270,8 +291,12 @@ msgstr "ë‚´ê°€ 팔로우하는 ì‚¬ëžŒì˜ ê¸°ë³¸ 피드만 추가하기" msgid "Add the following DNS record to your domain:" msgstr "ë„ë©”ì¸ì— ë‹¤ìŒ DNS ë ˆì½”ë“œë¥¼ 추가하세요:" -#: src/view/com/profile/ProfileMenu.tsx:265 +#: src/components/FeedCard.tsx:180 +msgid "Add this feed to your feeds" +msgstr "ì´ í”¼ë“œë¥¼ ë‚´ í”¼ë“œì— ì¶”ê°€í•˜ê¸°" + #: src/view/com/profile/ProfileMenu.tsx:268 +#: src/view/com/profile/ProfileMenu.tsx:271 msgid "Add to Lists" msgstr "ë¦¬ìŠ¤íŠ¸ì— ì¶”ê°€" @@ -306,7 +331,7 @@ msgstr "ì„±ì¸ ì½˜í…ì¸ ê°€ 비활성화ë˜ì–´ 있습니다." msgid "Advanced" msgstr "ê³ ê¸‰" -#: src/view/screens/Feeds.tsx:771 +#: src/view/screens/Feeds.tsx:737 msgid "All the feeds you've saved, right in one place." msgstr "ì €ìž¥í•œ ëª¨ë“ í”¼ë“œë¥¼ 한 ê³³ì—서 확ì¸í•˜ì„¸ìš”." @@ -331,17 +356,17 @@ msgstr "ì´ë¯¸ @{0}(으)로 로그ì¸í–ˆìŠµë‹ˆë‹¤" #: src/view/com/composer/GifAltText.tsx:93 #: src/view/com/composer/photos/Gallery.tsx:144 -#: src/view/com/util/post-embeds/GifEmbed.tsx:173 +#: src/view/com/util/post-embeds/GifEmbed.tsx:177 msgid "ALT" msgstr "ALT" #: src/view/com/composer/GifAltText.tsx:144 #: src/view/com/modals/EditImage.tsx:316 -#: src/view/screens/AccessibilitySettings.tsx:77 +#: src/view/screens/AccessibilitySettings.tsx:83 msgid "Alt text" msgstr "대체 í…스트" -#: src/view/com/util/post-embeds/GifEmbed.tsx:179 +#: src/view/com/util/post-embeds/GifEmbed.tsx:183 msgid "Alt Text" msgstr "대체 í…스트" @@ -379,9 +404,8 @@ msgstr "ë¬¸ì œê°€ ë°œìƒí–ˆìŠµë‹ˆë‹¤. 다시 시ë„í•´ 주세요." msgid "an unknown error occurred" msgstr "알 수 없는 오류가 ë°œìƒí–ˆìŠµë‹ˆë‹¤" -#: src/components/KnownFollowers.tsx:187 -#: src/view/com/notifications/FeedItem.tsx:258 -#: src/view/com/threadgate/WhoCanReply.tsx:180 +#: src/view/com/notifications/FeedItem.tsx:260 +#: src/view/com/threadgate/WhoCanReply.tsx:311 msgid "and" msgstr "ë°" @@ -389,7 +413,7 @@ msgstr "ë°" msgid "Animals" msgstr "ë™ë¬¼" -#: src/view/com/util/post-embeds/GifEmbed.tsx:148 +#: src/view/com/util/post-embeds/GifEmbed.tsx:149 msgid "Animated GIF" msgstr "움ì§ì´ëŠ” GIF" @@ -469,7 +493,11 @@ msgstr "ì •ë§ ì´ ëŒ€í™”ì—서 ë‚˜ê°€ì‹œê² ìŠµë‹ˆê¹Œ? 나ì—게 ë³´ì´ëŠ” ë©”ì‹ msgid "Are you sure you want to remove {0} from your feeds?" msgstr "피드ì—서 {0}ì„(를) ì œê±°í•˜ì‹œê² ìŠµë‹ˆê¹Œ?" -#: src/view/com/composer/Composer.tsx:630 +#: src/components/FeedCard.tsx:197 +msgid "Are you sure you want to remove this from your feeds?" +msgstr "ë‚´ 피드ì—서 ì´ í”¼ë“œë¥¼ ì‚ì œí•˜ì‹œê² ìŠµë‹ˆê¹Œ?" + +#: src/view/com/composer/Composer.tsx:632 msgid "Are you sure you'd like to discard this draft?" msgstr "ì´ ì´ˆì•ˆì„ ì‚ì œí•˜ì‹œê² ìŠµë‹ˆê¹Œ?" @@ -524,8 +552,8 @@ msgstr "ìƒë…„ì›”ì¼" msgid "Birthday:" msgstr "ìƒë…„ì›”ì¼:" -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:309 -#: src/view/com/profile/ProfileMenu.tsx:363 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:314 +#: src/view/com/profile/ProfileMenu.tsx:366 msgid "Block" msgstr "차단" @@ -534,12 +562,12 @@ msgstr "차단" msgid "Block account" msgstr "ê³„ì • 차단" -#: src/view/com/profile/ProfileMenu.tsx:302 -#: src/view/com/profile/ProfileMenu.tsx:309 +#: src/view/com/profile/ProfileMenu.tsx:305 +#: src/view/com/profile/ProfileMenu.tsx:312 msgid "Block Account" msgstr "ê³„ì • 차단" -#: src/view/com/profile/ProfileMenu.tsx:346 +#: src/view/com/profile/ProfileMenu.tsx:349 msgid "Block Account?" msgstr "ê³„ì •ì„ ì°¨ë‹¨í•˜ì‹œê² ìŠµë‹ˆê¹Œ?" @@ -569,7 +597,7 @@ msgstr "차단한 ê³„ì •" msgid "Blocked Accounts" msgstr "차단한 ê³„ì •" -#: src/view/com/profile/ProfileMenu.tsx:358 +#: src/view/com/profile/ProfileMenu.tsx:361 msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." msgstr "차단한 ê³„ì •ì€ ë‚´ ìŠ¤ë ˆë“œì— ë‹µê¸€ì„ ë‹¬ê±°ë‚˜ 나를 멘션하거나 기타 다른 ë°©ì‹ìœ¼ë¡œ 나와 ìƒí˜¸ìž‘ìš©í• ìˆ˜ 없습니다." @@ -577,7 +605,7 @@ msgstr "차단한 ê³„ì •ì€ ë‚´ ìŠ¤ë ˆë“œì— ë‹µê¸€ì„ ë‹¬ê±°ë‚˜ 나를 ë©˜ì…˜í• msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. You will not see their content and they will be prevented from seeing yours." msgstr "차단한 ê³„ì •ì€ ë‚´ ìŠ¤ë ˆë“œì— ë‹µê¸€ì„ ë‹¬ê±°ë‚˜ 나를 멘션하거나 기타 다른 ë°©ì‹ìœ¼ë¡œ 나와 ìƒí˜¸ìž‘ìš©í• ìˆ˜ 없습니다. 차단한 ê³„ì •ì˜ ì½˜í…ì¸ ë¥¼ ë³¼ 수 없으며 해당 ê³„ì •ë„ ë‚´ 콘í…ì¸ ë¥¼ ë³¼ 수 없게 ë©ë‹ˆë‹¤." -#: src/view/com/post-thread/PostThread.tsx:363 +#: src/view/com/post-thread/PostThread.tsx:367 msgid "Blocked post." msgstr "ì°¨ë‹¨ëœ ê²Œì‹œë¬¼." @@ -589,7 +617,7 @@ msgstr "차단하ë”ë¼ë„ ì´ ë¼ë²¨ëŸ¬ê°€ ë‚´ ê³„ì •ì— ë¼ë²¨ì„ ë¶™ì´ëŠ” ê² msgid "Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." msgstr "차단 목ë¡ì€ 공개ë©ë‹ˆë‹¤. 차단한 ê³„ì •ì€ ë‚´ ìŠ¤ë ˆë“œì— ë‹µê¸€ì„ ë‹¬ê±°ë‚˜ 나를 멘션하거나 기타 다른 ë°©ì‹ìœ¼ë¡œ 나와 ìƒí˜¸ìž‘ìš©í• ìˆ˜ 없습니다." -#: src/view/com/profile/ProfileMenu.tsx:355 +#: src/view/com/profile/ProfileMenu.tsx:358 msgid "Blocking will not prevent labels from being applied on your account, but it will stop this account from replying in your threads or interacting with you." msgstr "차단하ë”ë¼ë„ ë‚´ ê³„ì •ì— ë¼ë²¨ì´ 붙는 ê²ƒì€ ë§‰ì§€ 못하지만, ì´ ê³„ì •ì´ ë‚´ ìŠ¤ë ˆë“œì— ë‹µê¸€ì„ ë‹¬ê±°ë‚˜ 나와 ìƒí˜¸ìž‘용하는 ê²ƒì€ ì¤‘ì§€ë©ë‹ˆë‹¤." @@ -664,8 +692,8 @@ msgstr "글ìž, 숫ìž, 공백, 대시, 밑줄만 í¬í•¨í• 수 있습니다. ê¸ #: src/components/Prompt.tsx:121 #: src/components/TagMenu/index.tsx:268 #: src/screens/Deactivated.tsx:161 -#: src/view/com/composer/Composer.tsx:432 -#: src/view/com/composer/Composer.tsx:438 +#: src/view/com/composer/Composer.tsx:434 +#: src/view/com/composer/Composer.tsx:440 #: src/view/com/modals/ChangeEmail.tsx:213 #: src/view/com/modals/ChangeEmail.tsx:215 #: src/view/com/modals/ChangeHandle.tsx:148 @@ -681,8 +709,8 @@ msgstr "글ìž, 숫ìž, 공백, 대시, 밑줄만 í¬í•¨í• 수 있습니다. ê¸ #: src/view/com/modals/LinkWarning.tsx:107 #: src/view/com/modals/VerifyEmail.tsx:255 #: src/view/com/modals/VerifyEmail.tsx:261 -#: src/view/com/util/post-ctrls/RepostButton.tsx:138 -#: src/view/screens/Search/Search.tsx:735 +#: src/view/com/util/post-ctrls/RepostButton.tsx:139 +#: src/view/screens/Search/Search.tsx:704 #: src/view/shell/desktop/Search.tsx:218 msgid "Cancel" msgstr "취소" @@ -711,7 +739,7 @@ msgstr "ì´ë¯¸ì§€ ìžë¥´ê¸° 취소" msgid "Cancel profile editing" msgstr "프로필 편집 취소" -#: src/view/com/util/post-ctrls/RepostButton.tsx:132 +#: src/view/com/util/post-ctrls/RepostButton.tsx:133 msgid "Cancel quote post" msgstr "게시물 ì¸ìš© 취소" @@ -807,7 +835,7 @@ msgstr "ì´ë©”ì¼ì—서 ë¡œê·¸ì¸ ì½”ë“œë¥¼ 확ì¸í•œ 후 ì—¬ê¸°ì— ìž…ë ¥í•˜ì„¸ msgid "Check your inbox for an email with the confirmation code to enter below:" msgstr "ë°›ì€ íŽ¸ì§€í•¨ì—서 ì•„ëž˜ì— ìž…ë ¥í•˜ëŠ” ì¸ì¦ 코드가 í¬í•¨ëœ ì´ë©”ì¼ì´ 있는지 확ì¸í•˜ì„¸ìš”:" -#: src/view/com/modals/Threadgate.tsx:73 +#: src/view/com/modals/Threadgate.tsx:75 msgid "Choose \"Everybody\" or \"Nobody\"" msgstr "\"모ë‘\" ë˜ëŠ” \"ì—†ìŒ\"ì„ ì„ íƒí•˜ì„¸ìš”." @@ -844,7 +872,7 @@ msgid "Clear all storage data (restart after this)" msgstr "ëª¨ë“ ìŠ¤í† ë¦¬ì§€ ë°ì´í„° 지우기 (ì´í›„ 다시 시작)" #: src/view/com/util/forms/SearchInput.tsx:88 -#: src/view/screens/Search/Search.tsx:861 +#: src/view/screens/Search/Search.tsx:824 msgid "Clear search query" msgstr "검색어 지우기" @@ -889,7 +917,7 @@ msgstr "다그닥 🴠다그닥 ðŸ´" #: src/components/dms/dialogs/SearchablePeopleList.tsx:261 #: src/view/com/modals/ChangePassword.tsx:268 #: src/view/com/modals/ChangePassword.tsx:271 -#: src/view/com/util/post-embeds/GifEmbed.tsx:185 +#: src/view/com/util/post-embeds/GifEmbed.tsx:189 msgid "Close" msgstr "닫기" @@ -944,7 +972,7 @@ msgstr "하단 íƒìƒ‰ 막대를 닫습니다" msgid "Closes password update alert" msgstr "비밀번호 변경 ì•Œë¦¼ì„ ë‹«ìŠµë‹ˆë‹¤" -#: src/view/com/composer/Composer.tsx:434 +#: src/view/com/composer/Composer.tsx:436 msgid "Closes post composer and discards post draft" msgstr "게시물 작성 ìƒìžë¥¼ ë‹«ê³ ê²Œì‹œë¬¼ ì´ˆì•ˆì„ ì‚ì œí•©ë‹ˆë‹¤" @@ -952,11 +980,11 @@ msgstr "게시물 작성 ìƒìžë¥¼ ë‹«ê³ ê²Œì‹œë¬¼ ì´ˆì•ˆì„ ì‚ì œí•©ë‹ˆë‹¤" msgid "Closes viewer for header image" msgstr "í—¤ë” ì´ë¯¸ì§€ 뷰어를 닫습니다" -#: src/view/com/notifications/FeedItem.tsx:205 +#: src/view/com/notifications/FeedItem.tsx:207 msgid "Collapse list of users" msgstr "ì‚¬ìš©ìž ëª©ë¡ ì ‘ê¸°" -#: src/view/com/notifications/FeedItem.tsx:341 +#: src/view/com/notifications/FeedItem.tsx:343 msgid "Collapses list of users for a given notification" msgstr "ì´ ì•Œë¦¼ì— ëŒ€í•œ ì‚¬ìš©ìž ëª©ë¡ì„ 축소합니다" @@ -981,7 +1009,7 @@ msgstr "온보딩 완료 후 ê³„ì • 사용 시작" msgid "Complete the challenge" msgstr "챌린지 완료하기" -#: src/view/com/composer/Composer.tsx:551 +#: src/view/com/composer/Composer.tsx:553 msgid "Compose posts up to {MAX_GRAPHEME_LENGTH} characters in length" msgstr "최대 {MAX_GRAPHEME_LENGTH}ìž ê¸¸ì´ê¹Œì§€ ê¸€ì„ ìž‘ì„±í• ìˆ˜ 있습니다" @@ -997,8 +1025,8 @@ msgstr "{name} ì¹´í…Œê³ ë¦¬ì— ëŒ€í•œ 콘í…ì¸ í•„í„°ë§ ì„¤ì •ì„ êµ¬ì„±í•©ë‹ˆ msgid "Configured in <0>moderation settings</0>." msgstr "<0>ê²€í† ì„¤ì •</0>ì—서 ì„¤ì •í•©ë‹ˆë‹¤." -#: src/components/Prompt.tsx:159 #: src/components/Prompt.tsx:162 +#: src/components/Prompt.tsx:165 #: src/view/com/modals/SelfLabel.tsx:155 #: src/view/com/modals/VerifyEmail.tsx:239 #: src/view/com/modals/VerifyEmail.tsx:241 @@ -1092,7 +1120,7 @@ msgstr "{0}(으)로 계ì†í•˜ê¸° (현재 로그ì¸)" #: src/view/com/post-thread/PostThreadLoadMore.tsx:52 msgid "Continue thread..." -msgstr "" +msgstr "ìŠ¤ë ˆë“œ ë” ë³´ê¸°..." #: src/screens/Onboarding/StepInterests/index.tsx:250 #: src/screens/Onboarding/StepProfile/index.tsx:266 @@ -1121,7 +1149,7 @@ msgstr "빌드 ë²„ì „ í´ë¦½ë³´ë“œì— 복사ë¨" #: src/view/com/modals/AddAppPasswords.tsx:80 #: src/view/com/modals/ChangeHandle.tsx:320 #: src/view/com/modals/InviteCodes.tsx:153 -#: src/view/com/util/forms/PostDropdownBtn.tsx:182 +#: src/view/com/util/forms/PostDropdownBtn.tsx:189 msgid "Copied to clipboard" msgstr "í´ë¦½ë³´ë“œì— 복사ë¨" @@ -1150,8 +1178,8 @@ msgstr "코드 복사" msgid "Copy link to list" msgstr "리스트 ë§í¬ 복사" -#: src/view/com/util/forms/PostDropdownBtn.tsx:297 -#: src/view/com/util/forms/PostDropdownBtn.tsx:306 +#: src/view/com/util/forms/PostDropdownBtn.tsx:307 +#: src/view/com/util/forms/PostDropdownBtn.tsx:316 msgid "Copy link to post" msgstr "게시물 ë§í¬ 복사" @@ -1160,8 +1188,8 @@ msgstr "게시물 ë§í¬ 복사" msgid "Copy message text" msgstr "메시지 í…스트 복사" -#: src/view/com/util/forms/PostDropdownBtn.tsx:275 -#: src/view/com/util/forms/PostDropdownBtn.tsx:277 +#: src/view/com/util/forms/PostDropdownBtn.tsx:285 +#: src/view/com/util/forms/PostDropdownBtn.tsx:287 msgid "Copy post text" msgstr "게시물 í…스트 복사" @@ -1238,7 +1266,8 @@ msgstr "ì‚¬ìš©ìž ì§€ì •" msgid "Custom domain" msgstr "ì‚¬ìš©ìž ì§€ì • ë„ë©”ì¸" -#: src/view/screens/Feeds.tsx:797 +#: src/view/screens/Feeds.tsx:763 +#: src/view/screens/Search/Explore.tsx:383 msgid "Custom feeds built by the community bring you new experiences and help you find the content you love." msgstr "커뮤니티ì—서 구축한 맞춤 피드는 새로운 ê²½í—˜ì„ ì œê³µí•˜ê³ ì¢‹ì•„í•˜ëŠ” 콘í…ì¸ ë¥¼ ì°¾ì„ ìˆ˜ 있ë„ë¡ ë„와ì¤ë‹ˆë‹¤." @@ -1281,7 +1310,7 @@ msgid "Debug panel" msgstr "디버그 패ë„" #: src/components/dms/MessageMenu.tsx:151 -#: src/view/com/util/forms/PostDropdownBtn.tsx:423 +#: src/view/com/util/forms/PostDropdownBtn.tsx:433 #: src/view/screens/AppPasswords.tsx:285 #: src/view/screens/ProfileList.tsx:667 msgid "Delete" @@ -1332,8 +1361,8 @@ msgstr "ë‚´ ê³„ì • ì‚ì œ" msgid "Delete My Account…" msgstr "ë‚´ ê³„ì • ì‚ì œâ€¦" -#: src/view/com/util/forms/PostDropdownBtn.tsx:404 -#: src/view/com/util/forms/PostDropdownBtn.tsx:406 +#: src/view/com/util/forms/PostDropdownBtn.tsx:414 +#: src/view/com/util/forms/PostDropdownBtn.tsx:416 msgid "Delete post" msgstr "게시물 ì‚ì œ" @@ -1341,7 +1370,7 @@ msgstr "게시물 ì‚ì œ" msgid "Delete this list?" msgstr "ì´ ë¦¬ìŠ¤íŠ¸ë¥¼ ì‚ì œí•˜ì‹œê² ìŠµë‹ˆê¹Œ?" -#: src/view/com/util/forms/PostDropdownBtn.tsx:418 +#: src/view/com/util/forms/PostDropdownBtn.tsx:428 msgid "Delete this post?" msgstr "ì´ ê²Œì‹œë¬¼ì„ ì‚ì œí•˜ì‹œê² ìŠµë‹ˆê¹Œ?" @@ -1349,7 +1378,7 @@ msgstr "ì´ ê²Œì‹œë¬¼ì„ ì‚ì œí•˜ì‹œê² ìŠµë‹ˆê¹Œ?" msgid "Deleted" msgstr "ì‚ì œë¨" -#: src/view/com/post-thread/PostThread.tsx:349 +#: src/view/com/post-thread/PostThread.tsx:353 msgid "Deleted post." msgstr "ì‚ì œëœ ê²Œì‹œë¬¼." @@ -1380,7 +1409,7 @@ msgstr "어둑함" msgid "Direct messages are here!" msgstr "다ì´ë ‰íЏ 메시지가 ìƒê²¼ìŠµë‹ˆë‹¤!" -#: src/view/screens/AccessibilitySettings.tsx:94 +#: src/view/screens/AccessibilitySettings.tsx:107 msgid "Disable autoplay for GIFs" msgstr "GIF ìžë™ ìž¬ìƒ ë„기" @@ -1388,7 +1417,7 @@ msgstr "GIF ìžë™ ìž¬ìƒ ë„기" msgid "Disable Email 2FA" msgstr "ì´ë©”ì¼ 2단계 ì¸ì¦ ë„기" -#: src/view/screens/AccessibilitySettings.tsx:108 +#: src/view/screens/AccessibilitySettings.tsx:121 msgid "Disable haptic feedback" msgstr "햅틱 피드백 ë„기" @@ -1401,11 +1430,11 @@ msgstr "햅틱 피드백 ë„기" msgid "Disabled" msgstr "사용 안 함" -#: src/view/com/composer/Composer.tsx:632 +#: src/view/com/composer/Composer.tsx:634 msgid "Discard" msgstr "ì‚ì œ" -#: src/view/com/composer/Composer.tsx:629 +#: src/view/com/composer/Composer.tsx:631 msgid "Discard draft?" msgstr "초안 ì‚ì œ" @@ -1419,10 +1448,18 @@ msgstr "ì•±ì´ ë¡œê·¸ì•„ì›ƒí•œ 사용ìžì—게 ë‚´ ê³„ì •ì„ í‘œì‹œí•˜ì§€ ì•Šë„ msgid "Discover new custom feeds" msgstr "새로운 맞춤 피드 찾아보기" -#: src/view/screens/Feeds.tsx:794 +#: src/view/screens/Search/Explore.tsx:381 +msgid "Discover new feeds" +msgstr "새 피드 발견하기" + +#: src/view/screens/Feeds.tsx:760 msgid "Discover New Feeds" msgstr "새 피드 발견하기" +#: src/view/screens/AccessibilitySettings.tsx:95 +msgid "Display larger alt text badges" +msgstr "ë” í° ëŒ€ì²´ í…스트 ë°°ì§€ 표시" + #: src/view/com/modals/EditProfile.tsx:193 msgid "Display name" msgstr "표시 ì´ë¦„" @@ -1453,8 +1490,8 @@ msgstr "ë„ë©”ì¸ì„ 확ì¸í–ˆìŠµë‹ˆë‹¤." #: src/components/dialogs/BirthDateSettings.tsx:119 #: src/components/dialogs/BirthDateSettings.tsx:125 -#: src/components/forms/DateField/index.tsx:74 -#: src/components/forms/DateField/index.tsx:80 +#: src/components/forms/DateField/index.tsx:77 +#: src/components/forms/DateField/index.tsx:83 #: src/screens/Onboarding/StepProfile/index.tsx:322 #: src/screens/Onboarding/StepProfile/index.tsx:325 #: src/view/com/auth/server-input/index.tsx:169 @@ -1472,8 +1509,8 @@ msgstr "완료" #: src/view/com/modals/EditImage.tsx:334 #: src/view/com/modals/ListAddRemoveUsers.tsx:144 #: src/view/com/modals/SelfLabel.tsx:158 -#: src/view/com/modals/Threadgate.tsx:130 #: src/view/com/modals/Threadgate.tsx:133 +#: src/view/com/modals/Threadgate.tsx:136 #: src/view/com/modals/UserAddRemoveLists.tsx:108 #: src/view/com/modals/UserAddRemoveLists.tsx:111 #: src/view/screens/PreferencesThreads.tsx:162 @@ -1490,7 +1527,7 @@ msgstr "완료{extraText}" msgid "Download CAR file" msgstr "CAR íŒŒì¼ ë‹¤ìš´ë¡œë“œ" -#: src/view/com/composer/text-input/TextInput.web.tsx:261 +#: src/view/com/composer/text-input/TextInput.web.tsx:272 msgid "Drop to add images" msgstr "드ë¡í•˜ì—¬ ì´ë¯¸ì§€ 추가" @@ -1534,17 +1571,17 @@ msgstr "예: 반복ì 으로 ê´‘ê³ ë‹µê¸€ì„ ë‹¤ëŠ” ê³„ì •." msgid "Each code works once. You'll receive more invite codes periodically." msgstr "ê° ì½”ë“œëŠ” 한 번만 ì‚¬ìš©í• ìˆ˜ 있습니다. 주기ì 으로 ë” ë§Žì€ ì´ˆëŒ€ 코드를 받게 ë©ë‹ˆë‹¤." -#: src/view/screens/Feeds.tsx:400 -#: src/view/screens/Feeds.tsx:471 -msgid "Edit" -msgstr "" - #: src/view/com/lists/ListMembers.tsx:149 msgctxt "action" msgid "Edit" msgstr "편집" -#: src/view/com/util/UserAvatar.tsx:312 +#: src/view/screens/Feeds.tsx:370 +#: src/view/screens/Feeds.tsx:441 +msgid "Edit" +msgstr "편집" + +#: src/view/com/util/UserAvatar.tsx:325 #: src/view/com/util/UserBanner.tsx:92 msgid "Edit avatar" msgstr "아바타 편집" @@ -1563,8 +1600,8 @@ msgid "Edit Moderation List" msgstr "ê²€í† ë¦¬ìŠ¤íŠ¸ 편집" #: src/Navigation.tsx:269 -#: src/view/screens/Feeds.tsx:398 -#: src/view/screens/Feeds.tsx:469 +#: src/view/screens/Feeds.tsx:368 +#: src/view/screens/Feeds.tsx:439 #: src/view/screens/SavedFeeds.tsx:93 msgid "Edit My Feeds" msgstr "ë‚´ 피드 편집" @@ -1574,24 +1611,24 @@ msgid "Edit my profile" msgstr "ë‚´ 프로필 편집" #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:181 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:175 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:179 msgid "Edit profile" msgstr "프로필 편집" #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:184 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:178 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:182 msgid "Edit Profile" msgstr "프로필 편집" -#: src/view/com/home/HomeHeaderLayout.web.tsx:76 -#: src/view/screens/Feeds.tsx:416 -#~ msgid "Edit Saved Feeds" -#~ msgstr "ì €ìž¥í•œ 피드 편집" - #: src/view/com/modals/CreateOrEditList.tsx:234 msgid "Edit User List" msgstr "ì‚¬ìš©ìž ë¦¬ìŠ¤íŠ¸ 편집" +#: src/view/com/threadgate/WhoCanReply.tsx:73 +#: src/view/com/threadgate/WhoCanReply.tsx:130 +msgid "Edit who can reply" +msgstr "ë‹µê¸€ì„ ë‹¬ 수 있는 사람 편집" + #: src/view/com/modals/EditProfile.tsx:194 msgid "Edit your display name" msgstr "ë‚´ 표시 ì´ë¦„ 편집" @@ -1639,8 +1676,8 @@ msgid "Embed HTML code" msgstr "ìž„ë² ë“œ HTML 코드" #: src/components/dialogs/Embed.tsx:97 -#: src/view/com/util/forms/PostDropdownBtn.tsx:314 -#: src/view/com/util/forms/PostDropdownBtn.tsx:316 +#: src/view/com/util/forms/PostDropdownBtn.tsx:324 +#: src/view/com/util/forms/PostDropdownBtn.tsx:326 msgid "Embed post" msgstr "게시물 ìž„ë² ë“œ" @@ -1746,11 +1783,14 @@ msgstr "캡차 ì‘ë‹µì„ ìˆ˜ì‹ í•˜ëŠ” ë™ì•ˆ 오류가 ë°œìƒí–ˆìŠµë‹ˆë‹¤." msgid "Error:" msgstr "오류:" -#: src/view/com/modals/Threadgate.tsx:77 +#: src/view/com/modals/Threadgate.tsx:79 msgid "Everybody" msgstr "모ë‘" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:46 +#: src/view/com/threadgate/WhoCanReply.tsx:64 +#: src/view/com/threadgate/WhoCanReply.tsx:121 +#: src/view/com/threadgate/WhoCanReply.tsx:235 msgid "Everybody can reply" msgstr "누구나 ë‹µê¸€ì„ ë‹¬ 수 있ìŒ" @@ -1794,7 +1834,7 @@ msgstr "검색어 ìž…ë ¥ì„ ì¢…ë£Œí•©ë‹ˆë‹¤" msgid "Expand alt text" msgstr "대체 í…스트 확장" -#: src/view/com/notifications/FeedItem.tsx:206 +#: src/view/com/notifications/FeedItem.tsx:208 msgid "Expand list of users" msgstr "ì‚¬ìš©ìž ëª©ë¡ íŽ¼ì¹˜ê¸°" @@ -1853,10 +1893,15 @@ msgstr "리스트를 만들지 못했습니다. ì¸í„°ë„· ì—°ê²°ì„ í™•ì¸í•œ í› msgid "Failed to delete message" msgstr "메시지를 ì‚ì œí•˜ì§€ 못했습니다" -#: src/view/com/util/forms/PostDropdownBtn.tsx:149 +#: src/view/com/util/forms/PostDropdownBtn.tsx:152 msgid "Failed to delete post, please try again" msgstr "ê²Œì‹œë¬¼ì„ ì‚ì œí•˜ì§€ 못했습니다. 다시 시ë„í•´ 주세요" +#: src/view/screens/Search/Explore.tsx:417 +#: src/view/screens/Search/Explore.tsx:441 +msgid "Failed to load feeds preferences" +msgstr "피드 í™˜ê²½ì„¤ì •ì„ ë¶ˆëŸ¬ì˜¤ì§€ 못했습니다" + #: src/components/dialogs/GifSelect.ios.tsx:196 #: src/components/dialogs/GifSelect.tsx:212 msgid "Failed to load GIFs" @@ -1866,6 +1911,15 @@ msgstr "GIF를 불러오지 못했습니다" msgid "Failed to load past messages" msgstr "지난 메시지를 불러오지 못했습니다" +#: src/view/screens/Search/Explore.tsx:410 +#: src/view/screens/Search/Explore.tsx:434 +msgid "Failed to load suggested feeds" +msgstr "추천 피드를 불러오지 못했습니다" + +#: src/view/screens/Search/Explore.tsx:370 +msgid "Failed to load suggested follows" +msgstr "추천 팔로우를 불러오지 못했습니다" + #: src/view/com/lightbox/Lightbox.tsx:84 msgid "Failed to save image: {0}" msgstr "ì´ë¯¸ì§€ë¥¼ ì €ìž¥í•˜ì§€ 못함: {0}" @@ -1877,7 +1931,15 @@ msgstr "ì „ì†¡ 실패" #: src/components/moderation/LabelsOnMeDialog.tsx:223 #: src/screens/Messages/Conversation/ChatDisabled.tsx:87 msgid "Failed to submit appeal, please try again." -msgstr "ì´ì˜ì‹ ì²ì„ ì œì¶œí•˜ì§€ 못했습니다. 다시 시ë„하세요." +msgstr "ì´ì˜ì‹ ì²ì„ ì œì¶œí•˜ì§€ 못했습니다. 다시 시ë„í•´ 주세요." + +#: src/view/com/util/forms/PostDropdownBtn.tsx:180 +msgid "Failed to toggle thread mute, please try again" +msgstr "ìŠ¤ë ˆë“œ 뮤트를 ì „í™˜í•˜ì§€ 못했습니다. 다시 시ë„í•´ 주세요" + +#: src/components/FeedCard.tsx:160 +msgid "Failed to update feeds" +msgstr "피드를 ì—…ë°ì´íŠ¸í•˜ì§€ 못했습니다" #: src/components/dms/MessagesNUX.tsx:60 #: src/screens/Messages/Settings.tsx:35 @@ -1888,11 +1950,12 @@ msgstr "ì„¤ì •ì„ ì—…ë°ì´íŠ¸í•˜ì§€ 못했습니다" msgid "Feed" msgstr "피드" +#: src/components/FeedCard.tsx:91 #: src/view/com/feeds/FeedSourceCard.tsx:251 msgid "Feed by {0}" msgstr "{0} ë‹˜ì˜ í”¼ë“œ" -#: src/view/screens/Feeds.tsx:709 +#: src/view/screens/Feeds.tsx:675 msgid "Feed offline" msgstr "피드 오프ë¼ì¸" @@ -1901,9 +1964,10 @@ msgstr "피드 오프ë¼ì¸" msgid "Feedback" msgstr "피드백" -#: src/view/screens/Feeds.tsx:463 -#: src/view/screens/Feeds.tsx:570 +#: src/view/screens/Feeds.tsx:433 +#: src/view/screens/Feeds.tsx:536 #: src/view/screens/Profile.tsx:197 +#: src/view/screens/Search/Search.tsx:375 #: src/view/shell/desktop/LeftNav.tsx:367 #: src/view/shell/Drawer.tsx:493 #: src/view/shell/Drawer.tsx:494 @@ -1914,6 +1978,10 @@ msgstr "피드" msgid "Feeds are custom algorithms that users build with a little coding expertise. <0/> for more information." msgstr "피드는 사용ìžê°€ ì•½ê°„ì˜ ì½”ë”© ì „ë¬¸ ì§€ì‹ë§Œìœ¼ë¡œ êµ¬ì¶•í• ìˆ˜ 있는 맞춤 ì•Œê³ ë¦¬ì¦˜ìž…ë‹ˆë‹¤. <0/>ì—서 ìžì„¸í•œ ë‚´ìš©ì„ í™•ì¸í•˜ì„¸ìš”." +#: src/components/FeedCard.tsx:157 +msgid "Feeds updated!" +msgstr "피드 ì—…ë°ì´íЏë¨" + #: src/view/com/modals/ChangeHandle.tsx:475 msgid "File Contents" msgstr "íŒŒì¼ ì½˜í…ì¸ " @@ -1936,7 +2004,7 @@ msgstr "마무리 중" msgid "Find accounts to follow" msgstr "íŒ”ë¡œìš°í• ê³„ì • 찾아보기" -#: src/view/screens/Search/Search.tsx:470 +#: src/view/screens/Search/Search.tsx:439 msgid "Find posts and users on Bluesky" msgstr "Blueskyì—서 게시물 ë° ì‚¬ìš©ìž ì°¾ê¸°" @@ -1965,9 +2033,9 @@ msgstr "가로로 뒤집기" msgid "Flip vertically" msgstr "세로로 뒤집기" -#: src/components/ProfileHoverCard/index.web.tsx:412 -#: src/components/ProfileHoverCard/index.web.tsx:423 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:248 +#: src/components/ProfileHoverCard/index.web.tsx:446 +#: src/components/ProfileHoverCard/index.web.tsx:457 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:252 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:146 #: src/view/com/profile/ProfileHeaderSuggestedFollows.tsx:247 msgid "Follow" @@ -1978,7 +2046,7 @@ msgctxt "action" msgid "Follow" msgstr "팔로우" -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:234 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:238 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:128 msgid "Follow {0}" msgstr "{0} ë‹˜ì„ íŒ”ë¡œìš°" @@ -1987,8 +2055,8 @@ msgstr "{0} ë‹˜ì„ íŒ”ë¡œìš°" msgid "Follow {name}" msgstr "{name} ë‹˜ì„ íŒ”ë¡œìš°" -#: src/view/com/profile/ProfileMenu.tsx:244 -#: src/view/com/profile/ProfileMenu.tsx:255 +#: src/view/com/profile/ProfileMenu.tsx:247 +#: src/view/com/profile/ProfileMenu.tsx:258 msgid "Follow Account" msgstr "ê³„ì • 팔로우" @@ -1996,15 +2064,31 @@ msgstr "ê³„ì • 팔로우" msgid "Follow Back" msgstr "맞팔로우" -#: src/components/KnownFollowers.tsx:169 -msgid "Followed by" -msgstr "" +#: src/view/screens/Search/Explore.tsx:333 +msgid "Follow more accounts to get connected to your interests and build your network." +msgstr "ë” ë§Žì€ ê³„ì •ì„ íŒ”ë¡œìš°í•˜ê³ ê´€ì‹¬ 분야를 연결하여 네트워í¬ë¥¼ 구축하세요." #: src/view/com/profile/ProfileCard.tsx:227 msgid "Followed by {0}" msgstr "{0} ë‹˜ì´ íŒ”ë¡œìš°í•¨" -#: src/view/com/modals/Threadgate.tsx:99 +#: src/components/KnownFollowers.tsx:223 +msgid "Followed by <0>{0}</0>" +msgstr "<0>{0}</0> ë‹˜ì´ íŒ”ë¡œìš°í•¨" + +#: src/components/KnownFollowers.tsx:209 +msgid "Followed by <0>{0}</0> and {1, plural, one {# other} other {# others}}" +msgstr "<0>{0}</0> 님 외 {1, plural, other {#}}ëª…ì´ íŒ”ë¡œìš°í•¨" + +#: src/components/KnownFollowers.tsx:196 +msgid "Followed by <0>{0}</0> and <1>{1}</1>" +msgstr "<0>{0}</0> 님과 <1>{1}</1> ë‹˜ì´ íŒ”ë¡œìš°í•¨" + +#: src/components/KnownFollowers.tsx:178 +msgid "Followed by <0>{0}</0>, <1>{1}</1>, and {2, plural, one {# other} other {# others}}" +msgstr "<0>{0}</0> 님, <1>{1}</1> 님 외 {2, plural, other {#}}ëª…ì´ íŒ”ë¡œìš°í•¨" + +#: src/view/com/modals/Threadgate.tsx:101 msgid "Followed users" msgstr "팔로우한 사용ìž" @@ -2012,7 +2096,7 @@ msgstr "팔로우한 사용ìž" msgid "Followed users only" msgstr "팔로우한 사용ìžë§Œ" -#: src/view/com/notifications/FeedItem.tsx:173 +#: src/view/com/notifications/FeedItem.tsx:175 msgid "followed you" msgstr "ì´(ê°€) 나를 팔로우했습니다" @@ -2023,25 +2107,25 @@ msgstr "팔로워" #: src/Navigation.tsx:177 msgid "Followers of @{0} that you know" -msgstr "" +msgstr "ë‚´ê°€ 아는 @{0} ë‹˜ì˜ íŒ”ë¡œì›Œ" #: src/screens/Profile/KnownFollowers.tsx:108 #: src/screens/Profile/KnownFollowers.tsx:118 msgid "Followers you know" -msgstr "" +msgstr "ë‚´ê°€ 아는 팔로워" -#: src/components/ProfileHoverCard/index.web.tsx:411 -#: src/components/ProfileHoverCard/index.web.tsx:422 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:246 +#: src/components/ProfileHoverCard/index.web.tsx:445 +#: src/components/ProfileHoverCard/index.web.tsx:456 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:250 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:149 #: src/view/com/profile/ProfileFollows.tsx:104 -#: src/view/screens/Feeds.tsx:656 +#: src/view/screens/Feeds.tsx:622 #: src/view/screens/ProfileFollows.tsx:25 #: src/view/screens/SavedFeeds.tsx:415 msgid "Following" msgstr "팔로우 중" -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:94 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:98 msgid "Following {0}" msgstr "{0} ë‹˜ì„ íŒ”ë¡œìš°í–ˆìŠµë‹ˆë‹¤" @@ -2059,7 +2143,7 @@ msgstr "팔로우 중 피드 ì„¤ì •" msgid "Following Feed Preferences" msgstr "팔로우 중 피드 ì„¤ì •" -#: src/screens/Profile/Header/Handle.tsx:24 +#: src/screens/Profile/Header/Handle.tsx:31 msgid "Follows you" msgstr "나를 팔로우함" @@ -2100,7 +2184,7 @@ msgstr "ìž¦ì€ ì›ì¹˜ 않는 콘í…ì¸ ê²Œì‹œ" msgid "From @{sanitizedAuthor}" msgstr "@{sanitizedAuthor} ë‹˜ì˜ íƒœê·¸" -#: src/view/com/posts/FeedItem.tsx:232 +#: src/view/com/posts/FeedItem.tsx:236 msgctxt "from-feed" msgid "From <0/>" msgstr "<0/>ì—서" @@ -2118,6 +2202,10 @@ msgstr "시작하기" msgid "Get Started" msgstr "시작하기" +#: src/view/com/util/images/ImageHorzList.tsx:35 +msgid "GIF" +msgstr "GIF" + #: src/screens/Onboarding/StepProfile/index.tsx:225 msgid "Give your profile a face" msgstr "í”„ë¡œí•„ì— ì–¼êµ´ 달기" @@ -2188,7 +2276,7 @@ msgstr "그래픽 미디어" msgid "Handle" msgstr "핸들" -#: src/view/screens/AccessibilitySettings.tsx:103 +#: src/view/screens/AccessibilitySettings.tsx:116 msgid "Haptics" msgstr "햅틱" @@ -2223,35 +2311,35 @@ msgstr "앱 비밀번호입니다." #: src/components/moderation/ContentHider.tsx:116 #: src/components/moderation/LabelPreference.tsx:134 -#: src/components/moderation/PostHider.tsx:121 +#: src/components/moderation/PostHider.tsx:122 #: src/lib/moderation/useLabelBehaviorDescription.ts:15 #: src/lib/moderation/useLabelBehaviorDescription.ts:20 #: src/lib/moderation/useLabelBehaviorDescription.ts:25 #: src/lib/moderation/useLabelBehaviorDescription.ts:30 -#: src/view/com/util/forms/PostDropdownBtn.tsx:432 +#: src/view/com/util/forms/PostDropdownBtn.tsx:442 msgid "Hide" msgstr "숨기기" -#: src/view/com/notifications/FeedItem.tsx:348 +#: src/view/com/notifications/FeedItem.tsx:350 msgctxt "action" msgid "Hide" msgstr "숨기기" -#: src/view/com/util/forms/PostDropdownBtn.tsx:377 -#: src/view/com/util/forms/PostDropdownBtn.tsx:379 +#: src/view/com/util/forms/PostDropdownBtn.tsx:387 +#: src/view/com/util/forms/PostDropdownBtn.tsx:389 msgid "Hide post" msgstr "게시물 숨기기" #: src/components/moderation/ContentHider.tsx:68 -#: src/components/moderation/PostHider.tsx:78 +#: src/components/moderation/PostHider.tsx:79 msgid "Hide the content" msgstr "콘í…ì¸ ìˆ¨ê¸°ê¸°" -#: src/view/com/util/forms/PostDropdownBtn.tsx:429 +#: src/view/com/util/forms/PostDropdownBtn.tsx:439 msgid "Hide this post?" msgstr "ì´ ê²Œì‹œë¬¼ì„ ìˆ¨ê¸°ì‹œê² ìŠµë‹ˆê¹Œ?" -#: src/view/com/notifications/FeedItem.tsx:339 +#: src/view/com/notifications/FeedItem.tsx:341 msgid "Hide user list" msgstr "ì‚¬ìš©ìž ë¦¬ìŠ¤íŠ¸ 숨기기" @@ -2341,7 +2429,7 @@ msgstr "해당 êµê°€ì˜ ë²•ë¥ ì— ë”°ë¼ ì•„ì§ ì„±ì¸ì´ 아닌 경우, 부모 msgid "If you delete this list, you won't be able to recover it." msgstr "ì´ ë¦¬ìŠ¤íŠ¸ë¥¼ ì‚ì œí•˜ë©´ 다시 ë³µêµ¬í• ìˆ˜ 없습니다." -#: src/view/com/util/forms/PostDropdownBtn.tsx:420 +#: src/view/com/util/forms/PostDropdownBtn.tsx:430 msgid "If you remove this post, you won't be able to recover it." msgstr "ì´ ê²Œì‹œë¬¼ì„ ì‚ì œí•˜ë©´ 다시 ë³µêµ¬í• ìˆ˜ 없습니다." @@ -2357,7 +2445,7 @@ msgstr "핸들ì´ë‚˜ ì´ë©”ì¼ì„ ë³€ê²½í•˜ë ¤ëŠ” 경우 비활성화하기 ì „ì msgid "Illegal and Urgent" msgstr "불법 ë° ê¸´ê¸‰ 사í•" -#: src/view/com/util/images/Gallery.tsx:39 +#: src/view/com/util/images/Gallery.tsx:42 msgid "Image" msgstr "ì´ë¯¸ì§€" @@ -2426,7 +2514,7 @@ msgstr "다ì´ë ‰íЏ 메시지 소개" msgid "Invalid 2FA confirmation code." msgstr "ìž˜ëª»ëœ 2단계 ì¸ì¦ 코드입니다." -#: src/view/com/post-thread/PostThreadItem.tsx:235 +#: src/view/com/post-thread/PostThreadItem.tsx:236 msgid "Invalid or unsupported post record" msgstr "ìœ íš¨í•˜ì§€ 않거나 ì§€ì›ë˜ì§€ 않는 게시물 기ë¡" @@ -2442,7 +2530,7 @@ msgstr "친구 초대하기" msgid "Invite code" msgstr "초대 코드" -#: src/screens/Signup/state.ts:272 +#: src/screens/Signup/state.ts:275 msgid "Invite code not accepted. Check that you input it correctly and try again." msgstr "초대 코드가 올바르지 않습니다. 코드를 올바르게 ìž…ë ¥í–ˆëŠ”ì§€ 확ì¸í•œ 후 다시 시ë„하세요." @@ -2504,7 +2592,7 @@ msgid "Languages" msgstr "언어" #: src/screens/Hashtag.tsx:99 -#: src/view/screens/Search/Search.tsx:377 +#: src/view/screens/Search/Search.tsx:359 msgid "Latest" msgstr "ìµœì‹ " @@ -2517,7 +2605,7 @@ msgstr "ë” ì•Œì•„ë³´ê¸°" msgid "Learn more about the moderation applied to this content." msgstr "ì´ ì½˜í…ì¸ ì— ì ìš©ëœ ê²€í† ì„¤ì •ì— ëŒ€í•´ ìžì„¸ížˆ 알아보세요." -#: src/components/moderation/PostHider.tsx:99 +#: src/components/moderation/PostHider.tsx:100 #: src/components/moderation/ScreenHider.tsx:125 msgid "Learn more about this warning" msgstr "ì´ ê²½ê³ ì— ëŒ€í•´ ë” ì•Œì•„ë³´ê¸°" @@ -2593,11 +2681,11 @@ msgstr "좋아요 표시한 사용ìž" msgid "Liked By" msgstr "좋아요 표시한 사용ìž" -#: src/view/com/notifications/FeedItem.tsx:176 +#: src/view/com/notifications/FeedItem.tsx:178 msgid "liked your custom feed" msgstr "ì´(ê°€) ë‚´ 맞춤 피드를 좋아합니다" -#: src/view/com/notifications/FeedItem.tsx:168 +#: src/view/com/notifications/FeedItem.tsx:170 msgid "liked your post" msgstr "ì´(ê°€) ë‚´ ê²Œì‹œë¬¼ì„ ì¢‹ì•„í•©ë‹ˆë‹¤" @@ -2605,7 +2693,7 @@ msgstr "ì´(ê°€) ë‚´ ê²Œì‹œë¬¼ì„ ì¢‹ì•„í•©ë‹ˆë‹¤" msgid "Likes" msgstr "좋아요" -#: src/view/com/post-thread/PostThreadItem.tsx:196 +#: src/view/com/post-thread/PostThreadItem.tsx:197 msgid "Likes on this post" msgstr "ì´ ê²Œì‹œë¬¼ì„ ì¢‹ì•„ìš” 표시합니다" @@ -2658,6 +2746,18 @@ msgstr "리스트" msgid "Lists blocking this user:" msgstr "ì´ ì‚¬ìš©ìžë¥¼ 차단한 리스트:" +#: src/view/screens/Search/Explore.tsx:130 +msgid "Load more" +msgstr "ë” ë¶ˆëŸ¬ì˜¤ê¸°" + +#: src/view/screens/Search/Explore.tsx:218 +msgid "Load more suggested feeds" +msgstr "추천 피드 ë” ë¶ˆëŸ¬ì˜¤ê¸°" + +#: src/view/screens/Search/Explore.tsx:216 +msgid "Load more suggested follows" +msgstr "추천 팔로우 ë” ë¶ˆëŸ¬ì˜¤ê¸°" + #: src/view/screens/Notifications.tsx:184 msgid "Load new notifications" msgstr "새 알림 불러오기" @@ -2730,21 +2830,21 @@ msgstr "뮤트한 단어 ë° íƒœê·¸ 관리" msgid "Mark as read" msgstr "ì½ìŒìœ¼ë¡œ 표시" -#: src/view/screens/AccessibilitySettings.tsx:89 +#: src/view/screens/AccessibilitySettings.tsx:102 #: src/view/screens/Profile.tsx:195 msgid "Media" msgstr "미디어" -#: src/view/com/threadgate/WhoCanReply.tsx:139 +#: src/view/com/threadgate/WhoCanReply.tsx:270 msgid "mentioned users" msgstr "멘션한 사용ìž" -#: src/view/com/modals/Threadgate.tsx:94 +#: src/view/com/modals/Threadgate.tsx:96 msgid "Mentioned users" msgstr "멘션한 사용ìž" #: src/view/com/util/ViewHeader.tsx:91 -#: src/view/screens/Search/Search.tsx:714 +#: src/view/screens/Search/Search.tsx:683 msgid "Menu" msgstr "메뉴" @@ -2844,7 +2944,7 @@ msgstr "ê²€í† ë„구" msgid "Moderator has chosen to set a general warning on the content." msgstr "ê²€í† ìžê°€ 콘í…ì¸ ì— ì¼ë°˜ ê²½ê³ ë¥¼ ì„¤ì •í–ˆìŠµë‹ˆë‹¤." -#: src/view/com/post-thread/PostThreadItem.tsx:566 +#: src/view/com/post-thread/PostThreadItem.tsx:567 msgid "More" msgstr "ë” ë³´ê¸°" @@ -2868,8 +2968,8 @@ msgstr "뮤트" msgid "Mute {truncatedTag}" msgstr "{truncatedTag} 뮤트" -#: src/view/com/profile/ProfileMenu.tsx:281 -#: src/view/com/profile/ProfileMenu.tsx:288 +#: src/view/com/profile/ProfileMenu.tsx:284 +#: src/view/com/profile/ProfileMenu.tsx:291 msgid "Mute Account" msgstr "ê³„ì • 뮤트" @@ -2910,13 +3010,13 @@ msgstr "게시물 글 ë° íƒœê·¸ì—서 ì´ ë‹¨ì–´ 뮤트하기" msgid "Mute this word in tags only" msgstr "태그ì—서만 ì´ ë‹¨ì–´ 뮤트하기" -#: src/view/com/util/forms/PostDropdownBtn.tsx:352 -#: src/view/com/util/forms/PostDropdownBtn.tsx:358 +#: src/view/com/util/forms/PostDropdownBtn.tsx:362 +#: src/view/com/util/forms/PostDropdownBtn.tsx:368 msgid "Mute thread" msgstr "ìŠ¤ë ˆë“œ 뮤트" -#: src/view/com/util/forms/PostDropdownBtn.tsx:368 -#: src/view/com/util/forms/PostDropdownBtn.tsx:370 +#: src/view/com/util/forms/PostDropdownBtn.tsx:378 +#: src/view/com/util/forms/PostDropdownBtn.tsx:380 msgid "Mute words & tags" msgstr "단어 ë° íƒœê·¸ 뮤트" @@ -2954,7 +3054,7 @@ msgstr "뮤트 목ë¡ì€ 비공개입니다. 뮤트한 ê³„ì •ì€ ë‚˜ì™€ ìƒí˜¸ìž msgid "My Birthday" msgstr "ë‚´ ìƒë…„ì›”ì¼" -#: src/view/screens/Feeds.tsx:768 +#: src/view/screens/Feeds.tsx:734 msgid "My Feeds" msgstr "ë‚´ 피드" @@ -3047,7 +3147,7 @@ msgctxt "action" msgid "New post" msgstr "새 게시물" -#: src/view/screens/Feeds.tsx:600 +#: src/view/screens/Feeds.tsx:566 #: src/view/screens/Notifications.tsx:193 #: src/view/screens/Profile.tsx:464 #: src/view/screens/ProfileFeed.tsx:426 @@ -3062,6 +3162,10 @@ msgctxt "action" msgid "New Post" msgstr "새 게시물" +#: src/components/NewskieDialog.tsx:68 +msgid "New user info dialog" +msgstr "새 ì‚¬ìš©ìž ì •ë³´ 대화 ìƒìž" + #: src/view/com/modals/CreateOrEditList.tsx:236 msgid "New User List" msgstr "새 ì‚¬ìš©ìž ë¦¬ìŠ¤íŠ¸" @@ -3113,7 +3217,7 @@ msgstr "DNS íŒ¨ë„ ì—†ìŒ" msgid "No featured GIFs found. There may be an issue with Tenor." msgstr "ì¸ê¸° GIF를 ì°¾ì„ ìˆ˜ 없습니다. Tenorì— ë¬¸ì œê°€ ìžˆì„ ìˆ˜ 있습니다." -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:116 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:120 msgid "No longer following {0}" msgstr "ë” ì´ìƒ {0} ë‹˜ì„ íŒ”ë¡œìš°í•˜ì§€ 않ìŒ" @@ -3157,13 +3261,14 @@ msgstr "ê²°ê³¼ ì—†ìŒ" msgid "No results found" msgstr "결과를 ì°¾ì„ ìˆ˜ ì—†ìŒ" -#: src/view/screens/Feeds.tsx:530 +#: src/view/screens/Feeds.tsx:497 msgid "No results found for \"{query}\"" msgstr "\"{query}\"ì— ëŒ€í•œ 결과를 ì°¾ì„ ìˆ˜ 없습니다" #: src/view/com/modals/ListAddRemoveUsers.tsx:127 -#: src/view/screens/Search/Search.tsx:297 -#: src/view/screens/Search/Search.tsx:336 +#: src/view/screens/Search/Search.tsx:233 +#: src/view/screens/Search/Search.tsx:272 +#: src/view/screens/Search/Search.tsx:318 msgid "No results found for {query}" msgstr "{query}ì— ëŒ€í•œ 결과를 ì°¾ì„ ìˆ˜ 없습니다" @@ -3177,7 +3282,7 @@ msgstr "\"{search}\"ì— ëŒ€í•œ 검색 결과를 ì°¾ì„ ìˆ˜ 없습니다." msgid "No thanks" msgstr "사용하지 않ìŒ" -#: src/view/com/modals/Threadgate.tsx:83 +#: src/view/com/modals/Threadgate.tsx:85 msgid "Nobody" msgstr "ì—†ìŒ" @@ -3204,9 +3309,9 @@ msgstr "ì°¾ì„ ìˆ˜ ì—†ìŒ" msgid "Not right now" msgstr "ë‚˜ì¤‘ì— í•˜ê¸°" -#: src/view/com/profile/ProfileMenu.tsx:370 -#: src/view/com/util/forms/PostDropdownBtn.tsx:446 -#: src/view/com/util/post-ctrls/PostCtrls.tsx:308 +#: src/view/com/profile/ProfileMenu.tsx:373 +#: src/view/com/util/forms/PostDropdownBtn.tsx:456 +#: src/view/com/util/post-ctrls/PostCtrls.tsx:311 msgid "Note about sharing" msgstr "ê³µìœ ê´€ë ¨ ì°¸ê³ ì‚¬í•" @@ -3236,6 +3341,10 @@ msgstr "알림ìŒ" msgid "Notifications" msgstr "알림" +#: src/lib/hooks/useTimeAgo.ts:51 +msgid "now" +msgstr "지금" + #: src/components/dms/MessageItem.tsx:175 msgid "Now" msgstr "지금" @@ -3274,11 +3383,15 @@ msgstr "확ì¸" msgid "Oldest replies first" msgstr "ì˜¤ëž˜ëœ ìˆœ" +#: src/lib/hooks/useTimeAgo.ts:81 +msgid "on {str}" +msgstr "" + #: src/view/screens/Settings/index.tsx:256 msgid "Onboarding reset" msgstr "온보딩 ìž¬ì„¤ì •" -#: src/view/com/composer/Composer.tsx:503 +#: src/view/com/composer/Composer.tsx:505 msgid "One or more images is missing alt text." msgstr "하나 ì´ìƒì˜ ì´ë¯¸ì§€ì— 대체 í…스트가 누ë½ë˜ì—ˆìŠµë‹ˆë‹¤." @@ -3286,9 +3399,9 @@ msgstr "하나 ì´ìƒì˜ ì´ë¯¸ì§€ì— 대체 í…스트가 누ë½ë˜ì—ˆìŠµë‹ˆë‹¤. msgid "Only .jpg and .png files are supported" msgstr ".jpg ë° .png 파ì¼ë§Œ ì§€ì›í•©ë‹ˆë‹¤" -#: src/view/com/threadgate/WhoCanReply.tsx:100 -msgid "Only {0} can reply." -msgstr "{0}ë§Œ ë‹µê¸€ì„ ë‹¬ 수 있습니다." +#: src/view/com/threadgate/WhoCanReply.tsx:239 +msgid "Only {0} can reply" +msgstr "{0}ë§Œ ë‹µê¸€ì„ ë‹¬ 수 있ìŒ" #: src/screens/Signup/StepHandle.tsx:98 msgid "Only contains letters, numbers, and hyphens" @@ -3321,8 +3434,8 @@ msgstr "아바타 ìƒì„±ê¸° 열기" msgid "Open conversation options" msgstr "대화 옵션 열기" -#: src/view/com/composer/Composer.tsx:613 -#: src/view/com/composer/Composer.tsx:614 +#: src/view/com/composer/Composer.tsx:615 +#: src/view/com/composer/Composer.tsx:616 msgid "Open emoji picker" msgstr "ì´ëª¨í‹°ì½˜ ì„ íƒê¸° 열기" @@ -3346,7 +3459,7 @@ msgstr "뮤트한 단어 ë° íƒœê·¸ ì„¤ì • 열기" msgid "Open navigation" msgstr "내비게ì´ì…˜ 열기" -#: src/view/com/util/forms/PostDropdownBtn.tsx:237 +#: src/view/com/util/forms/PostDropdownBtn.tsx:247 msgid "Open post options menu" msgstr "게시물 옵션 메뉴 열기" @@ -3367,7 +3480,7 @@ msgstr "{numItems}번째 ì˜µì…˜ì„ ì—½ë‹ˆë‹¤" msgid "Opens accessibility settings" msgstr "ì ‘ê·¼ì„± ì„¤ì •ì„ ì—½ë‹ˆë‹¤" -#: src/view/screens/Log.tsx:54 +#: src/view/screens/Log.tsx:58 msgid "Opens additional details for a debug entry" msgstr "디버그 í•ëª©ì— ëŒ€í•œ 추가 세부 ì •ë³´ë¥¼ 엽니다" @@ -3449,11 +3562,6 @@ msgstr "ê²€í† ì„¤ì •ì„ ì—½ë‹ˆë‹¤" msgid "Opens password reset form" msgstr "비밀번호 ìž¬ì„¤ì • ì–‘ì‹ì„ 엽니다" -#: src/view/com/home/HomeHeaderLayout.web.tsx:77 -#: src/view/screens/Feeds.tsx:417 -#~ msgid "Opens screen to edit Saved Feeds" -#~ msgstr "ì €ìž¥í•œ 피드를 íŽ¸ì§‘í• ìˆ˜ 있는 í™”ë©´ì„ ì—½ë‹ˆë‹¤" - #: src/view/screens/Settings/index.tsx:617 msgid "Opens screen with all saved feeds" msgstr "ëª¨ë“ ì €ìž¥í•œ 피드 í™”ë©´ì„ ì—½ë‹ˆë‹¤" @@ -3483,8 +3591,8 @@ msgstr "시스템 로그 페ì´ì§€ë¥¼ 엽니다" msgid "Opens the threads preferences" msgstr "ìŠ¤ë ˆë“œ ì„¤ì •ì„ ì—½ë‹ˆë‹¤" -#: src/view/com/notifications/FeedItem.tsx:427 -#: src/view/com/util/UserAvatar.tsx:409 +#: src/view/com/notifications/FeedItem.tsx:429 +#: src/view/com/util/UserAvatar.tsx:422 msgid "Opens this profile" msgstr "ì´ í”„ë¡œí•„ì„ ì—½ë‹ˆë‹¤" @@ -3497,7 +3605,7 @@ msgstr "{numItems}ê°œ 중 {0}번째 옵션" msgid "Optionally provide additional information below:" msgstr "ì„ íƒ ì‚¬í•으로 ì•„ëž˜ì— ì¶”ê°€ ì •ë³´ë¥¼ ìž…ë ¥í•˜ì„¸ìš”:" -#: src/view/com/modals/Threadgate.tsx:90 +#: src/view/com/modals/Threadgate.tsx:92 msgid "Or combine these options:" msgstr "ë˜ëŠ” ë‹¤ìŒ ì˜µì…˜ì„ ê²°í•©í•˜ì„¸ìš”:" @@ -3553,11 +3661,11 @@ msgstr "비밀번호 변경ë¨" msgid "Password updated!" msgstr "비밀번호 변경ë¨" -#: src/view/com/util/post-embeds/GifEmbed.tsx:36 +#: src/view/com/util/post-embeds/GifEmbed.tsx:37 msgid "Pause" msgstr "ì¼ì‹œ ì •ì§€" -#: src/view/screens/Search/Search.tsx:387 +#: src/view/screens/Search/Search.tsx:369 msgid "People" msgstr "사람들" @@ -3602,7 +3710,7 @@ msgstr "ê³ ì •í•œ 피드" msgid "Pinned to your feeds" msgstr "ë‚´ í”¼ë“œì— ê³ ì •ë¨" -#: src/view/com/util/post-embeds/GifEmbed.tsx:36 +#: src/view/com/util/post-embeds/GifEmbed.tsx:37 msgid "Play" msgstr "재ìƒ" @@ -3610,7 +3718,7 @@ msgstr "재ìƒ" msgid "Play {0}" msgstr "{0} 재ìƒ" -#: src/view/com/util/post-embeds/GifEmbed.tsx:35 +#: src/view/com/util/post-embeds/GifEmbed.tsx:36 msgid "Play or pause the GIF" msgstr "GIP를 재ìƒí•˜ê±°ë‚˜ ì¼ì‹œ ì •ì§€í•©ë‹ˆë‹¤" @@ -3688,13 +3796,13 @@ msgstr "ì •ì¹˜" msgid "Porn" msgstr "ìŒëž€ë¬¼" -#: src/view/com/composer/Composer.tsx:477 -#: src/view/com/composer/Composer.tsx:485 +#: src/view/com/composer/Composer.tsx:479 +#: src/view/com/composer/Composer.tsx:487 msgctxt "action" msgid "Post" msgstr "게시하기" -#: src/view/com/post-thread/PostThread.tsx:430 +#: src/view/com/post-thread/PostThread.tsx:434 msgctxt "description" msgid "Post" msgstr "게시물" @@ -3709,7 +3817,7 @@ msgstr "{0} ë‹˜ì˜ ê²Œì‹œë¬¼" msgid "Post by @{0}" msgstr "@{0} ë‹˜ì˜ ê²Œì‹œë¬¼" -#: src/view/com/util/forms/PostDropdownBtn.tsx:129 +#: src/view/com/util/forms/PostDropdownBtn.tsx:132 msgid "Post deleted" msgstr "게시물 ì‚ì œë¨" @@ -3775,9 +3883,9 @@ msgstr "호스팅 ì œê³µìžë¥¼ ë³€ê²½í•˜ë ¤ë©´ 누릅니다" msgid "Press to retry" msgstr "다시 시ë„í•˜ë ¤ë©´ 누르기" -#: src/components/KnownFollowers.tsx:111 +#: src/components/KnownFollowers.tsx:116 msgid "Press to view followers of this account that you also follow" -msgstr "" +msgstr "ë‚´ê°€ 팔로우하는 ì´ ê³„ì •ì˜ íŒ”ë¡œì›Œë¥¼ ë³´ë ¤ë©´ 누르세요" #: src/view/com/lightbox/Lightbox.web.tsx:150 msgid "Previous image" @@ -3845,18 +3953,18 @@ msgstr "ì¼ê´„ 뮤트하거나 ì°¨ë‹¨í• ìˆ˜ 있는 공개ì ì´ê³ ê³µìœ ê°€ëŠ msgid "Public, shareable lists which can drive feeds." msgstr "피드를 íƒìƒ‰í• 수 있는 공개ì ì´ê³ ê³µìœ ê°€ëŠ¥í•œ 목ë¡ìž…니다." -#: src/view/com/composer/Composer.tsx:462 +#: src/view/com/composer/Composer.tsx:464 msgid "Publish post" msgstr "게시물 게시하기" -#: src/view/com/composer/Composer.tsx:462 +#: src/view/com/composer/Composer.tsx:464 msgid "Publish reply" msgstr "답글 게시하기" -#: src/view/com/util/post-ctrls/RepostButton.tsx:115 -#: src/view/com/util/post-ctrls/RepostButton.tsx:127 -#: src/view/com/util/post-ctrls/RepostButton.web.tsx:78 -#: src/view/com/util/post-ctrls/RepostButton.web.tsx:81 +#: src/view/com/util/post-ctrls/RepostButton.tsx:116 +#: src/view/com/util/post-ctrls/RepostButton.tsx:128 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:79 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:82 msgid "Quote post" msgstr "게시물 ì¸ìš©" @@ -3876,7 +3984,7 @@ msgstr "ê³„ì • 재활성화" msgid "Reason:" msgstr "ì´ìœ :" -#: src/view/screens/Search/Search.tsx:970 +#: src/view/screens/Search/Search.tsx:933 msgid "Recent Searches" msgstr "최근 검색" @@ -3889,6 +3997,7 @@ msgid "Reload conversations" msgstr "대화 다시 불러오기" #: src/components/dialogs/MutedWords.tsx:286 +#: src/components/FeedCard.tsx:200 #: src/view/com/feeds/FeedSourceCard.tsx:317 #: src/view/com/modals/ListAddRemoveUsers.tsx:268 #: src/view/com/modals/SelfLabel.tsx:84 @@ -3901,7 +4010,7 @@ msgstr "ì œê±°" msgid "Remove account" msgstr "ê³„ì • ì œê±°" -#: src/view/com/util/UserAvatar.tsx:371 +#: src/view/com/util/UserAvatar.tsx:384 msgid "Remove Avatar" msgstr "아바타 ì œê±°" @@ -3931,6 +4040,7 @@ msgstr "피드를 ì œê±°í•˜ì‹œê² ìŠµë‹ˆê¹Œ?" msgid "Remove from my feeds" msgstr "ë‚´ 피드ì—서 ì œê±°" +#: src/components/FeedCard.tsx:195 #: src/view/com/feeds/FeedSourceCard.tsx:312 msgid "Remove from my feeds?" msgstr "ë‚´ 피드ì—서 ì œê±°í•˜ì‹œê² ìŠµë‹ˆê¹Œ?" @@ -3947,11 +4057,11 @@ msgstr "ì´ë¯¸ì§€ 미리보기 ì œê±°" msgid "Remove mute word from your list" msgstr "목ë¡ì—서 뮤트한 단어 ì œê±°" -#: src/view/screens/Search/Search.tsx:1011 +#: src/view/screens/Search/Search.tsx:974 msgid "Remove profile" msgstr "프로필 ì œê±°" -#: src/view/screens/Search/Search.tsx:1013 +#: src/view/screens/Search/Search.tsx:976 msgid "Remove profile from search history" msgstr "검색 기ë¡ì—서 í”„ë¡œí•„ì„ ì œê±°í•©ë‹ˆë‹¤" @@ -3959,8 +4069,8 @@ msgstr "검색 기ë¡ì—서 í”„ë¡œí•„ì„ ì œê±°í•©ë‹ˆë‹¤" msgid "Remove quote" msgstr "ì¸ìš© ì œê±°" -#: src/view/com/util/post-ctrls/RepostButton.tsx:92 -#: src/view/com/util/post-ctrls/RepostButton.tsx:108 +#: src/view/com/util/post-ctrls/RepostButton.tsx:93 +#: src/view/com/util/post-ctrls/RepostButton.tsx:109 msgid "Remove repost" msgstr "재게시를 취소합니다" @@ -4000,11 +4110,19 @@ msgstr "Discover로 êµì²´" msgid "Replies" msgstr "답글" -#: src/view/com/threadgate/WhoCanReply.tsx:98 +#: src/view/com/threadgate/WhoCanReply.tsx:66 +msgid "Replies disabled" +msgstr "답글 비활성화ë¨" + +#: src/view/com/threadgate/WhoCanReply.tsx:123 +msgid "Replies on this thread are disabled" +msgstr "ì´ ìŠ¤ë ˆë“œì— ëŒ€í•œ ë‹µê¸€ì´ ë¹„í™œì„±í™”ë¨" + +#: src/view/com/threadgate/WhoCanReply.tsx:237 msgid "Replies to this thread are disabled" -msgstr "ì´ ìŠ¤ë ˆë“œì— ëŒ€í•œ ë‹µê¸€ì´ ë¹„í™œì„±í™”ë©ë‹ˆë‹¤." +msgstr "ì´ ìŠ¤ë ˆë“œì— ëŒ€í•œ ë‹µê¸€ì´ ë¹„í™œì„±í™”ë¨" -#: src/view/com/composer/Composer.tsx:475 +#: src/view/com/composer/Composer.tsx:477 msgctxt "action" msgid "Reply" msgstr "답글" @@ -4014,19 +4132,24 @@ msgid "Reply Filters" msgstr "답글 í•„í„°" #: src/view/com/post/Post.tsx:190 -#: src/view/com/posts/FeedItem.tsx:427 +#: src/view/com/posts/FeedItem.tsx:439 msgctxt "description" msgid "Reply to <0><1/></0>" msgstr "<0><1/></0> 님ì—게 보내는 답글" +#: src/view/com/posts/FeedItem.tsx:437 +msgctxt "description" +msgid "Reply to a blocked post" +msgstr "ì°¨ë‹¨ëœ ê²Œì‹œë¬¼ì— ë³´ë‚´ëŠ” 답글" + #: src/components/dms/MessageMenu.tsx:132 #: src/components/dms/MessagesListBlockedFooter.tsx:77 #: src/components/dms/MessagesListBlockedFooter.tsx:84 msgid "Report" msgstr "ì‹ ê³ " -#: src/view/com/profile/ProfileMenu.tsx:321 #: src/view/com/profile/ProfileMenu.tsx:324 +#: src/view/com/profile/ProfileMenu.tsx:327 msgid "Report Account" msgstr "ê³„ì • ì‹ ê³ " @@ -4053,8 +4176,8 @@ msgstr "리스트 ì‹ ê³ " msgid "Report message" msgstr "메시지 ì‹ ê³ " -#: src/view/com/util/forms/PostDropdownBtn.tsx:394 -#: src/view/com/util/forms/PostDropdownBtn.tsx:396 +#: src/view/com/util/forms/PostDropdownBtn.tsx:404 +#: src/view/com/util/forms/PostDropdownBtn.tsx:406 msgid "Report post" msgstr "게시물 ì‹ ê³ " @@ -4084,21 +4207,21 @@ msgstr "ì´ ê²Œì‹œë¬¼ ì‹ ê³ í•˜ê¸°" msgid "Report this user" msgstr "ì´ ì‚¬ìš©ìž ì‹ ê³ í•˜ê¸°" -#: src/view/com/util/post-ctrls/RepostButton.tsx:64 -#: src/view/com/util/post-ctrls/RepostButton.tsx:93 -#: src/view/com/util/post-ctrls/RepostButton.tsx:109 +#: src/view/com/util/post-ctrls/RepostButton.tsx:65 +#: src/view/com/util/post-ctrls/RepostButton.tsx:94 +#: src/view/com/util/post-ctrls/RepostButton.tsx:110 msgctxt "action" msgid "Repost" msgstr "재게시" -#: src/view/com/util/post-ctrls/RepostButton.web.tsx:69 -#: src/view/com/util/post-ctrls/RepostButton.web.tsx:73 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:70 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:74 msgid "Repost" msgstr "재게시" -#: src/view/com/util/post-ctrls/RepostButton.tsx:85 -#: src/view/com/util/post-ctrls/RepostButton.web.tsx:46 -#: src/view/com/util/post-ctrls/RepostButton.web.tsx:92 +#: src/view/com/util/post-ctrls/RepostButton.tsx:86 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:47 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:93 msgid "Repost or quote post" msgstr "재게시 ë˜ëŠ” 게시물 ì¸ìš©" @@ -4106,19 +4229,19 @@ msgstr "재게시 ë˜ëŠ” 게시물 ì¸ìš©" msgid "Reposted By" msgstr "재게시한 사용ìž" -#: src/view/com/posts/FeedItem.tsx:250 +#: src/view/com/posts/FeedItem.tsx:254 msgid "Reposted by {0}" msgstr "{0} ë‹˜ì´ ìž¬ê²Œì‹œí•¨" -#: src/view/com/posts/FeedItem.tsx:265 +#: src/view/com/posts/FeedItem.tsx:269 msgid "Reposted by <0><1/></0>" msgstr "<0><1/></0> ë‹˜ì´ ìž¬ê²Œì‹œí•¨" -#: src/view/com/notifications/FeedItem.tsx:170 +#: src/view/com/notifications/FeedItem.tsx:172 msgid "reposted your post" msgstr "ì´(ê°€) ë‚´ ê²Œì‹œë¬¼ì„ ìž¬ê²Œì‹œí–ˆìŠµë‹ˆë‹¤" -#: src/view/com/post-thread/PostThreadItem.tsx:201 +#: src/view/com/post-thread/PostThreadItem.tsx:202 msgid "Reposts of this post" msgstr "ì´ ê²Œì‹œë¬¼ì˜ ìž¬ê²Œì‹œ" @@ -4132,7 +4255,7 @@ msgstr "변경 ìš”ì²" msgid "Request Code" msgstr "코드 ìš”ì²" -#: src/view/screens/AccessibilitySettings.tsx:82 +#: src/view/screens/AccessibilitySettings.tsx:88 msgid "Require alt text before posting" msgstr "게시하기 ì „ 대체 í…스트 필수" @@ -4282,6 +4405,7 @@ msgid "Saves image crop settings" msgstr "ì´ë¯¸ì§€ ìžë¥´ê¸° ì„¤ì •ì„ ì €ìž¥í•©ë‹ˆë‹¤" #: src/components/dms/ChatEmptyPill.tsx:33 +#: src/components/NewskieDialog.tsx:72 msgid "Say hello!" msgstr "ì¸ì‚¬í•´ 보세요!" @@ -4299,9 +4423,9 @@ msgstr "맨 위로 스í¬ë¡¤" #: src/view/com/modals/ListAddRemoveUsers.tsx:75 #: src/view/com/util/forms/SearchInput.tsx:67 #: src/view/com/util/forms/SearchInput.tsx:79 -#: src/view/screens/Search/Search.tsx:452 -#: src/view/screens/Search/Search.tsx:822 -#: src/view/screens/Search/Search.tsx:850 +#: src/view/screens/Search/Search.tsx:421 +#: src/view/screens/Search/Search.tsx:791 +#: src/view/screens/Search/Search.tsx:813 #: src/view/shell/bottom-bar/BottomBar.tsx:179 #: src/view/shell/desktop/LeftNav.tsx:343 #: src/view/shell/desktop/Search.tsx:194 @@ -4315,7 +4439,7 @@ msgstr "검색" msgid "Search for \"{query}\"" msgstr "\"{query}\"ì— ëŒ€í•œ 검색 ê²°ê³¼" -#: src/view/screens/Search/Search.tsx:906 +#: src/view/screens/Search/Search.tsx:869 msgid "Search for \"{searchText}\"" msgstr "\"{searchText}\"ì— ëŒ€í•œ 검색 ê²°ê³¼" @@ -4496,8 +4620,8 @@ msgstr "{0} 님ì—게 ì‹ ê³ ë³´ë‚´ê¸°" msgid "Send verification email" msgstr "ì¸ì¦ ì´ë©”ì¼ ë³´ë‚´ê¸°" -#: src/view/com/util/forms/PostDropdownBtn.tsx:286 -#: src/view/com/util/forms/PostDropdownBtn.tsx:289 +#: src/view/com/util/forms/PostDropdownBtn.tsx:296 +#: src/view/com/util/forms/PostDropdownBtn.tsx:299 msgid "Send via direct message" msgstr "다ì´ë ‰íЏ 메시지로 보내기" @@ -4602,11 +4726,11 @@ msgctxt "action" msgid "Share" msgstr "ê³µìœ " -#: src/view/com/profile/ProfileMenu.tsx:217 -#: src/view/com/profile/ProfileMenu.tsx:226 -#: src/view/com/util/forms/PostDropdownBtn.tsx:297 -#: src/view/com/util/forms/PostDropdownBtn.tsx:306 -#: src/view/com/util/post-ctrls/PostCtrls.tsx:297 +#: src/view/com/profile/ProfileMenu.tsx:220 +#: src/view/com/profile/ProfileMenu.tsx:229 +#: src/view/com/util/forms/PostDropdownBtn.tsx:307 +#: src/view/com/util/forms/PostDropdownBtn.tsx:316 +#: src/view/com/util/post-ctrls/PostCtrls.tsx:300 #: src/view/screens/ProfileList.tsx:428 msgid "Share" msgstr "ê³µìœ " @@ -4619,9 +4743,9 @@ msgstr "ë©‹ì§„ ì´ì•¼ê¸°ë¥¼ ì „í•˜ì„¸ìš”!" msgid "Share a fun fact!" msgstr "재미있는 ì‚¬ì‹¤ì„ ì „í•˜ì„¸ìš”!" -#: src/view/com/profile/ProfileMenu.tsx:375 -#: src/view/com/util/forms/PostDropdownBtn.tsx:451 -#: src/view/com/util/post-ctrls/PostCtrls.tsx:313 +#: src/view/com/profile/ProfileMenu.tsx:378 +#: src/view/com/util/forms/PostDropdownBtn.tsx:461 +#: src/view/com/util/post-ctrls/PostCtrls.tsx:316 msgid "Share anyway" msgstr "ë¬´ì‹œí•˜ê³ ê³µìœ " @@ -4645,12 +4769,12 @@ msgstr "ì—°ê²°ëœ ì›¹ì‚¬ì´íŠ¸ë¥¼ ê³µìœ í•©ë‹ˆë‹¤" #: src/components/moderation/ContentHider.tsx:116 #: src/components/moderation/LabelPreference.tsx:136 -#: src/components/moderation/PostHider.tsx:121 +#: src/components/moderation/PostHider.tsx:122 #: src/view/screens/Settings/index.tsx:381 msgid "Show" msgstr "표시" -#: src/view/com/util/post-embeds/GifEmbed.tsx:167 +#: src/view/com/util/post-embeds/GifEmbed.tsx:169 msgid "Show alt text" msgstr "대체 í…스트 표시" @@ -4668,7 +4792,7 @@ msgstr "ë°°ì§€ 표시" msgid "Show badge and filter from feeds" msgstr "ë°°ì§€ 표시 ë° í”¼ë“œì—서 í•„í„°ë§" -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:211 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:215 msgid "Show follows similar to {0}" msgstr "{0} 님과 비슷한 팔로우 표시" @@ -4676,19 +4800,19 @@ msgstr "{0} 님과 비슷한 팔로우 표시" msgid "Show hidden replies" msgstr "숨겨진 답글 표시" -#: src/view/com/util/forms/PostDropdownBtn.tsx:336 -#: src/view/com/util/forms/PostDropdownBtn.tsx:338 +#: src/view/com/util/forms/PostDropdownBtn.tsx:346 +#: src/view/com/util/forms/PostDropdownBtn.tsx:348 msgid "Show less like this" msgstr "ì´ëŸ° í•목 ëœ ë³´ê¸°" -#: src/view/com/post-thread/PostThreadItem.tsx:532 +#: src/view/com/post-thread/PostThreadItem.tsx:533 #: src/view/com/post/Post.tsx:227 -#: src/view/com/posts/FeedItem.tsx:392 +#: src/view/com/posts/FeedItem.tsx:396 msgid "Show More" msgstr "ë” ë³´ê¸°" -#: src/view/com/util/forms/PostDropdownBtn.tsx:328 -#: src/view/com/util/forms/PostDropdownBtn.tsx:330 +#: src/view/com/util/forms/PostDropdownBtn.tsx:338 +#: src/view/com/util/forms/PostDropdownBtn.tsx:340 msgid "Show more like this" msgstr "ì´ëŸ° í•목 ë” ë³´ê¸°" @@ -4717,7 +4841,7 @@ msgid "Show Reposts" msgstr "재게시 표시" #: src/components/moderation/ContentHider.tsx:69 -#: src/components/moderation/PostHider.tsx:78 +#: src/components/moderation/PostHider.tsx:79 msgid "Show the content" msgstr "콘í…ì¸ í‘œì‹œ" @@ -4818,8 +4942,10 @@ msgid "Software Dev" msgstr "소프트웨어 개발" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 +#: src/view/com/threadgate/WhoCanReply.tsx:67 +#: src/view/com/threadgate/WhoCanReply.tsx:124 msgid "Some people can reply" -msgstr "몇몇 ì‚¬ëžŒë“¤ì´ ë‹µê¸€ì„ ë‹¬ 수 있ìŒ" +msgstr "ì¼ë¶€ ì‚¬ëžŒë“¤ì´ ë‹µê¸€ì„ ë‹¬ 수 있ìŒ" #: src/screens/Messages/Conversation/index.tsx:106 msgid "Something went wrong" @@ -4836,7 +4962,7 @@ msgstr "알 수 없는 오류가 ë°œìƒí–ˆìŠµë‹ˆë‹¤. 다시 시ë„í•´ 주세요" msgid "Something went wrong, please try again." msgstr "알 수 없는 오류가 ë°œìƒí–ˆìŠµë‹ˆë‹¤. 다시 시ë„í•´ 주세요." -#: src/App.native.tsx:85 +#: src/App.native.tsx:92 #: src/App.web.tsx:74 msgid "Sorry! Your session expired. Please log in again." msgstr "죄송합니다. ì„¸ì…˜ì´ ë§Œë£Œë˜ì—ˆìŠµë‹ˆë‹¤. 다시 로그ì¸í•´ 주세요." @@ -4926,9 +5052,9 @@ msgstr "ì´ ë¼ë²¨ëŸ¬ 구ë…하기" msgid "Subscribe to this list" msgstr "ì´ ë¦¬ìŠ¤íŠ¸ 구ë…하기" -#: src/view/screens/Search/Search.tsx:425 -msgid "Suggested Follows" -msgstr "팔로우 추천" +#: src/view/screens/Search/Explore.tsx:331 +msgid "Suggested accounts" +msgstr "추천 ê³„ì •" #: src/view/com/profile/ProfileHeaderSuggestedFollows.tsx:65 msgid "Suggested for you" @@ -5029,8 +5155,8 @@ msgstr "í…스트 íŒŒì¼ ë‚´ìš©:" msgid "That handle is already taken." msgstr "ì´ í•¸ë“¤ì€ ì´ë¯¸ 사용 중입니다." -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:305 -#: src/view/com/profile/ProfileMenu.tsx:351 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:310 +#: src/view/com/profile/ProfileMenu.tsx:354 msgid "The account will be able to interact with you after unblocking." msgstr "ì°¨ë‹¨ì„ í•´ì œí•˜ë©´ ì´ ê³„ì •ì´ ë‚˜ì™€ ìƒí˜¸ìž‘ìš©í• ìˆ˜ 있게 ë©ë‹ˆë‹¤." @@ -5139,17 +5265,17 @@ msgstr "ì‹ ê³ ë¥¼ ì „ì†¡í•˜ëŠ” ë™ì•ˆ ë¬¸ì œê°€ ë°œìƒí–ˆìŠµë‹ˆë‹¤. ì¸í„°ë„· ì— msgid "There was an issue with fetching your app passwords" msgstr "앱 비밀번호를 ê°€ì ¸ì˜¤ëŠ” ë™ì•ˆ ë¬¸ì œê°€ ë°œìƒí–ˆìŠµë‹ˆë‹¤" -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:103 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:125 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:139 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:107 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:129 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:143 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:99 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:111 -#: src/view/com/profile/ProfileMenu.tsx:109 -#: src/view/com/profile/ProfileMenu.tsx:120 -#: src/view/com/profile/ProfileMenu.tsx:135 -#: src/view/com/profile/ProfileMenu.tsx:146 -#: src/view/com/profile/ProfileMenu.tsx:160 -#: src/view/com/profile/ProfileMenu.tsx:173 +#: src/view/com/profile/ProfileMenu.tsx:112 +#: src/view/com/profile/ProfileMenu.tsx:123 +#: src/view/com/profile/ProfileMenu.tsx:138 +#: src/view/com/profile/ProfileMenu.tsx:149 +#: src/view/com/profile/ProfileMenu.tsx:163 +#: src/view/com/profile/ProfileMenu.tsx:176 msgid "There was an issue! {0}" msgstr "ë¬¸ì œê°€ ë°œìƒí–ˆìŠµë‹ˆë‹¤! {0}" @@ -5284,16 +5410,16 @@ msgstr "ì´ ì´ë¦„ì€ ì´ë¯¸ 사용 중입니다" msgid "This post has been deleted." msgstr "ì´ ê²Œì‹œë¬¼ì€ ì‚ì œë˜ì—ˆìŠµë‹ˆë‹¤." -#: src/view/com/util/forms/PostDropdownBtn.tsx:448 -#: src/view/com/util/post-ctrls/PostCtrls.tsx:310 +#: src/view/com/util/forms/PostDropdownBtn.tsx:458 +#: src/view/com/util/post-ctrls/PostCtrls.tsx:313 msgid "This post is only visible to logged-in users. It won't be visible to people who aren't logged in." msgstr "ì´ ê²Œì‹œë¬¼ì€ ë¡œê·¸ì¸í•œ 사용ìžì—게만 표시ë©ë‹ˆë‹¤. 로그ì¸í•˜ì§€ ì•Šì€ ì‚¬ìš©ìžì—게는 표시ë˜ì§€ 않습니다." -#: src/view/com/util/forms/PostDropdownBtn.tsx:430 +#: src/view/com/util/forms/PostDropdownBtn.tsx:440 msgid "This post will be hidden from feeds." msgstr "ì´ ê²Œì‹œë¬¼ì„ í”¼ë“œì—서 숨ê¹ë‹ˆë‹¤." -#: src/view/com/profile/ProfileMenu.tsx:372 +#: src/view/com/profile/ProfileMenu.tsx:375 msgid "This profile is only visible to logged-in users. It won't be visible to people who aren't logged in." msgstr "ì´ í”„ë¡œí•„ì€ ë¡œê·¸ì¸í•œ 사용ìžì—게만 표시ë©ë‹ˆë‹¤. 로그ì¸í•˜ì§€ ì•Šì€ ì‚¬ìš©ìžì—게는 표시ë˜ì§€ 않습니다." @@ -5330,6 +5456,10 @@ msgstr "ì´ ì‚¬ìš©ìžëŠ” ë‚´ê°€ 차단한 <0>{0}</0> ë¦¬ìŠ¤íŠ¸ì— í¬í•¨ë˜ì–´ ì msgid "This user is included in the <0>{0}</0> list which you have muted." msgstr "ì´ ì‚¬ìš©ìžëŠ” ë‚´ê°€ 뮤트한 <0>{0}</0> ë¦¬ìŠ¤íŠ¸ì— í¬í•¨ë˜ì–´ 있습니다." +#: src/components/NewskieDialog.tsx:50 +msgid "This user is new here. Press for more info about when they joined." +msgstr "ì´ ì‚¬ìš©ìžëŠ” 새로 가입했습니다. ì–¸ì œ 가입했는지 ìžì„¸í•œ ì •ë³´ë¥¼ ë³´ë ¤ë©´ 누르세요." + #: src/view/com/profile/ProfileFollows.tsx:87 msgid "This user isn't following anyone." msgstr "ì´ ì‚¬ìš©ìžëŠ” ì•„ë¬´ë„ íŒ”ë¡œìš°í•˜ì§€ 않았습니다." @@ -5380,7 +5510,7 @@ msgid "Toggle to enable or disable adult content" msgstr "ì„±ì¸ ì½˜í…ì¸ í™œì„±í™” ë˜ëŠ” 비활성화 ì „í™˜" #: src/screens/Hashtag.tsx:88 -#: src/view/screens/Search/Search.tsx:367 +#: src/view/screens/Search/Search.tsx:349 msgid "Top" msgstr "ì¸ê¸°" @@ -5390,10 +5520,10 @@ msgstr "변형" #: src/components/dms/MessageMenu.tsx:103 #: src/components/dms/MessageMenu.tsx:105 -#: src/view/com/post-thread/PostThreadItem.tsx:674 -#: src/view/com/post-thread/PostThreadItem.tsx:676 -#: src/view/com/util/forms/PostDropdownBtn.tsx:267 -#: src/view/com/util/forms/PostDropdownBtn.tsx:269 +#: src/view/com/post-thread/PostThreadItem.tsx:681 +#: src/view/com/post-thread/PostThreadItem.tsx:683 +#: src/view/com/util/forms/PostDropdownBtn.tsx:277 +#: src/view/com/util/forms/PostDropdownBtn.tsx:279 msgid "Translate" msgstr "번ì—" @@ -5435,14 +5565,14 @@ msgstr "ì„œë¹„ìŠ¤ì— ì—°ê²°í• ìˆ˜ 없습니다. ì¸í„°ë„· ì—°ê²°ì„ í™•ì¸í•˜ì„ #: src/components/dms/MessagesListBlockedFooter.tsx:96 #: src/components/dms/MessagesListBlockedFooter.tsx:104 #: src/components/dms/MessagesListBlockedFooter.tsx:111 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:188 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:309 -#: src/view/com/profile/ProfileMenu.tsx:363 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:192 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:314 +#: src/view/com/profile/ProfileMenu.tsx:366 #: src/view/screens/ProfileList.tsx:626 msgid "Unblock" msgstr "차단 í•´ì œ" -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:193 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:197 msgctxt "action" msgid "Unblock" msgstr "차단 í•´ì œ" @@ -5452,19 +5582,19 @@ msgstr "차단 í•´ì œ" msgid "Unblock account" msgstr "ê³„ì • 차단 í•´ì œ" -#: src/view/com/profile/ProfileMenu.tsx:301 -#: src/view/com/profile/ProfileMenu.tsx:307 +#: src/view/com/profile/ProfileMenu.tsx:304 +#: src/view/com/profile/ProfileMenu.tsx:310 msgid "Unblock Account" msgstr "ê³„ì • 차단 í•´ì œ" -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:303 -#: src/view/com/profile/ProfileMenu.tsx:345 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:308 +#: src/view/com/profile/ProfileMenu.tsx:348 msgid "Unblock Account?" msgstr "ê³„ì •ì„ ì°¨ë‹¨ í•´ì œí•˜ì‹œê² ìŠµë‹ˆê¹Œ?" -#: src/view/com/util/post-ctrls/RepostButton.tsx:63 -#: src/view/com/util/post-ctrls/RepostButton.web.tsx:69 -#: src/view/com/util/post-ctrls/RepostButton.web.tsx:73 +#: src/view/com/util/post-ctrls/RepostButton.tsx:64 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:70 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:74 msgid "Undo repost" msgstr "재게시 취소" @@ -5477,12 +5607,12 @@ msgstr "언팔로우" msgid "Unfollow" msgstr "언팔로우" -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:233 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:237 msgid "Unfollow {0}" msgstr "{0} ë‹˜ì„ ì–¸íŒ”ë¡œìš°" -#: src/view/com/profile/ProfileMenu.tsx:243 -#: src/view/com/profile/ProfileMenu.tsx:253 +#: src/view/com/profile/ProfileMenu.tsx:246 +#: src/view/com/profile/ProfileMenu.tsx:256 msgid "Unfollow Account" msgstr "ê³„ì • 언팔로우" @@ -5499,8 +5629,8 @@ msgstr "언뮤트" msgid "Unmute {truncatedTag}" msgstr "{truncatedTag} 언뮤트" -#: src/view/com/profile/ProfileMenu.tsx:280 -#: src/view/com/profile/ProfileMenu.tsx:286 +#: src/view/com/profile/ProfileMenu.tsx:283 +#: src/view/com/profile/ProfileMenu.tsx:289 msgid "Unmute Account" msgstr "ê³„ì • 언뮤트" @@ -5512,8 +5642,8 @@ msgstr "ëª¨ë“ {tag} 게시물 언뮤트" msgid "Unmute conversation" msgstr "알림 언뮤트" -#: src/view/com/util/forms/PostDropdownBtn.tsx:352 -#: src/view/com/util/forms/PostDropdownBtn.tsx:357 +#: src/view/com/util/forms/PostDropdownBtn.tsx:362 +#: src/view/com/util/forms/PostDropdownBtn.tsx:367 msgid "Unmute thread" msgstr "ìŠ¤ë ˆë“œ 언뮤트" @@ -5567,20 +5697,20 @@ msgstr "ëŒ€ì‹ ì‚¬ì§„ 업로드하기" msgid "Upload a text file to:" msgstr "í…스트 íŒŒì¼ ì—…ë¡œë“œ 경로:" -#: src/view/com/util/UserAvatar.tsx:339 -#: src/view/com/util/UserAvatar.tsx:342 +#: src/view/com/util/UserAvatar.tsx:352 +#: src/view/com/util/UserAvatar.tsx:355 #: src/view/com/util/UserBanner.tsx:123 #: src/view/com/util/UserBanner.tsx:126 msgid "Upload from Camera" msgstr "ì¹´ë©”ë¼ì—서 업로드" -#: src/view/com/util/UserAvatar.tsx:356 +#: src/view/com/util/UserAvatar.tsx:369 #: src/view/com/util/UserBanner.tsx:140 msgid "Upload from Files" msgstr "파ì¼ì—서 업로드" -#: src/view/com/util/UserAvatar.tsx:350 -#: src/view/com/util/UserAvatar.tsx:354 +#: src/view/com/util/UserAvatar.tsx:363 +#: src/view/com/util/UserAvatar.tsx:367 #: src/view/com/util/UserBanner.tsx:134 #: src/view/com/util/UserBanner.tsx:138 msgid "Upload from Library" @@ -5688,7 +5818,7 @@ msgstr "ì‚¬ìš©ìž ì´ë¦„ ë˜ëŠ” ì´ë©”ì¼ ì£¼ì†Œ" msgid "Users" msgstr "사용ìž" -#: src/view/com/threadgate/WhoCanReply.tsx:143 +#: src/view/com/threadgate/WhoCanReply.tsx:274 msgid "users followed by <0/>" msgstr "<0/> ë‹˜ì´ íŒ”ë¡œìš°í•œ 사용ìž" @@ -5699,7 +5829,7 @@ msgstr "<0/> ë‹˜ì´ íŒ”ë¡œìš°í•œ 사용ìž" msgid "Users I follow" msgstr "ë‚´ê°€ 팔로우하는 사용ìž" -#: src/view/com/modals/Threadgate.tsx:107 +#: src/view/com/modals/Threadgate.tsx:109 msgid "Users in \"{0}\"" msgstr "\"{0}\"ì— ìžˆëŠ” 사용ìž" @@ -5752,11 +5882,15 @@ msgstr "비디오 게임" msgid "View {0}'s avatar" msgstr "{0} ë‹˜ì˜ ì•„ë°”íƒ€ë¥¼ 봅니다" -#: src/view/com/notifications/FeedItem.tsx:213 +#: src/view/com/notifications/FeedItem.tsx:215 msgid "View {0}'s profile" msgstr "{0} ë‹˜ì˜ í”„ë¡œí•„ 보기" -#: src/view/screens/Log.tsx:52 +#: src/components/ProfileHoverCard/index.web.tsx:430 +msgid "View blocked user's profile" +msgstr "차단한 사용ìžì˜ 프로필 보기" + +#: src/view/screens/Log.tsx:56 msgid "View debug entry" msgstr "디버그 í•목 보기" @@ -5768,7 +5902,7 @@ msgstr "세부 ì •ë³´ 보기" msgid "View details for reporting a copyright violation" msgstr "ì €ìž‘ê¶Œ 위반 ì‹ ê³ ì— ëŒ€í•œ 세부 ì •ë³´ 보기" -#: src/view/com/posts/FeedSlice.tsx:120 +#: src/view/com/posts/FeedSlice.tsx:124 msgid "View full thread" msgstr "ì „ì²´ ìŠ¤ë ˆë“œ 보기" @@ -5776,8 +5910,9 @@ msgstr "ì „ì²´ ìŠ¤ë ˆë“œ 보기" msgid "View information about these labels" msgstr "ì´ ë¼ë²¨ì— 대한 ì •ë³´ 보기" -#: src/components/ProfileHoverCard/index.web.tsx:396 -#: src/components/ProfileHoverCard/index.web.tsx:429 +#: src/components/ProfileHoverCard/index.web.tsx:418 +#: src/components/ProfileHoverCard/index.web.tsx:436 +#: src/components/ProfileHoverCard/index.web.tsx:463 #: src/view/com/posts/AviFollowButton.tsx:58 #: src/view/com/posts/FeedErrorMessage.tsx:174 msgid "View profile" @@ -5795,10 +5930,10 @@ msgstr "{0} ë‹˜ì´ ì œê³µí•˜ëŠ” ë¼ë²¨ë§ 서비스 보기" msgid "View users who like this feed" msgstr "ì´ í”¼ë“œë¥¼ 좋아하는 ì‚¬ìš©ìž ë³´ê¸°" -#: src/view/com/home/HomeHeaderLayout.web.tsx:78 +#: src/view/com/home/HomeHeaderLayout.web.tsx:79 #: src/view/com/home/HomeHeaderLayoutMobile.tsx:84 msgid "View your feeds and explore more" -msgstr "" +msgstr "ë‚´ 피드를 보거나 새 피드를 íƒìƒ‰í•©ë‹ˆë‹¤" #: src/view/com/modals/LinkWarning.tsx:89 #: src/view/com/modals/LinkWarning.tsx:95 @@ -5879,7 +6014,7 @@ msgstr "죄송하지만 ì´ ë¦¬ìŠ¤íŠ¸ë¥¼ 불러올 수 없습니다. ì´ ë¬¸ì œê msgid "We're sorry, but we weren't able to load your muted words at this time. Please try again." msgstr "죄송하지만 현재 뮤트한 단어를 불러올 수 없습니다. 다시 시ë„í•´ 주세요." -#: src/view/screens/Search/Search.tsx:270 +#: src/view/screens/Search/Search.tsx:206 msgid "We're sorry, but your search could not be completed. Please try again in a few minutes." msgstr "죄송하지만 ê²€ìƒ‰ì„ ì™„ë£Œí• ìˆ˜ 없습니다. 몇 ë¶„ í›„ì— ë‹¤ì‹œ 시ë„í•´ 주세요." @@ -5893,8 +6028,8 @@ msgid "We're sorry! We can't find the page you were looking for." msgstr "죄송합니다. 페ì´ì§€ë¥¼ ì°¾ì„ ìˆ˜ 없습니다." #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:330 -msgid "We're sorry! You can only subscribe to ten labelers, and you've reached your limit of ten." -msgstr "죄송합니다. ë¼ë²¨ëŸ¬ëŠ” 10개까지만 구ë…í• ìˆ˜ 있으며 10ê°œì— ë„달했습니다." +msgid "We're sorry! You can only subscribe to twenty labelers, and you've reached your limit of twenty." +msgstr "죄송합니다. ë¼ë²¨ëŸ¬ëŠ” 20개까지만 구ë…í• ìˆ˜ 있으며 20ê°œì— ë„달했습니다." #: src/screens/Deactivated.tsx:128 msgid "Welcome back!" @@ -5923,10 +6058,20 @@ msgstr "ì•Œê³ ë¦¬ì¦˜ í”¼ë“œì— ì–´ë–¤ 언어를 í‘œì‹œí•˜ì‹œê² ìŠµë‹ˆê¹Œ?" msgid "Who can message you?" msgstr "ëˆ„êµ¬ì˜ ë©”ì‹œì§€ë¥¼ í—ˆìš©í•˜ì‹œê² ìŠµë‹ˆê¹Œ?" -#: src/view/com/modals/Threadgate.tsx:67 +#: src/view/com/modals/Threadgate.tsx:69 +#: src/view/com/threadgate/WhoCanReply.tsx:73 +#: src/view/com/threadgate/WhoCanReply.tsx:130 msgid "Who can reply" msgstr "ë‹µê¸€ì„ ë‹¬ 수 있는 사람" +#: src/view/com/threadgate/WhoCanReply.tsx:206 +msgid "Who can reply dialog" +msgstr "ë‹µê¸€ì„ ë‹¬ 수 있는 사람 대화 ìƒìž" + +#: src/view/com/threadgate/WhoCanReply.tsx:210 +msgid "Who can reply?" +msgstr "누가 ë‹µê¸€ì„ ë‹¬ 수 있나요?" + #: src/screens/Home/NoFeedsPinned.tsx:79 #: src/screens/Messages/List/index.tsx:185 msgid "Whoops!" @@ -5965,7 +6110,7 @@ msgstr "가로" msgid "Write a message" msgstr "메시지를 ìž…ë ¥í•˜ì„¸ìš”" -#: src/view/com/composer/Composer.tsx:549 +#: src/view/com/composer/Composer.tsx:551 msgid "Write post" msgstr "게시물 작성" @@ -6041,7 +6186,7 @@ msgstr "팔로워가 없습니다." #: src/screens/Profile/KnownFollowers.tsx:99 msgid "You don't follow any users who follow @{name}." -msgstr "" +msgstr "@{name} ë‹˜ì„ íŒ”ë¡œìš°í•˜ëŠ” 사용ìžë¥¼ íŒ”ë¡œìš°í•˜ê³ ìžˆì§€ 않습니다." #: src/view/com/modals/InviteCodes.tsx:67 msgid "You don't have any invite codes yet! We'll send you some when you've been on Bluesky for a little longer." @@ -6146,11 +6291,11 @@ msgstr "ì‹ ê³ í•˜ë ¤ë©´ 하나 ì´ìƒì˜ ë¼ë²¨ì„ ì„ íƒí•´ì•¼ 합니다." msgid "You previously deactivated @{0}." msgstr "ì´ì „ì— @{0}ì„(를) 비활성화했습니다." -#: src/view/com/util/forms/PostDropdownBtn.tsx:168 +#: src/view/com/util/forms/PostDropdownBtn.tsx:174 msgid "You will no longer receive notifications for this thread" msgstr "ì´ ìŠ¤ë ˆë“œì— ëŒ€í•œ ì•Œë¦¼ì„ ë” ì´ìƒ 받지 않습니다" -#: src/view/com/util/forms/PostDropdownBtn.tsx:171 +#: src/view/com/util/forms/PostDropdownBtn.tsx:170 msgid "You will now receive notifications for this thread" msgstr "ì´ì œ ì´ ìŠ¤ë ˆë“œì— ëŒ€í•œ ì•Œë¦¼ì„ ë°›ìŠµë‹ˆë‹¤" diff --git a/src/state/a11y.tsx b/src/state/a11y.tsx new file mode 100644 index 000000000..aefcfd1ec --- /dev/null +++ b/src/state/a11y.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import {AccessibilityInfo} from 'react-native' +import {isReducedMotion} from 'react-native-reanimated' + +import {isWeb} from '#/platform/detection' + +const Context = React.createContext({ + reduceMotionEnabled: false, + screenReaderEnabled: false, +}) + +export function useA11y() { + return React.useContext(Context) +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [reduceMotionEnabled, setReduceMotionEnabled] = React.useState(() => + isReducedMotion(), + ) + const [screenReaderEnabled, setScreenReaderEnabled] = React.useState(false) + + React.useEffect(() => { + const reduceMotionChangedSubscription = AccessibilityInfo.addEventListener( + 'reduceMotionChanged', + enabled => { + setReduceMotionEnabled(enabled) + }, + ) + const screenReaderChangedSubscription = AccessibilityInfo.addEventListener( + 'screenReaderChanged', + enabled => { + setScreenReaderEnabled(enabled) + }, + ) + + ;(async () => { + const [_reduceMotionEnabled, _screenReaderEnabled] = await Promise.all([ + AccessibilityInfo.isReduceMotionEnabled(), + AccessibilityInfo.isScreenReaderEnabled(), + ]) + setReduceMotionEnabled(_reduceMotionEnabled) + setScreenReaderEnabled(_screenReaderEnabled) + })() + + return () => { + reduceMotionChangedSubscription.remove() + screenReaderChangedSubscription.remove() + } + }, []) + + const ctx = React.useMemo(() => { + return { + reduceMotionEnabled, + /** + * Always returns true on web. For now, we're using this for mobile a11y, + * so we reset to false on web. + * + * @see https://github.com/necolas/react-native-web/discussions/2072 + */ + screenReaderEnabled: isWeb ? false : screenReaderEnabled, + } + }, [reduceMotionEnabled, screenReaderEnabled]) + + return <Context.Provider value={ctx}>{children}</Context.Provider> +} diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx index 64bdd4b89..88f50daca 100644 --- a/src/state/feed-feedback.tsx +++ b/src/state/feed-feedback.tsx @@ -4,6 +4,7 @@ import {AppBskyFeedDefs, BskyAgent} from '@atproto/api' import throttle from 'lodash.throttle' import {PROD_DEFAULT_FEED} from '#/lib/constants' +import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import { FeedDescriptor, @@ -34,6 +35,16 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) { WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction> >(new WeakSet()) + const aggregatedStats = React.useRef<AggregatedStats | null>(null) + const throttledFlushAggregatedStats = React.useMemo( + () => + throttle(() => flushToStatsig(aggregatedStats.current), 45e3, { + leading: true, // The outer call is already throttled somewhat. + trailing: true, + }), + [], + ) + const sendToFeedNoDelay = React.useCallback(() => { const proxyAgent = agent.withProxy( // @ts-ignore TODO need to update withProxy() to support this key -prf @@ -45,12 +56,20 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) { const interactions = Array.from(queue.current).map(toInteraction) queue.current.clear() + // Send to the feed proxyAgent.app.bsky.feed .sendInteractions({interactions}) .catch((e: any) => { logger.warn('Failed to send feed interactions', {error: e}) }) - }, [agent]) + + // Send to Statsig + if (aggregatedStats.current === null) { + aggregatedStats.current = createAggregatedStats() + } + sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions) + throttledFlushAggregatedStats() + }, [agent, throttledFlushAggregatedStats]) const sendToFeed = React.useMemo( () => @@ -149,3 +168,89 @@ function toInteraction(str: string): AppBskyFeedDefs.Interaction { const [item, event, feedContext] = str.split('|') return {item, event, feedContext} } + +type AggregatedStats = { + clickthroughCount: number + engagedCount: number + seenCount: number +} + +function createAggregatedStats(): AggregatedStats { + return { + clickthroughCount: 0, + engagedCount: 0, + seenCount: 0, + } +} + +function sendOrAggregateInteractionsForStats( + stats: AggregatedStats, + interactions: AppBskyFeedDefs.Interaction[], +) { + for (let interaction of interactions) { + switch (interaction.event) { + // Pressing "Show more" / "Show less" is relatively uncommon so we won't aggregate them. + // This lets us send the feed context together with them. + case 'app.bsky.feed.defs#requestLess': { + logEvent('discover:showLess', { + feedContext: interaction.feedContext ?? '', + }) + break + } + case 'app.bsky.feed.defs#requestMore': { + logEvent('discover:showMore', { + feedContext: interaction.feedContext ?? '', + }) + break + } + + // The rest of the events are aggregated and sent later in batches. + case 'app.bsky.feed.defs#clickthroughAuthor': + case 'app.bsky.feed.defs#clickthroughEmbed': + case 'app.bsky.feed.defs#clickthroughItem': + case 'app.bsky.feed.defs#clickthroughReposter': { + stats.clickthroughCount++ + break + } + case 'app.bsky.feed.defs#interactionLike': + case 'app.bsky.feed.defs#interactionQuote': + case 'app.bsky.feed.defs#interactionReply': + case 'app.bsky.feed.defs#interactionRepost': + case 'app.bsky.feed.defs#interactionShare': { + stats.engagedCount++ + break + } + case 'app.bsky.feed.defs#interactionSeen': { + stats.seenCount++ + break + } + } + } +} + +function flushToStatsig(stats: AggregatedStats | null) { + if (stats === null) { + return + } + + if (stats.clickthroughCount > 0) { + logEvent('discover:clickthrough:sampled', { + count: stats.clickthroughCount, + }) + stats.clickthroughCount = 0 + } + + if (stats.engagedCount > 0) { + logEvent('discover:engaged:sampled', { + count: stats.engagedCount, + }) + stats.engagedCount = 0 + } + + if (stats.seenCount > 0) { + logEvent('discover:seen:sampled', { + count: stats.seenCount, + }) + stats.seenCount = 0 + } +} diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index 83d6a7634..e5d615177 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -9,20 +9,24 @@ import { } from '@atproto/api' import { InfiniteData, + QueryClient, QueryKey, useInfiniteQuery, useMutation, useQuery, + useQueryClient, } from '@tanstack/react-query' import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {STALE} from '#/state/queries' +import {RQKEY as listQueryKey} from '#/state/queries/list' import {usePreferencesQuery} from '#/state/queries/preferences' import {useAgent, useSession} from '#/state/session' import {router} from '#/routes' import {FeedDescriptor} from './post-feed' +import {precacheResolvedUri} from './resolve-uri' export type FeedSourceFeedInfo = { type: 'feed' @@ -201,6 +205,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { const agent = useAgent() const limit = options?.limit || 10 const {data: preferences} = usePreferencesQuery() + const queryClient = useQueryClient() // Make sure this doesn't invalidate unless really needed. const selectArgs = useMemo( @@ -225,6 +230,13 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { limit, cursor: pageParam, }) + + // precache feeds + for (const feed of res.data.feeds) { + const hydratedFeed = hydrateFeedGenerator(feed) + precacheFeed(queryClient, hydratedFeed) + } + return res.data }, initialPageParam: undefined, @@ -449,3 +461,138 @@ export function usePinnedFeedsInfos() { }, }) } + +export type SavedFeedItem = + | { + type: 'feed' + config: AppBskyActorDefs.SavedFeed + view: AppBskyFeedDefs.GeneratorView + } + | { + type: 'list' + config: AppBskyActorDefs.SavedFeed + view: AppBskyGraphDefs.ListView + } + | { + type: 'timeline' + config: AppBskyActorDefs.SavedFeed + view: undefined + } + +export function useSavedFeeds() { + const agent = useAgent() + const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery() + const savedItems = preferences?.savedFeeds ?? [] + const queryClient = useQueryClient() + + return useQuery({ + staleTime: STALE.INFINITY, + enabled: !isLoadingPrefs, + queryKey: [pinnedFeedInfosQueryKeyRoot, ...savedItems], + placeholderData: previousData => { + return ( + previousData || { + count: savedItems.length, + feeds: [], + } + ) + }, + queryFn: async () => { + const resolvedFeeds = new Map<string, AppBskyFeedDefs.GeneratorView>() + const resolvedLists = new Map<string, AppBskyGraphDefs.ListView>() + + const savedFeeds = savedItems.filter(feed => feed.type === 'feed') + const savedLists = savedItems.filter(feed => feed.type === 'list') + + let feedsPromise = Promise.resolve() + if (savedFeeds.length > 0) { + feedsPromise = agent.app.bsky.feed + .getFeedGenerators({ + feeds: savedFeeds.map(f => f.value), + }) + .then(res => { + res.data.feeds.forEach(f => { + resolvedFeeds.set(f.uri, f) + }) + }) + } + + const listsPromises = savedLists.map(list => + agent.app.bsky.graph + .getList({ + list: list.value, + limit: 1, + }) + .then(res => { + const listView = res.data.list + resolvedLists.set(listView.uri, listView) + }), + ) + + await Promise.allSettled([feedsPromise, ...listsPromises]) + + resolvedFeeds.forEach(feed => { + const hydratedFeed = hydrateFeedGenerator(feed) + precacheFeed(queryClient, hydratedFeed) + }) + resolvedLists.forEach(list => { + precacheList(queryClient, list) + }) + + const res: SavedFeedItem[] = savedItems.map(s => { + if (s.type === 'timeline') { + return { + type: 'timeline', + config: s, + view: undefined, + } + } + + return { + type: s.type, + config: s, + view: + s.type === 'feed' + ? resolvedFeeds.get(s.value) + : resolvedLists.get(s.value), + } + }) as SavedFeedItem[] + + return { + count: savedItems.length, + feeds: res, + } + }, + }) +} + +function precacheFeed(queryClient: QueryClient, hydratedFeed: FeedSourceInfo) { + precacheResolvedUri( + queryClient, + hydratedFeed.creatorHandle, + hydratedFeed.creatorDid, + ) + queryClient.setQueryData<FeedSourceInfo>( + feedSourceInfoQueryKey({uri: hydratedFeed.uri}), + hydratedFeed, + ) +} + +export function precacheList( + queryClient: QueryClient, + list: AppBskyGraphDefs.ListView, +) { + precacheResolvedUri(queryClient, list.creator.handle, list.creator.did) + queryClient.setQueryData<AppBskyGraphDefs.ListView>( + listQueryKey(list.uri), + list, + ) +} + +export function precacheFeedFromGeneratorView( + queryClient: QueryClient, + view: AppBskyFeedDefs.GeneratorView, +) { + const hydratedFeed = hydrateFeedGenerator(view) + precacheFeed(queryClient, hydratedFeed) +} diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts index 7bd26435c..c1fd8e240 100644 --- a/src/state/queries/resolve-uri.ts +++ b/src/state/queries/resolve-uri.ts @@ -1,5 +1,10 @@ import {AppBskyActorDefs, AtUri} from '@atproto/api' -import {useQuery, useQueryClient, UseQueryResult} from '@tanstack/react-query' +import { + QueryClient, + useQuery, + useQueryClient, + UseQueryResult, +} from '@tanstack/react-query' import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' @@ -50,3 +55,11 @@ export function useResolveDidQuery(didOrHandle: string | undefined) { enabled: !!didOrHandle, }) } + +export function precacheResolvedUri( + queryClient: QueryClient, + handle: string, + did: string, +) { + queryClient.setQueryData<string>(RQKEY(handle), did) +} diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts index 40251d43d..a1244721a 100644 --- a/src/state/queries/suggested-follows.ts +++ b/src/state/queries/suggested-follows.ts @@ -34,13 +34,14 @@ const suggestedFollowsByActorQueryKey = (did: string) => [ did, ] -type SuggestedFollowsOptions = {limit?: number} +type SuggestedFollowsOptions = {limit?: number; subsequentPageLimit?: number} export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) { const {currentAccount} = useSession() const agent = useAgent() const moderationOpts = useModerationOpts() const {data: preferences} = usePreferencesQuery() + const limit = options?.limit || 25 return useInfiniteQuery< AppBskyActorGetSuggestions.OutputSchema, @@ -54,9 +55,13 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) { queryKey: suggestedFollowsQueryKey(options), queryFn: async ({pageParam}) => { const contentLangs = getContentLanguages().join(',') + const maybeDifferentLimit = + options?.subsequentPageLimit && pageParam + ? options.subsequentPageLimit + : limit const res = await agent.app.bsky.actor.getSuggestions( { - limit: options?.limit || 25, + limit: maybeDifferentLimit, cursor: pageParam, }, { diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 314945bcf..3aac19025 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -19,6 +19,7 @@ import { import {getInitialState, reducer} from './reducer' export {isSignupQueued} from './util' +import {addSessionDebugLog} from './logging' export type {SessionAccount} from '#/state/session/types' import {SessionApiContext, SessionStateContext} from '#/state/session/types' @@ -40,9 +41,11 @@ const ApiContext = React.createContext<SessionApiContext>({ export function Provider({children}: React.PropsWithChildren<{}>) { const cancelPendingTask = useOneTaskAtATime() - const [state, dispatch] = React.useReducer(reducer, null, () => - getInitialState(persisted.get('session').accounts), - ) + const [state, dispatch] = React.useReducer(reducer, null, () => { + const initialState = getInitialState(persisted.get('session').accounts) + addSessionDebugLog({type: 'reducer:init', state: initialState}) + return initialState + }) const onAgentSessionChange = React.useCallback( (agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => { @@ -63,6 +66,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const createAccount = React.useCallback<SessionApiContext['createAccount']>( async params => { + addSessionDebugLog({type: 'method:start', method: 'createAccount'}) const signal = cancelPendingTask() track('Try Create Account') logEvent('account:create:begin', {}) @@ -81,12 +85,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }) track('Create Account') logEvent('account:create:success', {}) + addSessionDebugLog({type: 'method:end', method: 'createAccount', account}) }, [onAgentSessionChange, cancelPendingTask], ) const login = React.useCallback<SessionApiContext['login']>( async (params, logContext) => { + addSessionDebugLog({type: 'method:start', method: 'login'}) const signal = cancelPendingTask() const {agent, account} = await createAgentAndLogin( params, @@ -103,23 +109,31 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }) track('Sign In', {resumedSession: false}) logEvent('account:loggedIn', {logContext, withPassword: true}) + addSessionDebugLog({type: 'method:end', method: 'login', account}) }, [onAgentSessionChange, cancelPendingTask], ) const logout = React.useCallback<SessionApiContext['logout']>( logContext => { + addSessionDebugLog({type: 'method:start', method: 'logout'}) cancelPendingTask() dispatch({ type: 'logged-out', }) logEvent('account:loggedOut', {logContext}) + addSessionDebugLog({type: 'method:end', method: 'logout'}) }, [cancelPendingTask], ) const resumeSession = React.useCallback<SessionApiContext['resumeSession']>( async storedAccount => { + addSessionDebugLog({ + type: 'method:start', + method: 'resumeSession', + account: storedAccount, + }) const signal = cancelPendingTask() const {agent, account} = await createAgentAndResume( storedAccount, @@ -134,17 +148,24 @@ export function Provider({children}: React.PropsWithChildren<{}>) { newAgent: agent, newAccount: account, }) + addSessionDebugLog({type: 'method:end', method: 'resumeSession', account}) }, [onAgentSessionChange, cancelPendingTask], ) const removeAccount = React.useCallback<SessionApiContext['removeAccount']>( account => { + addSessionDebugLog({ + type: 'method:start', + method: 'removeAccount', + account, + }) cancelPendingTask() dispatch({ type: 'removed-account', accountDid: account.did, }) + addSessionDebugLog({type: 'method:end', method: 'removeAccount', account}) }, [cancelPendingTask], ) @@ -152,18 +173,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) { React.useEffect(() => { if (state.needsPersist) { state.needsPersist = false - persisted.write('session', { + const persistedData = { accounts: state.accounts, currentAccount: state.accounts.find( a => a.did === state.currentAgentState.did, ), - }) + } + addSessionDebugLog({type: 'persisted:broadcast', data: persistedData}) + persisted.write('session', persistedData) } }, [state]) React.useEffect(() => { return persisted.onUpdate(() => { const synced = persisted.get('session') + addSessionDebugLog({type: 'persisted:receive', data: synced}) dispatch({ type: 'synced-accounts', syncedAccounts: synced.accounts, @@ -177,7 +201,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { resumeSession(syncedAccount) } else { const agent = state.currentAgentState.agent as BskyAgent + const prevSession = agent.session agent.session = sessionAccountToSession(syncedAccount) + addSessionDebugLog({ + type: 'agent:patch', + agent, + prevSession, + nextSession: agent.session, + }) } } }) @@ -215,6 +246,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { // Read the previous value and immediately advance the pointer. const prevAgent = currentAgentRef.current currentAgentRef.current = agent + addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent}) // We never reuse agents so let's fully neutralize the previous one. // This ensures it won't try to consume any refresh tokens. prevAgent.session = undefined diff --git a/src/state/session/logging.ts b/src/state/session/logging.ts new file mode 100644 index 000000000..16aa66fe7 --- /dev/null +++ b/src/state/session/logging.ts @@ -0,0 +1,137 @@ +import {AtpSessionData} from '@atproto/api' +import {sha256} from 'js-sha256' +import {Statsig} from 'statsig-react-native-expo' + +import {Schema} from '../persisted' +import {Action, State} from './reducer' +import {SessionAccount} from './types' + +type Reducer = (state: State, action: Action) => State + +type Log = + | { + type: 'reducer:init' + state: State + } + | { + type: 'reducer:call' + action: Action + prevState: State + nextState: State + } + | { + type: 'method:start' + method: + | 'createAccount' + | 'login' + | 'logout' + | 'resumeSession' + | 'removeAccount' + account?: SessionAccount + } + | { + type: 'method:end' + method: + | 'createAccount' + | 'login' + | 'logout' + | 'resumeSession' + | 'removeAccount' + account?: SessionAccount + } + | { + type: 'persisted:broadcast' + data: Schema['session'] + } + | { + type: 'persisted:receive' + data: Schema['session'] + } + | { + type: 'agent:switch' + prevAgent: object + nextAgent: object + } + | { + type: 'agent:patch' + agent: object + prevSession: AtpSessionData | undefined + nextSession: AtpSessionData + } + +export function wrapSessionReducerForLogging(reducer: Reducer): Reducer { + return function loggingWrapper(prevState: State, action: Action): State { + const nextState = reducer(prevState, action) + addSessionDebugLog({type: 'reducer:call', prevState, action, nextState}) + return nextState + } +} + +let nextMessageIndex = 0 +const MAX_SLICE_LENGTH = 1000 + +export function addSessionDebugLog(log: Log) { + try { + if (!Statsig.initializeCalled() || !Statsig.getStableID()) { + // Drop these logs for now. + return + } + if (!Statsig.checkGate('debug_session')) { + return + } + const messageIndex = nextMessageIndex++ + const {type, ...content} = log + let payload = JSON.stringify(content, replacer) + + let nextSliceIndex = 0 + while (payload.length > 0) { + const sliceIndex = nextSliceIndex++ + const slice = payload.slice(0, MAX_SLICE_LENGTH) + payload = payload.slice(MAX_SLICE_LENGTH) + Statsig.logEvent('session:debug', null, { + realmId, + messageIndex: String(messageIndex), + messageType: type, + sliceIndex: String(sliceIndex), + slice, + }) + } + } catch (e) { + console.error(e) + } +} + +let agentIds = new WeakMap<object, string>() +let realmId = Math.random().toString(36).slice(2) +let nextAgentId = 1 + +function getAgentId(agent: object) { + let id = agentIds.get(agent) + if (id === undefined) { + id = realmId + '::' + nextAgentId++ + agentIds.set(agent, id) + } + return id +} + +function replacer(key: string, value: unknown) { + if (typeof value === 'object' && value != null && 'api' in value) { + return getAgentId(value) + } + if ( + key === 'service' || + key === 'email' || + key === 'emailConfirmed' || + key === 'emailAuthFactor' || + key === 'pdsUrl' + ) { + return undefined + } + if ( + typeof value === 'string' && + (key === 'refreshJwt' || key === 'accessJwt') + ) { + return sha256(value) + } + return value +} diff --git a/src/state/session/reducer.ts b/src/state/session/reducer.ts index 7f3080935..0a537b42c 100644 --- a/src/state/session/reducer.ts +++ b/src/state/session/reducer.ts @@ -1,6 +1,7 @@ import {AtpSessionEvent} from '@atproto/api' import {createPublicAgent} from './agent' +import {wrapSessionReducerForLogging} from './logging' import {SessionAccount} from './types' // A hack so that the reducer can't read anything from the agent. @@ -64,7 +65,7 @@ export function getInitialState(persistedAccounts: SessionAccount[]): State { } } -export function reducer(state: State, action: Action): State { +let reducer = (state: State, action: Action): State => { switch (action.type) { case 'received-agent-event': { const {agent, accountDid, refreshedAccount, sessionEvent} = action @@ -166,3 +167,5 @@ export function reducer(state: State, action: Action): State { } } } +reducer = wrapSessionReducerForLogging(reducer) +export {reducer} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 80bce5351..9e2f77d4d 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -24,12 +24,18 @@ import Animated, { } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {LinearGradient} from 'expo-linear-gradient' +import { + AppBskyFeedDefs, + AppBskyFeedGetPostThread, + BskyAgent, +} from '@atproto/api' import {RichText} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {observer} from 'mobx-react-lite' +import {until} from '#/lib/async/until' import { createGIFDescription, parseAltFromGIFDescription, @@ -299,6 +305,17 @@ export const ComposePost = observer(function ComposePost({ langs: toPostLanguages(langPrefs.postLanguage), }) ).uri + try { + await whenAppViewReady(agent, postUri, res => { + const thread = res.data.thread + return AppBskyFeedDefs.isThreadViewPost(thread) + }) + } catch (waitErr: any) { + logger.error(waitErr, { + message: `Waiting for app view failed`, + }) + // Keep going because the post *was* published. + } } catch (e: any) { logger.error(e, { message: `Composer: create post failed`, @@ -756,6 +773,23 @@ function useKeyboardVerticalOffset() { return top + 10 } +async function whenAppViewReady( + agent: BskyAgent, + uri: string, + fn: (res: AppBskyFeedGetPostThread.Response) => boolean, +) { + await until( + 5, // 5 tries + 1e3, // 1s delay between tries + fn, + () => + agent.app.bsky.feed.getPostThread({ + uri, + depth: 0, + }), + ) +} + const styles = StyleSheet.create({ topbarInner: { flexDirection: 'row', diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx index 197f35e4d..ec1a55e22 100644 --- a/src/view/com/feeds/ProfileFeedgens.tsx +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -3,7 +3,6 @@ import { findNodeHandle, ListRenderItemInfo, StyleProp, - StyleSheet, View, ViewStyle, } from 'react-native' @@ -12,18 +11,17 @@ import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import {cleanError} from '#/lib/strings/errors' -import {useTheme} from '#/lib/ThemeContext' import {logger} from '#/logger' import {isNative, isWeb} from '#/platform/detection' -import {hydrateFeedGenerator} from '#/state/queries/feed' import {usePreferencesQuery} from '#/state/queries/preferences' import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {EmptyState} from 'view/com/util/EmptyState' +import {atoms as a, useTheme} from '#/alf' +import * as FeedCard from '#/components/FeedCard' import {ErrorMessage} from '../util/error/ErrorMessage' import {List, ListRef} from '../util/List' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' -import {FeedSourceCardLoaded} from './FeedSourceCard' const LOADING = {_reactKey: '__loading__'} const EMPTY = {_reactKey: '__empty__'} @@ -52,7 +50,7 @@ export const ProfileFeedgens = React.forwardRef< ref, ) { const {_} = useLingui() - const theme = useTheme() + const t = useTheme() const [isPTRing, setIsPTRing] = React.useState(false) const opts = React.useMemo(() => ({enabled}), [enabled]) const { @@ -79,10 +77,9 @@ export const ProfileFeedgens = React.forwardRef< items = items.concat([EMPTY]) } else if (data?.pages) { for (const page of data?.pages) { - items = items.concat(page.feeds.map(feed => hydrateFeedGenerator(feed))) + items = items.concat(page.feeds) } - } - if (isError && !isEmpty) { + } else if (isError && !isEmpty) { items = items.concat([LOAD_MORE_ERROR_ITEM]) } return items @@ -132,48 +129,46 @@ export const ProfileFeedgens = React.forwardRef< // rendering // = - const renderItemInner = React.useCallback( - ({item, index}: ListRenderItemInfo<any>) => { - if (item === EMPTY) { - return ( - <EmptyState - icon="hashtag" - message={_(msg`You have no feeds.`)} - testID="listsEmpty" - /> - ) - } else if (item === ERROR_ITEM) { - return ( - <ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} /> - ) - } else if (item === LOAD_MORE_ERROR_ITEM) { - return ( - <LoadMoreRetryBtn - label={_( - msg`There was an issue fetching your lists. Tap here to try again.`, - )} - onPress={onPressRetryLoadMore} - /> - ) - } else if (item === LOADING) { - return <FeedLoadingPlaceholder /> - } - if (preferences) { - return ( - <FeedSourceCardLoaded - feedUri={item.uri} - feed={item} - preferences={preferences} - style={styles.item} - showLikes - hideTopBorder={index === 0 && !isWeb} - /> - ) - } - return null - }, - [error, refetch, onPressRetryLoadMore, preferences, _], - ) + const renderItem = ({item, index}: ListRenderItemInfo<any>) => { + if (item === EMPTY) { + return ( + <EmptyState + icon="hashtag" + message={_(msg`You have no feeds.`)} + testID="listsEmpty" + /> + ) + } else if (item === ERROR_ITEM) { + return ( + <ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} /> + ) + } else if (item === LOAD_MORE_ERROR_ITEM) { + return ( + <LoadMoreRetryBtn + label={_( + msg`There was an issue fetching your lists. Tap here to try again.`, + )} + onPress={onPressRetryLoadMore} + /> + ) + } else if (item === LOADING) { + return <FeedLoadingPlaceholder /> + } + if (preferences) { + return ( + <View + style={[ + (index !== 0 || isWeb) && a.border_t, + t.atoms.border_contrast_low, + a.px_lg, + a.py_lg, + ]}> + <FeedCard.Default type="feed" view={item} /> + </View> + ) + } + return null + } React.useEffect(() => { if (enabled && scrollElRef.current) { @@ -189,12 +184,12 @@ export const ProfileFeedgens = React.forwardRef< ref={scrollElRef} data={items} keyExtractor={(item: any) => item._reactKey || item.uri} - renderItem={renderItemInner} + renderItem={renderItem} refreshing={isPTRing} onRefresh={onRefresh} headerOffset={headerOffset} contentContainerStyle={isNative && {paddingBottom: headerOffset + 100}} - indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} + indicatorStyle={t.name === 'light' ? 'black' : 'white'} removeClippedSubviews={true} // @ts-ignore our .web version only -prf desktopFixedHeight @@ -203,9 +198,3 @@ export const ProfileFeedgens = React.forwardRef< </View> ) }) - -const styles = StyleSheet.create({ - item: { - paddingHorizontal: 18, - }, -}) diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx index e7fdfe4bd..62c944efc 100644 --- a/src/view/com/lists/ProfileLists.tsx +++ b/src/view/com/lists/ProfileLists.tsx @@ -3,7 +3,6 @@ import { findNodeHandle, ListRenderItemInfo, StyleProp, - StyleSheet, View, ViewStyle, } from 'react-native' @@ -12,17 +11,17 @@ import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import {cleanError} from '#/lib/strings/errors' -import {useTheme} from '#/lib/ThemeContext' import {logger} from '#/logger' import {isNative, isWeb} from '#/platform/detection' import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists' import {useAnalytics} from 'lib/analytics/analytics' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {EmptyState} from 'view/com/util/EmptyState' +import {atoms as a, useTheme} from '#/alf' +import * as FeedCard from '#/components/FeedCard' import {ErrorMessage} from '../util/error/ErrorMessage' import {List, ListRef} from '../util/List' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' -import {ListCard} from './ListCard' const LOADING = {_reactKey: '__loading__'} const EMPTY = {_reactKey: '__empty__'} @@ -48,7 +47,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( {did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag}, ref, ) { - const theme = useTheme() + const t = useTheme() const {track} = useAnalytics() const {_} = useLingui() const [isPTRing, setIsPTRing] = React.useState(false) @@ -166,15 +165,18 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( return <FeedLoadingPlaceholder /> } return ( - <ListCard - list={item} - testID={`list-${item.name}`} - style={styles.item} - noBorder={index === 0 && !isWeb} - /> + <View + style={[ + (index !== 0 || isWeb) && a.border_t, + t.atoms.border_contrast_low, + a.px_lg, + a.py_lg, + ]}> + <FeedCard.Default type="list" view={item} /> + </View> ) }, - [error, refetch, onPressRetryLoadMore, _], + [error, refetch, onPressRetryLoadMore, _, t.atoms.border_contrast_low], ) React.useEffect(() => { @@ -198,7 +200,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( contentContainerStyle={ isNative && {paddingBottom: headerOffset + 100} } - indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} + indicatorStyle={t.name === 'light' ? 'black' : 'white'} removeClippedSubviews={true} // @ts-ignore our .web version only -prf desktopFixedHeight @@ -208,9 +210,3 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( ) }, ) - -const styles = StyleSheet.create({ - item: { - paddingHorizontal: 18, - }, -}) diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 2b0790002..a3cd5ca1b 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -328,6 +328,7 @@ const styles = StyleSheet.create({ borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2, + justifyContent: 'center', }, btn: { paddingVertical: 7, diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 472ce4043..231808bf2 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -6,6 +6,7 @@ import { View, type ViewStyle, } from 'react-native' +import * as Clipboard from 'expo-clipboard' import { AppBskyFeedDefs, AppBskyFeedPost, @@ -19,6 +20,7 @@ import {POST_CTRL_HITSLOP} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import {makeProfileLink} from '#/lib/routes/links' import {shareUrl} from '#/lib/sharing' +import {useGate} from '#/lib/statsig/statsig' import {toShareUrl} from '#/lib/strings/url-helpers' import {s} from '#/lib/styles' import {Shadow} from '#/state/cache/types' @@ -41,6 +43,7 @@ import * as Prompt from '#/components/Prompt' import {PostDropdownBtn} from '../forms/PostDropdownBtn' import {formatCount} from '../numeric/format' import {Text} from '../text/Text' +import * as Toast from '../Toast' import {RepostButton} from './RepostButton' let PostCtrls = ({ @@ -75,6 +78,7 @@ let PostCtrls = ({ const loggedOutWarningPromptControl = useDialogControl() const {sendInteraction} = useFeedFeedbackContext() const playHaptic = useHaptics() + const gate = useGate() const shouldShowLoggedOutWarning = React.useMemo(() => { return ( @@ -329,6 +333,31 @@ let PostCtrls = ({ timestamp={post.indexedAt} /> </View> + {gate('debug_show_feedcontext') && feedContext && ( + <Pressable + accessible={false} + style={{ + position: 'absolute', + top: 0, + bottom: 0, + right: 0, + display: 'flex', + justifyContent: 'center', + }} + onPress={e => { + e.stopPropagation() + Clipboard.setStringAsync(feedContext) + Toast.show(_(msg`Copied to clipboard`)) + }}> + <Text + style={{ + color: t.palette.contrast_400, + fontSize: 7, + }}> + {feedContext} + </Text> + </Pressable> + )} </View> ) } diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 134521177..2e5b48513 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -1,8 +1,6 @@ import React from 'react' import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native' -import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' +import {AppBskyFeedDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' @@ -10,12 +8,11 @@ import debounce from 'lodash.debounce' import {isNative, isWeb} from '#/platform/detection' import { - getAvatarTypeFromUri, - useFeedSourceInfoQuery, + SavedFeedItem, useGetPopularFeedsQuery, + useSavedFeeds, useSearchPopularFeedsMutation, } from '#/state/queries/feed' -import {usePreferencesQuery} from '#/state/queries/preferences' import {useSession} from '#/state/session' import {useSetMinimalShellMode} from '#/state/shell' import {useComposerControls} from '#/state/shell/composer' @@ -28,14 +25,10 @@ import {s} from 'lib/styles' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {FAB} from 'view/com/util/fab/FAB' import {SearchInput} from 'view/com/util/forms/SearchInput' -import {Link, TextLink} from 'view/com/util/Link' +import {TextLink} from 'view/com/util/Link' import {List} from 'view/com/util/List' -import { - FeedFeedLoadingPlaceholder, - LoadingPlaceholder, -} from 'view/com/util/LoadingPlaceholder' +import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {Text} from 'view/com/util/text/Text' -import {UserAvatar} from 'view/com/util/UserAvatar' import {ViewHeader} from 'view/com/util/ViewHeader' import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' @@ -47,6 +40,7 @@ import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkl import hairlineWidth = StyleSheet.hairlineWidth import {Divider} from '#/components/Divider' import * as FeedCard from '#/components/FeedCard' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'> @@ -61,9 +55,8 @@ type FlatlistSlice = key: string } | { - type: 'savedFeedsLoading' + type: 'savedFeedPlaceholder' key: string - // pendingItems: number, } | { type: 'savedFeedNoResults' @@ -72,8 +65,7 @@ type FlatlistSlice = | { type: 'savedFeed' key: string - feedUri: string - savedFeedConfig: AppBskyActorDefs.SavedFeed + savedFeed: SavedFeedItem } | { type: 'savedFeedsLoadMore' @@ -113,11 +105,11 @@ export function FeedsScreen(_props: Props) { const [query, setQuery] = React.useState('') const [isPTR, setIsPTR] = React.useState(false) const { - data: preferences, - isLoading: isPreferencesLoading, - error: preferencesError, - refetch: refetchPreferences, - } = usePreferencesQuery() + data: savedFeeds, + isPlaceholderData: isSavedFeedsPlaceholder, + error: savedFeedsError, + refetch: refetchSavedFeeds, + } = useSavedFeeds() const { data: popularFeeds, isFetching: isPopularFeedsFetching, @@ -173,11 +165,11 @@ export function FeedsScreen(_props: Props) { const onPullToRefresh = React.useCallback(async () => { setIsPTR(true) await Promise.all([ - refetchPreferences().catch(_e => undefined), + refetchSavedFeeds().catch(_e => undefined), refetchPopularFeeds().catch(_e => undefined), ]) setIsPTR(false) - }, [setIsPTR, refetchPreferences, refetchPopularFeeds]) + }, [setIsPTR, refetchSavedFeeds, refetchPopularFeeds]) const onEndReached = React.useCallback(() => { if ( isPopularFeedsFetching || @@ -203,6 +195,11 @@ export function FeedsScreen(_props: Props) { const items = React.useMemo(() => { let slices: FlatlistSlice[] = [] + const hasActualSavedCount = + !isSavedFeedsPlaceholder || + (isSavedFeedsPlaceholder && (savedFeeds?.count || 0) > 0) + const canShowDiscoverSection = + !hasSession || (hasSession && hasActualSavedCount) if (hasSession) { slices.push({ @@ -210,47 +207,63 @@ export function FeedsScreen(_props: Props) { type: 'savedFeedsHeader', }) - if (preferencesError) { + if (savedFeedsError) { slices.push({ key: 'savedFeedsError', type: 'error', - error: cleanError(preferencesError.toString()), + error: cleanError(savedFeedsError.toString()), }) } else { - if (isPreferencesLoading || !preferences?.savedFeeds) { - slices.push({ - key: 'savedFeedsLoading', - type: 'savedFeedsLoading', - // pendingItems: this.rootStore.preferences.savedFeeds.length || 3, - }) + if (isSavedFeedsPlaceholder && !savedFeeds?.feeds.length) { + /* + * Initial render in placeholder state is 0 on a cold page load, + * because preferences haven't loaded yet. + * + * In practice, `savedFeeds` is always defined, but we check for TS + * and for safety. + * + * In both cases, we show 4 as the the loading state. + */ + const min = 8 + const count = savedFeeds + ? savedFeeds.count === 0 + ? min + : savedFeeds.count + : min + Array(count) + .fill(0) + .forEach((_, i) => { + slices.push({ + key: 'savedFeedPlaceholder' + i, + type: 'savedFeedPlaceholder', + }) + }) } else { - if (preferences.savedFeeds?.length) { - const noFollowingFeed = preferences.savedFeeds.every( + if (savedFeeds?.feeds?.length) { + const noFollowingFeed = savedFeeds.feeds.every( f => f.type !== 'timeline', ) slices = slices.concat( - preferences.savedFeeds - .filter(f => { - return f.pinned + savedFeeds.feeds + .filter(s => { + return s.config.pinned }) - .map(feed => ({ - key: `savedFeed:${feed.value}:${feed.id}`, + .map(s => ({ + key: `savedFeed:${s.view?.uri}:${s.config.id}`, type: 'savedFeed', - feedUri: feed.value, - savedFeedConfig: feed, + savedFeed: s, })), ) slices = slices.concat( - preferences.savedFeeds - .filter(f => { - return !f.pinned + savedFeeds.feeds + .filter(s => { + return !s.config.pinned }) - .map(feed => ({ - key: `savedFeed:${feed.value}:${feed.id}`, + .map(s => ({ + key: `savedFeed:${s.view?.uri}:${s.config.id}`, type: 'savedFeed', - feedUri: feed.value, - savedFeedConfig: feed, + savedFeed: s, })), ) @@ -270,59 +283,36 @@ export function FeedsScreen(_props: Props) { } } - slices.push({ - key: 'popularFeedsHeader', - type: 'popularFeedsHeader', - }) - - if (popularFeedsError || searchError) { + if (!hasSession || (hasSession && canShowDiscoverSection)) { slices.push({ - key: 'popularFeedsError', - type: 'error', - error: cleanError( - popularFeedsError?.toString() ?? searchError?.toString() ?? '', - ), + key: 'popularFeedsHeader', + type: 'popularFeedsHeader', }) - } else { - if (isUserSearching) { - if (isSearchPending || !searchResults) { - slices.push({ - key: 'popularFeedsLoading', - type: 'popularFeedsLoading', - }) - } else { - if (!searchResults || searchResults?.length === 0) { - slices.push({ - key: 'popularFeedsNoResults', - type: 'popularFeedsNoResults', - }) - } else { - slices = slices.concat( - searchResults.map(feed => ({ - key: `popularFeed:${feed.uri}`, - type: 'popularFeed', - feedUri: feed.uri, - feed, - })), - ) - } - } + + if (popularFeedsError || searchError) { + slices.push({ + key: 'popularFeedsError', + type: 'error', + error: cleanError( + popularFeedsError?.toString() ?? searchError?.toString() ?? '', + ), + }) } else { - if (isPopularFeedsFetching && !popularFeeds?.pages) { - slices.push({ - key: 'popularFeedsLoading', - type: 'popularFeedsLoading', - }) - } else { - if (!popularFeeds?.pages) { + if (isUserSearching) { + if (isSearchPending || !searchResults) { slices.push({ - key: 'popularFeedsNoResults', - type: 'popularFeedsNoResults', + key: 'popularFeedsLoading', + type: 'popularFeedsLoading', }) } else { - for (const page of popularFeeds.pages || []) { + if (!searchResults || searchResults?.length === 0) { + slices.push({ + key: 'popularFeedsNoResults', + type: 'popularFeedsNoResults', + }) + } else { slices = slices.concat( - page.feeds.map(feed => ({ + searchResults.map(feed => ({ key: `popularFeed:${feed.uri}`, type: 'popularFeed', feedUri: feed.uri, @@ -330,12 +320,37 @@ export function FeedsScreen(_props: Props) { })), ) } - - if (isPopularFeedsFetchingNextPage) { + } + } else { + if (isPopularFeedsFetching && !popularFeeds?.pages) { + slices.push({ + key: 'popularFeedsLoading', + type: 'popularFeedsLoading', + }) + } else { + if (!popularFeeds?.pages) { slices.push({ - key: 'popularFeedsLoadingMore', - type: 'popularFeedsLoadingMore', + key: 'popularFeedsNoResults', + type: 'popularFeedsNoResults', }) + } else { + for (const page of popularFeeds.pages || []) { + slices = slices.concat( + page.feeds.map(feed => ({ + key: `popularFeed:${feed.uri}`, + type: 'popularFeed', + feedUri: feed.uri, + feed, + })), + ) + } + + if (isPopularFeedsFetchingNextPage) { + slices.push({ + key: 'popularFeedsLoadingMore', + type: 'popularFeedsLoadingMore', + }) + } } } } @@ -345,9 +360,9 @@ export function FeedsScreen(_props: Props) { return slices }, [ hasSession, - preferences, - isPreferencesLoading, - preferencesError, + savedFeeds, + isSavedFeedsPlaceholder, + savedFeedsError, popularFeeds, isPopularFeedsFetching, popularFeedsError, @@ -407,10 +422,7 @@ export function FeedsScreen(_props: Props) { ({item}: {item: FlatlistSlice}) => { if (item.type === 'error') { return <ErrorMessage message={item.error} /> - } else if ( - item.type === 'popularFeedsLoadingMore' || - item.type === 'savedFeedsLoading' - ) { + } else if (item.type === 'popularFeedsLoadingMore') { return ( <View style={s.p10}> <ActivityIndicator size="large" /> @@ -459,8 +471,10 @@ export function FeedsScreen(_props: Props) { <NoSavedFeedsOfAnyType /> </View> ) + } else if (item.type === 'savedFeedPlaceholder') { + return <SavedFeedPlaceholder /> } else if (item.type === 'savedFeed') { - return <FeedOrFollowing savedFeedConfig={item.savedFeedConfig} /> + return <FeedOrFollowing savedFeed={item.savedFeed} /> } else if (item.type === 'popularFeedsHeader') { return ( <> @@ -481,7 +495,7 @@ export function FeedsScreen(_props: Props) { } else if (item.type === 'popularFeed') { return ( <View style={[a.px_lg, a.pt_lg, a.gap_lg]}> - <FeedCard.Default feed={item.feed} /> + <FeedCard.Default type="feed" view={item.feed} /> <Divider /> </View> ) @@ -571,136 +585,106 @@ export function FeedsScreen(_props: Props) { ) } -function FeedOrFollowing({ - savedFeedConfig: feed, -}: { - savedFeedConfig: AppBskyActorDefs.SavedFeed -}) { - return feed.type === 'timeline' ? ( +function FeedOrFollowing({savedFeed}: {savedFeed: SavedFeedItem}) { + return savedFeed.type === 'timeline' ? ( <FollowingFeed /> ) : ( - <SavedFeed savedFeedConfig={feed} /> + <SavedFeed savedFeed={savedFeed} /> ) } function FollowingFeed() { - const pal = usePalette('default') const t = useTheme() - const {isMobile} = useWebMediaQueries() + const {_} = useLingui() return ( <View - testID={`saved-feed-timeline`} style={[ - pal.border, - styles.savedFeed, - isMobile && styles.savedFeedMobile, + a.flex_1, + a.px_lg, + a.py_md, + a.border_b, + t.atoms.border_contrast_low, ]}> - <View - style={[ - a.align_center, - a.justify_center, - { - width: 28, - height: 28, - borderRadius: 3, - backgroundColor: t.palette.primary_500, - }, - ]}> - <FilterTimeline + <FeedCard.Header> + <View style={[ + a.align_center, + a.justify_center, { - width: 18, - height: 18, + width: 28, + height: 28, + borderRadius: 3, + backgroundColor: t.palette.primary_500, }, - ]} - fill={t.palette.white} - /> - </View> - <View - style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> - <Text type="lg-medium" style={pal.text} numberOfLines={1}> - <Trans>Following</Trans> - </Text> - </View> + ]}> + <FilterTimeline + style={[ + { + width: 18, + height: 18, + }, + ]} + fill={t.palette.white} + /> + </View> + <FeedCard.TitleAndByline title={_(msg`Following`)} type="feed" /> + </FeedCard.Header> </View> ) } function SavedFeed({ - savedFeedConfig: feed, + savedFeed, }: { - savedFeedConfig: AppBskyActorDefs.SavedFeed + savedFeed: SavedFeedItem & {type: 'feed' | 'list'} }) { - const pal = usePalette('default') - const {isMobile} = useWebMediaQueries() - const {data: info, error} = useFeedSourceInfoQuery({uri: feed.value}) - const typeAvatar = getAvatarTypeFromUri(feed.value) - - if (!info) - return ( - <SavedFeedLoadingPlaceholder - key={`savedFeedLoadingPlaceholder:${feed.value}`} - /> - ) + const t = useTheme() + const {view: feed} = savedFeed + const displayName = + savedFeed.type === 'feed' ? savedFeed.view.displayName : savedFeed.view.name return ( - <Link - testID={`saved-feed-${info.displayName}`} - href={info.route.href} - style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]} - hoverStyle={pal.viewLight} - accessibilityLabel={info.displayName} - accessibilityHint="" - asAnchor - anchorNoUnderline> - {error ? ( + <FeedCard.Link testID={`saved-feed-${feed.displayName}`} {...savedFeed}> + {({hovered, pressed}) => ( <View - style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}> - <FontAwesomeIcon - icon="exclamation-circle" - color={pal.colors.textLight} - /> + style={[ + a.flex_1, + a.px_lg, + a.py_md, + a.border_b, + t.atoms.border_contrast_low, + (hovered || pressed) && t.atoms.bg_contrast_25, + ]}> + <FeedCard.Header> + <FeedCard.Avatar src={feed.avatar} size={28} /> + <FeedCard.TitleAndByline + title={displayName} + type={savedFeed.type} + /> + + <ChevronRight size="sm" fill={t.atoms.text_contrast_low.color} /> + </FeedCard.Header> </View> - ) : ( - <UserAvatar type={typeAvatar} size={28} avatar={info.avatar} /> )} - <View - style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> - <Text type="lg-medium" style={pal.text} numberOfLines={1}> - {info.displayName} - </Text> - {error ? ( - <View style={[styles.offlineSlug, pal.borderDark]}> - <Text type="xs" style={pal.textLight}> - <Trans>Feed offline</Trans> - </Text> - </View> - ) : null} - </View> - - {isMobile && ( - <FontAwesomeIcon - icon="chevron-right" - size={14} - style={pal.textLight as FontAwesomeIconStyle} - /> - )} - </Link> + </FeedCard.Link> ) } -function SavedFeedLoadingPlaceholder() { - const pal = usePalette('default') - const {isMobile} = useWebMediaQueries() +function SavedFeedPlaceholder() { + const t = useTheme() return ( <View style={[ - pal.border, - styles.savedFeed, - isMobile && styles.savedFeedMobile, + a.flex_1, + a.px_lg, + a.py_md, + a.border_b, + t.atoms.border_contrast_low, ]}> - <LoadingPlaceholder width={28} height={28} style={{borderRadius: 4}} /> - <LoadingPlaceholder width={140} height={12} /> + <FeedCard.Header> + <FeedCard.AvatarPlaceholder size={28} /> + <FeedCard.TitleAndBylinePlaceholder /> + </FeedCard.Header> </View> ) } diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx index dd93bf813..8f6f6d4ba 100644 --- a/src/view/screens/Search/Explore.tsx +++ b/src/view/screens/Search/Explore.tsx @@ -282,7 +282,7 @@ export function Explore() { isFetchingNextPage: isFetchingNextProfilesPage, error: profilesError, fetchNextPage: fetchNextProfilesPage, - } = useSuggestedFollowsQuery({limit: 3}) + } = useSuggestedFollowsQuery({limit: 6, subsequentPageLimit: 10}) const { data: feeds, hasNextPage: hasNextFeedsPage, @@ -290,7 +290,7 @@ export function Explore() { isFetchingNextPage: isFetchingNextFeedsPage, error: feedsError, fetchNextPage: fetchNextFeedsPage, - } = useGetPopularFeedsQuery({limit: 3}) + } = useGetPopularFeedsQuery({limit: 10}) const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles const onLoadMoreProfiles = React.useCallback(async () => { @@ -340,11 +340,12 @@ export function Explore() { // Currently the responses contain duplicate items. // Needs to be fixed on backend, but let's dedupe to be safe. let seen = new Set() + const profileItems: ExploreScreenItems[] = [] for (const page of profiles.pages) { for (const actor of page.actors) { if (!seen.has(actor.did)) { seen.add(actor.did) - i.push({ + profileItems.push({ type: 'profile', key: actor.did, profile: actor, @@ -354,13 +355,19 @@ export function Explore() { } if (hasNextProfilesPage) { + // splice off 3 as previews if we have a next page + const previews = profileItems.splice(-3) + // push remainder + i.push(...profileItems) i.push({ type: 'loadMore', key: 'loadMoreProfiles', isLoadingMore: isLoadingMoreProfiles, onLoadMore: onLoadMoreProfiles, - items: i.filter(item => item.type === 'profile').slice(-3), + items: previews, }) + } else { + i.push(...profileItems) } } else { if (profilesError) { @@ -390,11 +397,12 @@ export function Explore() { // Currently the responses contain duplicate items. // Needs to be fixed on backend, but let's dedupe to be safe. let seen = new Set() + const feedItems: ExploreScreenItems[] = [] for (const page of feeds.pages) { for (const feed of page.feeds) { if (!seen.has(feed.uri)) { seen.add(feed.uri) - i.push({ + feedItems.push({ type: 'feed', key: feed.uri, feed, @@ -403,6 +411,7 @@ export function Explore() { } } + // feeds errors can occur during pagination, so feeds is truthy if (feedsError) { i.push({ type: 'error', @@ -418,13 +427,17 @@ export function Explore() { error: cleanError(preferencesError), }) } else if (hasNextFeedsPage) { + const preview = feedItems.splice(-3) + i.push(...feedItems) i.push({ type: 'loadMore', key: 'loadMoreFeeds', isLoadingMore: isLoadingMoreFeeds, onLoadMore: onLoadMoreFeeds, - items: i.filter(item => item.type === 'feed').slice(-3), + items: preview, }) + } else { + i.push(...feedItems) } } else { if (feedsError) { @@ -492,7 +505,7 @@ export function Explore() { a.px_lg, a.py_lg, ]}> - <FeedCard.Default feed={item.feed} /> + <FeedCard.Default type="feed" view={item.feed} /> </View> ) } diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index 0b1fe37aa..76ffba935 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -306,7 +306,7 @@ let SearchScreenFeedsResults = ({ a.px_lg, a.py_lg, ]}> - <FeedCard.Default feed={item} /> + <FeedCard.Default type="feed" view={item} /> </View> )} keyExtractor={item => item.uri} |