diff --git a/.eslintrc.js b/.eslintrc.js index 541b3d6153..9d2b7bbb1a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -31,6 +31,7 @@ module.exports = { }, }, ], + 'bsky-internal/use-exact-imports': 'error', 'bsky-internal/use-typed-gates': 'error', 'simple-import-sort/imports': [ 'warn', diff --git a/.github/workflows/build-and-push-bskyweb-aws.yaml b/.github/workflows/build-and-push-bskyweb-aws.yaml index 6eb9485b14..bcd759b0ce 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 0000000000..f91af48770 --- /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 0000000000..5d6ff041d3 --- /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/.husky/pre-commit b/.husky/pre-commit index 5a182ef106..d24fdfc601 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -yarn lint-staged +npx lint-staged diff --git a/Dockerfile.bskylink b/Dockerfile.bskylink new file mode 100644 index 0000000000..acde232256 --- /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 0000000000..aa68add595 --- /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/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 78478a26d4..0da9551e30 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -1,12 +1,16 @@ import {RichText} from '@atproto/api' import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player' +import { + createStarterPackGooglePlayUri, + createStarterPackLinkFromAndroidReferrer, + parseStarterPackUri, +} from 'lib/strings/starter-pack' import {cleanError} from '../../src/lib/strings/errors' import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles' import {enforceLen} from '../../src/lib/strings/helpers' import {detectLinkables} from '../../src/lib/strings/rich-text-detection' import {shortenLinks} from '../../src/lib/strings/rich-text-manip' -import {ago} from '../../src/lib/strings/time' import { makeRecordUri, toNiceDomain, @@ -142,79 +146,6 @@ describe('makeRecordUri', () => { }) }) -// FIXME: Reenable after fixing non-deterministic test. -describe.skip('ago', () => { - const oneYearDate = new Date( - new Date().setMonth(new Date().getMonth() - 11), - ).setDate(new Date().getDate() - 28) - - const inputs = [ - 1671461038, - '04 Dec 1995 00:12:00 GMT', - new Date(), - new Date().setSeconds(new Date().getSeconds() - 10), - new Date().setMinutes(new Date().getMinutes() - 10), - new Date().setHours(new Date().getHours() - 1), - new Date().setDate(new Date().getDate() - 1), - new Date().setDate(new Date().getDate() - 20), - new Date().setDate(new Date().getDate() - 25), - new Date().setDate(new Date().getDate() - 28), - new Date().setDate(new Date().getDate() - 29), - new Date().setDate(new Date().getDate() - 30), - new Date().setMonth(new Date().getMonth() - 1), - new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate( - new Date().getDate() - 20, - ), - new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate( - new Date().getDate() - 25, - ), - new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate( - new Date().getDate() - 28, - ), - new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate( - new Date().getDate() - 29, - ), - new Date().setMonth(new Date().getMonth() - 11), - new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate( - new Date().getDate() - 20, - ), - new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate( - new Date().getDate() - 25, - ), - oneYearDate, - ] - const outputs = [ - new Date(1671461038).toLocaleDateString(), - new Date('04 Dec 1995 00:12:00 GMT').toLocaleDateString(), - 'now', - '10s', - '10m', - '1h', - '1d', - '20d', - '25d', - '28d', - '29d', - '1mo', - '1mo', - '1mo', - '1mo', - '2mo', - '2mo', - '11mo', - '11mo', - '11mo', - new Date(oneYearDate).toLocaleDateString(), - ] - - it('correctly calculates how much time passed, in a string', () => { - for (let i = 0; i < inputs.length; i++) { - const result = ago(inputs[i]) - expect(result).toEqual(outputs[i]) - } - }) -}) - describe('makeValidHandle', () => { const inputs = [ 'test-handle-123', @@ -870,3 +801,179 @@ describe('parseEmbedPlayerFromUrl', () => { } }) }) + +describe('createStarterPackLinkFromAndroidReferrer', () => { + const validOutput = 'at://haileyok.com/app.bsky.graph.starterpack/rkey' + + it('returns a link when input contains utm_source and utm_content', () => { + expect( + createStarterPackLinkFromAndroidReferrer( + 'utm_source=bluesky&utm_content=starterpack_haileyok.com_rkey', + ), + ).toEqual(validOutput) + + expect( + createStarterPackLinkFromAndroidReferrer( + 'utm_source=bluesky&utm_content=starterpack_test-lover-9000.com_rkey', + ), + ).toEqual('at://test-lover-9000.com/app.bsky.graph.starterpack/rkey') + }) + + it('returns a link when input contains utm_source and utm_content in different order', () => { + expect( + createStarterPackLinkFromAndroidReferrer( + 'utm_content=starterpack_haileyok.com_rkey&utm_source=bluesky', + ), + ).toEqual(validOutput) + }) + + it('returns a link when input contains other parameters as well', () => { + expect( + createStarterPackLinkFromAndroidReferrer( + 'utm_source=bluesky&utm_medium=starterpack&utm_content=starterpack_haileyok.com_rkey', + ), + ).toEqual(validOutput) + }) + + it('returns null when utm_source is not present', () => { + expect( + createStarterPackLinkFromAndroidReferrer( + 'utm_content=starterpack_haileyok.com_rkey', + ), + ).toEqual(null) + }) + + it('returns null when utm_content is not present', () => { + expect( + createStarterPackLinkFromAndroidReferrer('utm_source=bluesky'), + ).toEqual(null) + }) + + it('returns null when utm_content is malformed', () => { + expect( + createStarterPackLinkFromAndroidReferrer( + 'utm_content=starterpack_haileyok.com', + ), + ).toEqual(null) + + expect( + createStarterPackLinkFromAndroidReferrer('utm_content=starterpack'), + ).toEqual(null) + + expect( + createStarterPackLinkFromAndroidReferrer( + 'utm_content=starterpack_haileyok.com_rkey_more', + ), + ).toEqual(null) + + expect( + createStarterPackLinkFromAndroidReferrer( + 'utm_content=notastarterpack_haileyok.com_rkey', + ), + ).toEqual(null) + }) +}) + +describe('parseStarterPackHttpUri', () => { + const baseUri = 'https://bsky.app/start' + + it('returns a valid at uri when http uri is valid', () => { + const validHttpUri = `${baseUri}/haileyok.com/rkey` + expect(parseStarterPackUri(validHttpUri)).toEqual({ + name: 'haileyok.com', + rkey: 'rkey', + }) + + const validHttpUri2 = `${baseUri}/haileyok.com/ilovetesting` + expect(parseStarterPackUri(validHttpUri2)).toEqual({ + name: 'haileyok.com', + rkey: 'ilovetesting', + }) + + const validHttpUri3 = `${baseUri}/testlover9000.com/rkey` + expect(parseStarterPackUri(validHttpUri3)).toEqual({ + name: 'testlover9000.com', + rkey: 'rkey', + }) + }) + + it('returns null when there is no rkey', () => { + const validHttpUri = `${baseUri}/haileyok.com` + expect(parseStarterPackUri(validHttpUri)).toEqual(null) + }) + + it('returns null when there is an extra path', () => { + const validHttpUri = `${baseUri}/haileyok.com/rkey/other` + expect(parseStarterPackUri(validHttpUri)).toEqual(null) + }) + + it('returns null when there is no handle or rkey', () => { + const validHttpUri = `${baseUri}` + expect(parseStarterPackUri(validHttpUri)).toEqual(null) + }) + + it('returns null when the route is not /start or /starter-pack', () => { + const validHttpUri = 'https://bsky.app/start/haileyok.com/rkey' + expect(parseStarterPackUri(validHttpUri)).toEqual({ + name: 'haileyok.com', + rkey: 'rkey', + }) + + const validHttpUri2 = 'https://bsky.app/starter-pack/haileyok.com/rkey' + expect(parseStarterPackUri(validHttpUri2)).toEqual({ + name: 'haileyok.com', + rkey: 'rkey', + }) + + const invalidHttpUri = 'https://bsky.app/profile/haileyok.com/rkey' + expect(parseStarterPackUri(invalidHttpUri)).toEqual(null) + }) + + it('returns the at uri when the input is a valid starterpack at uri', () => { + const validAtUri = 'at://did:123/app.bsky.graph.starterpack/rkey' + expect(parseStarterPackUri(validAtUri)).toEqual({ + name: 'did:123', + rkey: 'rkey', + }) + }) + + it('returns null when the at uri has no rkey', () => { + const validAtUri = 'at://did:123/app.bsky.graph.starterpack' + expect(parseStarterPackUri(validAtUri)).toEqual(null) + }) + + it('returns null when the collection is not app.bsky.graph.starterpack', () => { + const validAtUri = 'at://did:123/app.bsky.graph.list/rkey' + expect(parseStarterPackUri(validAtUri)).toEqual(null) + }) + + it('returns null when the input is undefined', () => { + expect(parseStarterPackUri(undefined)).toEqual(null) + }) +}) + +describe('createStarterPackGooglePlayUri', () => { + const base = + 'https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack_' + + it('returns valid google play uri when input is valid', () => { + expect(createStarterPackGooglePlayUri('name', 'rkey')).toEqual( + `${base}name_rkey`, + ) + }) + + it('returns null when no rkey is supplied', () => { + // @ts-expect-error test + expect(createStarterPackGooglePlayUri('name', undefined)).toEqual(null) + }) + + it('returns null when no name or rkey are supplied', () => { + // @ts-expect-error test + expect(createStarterPackGooglePlayUri(undefined, undefined)).toEqual(null) + }) + + it('returns null when rkey is supplied but no name', () => { + // @ts-expect-error test + expect(createStarterPackGooglePlayUri(undefined, 'rkey')).toEqual(null) + }) +}) diff --git a/app.config.js b/app.config.js index eafacc6cc1..57d4305865 100644 --- a/app.config.js +++ b/app.config.js @@ -39,6 +39,17 @@ module.exports = function (config) { const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight' const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production' + const ASSOCIATED_DOMAINS = [ + 'applinks:bsky.app', + 'applinks:staging.bsky.app', + 'appclips:bsky.app', + 'appclips:go.bsky.app', // Allows App Clip to work when scanning QR codes + // When testing local services, enter an ngrok (et al) domain here. It must use a standard HTTP/HTTPS port. + ...(IS_DEV || IS_TESTFLIGHT + ? ['appclips:sptesting.haileyok.com', 'applinks:sptesting.haileyok.com'] + : []), + ] + const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : IS_PRODUCTION @@ -83,7 +94,7 @@ module.exports = function (config) { NSPhotoLibraryUsageDescription: 'Used for profile pictures, posts, and other kinds of content', }, - associatedDomains: ['applinks:bsky.app', 'applinks:staging.bsky.app'], + associatedDomains: ASSOCIATED_DOMAINS, splash: { ...SPLASH_CONFIG, dark: DARK_SPLASH_CONFIG, @@ -202,6 +213,7 @@ module.exports = function (config) { sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'], }, ], + './plugins/starterPackAppClipExtension/withStarterPackAppClip.js', './plugins/withAndroidManifestPlugin.js', './plugins/withAndroidManifestFCMIconPlugin.js', './plugins/withAndroidStylesWindowBackgroundPlugin.js', @@ -234,6 +246,10 @@ module.exports = function (config) { ], }, }, + { + targetName: 'BlueskyClip', + bundleIdentifier: 'xyz.blueskyweb.app.AppClip', + }, ], }, }, diff --git a/assets/icons/arrowBottom_stroke2_corner0_rounded.svg b/assets/icons/arrowBottom_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..5f4a11e09a --- /dev/null +++ b/assets/icons/arrowBottom_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/newskie.svg b/assets/icons/newskie.svg new file mode 100644 index 0000000000..e3a9d83c80 --- /dev/null +++ b/assets/icons/newskie.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/qrCode_stroke2_corner0_rounded.svg b/assets/icons/qrCode_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..b17db39533 --- /dev/null +++ b/assets/icons/qrCode_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/starterPack.svg b/assets/icons/starterPack.svg new file mode 100644 index 0000000000..7f0df55952 --- /dev/null +++ b/assets/icons/starterPack.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/starter_pack_icon.svg b/assets/icons/starter_pack_icon.svg new file mode 100644 index 0000000000..47a2f49b64 --- /dev/null +++ b/assets/icons/starter_pack_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000000..cc2d660341 Binary files /dev/null and b/assets/logo.png differ diff --git a/bskylink/package.json b/bskylink/package.json new file mode 100644 index 0000000000..5fdee206b4 --- /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 0000000000..17f068841b --- /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 0000000000..ce409cccca --- /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 0000000000..7e6f2f34e8 --- /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) { + 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 0000000000..5f201cc07d --- /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, 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({ + dialect: new PostgresDialect({pool}), + }) + + return new Database(db, { + pool, + schema, + url, + txLockNonce, + }) + } + + async transaction(fn: (db: Database) => Promise): Promise { + 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 { + 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> { + 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 0000000000..fe3bcf1867 --- /dev/null +++ b/bskylink/src/db/migrations/001-init.ts @@ -0,0 +1,15 @@ +import {Kysely} from 'kysely' + +export async function up(db: Kysely): Promise { + 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): Promise { + 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 0000000000..05e4de9377 --- /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 0000000000..bef93a48fd --- /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) {} + async getMigrations(): Promise> { + return this.migrations + } +} diff --git a/bskylink/src/db/schema.ts b/bskylink/src/db/schema.ts new file mode 100644 index 0000000000..8d97f58005 --- /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 diff --git a/bskylink/src/index.ts b/bskylink/src/index.ts new file mode 100644 index 0000000000..ca425eee8c --- /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 { + 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 0000000000..25bb590a1d --- /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 0000000000..db7c3f8090 --- /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 0000000000..c8a30c59ea --- /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 0000000000..f60b99bcb7 --- /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 0000000000..7791ea815e --- /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 0000000000..ae3b42e304 --- /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 0000000000..bcac64b01d --- /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 + +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 0000000000..0b57dd5c5b --- /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 0000000000..51449c21be --- /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 { + 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 0000000000..1587f5c70a --- /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 " + 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 0000000000..4bc939e01c --- /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 0000000000..cc083491a5 --- /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 0000000000..3c382acc41 --- /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 0000000000..d2fa31456b --- /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 0000000000..3be1337fc3 --- /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 0000000000..fe23eeb9c9 Binary files /dev/null and b/bskyogcard/src/assets/Inter-Bold.ttf differ diff --git a/bskyogcard/src/bin.ts b/bskyogcard/src/bin.ts new file mode 100644 index 0000000000..ff550809db --- /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() + 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 0000000000..5a4124975c --- /dev/null +++ b/bskyogcard/src/components/Butterfly.tsx @@ -0,0 +1,16 @@ +import React from 'react' + +export function Butterfly(props: React.SVGAttributes) { + return ( + + + + ) +} diff --git a/bskyogcard/src/components/Img.tsx b/bskyogcard/src/components/Img.tsx new file mode 100644 index 0000000000..dac223180c --- /dev/null +++ b/bskyogcard/src/components/Img.tsx @@ -0,0 +1,10 @@ +import React from 'react' + +export function Img( + props: Omit, 'src'> & {src: Buffer}, +) { + const {src, ...others} = props + return ( + + ) +} diff --git a/bskyogcard/src/components/StarterPack.tsx b/bskyogcard/src/components/StarterPack.tsx new file mode 100644 index 0000000000..f73442190c --- /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 +}) { + 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 ( +
+ {/* image tiles */} +
+ {[...Array(18)].map((_, i) => { + const image = imagesArray.at(i % imagesArray.length) + return ( +
+ {image && } +
+ ) + })} + {/* background overlay */} +
+
+ {/* foreground text & images */} +
+
+ JOIN THE CONVERSATION +
+
+ {imagesAcross.map((image, i) => { + return ( +
+ +
+ ) + })} +
+
+ {record?.name || 'Starter Pack'} +
+
+ on Bluesky +
+
+
+ ) +} diff --git a/bskyogcard/src/config.ts b/bskyogcard/src/config.ts new file mode 100644 index 0000000000..fafa18e743 --- /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 0000000000..f92651cafb --- /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) { + 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 0000000000..ef8d48494d --- /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 { + 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 0000000000..04b5d90469 --- /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 0000000000..0cc69515eb --- /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 0000000000..0c40f89d3b --- /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 0000000000..cb3a553272 --- /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( + , + { + 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 0000000000..718ed592a1 --- /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 + +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 0000000000..a5c3beecb1 --- /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 0000000000..0403efb84e --- /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 5185ff573a..49629e3f2b 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 bb81e780f5..96fb07ddfe 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,18 @@ 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) + // starter packs + e.GET("/starter-pack/:handleOrDID/:rkey", server.WebGeneric) + e.GET("/start/:handleOrDID/:rkey", 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 +306,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/bskyweb/static/.well-known/apple-app-site-association b/bskyweb/static/.well-known/apple-app-site-association index 232acdf255..0a05fa35f4 100644 --- a/bskyweb/static/.well-known/apple-app-site-association +++ b/bskyweb/static/.well-known/apple-app-site-association @@ -1,6 +1,8 @@ { "applinks": { - "apps": [], + "appclips": { + "apps": ["B3LX46C5HS.xyz.blueskyweb.app.AppClip"] + }, "details": [ { "appID": "B3LX46C5HS.xyz.blueskyweb.app", @@ -10,4 +12,4 @@ } ] } -} \ No newline at end of file +} diff --git a/eslint/index.js b/eslint/index.js index bb31a942d1..cf5d41225d 100644 --- a/eslint/index.js +++ b/eslint/index.js @@ -3,6 +3,7 @@ module.exports = { rules: { 'avoid-unwrapped-text': require('./avoid-unwrapped-text'), + 'use-exact-imports': require('./use-exact-imports'), 'use-typed-gates': require('./use-typed-gates'), }, } diff --git a/eslint/use-exact-imports.js b/eslint/use-exact-imports.js new file mode 100644 index 0000000000..06723043fe --- /dev/null +++ b/eslint/use-exact-imports.js @@ -0,0 +1,22 @@ +/* eslint-disable bsky-internal/use-exact-imports */ +const BANNED_IMPORTS = [ + '@fortawesome/free-regular-svg-icons', + '@fortawesome/free-solid-svg-icons', +] + +exports.create = function create(context) { + return { + Literal(node) { + if (typeof node.value !== 'string') { + return + } + if (BANNED_IMPORTS.includes(node.value)) { + context.report({ + node, + message: + 'Import the specific thing you want instead of the entire package', + }) + } + }, + } +} diff --git a/modules/BlueskyClip/AppDelegate.swift b/modules/BlueskyClip/AppDelegate.swift new file mode 100644 index 0000000000..684194953c --- /dev/null +++ b/modules/BlueskyClip/AppDelegate.swift @@ -0,0 +1,32 @@ +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + var controller: ViewController? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + let window = UIWindow() + self.window = UIWindow() + + let controller = ViewController(window: window) + self.controller = controller + + window.rootViewController = self.controller + window.makeKeyAndVisible() + + return true + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + self.controller?.handleURL(url: url) + return true + } + + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + if let incomingURL = userActivity.webpageURL { + self.controller?.handleURL(url: incomingURL) + } + return true + } +} diff --git a/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png new file mode 100644 index 0000000000..75ce4b813a Binary files /dev/null and b/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png differ diff --git a/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/Contents.json b/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..c3bb428dbd --- /dev/null +++ b/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "App-Icon-1024x1024@1x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/modules/BlueskyClip/Images.xcassets/Contents.json b/modules/BlueskyClip/Images.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/modules/BlueskyClip/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/modules/BlueskyClip/ViewController.swift b/modules/BlueskyClip/ViewController.swift new file mode 100644 index 0000000000..b178644b8f --- /dev/null +++ b/modules/BlueskyClip/ViewController.swift @@ -0,0 +1,133 @@ +import UIKit +import WebKit +import StoreKit + +class ViewController: UIViewController, WKScriptMessageHandler, WKNavigationDelegate { + let defaults = UserDefaults(suiteName: "group.app.bsky") + + var window: UIWindow + var webView: WKWebView? + + var prevUrl: URL? + var starterPackUrl: URL? + + init(window: UIWindow) { + self.window = window + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + let contentController = WKUserContentController() + contentController.add(self, name: "onMessage") + let configuration = WKWebViewConfiguration() + configuration.userContentController = contentController + + let webView = WKWebView(frame: self.view.bounds, configuration: configuration) + webView.translatesAutoresizingMaskIntoConstraints = false + webView.contentMode = .scaleToFill + webView.navigationDelegate = self + self.view.addSubview(webView) + self.webView = webView + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let response = message.body as? String, + let data = response.data(using: .utf8), + let payload = try? JSONDecoder().decode(WebViewActionPayload.self, from: data) else { + return + } + + switch payload.action { + case .present: + guard let url = self.starterPackUrl else { + return + } + + self.presentAppStoreOverlay() + defaults?.setValue(url.absoluteString, forKey: "starterPackUri") + + case .store: + guard let keyToStoreAs = payload.keyToStoreAs, let jsonToStore = payload.jsonToStore else { + return + } + + self.defaults?.setValue(jsonToStore, forKey: keyToStoreAs) + } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + // Detect when we land on the right URL. This is incase of a short link opening the app clip + guard let url = navigationAction.request.url else { + return .allow + } + + // Store the previous one to compare later, but only set starterPackUrl when we find the right one + prevUrl = url + // pathComponents starts with "/" as the first component, then each path name. so... + // ["/", "start", "name", "rkey"] + if url.pathComponents.count == 4, + url.pathComponents[1] == "start" { + self.starterPackUrl = url + } + + return .allow + } + + func handleURL(url: URL) { + let urlString = "\(url.absoluteString)?clip=true" + if let url = URL(string: urlString) { + self.webView?.load(URLRequest(url: url)) + } + } + + func presentAppStoreOverlay() { + guard let windowScene = self.window.windowScene else { + return + } + + let configuration = SKOverlay.AppClipConfiguration(position: .bottomRaised) + let overlay = SKOverlay(configuration: configuration) + + overlay.present(in: windowScene) + } + + func getHost(_ url: URL?) -> String? { + if #available(iOS 16.0, *) { + return url?.host() + } else { + return url?.host + } + } + + func getQuery(_ url: URL?) -> String? { + if #available(iOS 16.0, *) { + return url?.query() + } else { + return url?.query + } + } + + func urlMatchesPrevious(_ url: URL?) -> Bool { + if #available(iOS 16.0, *) { + return url?.query() == prevUrl?.query() && url?.host() == prevUrl?.host() && url?.query() == prevUrl?.query() + } else { + return url?.query == prevUrl?.query && url?.host == prevUrl?.host && url?.query == prevUrl?.query + } + } +} + +struct WebViewActionPayload: Decodable { + enum Action: String, Decodable { + case present, store + } + + let action: Action + let keyToStoreAs: String? + let jsonToStore: String? +} diff --git a/modules/expo-bluesky-swiss-army/android/build.gradle b/modules/expo-bluesky-swiss-army/android/build.gradle new file mode 100644 index 0000000000..b031cde57e --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/build.gradle @@ -0,0 +1,47 @@ +apply plugin: 'com.android.library' + +group = 'expo.modules.blueskyswissarmy' +version = '0.6.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() + +// If you want to use the managed Android SDK versions from expo-modules-core, set this to true. +// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. +// Most of the time, you may like to manage the Android SDK versions yourself. +def useManagedAndroidSdkVersions = false +if (useManagedAndroidSdkVersions) { + useDefaultAndroidSdkVersions() +} else { + buildscript { + // Simple helper that allows the root project to override versions declared by this library. + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback + } + } + project.android { + compileSdkVersion safeExtGet("compileSdkVersion", 34) + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 21) + targetSdkVersion safeExtGet("targetSdkVersion", 34) + } + } +} + +android { + namespace "expo.modules.blueskyswissarmy" + defaultConfig { + versionCode 1 + versionName "0.6.0" + } + lintOptions { + abortOnError false + } +} + +dependencies { + implementation("com.android.installreferrer:installreferrer:2.2") +} diff --git a/modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml b/modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..bdae66c8f5 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt new file mode 100644 index 0000000000..29017f17aa --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt @@ -0,0 +1,10 @@ +package expo.modules.blueskyswissarmy.deviceprefs + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class ExpoBlueskyDevicePrefsModule : Module() { + override fun definition() = ModuleDefinition { + Name("ExpoBlueskyDevicePrefs") + } +} diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt new file mode 100644 index 0000000000..3589b364e0 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt @@ -0,0 +1,54 @@ +package expo.modules.blueskyswissarmy.referrer + +import android.util.Log +import com.android.installreferrer.api.InstallReferrerClient +import com.android.installreferrer.api.InstallReferrerStateListener +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.Promise + +class ExpoBlueskyReferrerModule : Module() { + override fun definition() = ModuleDefinition { + Name("ExpoBlueskyReferrer") + + AsyncFunction("getGooglePlayReferrerInfoAsync") { promise: Promise -> + val referrerClient = InstallReferrerClient.newBuilder(appContext.reactContext).build() + referrerClient.startConnection(object : InstallReferrerStateListener { + override fun onInstallReferrerSetupFinished(responseCode: Int) { + if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) { + Log.d("ExpoGooglePlayReferrer", "Successfully retrieved referrer info.") + + val response = referrerClient.installReferrer + Log.d("ExpoGooglePlayReferrer", "Install referrer: ${response.installReferrer}") + + promise.resolve( + mapOf( + "installReferrer" to response.installReferrer, + "clickTimestamp" to response.referrerClickTimestampSeconds, + "installTimestamp" to response.installBeginTimestampSeconds + ) + ) + } else { + Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Unknown error.") + promise.reject( + "ERR_GOOGLE_PLAY_REFERRER_UNKNOWN", + "Failed to get referrer info", + Exception("Failed to get referrer info") + ) + } + referrerClient.endConnection() + } + + override fun onInstallReferrerServiceDisconnected() { + Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Service disconnected.") + referrerClient.endConnection() + promise.reject( + "ERR_GOOGLE_PLAY_REFERRER_DISCONNECTED", + "Failed to get referrer info", + Exception("Failed to get referrer info") + ) + } + }) + } + } +} \ No newline at end of file diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json new file mode 100644 index 0000000000..730bc6114f --- /dev/null +++ b/modules/expo-bluesky-swiss-army/expo-module.config.json @@ -0,0 +1,12 @@ +{ + "platforms": ["ios", "tvos", "android", "web"], + "ios": { + "modules": ["ExpoBlueskyDevicePrefsModule", "ExpoBlueskyReferrerModule"] + }, + "android": { + "modules": [ + "expo.modules.blueskyswissarmy.deviceprefs.ExpoBlueskyDevicePrefsModule", + "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule" + ] + } +} diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts new file mode 100644 index 0000000000..1b2f892494 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/index.ts @@ -0,0 +1,4 @@ +import * as DevicePrefs from './src/DevicePrefs' +import * as Referrer from './src/Referrer' + +export {DevicePrefs, Referrer} diff --git a/modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift b/modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift new file mode 100644 index 0000000000..b13a9fe3fa --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift @@ -0,0 +1,23 @@ +import ExpoModulesCore + +public class ExpoBlueskyDevicePrefsModule: Module { + func getDefaults(_ useAppGroup: Bool) -> UserDefaults? { + if useAppGroup { + return UserDefaults(suiteName: "group.app.bsky") + } else { + return UserDefaults.standard + } + } + + public func definition() -> ModuleDefinition { + Name("ExpoBlueskyDevicePrefs") + + AsyncFunction("getStringValueAsync") { (key: String, useAppGroup: Bool) in + return self.getDefaults(useAppGroup)?.string(forKey: key) + } + + AsyncFunction("setStringValueAsync") { (key: String, value: String?, useAppGroup: Bool) in + self.getDefaults(useAppGroup)?.setValue(value, forKey: key) + } + } +} diff --git a/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec b/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec new file mode 100644 index 0000000000..be4b0eae45 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'ExpoBlueskySwissArmy' + s.version = '1.0.0' + s.summary = 'A collection of native tools for Bluesky' + s.description = 'A collection of native tools for Bluesky' + s.author = '' + s.homepage = 'https://github.com/bluesky-social/social-app' + s.platforms = { :ios => '13.4', :tvos => '13.4' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + # Swift/Objective-C compatibility + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/modules/expo-bluesky-swiss-army/ios/Referrer/ExpoBlueskyReferrerModule.swift b/modules/expo-bluesky-swiss-army/ios/Referrer/ExpoBlueskyReferrerModule.swift new file mode 100644 index 0000000000..fd28c51e6f --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/Referrer/ExpoBlueskyReferrerModule.swift @@ -0,0 +1,7 @@ +import ExpoModulesCore + +public class ExpoBlueskyReferrerModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoBlueskyReferrer") + } +} diff --git a/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts new file mode 100644 index 0000000000..4271850866 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts @@ -0,0 +1,18 @@ +import {requireNativeModule} from 'expo-modules-core' + +const NativeModule = requireNativeModule('ExpoBlueskyDevicePrefs') + +export function getStringValueAsync( + key: string, + useAppGroup?: boolean, +): Promise { + return NativeModule.getStringValueAsync(key, useAppGroup) +} + +export function setStringValueAsync( + key: string, + value: string | null, + useAppGroup?: boolean, +): Promise { + return NativeModule.setStringValueAsync(key, value, useAppGroup) +} diff --git a/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts new file mode 100644 index 0000000000..f1eee6c282 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts @@ -0,0 +1,16 @@ +import {NotImplementedError} from '../NotImplemented' + +export function getStringValueAsync( + key: string, + useAppGroup?: boolean, +): Promise { + throw new NotImplementedError({key, useAppGroup}) +} + +export function setStringValueAsync( + key: string, + value: string | null, + useAppGroup?: boolean, +): Promise { + throw new NotImplementedError({key, value, useAppGroup}) +} diff --git a/modules/expo-bluesky-swiss-army/src/NotImplemented.ts b/modules/expo-bluesky-swiss-army/src/NotImplemented.ts new file mode 100644 index 0000000000..876cd7b328 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/NotImplemented.ts @@ -0,0 +1,16 @@ +import {Platform} from 'react-native' + +export class NotImplementedError extends Error { + constructor(params = {}) { + if (__DEV__) { + const caller = new Error().stack?.split('\n')[2] + super( + `Not implemented on ${Platform.OS}. Given params: ${JSON.stringify( + params, + )} ${caller}`, + ) + } else { + super('Not implemented') + } + } +} diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts b/modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts new file mode 100644 index 0000000000..06dfd2d09c --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts @@ -0,0 +1,9 @@ +import {requireNativeModule} from 'expo' + +import {GooglePlayReferrerInfo} from './types' + +export const NativeModule = requireNativeModule('ExpoBlueskyReferrer') + +export function getGooglePlayReferrerInfoAsync(): Promise { + return NativeModule.getGooglePlayReferrerInfoAsync() +} diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/index.ts b/modules/expo-bluesky-swiss-army/src/Referrer/index.ts new file mode 100644 index 0000000000..2553985527 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/Referrer/index.ts @@ -0,0 +1,7 @@ +import {NotImplementedError} from '../NotImplemented' +import {GooglePlayReferrerInfo} from './types' + +// @ts-ignore throws +export function getGooglePlayReferrerInfoAsync(): Promise { + throw new NotImplementedError() +} diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/types.ts b/modules/expo-bluesky-swiss-army/src/Referrer/types.ts new file mode 100644 index 0000000000..55faaff4d3 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/Referrer/types.ts @@ -0,0 +1,7 @@ +export type GooglePlayReferrerInfo = + | { + installReferrer?: string + clickTimestamp?: number + installTimestamp?: number + } + | undefined diff --git a/package.json b/package.json index 5f9a898fc9..6577703099 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.86.0", + "version": "1.87.0", "private": true, "engines": { "node": ">=18" @@ -49,7 +49,7 @@ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" }, "dependencies": { - "@atproto/api": "^0.12.18", + "@atproto/api": "0.12.22-next.0", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", @@ -58,8 +58,9 @@ "@expo/webpack-config": "^19.0.0", "@floating-ui/dom": "^1.6.3", "@floating-ui/react-dom": "^2.0.8", - "@formatjs/intl-locale": "^3.4.3", - "@formatjs/intl-pluralrules": "^5.2.10", + "@formatjs/intl-locale": "^4.0.0", + "@formatjs/intl-numberformat": "^8.10.3", + "@formatjs/intl-pluralrules": "^5.2.14", "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", @@ -176,6 +177,7 @@ "react-native-pager-view": "6.2.3", "react-native-picker-select": "^9.1.3", "react-native-progress": "bluesky-social/react-native-progress", + "react-native-qrcode-styled": "^0.3.1", "react-native-reanimated": "^3.11.0", "react-native-root-siblings": "^4.1.1", "react-native-safe-area-context": "4.10.1", @@ -204,7 +206,7 @@ "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.20.0", "@did-plc/server": "^0.0.1", - "@expo/config-plugins": "7.8.0", + "@expo/config-plugins": "8.0.4", "@expo/prebuild-config": "6.7.0", "@lingui/cli": "^4.5.0", "@lingui/macro": "^4.5.0", @@ -271,6 +273,7 @@ "resolutions": { "@types/react": "^18", "**/zeed-dom": "0.10.9", + "**/zod": "3.23.8", "**/expo-constants": "16.0.1", "**/expo-device": "6.0.2", "@react-native/babel-preset": "0.74.1" diff --git a/patches/expo-haptics+12.8.1.patch b/patches/expo-haptics+13.0.1.patch similarity index 96% rename from patches/expo-haptics+12.8.1.patch rename to patches/expo-haptics+13.0.1.patch index a95b56f3be..9c7b9a6663 100644 --- a/patches/expo-haptics+12.8.1.patch +++ b/patches/expo-haptics+13.0.1.patch @@ -1,9 +1,9 @@ diff --git a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt -index 26c52af..b949a4c 100644 +index 1520465..6ea988a 100644 --- a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt +++ b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt @@ -42,7 +42,7 @@ class HapticsModule : Module() { - + private fun vibrate(type: HapticsVibrationType) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - vibrator.vibrate(VibrationEffect.createWaveform(type.timings, type.amplitudes, -1)) diff --git a/patches/expo-updates+0.25.11.patch b/patches/expo-updates+0.25.14.patch similarity index 96% rename from patches/expo-updates+0.25.11.patch rename to patches/expo-updates+0.25.14.patch index 5f9eceef48..6fc4fc5fcf 100644 --- a/patches/expo-updates+0.25.11.patch +++ b/patches/expo-updates+0.25.14.patch @@ -1,11 +1,11 @@ diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift -index b85291e..07a5d3c 100644 +index b85291e..546709d 100644 --- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift +++ b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift @@ -78,13 +78,20 @@ public final class ExpoUpdatesUpdate: Update { status = UpdateStatus.StatusPending } - + + // Instead of relying on various hacks to get the correct format for the specific + // platform on the backend, we can just add this little patch.. + let dateFormatter = DateFormatter() diff --git a/patches/react-native+0.74.1.patch b/patches/react-native+0.74.1.patch index 5d2900a731..789ba84ace 100644 --- a/patches/react-native+0.74.1.patch +++ b/patches/react-native+0.74.1.patch @@ -1,3 +1,29 @@ +diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm +index b0d71dc..9974932 100644 +--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm ++++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm +@@ -377,10 +377,6 @@ - (void)textInputDidBeginEditing + self.backedTextInputView.attributedText = [NSAttributedString new]; + } + +- if (_selectTextOnFocus) { +- [self.backedTextInputView selectAll:nil]; +- } +- + [_eventDispatcher sendTextEventWithType:RCTTextEventTypeFocus + reactTag:self.reactTag + text:[self.backedTextInputView.attributedText.string copy] +@@ -611,6 +607,10 @@ - (UIView *)reactAccessibilityElement + - (void)reactFocus + { + [self.backedTextInputView reactFocus]; ++ ++ if (_selectTextOnFocus) { ++ [self.backedTextInputView selectAll:nil]; ++ } + } + + - (void)reactBlur diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h index e9b330f..1ecdf0a 100644 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h @@ -5,7 +31,7 @@ index e9b330f..1ecdf0a 100644 @@ -16,4 +16,6 @@ @property (nonatomic, copy) RCTDirectEventBlock onRefresh; @property (nonatomic, weak) UIScrollView *scrollView; - + +- (void)forwarderBeginRefreshing; + @end @@ -16,7 +42,7 @@ index b09e653..4c32b31 100644 @@ -198,9 +198,53 @@ - (void)refreshControlValueChanged [self setCurrentRefreshingState:super.refreshing]; _refreshingProgrammatically = NO; - + + if (@available(iOS 17.4, *)) { + if (_currentRefreshingState) { + UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight]; @@ -29,7 +55,7 @@ index b09e653..4c32b31 100644 _onRefresh(nil); } } - + +/* + This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native + libraries to perform a refresh of a scrollview and access the refresh control's onRefresh @@ -38,15 +64,15 @@ index b09e653..4c32b31 100644 +- (void)forwarderBeginRefreshing +{ + _refreshingProgrammatically = NO; -+ ++ + [self sizeToFit]; -+ ++ + if (!self.scrollView) { + return; + } -+ ++ + UIScrollView *scrollView = (UIScrollView *)self.scrollView; -+ ++ + [UIView animateWithDuration:0.3 + delay:0 + options:UIViewAnimationOptionBeginFromCurrentState @@ -58,7 +84,7 @@ index b09e653..4c32b31 100644 + completion:^(__unused BOOL finished) { + [super beginRefreshing]; + [self setCurrentRefreshingState:super.refreshing]; -+ ++ + if (self->_onRefresh) { + self->_onRefresh(nil); + } @@ -73,7 +99,7 @@ index 5f5e1ab..aac00b6 100644 +++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java @@ -99,8 +99,9 @@ public class JavaTimerManager { } - + // If the JS thread is busy for multiple frames we cancel any other pending runnable. - if (mCurrentIdleCallbackRunnable != null) { - mCurrentIdleCallbackRunnable.cancel(); @@ -81,5 +107,5 @@ index 5f5e1ab..aac00b6 100644 + if (currentRunnable != null) { + currentRunnable.cancel(); } - + mCurrentIdleCallbackRunnable = new IdleCallbackRunnable(frameTimeNanos); diff --git a/patches/react-native+0.74.1.patch.md b/patches/react-native+0.74.1.patch.md index 9c93aee5cb..84953df2bd 100644 --- a/patches/react-native+0.74.1.patch.md +++ b/patches/react-native+0.74.1.patch.md @@ -11,3 +11,10 @@ in the RN repo: https://github.com/facebook/react-native/issues/43388 Patching `RCTRefreshControl.m` and `RCTRefreshControl.h` to add a new `forwarderBeginRefreshing` method to the class. This method is used by `ExpoScrollForwarder` to initiate a refresh of the underlying `UIScrollView` from inside that module. + + +## TextInput Patch - `selectTextOnFocus` fix + +Patching `RCTBaseTextInputView.m` to fix an issue where `selectTextOnFocus` does not work as expected on iOS 17. This +patch _only_ fixes the Paper version of `TextInput`. If we migrate to Fabric and the fix has not been made upstream, +we can apply the same fix. See https://github.com/facebook/react-native/pull/44307. diff --git a/plugins/starterPackAppClipExtension/withAppEntitlements.js b/plugins/starterPackAppClipExtension/withAppEntitlements.js new file mode 100644 index 0000000000..1bffd82080 --- /dev/null +++ b/plugins/starterPackAppClipExtension/withAppEntitlements.js @@ -0,0 +1,16 @@ +const {withEntitlementsPlist} = require('@expo/config-plugins') + +const withAppEntitlements = config => { + // eslint-disable-next-line no-shadow + return withEntitlementsPlist(config, async config => { + config.modResults['com.apple.security.application-groups'] = [ + `group.app.bsky`, + ] + config.modResults[ + 'com.apple.developer.associated-appclip-app-identifiers' + ] = [`$(AppIdentifierPrefix)${config.ios.bundleIdentifier}.AppClip`] + return config + }) +} + +module.exports = {withAppEntitlements} diff --git a/plugins/starterPackAppClipExtension/withClipEntitlements.js b/plugins/starterPackAppClipExtension/withClipEntitlements.js new file mode 100644 index 0000000000..77636b5c94 --- /dev/null +++ b/plugins/starterPackAppClipExtension/withClipEntitlements.js @@ -0,0 +1,32 @@ +const {withInfoPlist} = require('@expo/config-plugins') +const plist = require('@expo/plist') +const path = require('path') +const fs = require('fs') + +const withClipEntitlements = (config, {targetName}) => { + // eslint-disable-next-line no-shadow + return withInfoPlist(config, config => { + const entitlementsPath = path.join( + config.modRequest.platformProjectRoot, + targetName, + `${targetName}.entitlements`, + ) + + const appClipEntitlements = { + 'com.apple.security.application-groups': [`group.app.bsky`], + 'com.apple.developer.parent-application-identifiers': [ + `$(AppIdentifierPrefix)${config.ios.bundleIdentifier}`, + ], + 'com.apple.developer.associated-domains': config.ios.associatedDomains, + } + + fs.mkdirSync(path.dirname(entitlementsPath), { + recursive: true, + }) + fs.writeFileSync(entitlementsPath, plist.default.build(appClipEntitlements)) + + return config + }) +} + +module.exports = {withClipEntitlements} diff --git a/plugins/starterPackAppClipExtension/withClipInfoPlist.js b/plugins/starterPackAppClipExtension/withClipInfoPlist.js new file mode 100644 index 0000000000..59fbed1a9e --- /dev/null +++ b/plugins/starterPackAppClipExtension/withClipInfoPlist.js @@ -0,0 +1,38 @@ +const {withInfoPlist} = require('@expo/config-plugins') +const plist = require('@expo/plist') +const path = require('path') +const fs = require('fs') + +const withClipInfoPlist = (config, {targetName}) => { + // eslint-disable-next-line no-shadow + return withInfoPlist(config, config => { + const targetPath = path.join( + config.modRequest.platformProjectRoot, + targetName, + 'Info.plist', + ) + + const newPlist = plist.default.build({ + NSAppClip: { + NSAppClipRequestEphemeralUserNotification: false, + NSAppClipRequestLocationConfirmation: false, + }, + UILaunchScreen: {}, + CFBundleName: '$(PRODUCT_NAME)', + CFBundleIdentifier: '$(PRODUCT_BUNDLE_IDENTIFIER)', + CFBundleVersion: '$(CURRENT_PROJECT_VERSION)', + CFBundleExecutable: '$(EXECUTABLE_NAME)', + CFBundlePackageType: '$(PRODUCT_BUNDLE_PACKAGE_TYPE)', + CFBundleShortVersionString: config.version, + CFBundleIconName: 'AppIcon', + UIViewControllerBasedStatusBarAppearance: 'NO', + }) + + fs.mkdirSync(path.dirname(targetPath), {recursive: true}) + fs.writeFileSync(targetPath, newPlist) + + return config + }) +} + +module.exports = {withClipInfoPlist} diff --git a/plugins/starterPackAppClipExtension/withFiles.js b/plugins/starterPackAppClipExtension/withFiles.js new file mode 100644 index 0000000000..ad99f5ae41 --- /dev/null +++ b/plugins/starterPackAppClipExtension/withFiles.js @@ -0,0 +1,40 @@ +const {withXcodeProject} = require('@expo/config-plugins') +const path = require('path') +const fs = require('fs') + +const FILES = ['AppDelegate.swift', 'ViewController.swift'] + +const withFiles = (config, {targetName}) => { + // eslint-disable-next-line no-shadow + return withXcodeProject(config, config => { + const basePath = path.join( + config.modRequest.projectRoot, + 'modules', + targetName, + ) + + for (const file of FILES) { + const sourcePath = path.join(basePath, file) + const targetPath = path.join( + config.modRequest.platformProjectRoot, + targetName, + file, + ) + + fs.mkdirSync(path.dirname(targetPath), {recursive: true}) + fs.copyFileSync(sourcePath, targetPath) + } + + const imagesBasePath = path.join(basePath, 'Images.xcassets') + const imagesTargetPath = path.join( + config.modRequest.platformProjectRoot, + targetName, + 'Images.xcassets', + ) + fs.cpSync(imagesBasePath, imagesTargetPath, {recursive: true}) + + return config + }) +} + +module.exports = {withFiles} diff --git a/plugins/starterPackAppClipExtension/withStarterPackAppClip.js b/plugins/starterPackAppClipExtension/withStarterPackAppClip.js new file mode 100644 index 0000000000..1e3f0b7029 --- /dev/null +++ b/plugins/starterPackAppClipExtension/withStarterPackAppClip.js @@ -0,0 +1,40 @@ +const {withPlugins} = require('@expo/config-plugins') +const {withAppEntitlements} = require('./withAppEntitlements') +const {withClipEntitlements} = require('./withClipEntitlements') +const {withClipInfoPlist} = require('./withClipInfoPlist') +const {withFiles} = require('./withFiles') +const {withXcodeTarget} = require('./withXcodeTarget') + +const APP_CLIP_TARGET_NAME = 'BlueskyClip' + +const withStarterPackAppClip = config => { + return withPlugins(config, [ + withAppEntitlements, + [ + withClipEntitlements, + { + targetName: APP_CLIP_TARGET_NAME, + }, + ], + [ + withClipInfoPlist, + { + targetName: APP_CLIP_TARGET_NAME, + }, + ], + [ + withFiles, + { + targetName: APP_CLIP_TARGET_NAME, + }, + ], + [ + withXcodeTarget, + { + targetName: APP_CLIP_TARGET_NAME, + }, + ], + ]) +} + +module.exports = withStarterPackAppClip diff --git a/plugins/starterPackAppClipExtension/withXcodeTarget.js b/plugins/starterPackAppClipExtension/withXcodeTarget.js new file mode 100644 index 0000000000..61d5f81b07 --- /dev/null +++ b/plugins/starterPackAppClipExtension/withXcodeTarget.js @@ -0,0 +1,91 @@ +const {withXcodeProject} = require('@expo/config-plugins') + +const BUILD_PHASE_FILES = ['AppDelegate.swift', 'ViewController.swift'] + +const withXcodeTarget = (config, {targetName}) => { + // eslint-disable-next-line no-shadow + return withXcodeProject(config, config => { + const pbxProject = config.modResults + + const target = pbxProject.addTarget(targetName, 'application', targetName) + target.pbxNativeTarget.productType = `"com.apple.product-type.application.on-demand-install-capable"` + pbxProject.addBuildPhase( + BUILD_PHASE_FILES.map(f => `${targetName}/${f}`), + 'PBXSourcesBuildPhase', + 'Sources', + target.uuid, + 'application', + '"AppClips"', + ) + pbxProject.addBuildPhase( + [`${targetName}/Images.xcassets`], + 'PBXResourcesBuildPhase', + 'Resources', + target.uuid, + 'application', + '"AppClips"', + ) + + const pbxGroup = pbxProject.addPbxGroup([ + 'AppDelegate.swift', + 'ViewController.swift', + 'Images.xcassets', + `${targetName}.entitlements`, + 'Info.plist', + ]) + + pbxProject.addFile(`${targetName}/Info.plist`, pbxGroup.uuid) + const configurations = pbxProject.pbxXCBuildConfigurationSection() + for (const key in configurations) { + if (typeof configurations[key].buildSettings !== 'undefined') { + const buildSettingsObj = configurations[key].buildSettings + if ( + typeof buildSettingsObj.PRODUCT_NAME !== 'undefined' && + buildSettingsObj.PRODUCT_NAME === `"${targetName}"` + ) { + buildSettingsObj.CLANG_ENABLE_MODULES = 'YES' + buildSettingsObj.INFOPLIST_FILE = `"${targetName}/Info.plist"` + buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${targetName}/${targetName}.entitlements"` + buildSettingsObj.CODE_SIGN_STYLE = 'Automatic' + buildSettingsObj.CURRENT_PROJECT_VERSION = `"${ + process.env.BSKY_IOS_BUILD_NUMBER ?? '1' + }"` + buildSettingsObj.GENERATE_INFOPLIST_FILE = 'YES' + buildSettingsObj.MARKETING_VERSION = `"${config.version}"` + buildSettingsObj.PRODUCT_BUNDLE_IDENTIFIER = `"${config.ios?.bundleIdentifier}.AppClip"` + buildSettingsObj.SWIFT_EMIT_LOC_STRINGS = 'YES' + buildSettingsObj.SWIFT_VERSION = '5.0' + buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"` + buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS' + buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0' + buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon' + } + } + } + + pbxProject.addTargetAttribute('DevelopmentTeam', 'B3LX46C5HS', targetName) + + if (!pbxProject.hash.project.objects.PBXTargetDependency) { + pbxProject.hash.project.objects.PBXTargetDependency = {} + } + if (!pbxProject.hash.project.objects.PBXContainerItemProxy) { + pbxProject.hash.project.objects.PBXContainerItemProxy = {} + } + pbxProject.addTargetDependency(pbxProject.getFirstTarget().uuid, [ + target.uuid, + ]) + + pbxProject.addBuildPhase( + [`${targetName}.app`], + 'PBXCopyFilesBuildPhase', + 'Embed App Clips', + pbxProject.getFirstTarget().uuid, + 'application', + '"AppClips"', + ) + + return config + }) +} + +module.exports = {withXcodeTarget} diff --git a/scripts/updateExtensions.sh b/scripts/updateExtensions.sh index f3e972aa7b..b01134eebe 100755 --- a/scripts/updateExtensions.sh +++ b/scripts/updateExtensions.sh @@ -1,6 +1,7 @@ #!/bin/bash IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky" IOS_NOTIFICATION_EXTENSION_DIRECTORY="./ios/BlueskyNSE" +IOS_APP_CLIP_DIRECTORY="./ios/BlueskyClip" MODULES_DIRECTORY="./modules" if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then @@ -16,3 +17,11 @@ if [ ! -d $IOS_NOTIFICATION_EXTENSION_DIRECTORY ]; then else cp -R $IOS_NOTIFICATION_EXTENSION_DIRECTORY $MODULES_DIRECTORY fi + + +if [ ! -d $IOS_APP_CLIP_DIRECTORY ]; then + echo "$IOS_APP_CLIP_DIRECTORY not found inside of your iOS project." + exit 1 +else + cp -R $IOS_APP_CLIP_DIRECTORY $MODULES_DIRECTORY +fi diff --git a/src/App.native.tsx b/src/App.native.tsx index 5b2071e102..639276a12d 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -14,38 +14,45 @@ import * as SplashScreen from 'expo-splash-screen' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {Provider as StatsigProvider} from '#/lib/statsig/statsig' +import {useIntentHandler} from '#/lib/hooks/useIntentHandler' +import {QueryProvider} from '#/lib/react-query' +import { + initialize, + Provider as StatsigProvider, + tryFetchGates, +} from '#/lib/statsig/statsig' +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' +import {Provider as LightboxStateProvider} from '#/state/lightbox' import {MessagesProvider} from '#/state/messages' +import {Provider as ModalStateProvider} from '#/state/modals' import {init as initPersistedState} from '#/state/persisted' +import {Provider as PrefsStateProvider} from '#/state/preferences' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' -import {readLastActiveAccount} from '#/state/session/util' -import {useIntentHandler} from 'lib/hooks/useIntentHandler' -import {QueryProvider} from 'lib/react-query' -import {s} from 'lib/styles' -import {ThemeProvider} from 'lib/ThemeContext' -import {Provider as DialogStateProvider} from 'state/dialogs' -import {Provider as InvitesStateProvider} from 'state/invites' -import {Provider as LightboxStateProvider} from 'state/lightbox' -import {Provider as ModalStateProvider} from 'state/modals' -import {Provider as MutedThreadsProvider} from 'state/muted-threads' -import {Provider as PrefsStateProvider} from 'state/preferences' -import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' +import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread' import { Provider as SessionProvider, SessionAccount, useSession, useSessionApi, -} from 'state/session' -import {Provider as ShellStateProvider} from 'state/shell' -import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' -import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' -import {TestCtrls} from 'view/com/testing/TestCtrls' -import * as Toast from 'view/com/util/Toast' -import {Shell} from 'view/shell' +} from '#/state/session' +import {readLastActiveAccount} from '#/state/session/util' +import {Provider as ShellStateProvider} from '#/state/shell' +import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' +import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' +import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' +import {TestCtrls} from '#/view/com/testing/TestCtrls' +import * as Toast from '#/view/com/util/Toast' +import {Shell} from '#/view/shell' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' +import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {Provider as PortalProvider} from '#/components/Portal' import {Splash} from '#/Splash' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' @@ -62,6 +69,7 @@ function InnerApp() { const {_} = useLingui() useIntentHandler() + const hasCheckedReferrer = useStarterPackEntry() // init useEffect(() => { @@ -69,6 +77,9 @@ function InnerApp() { try { if (account) { await resumeSession(account) + } else { + await initialize() + await tryFetchGates(undefined, 'prefer-fresh-gates') } } catch (e) { logger.error(`session: resume failed`, {message: e}) @@ -90,7 +101,7 @@ function InnerApp() { - + - - - - + + + + + + @@ -143,29 +156,31 @@ function App() { * that is set up in the InnerApp component above. */ return ( - - - - - + + + + + - + + + - - - - - + + + + + ) } diff --git a/src/App.web.tsx b/src/App.web.tsx index 5c4dc4e637..31a59d97dd 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -8,37 +8,40 @@ import {SafeAreaProvider} from 'react-native-safe-area-context' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useIntentHandler} from '#/lib/hooks/useIntentHandler' +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' +import {Provider as LightboxStateProvider} from '#/state/lightbox' import {MessagesProvider} from '#/state/messages' +import {Provider as ModalStateProvider} from '#/state/modals' import {init as initPersistedState} from '#/state/persisted' +import {Provider as PrefsStateProvider} from '#/state/preferences' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' -import {readLastActiveAccount} from '#/state/session/util' -import {useIntentHandler} from 'lib/hooks/useIntentHandler' -import {QueryProvider} from 'lib/react-query' -import {ThemeProvider} from 'lib/ThemeContext' -import {Provider as DialogStateProvider} from 'state/dialogs' -import {Provider as InvitesStateProvider} from 'state/invites' -import {Provider as LightboxStateProvider} from 'state/lightbox' -import {Provider as ModalStateProvider} from 'state/modals' -import {Provider as MutedThreadsProvider} from 'state/muted-threads' -import {Provider as PrefsStateProvider} from 'state/preferences' -import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' +import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread' import { Provider as SessionProvider, SessionAccount, useSession, useSessionApi, -} from 'state/session' -import {Provider as ShellStateProvider} from 'state/shell' -import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' -import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' -import * as Toast from 'view/com/util/Toast' -import {ToastContainer} from 'view/com/util/Toast.web' -import {Shell} from 'view/shell/index' +} from '#/state/session' +import {readLastActiveAccount} from '#/state/session/util' +import {Provider as ShellStateProvider} from '#/state/shell' +import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' +import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' +import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' +import * as Toast from '#/view/com/util/Toast' +import {ToastContainer} from '#/view/com/util/Toast.web' +import {Shell} from '#/view/shell/index' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' +import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {Provider as PortalProvider} from '#/components/Portal' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import I18nProvider from './locale/i18nProvider' @@ -51,6 +54,7 @@ function InnerApp() { const theme = useColorModeTheme() const {_} = useLingui() useIntentHandler() + const hasCheckedReferrer = useStarterPackEntry() // init useEffect(() => { @@ -76,7 +80,7 @@ function InnerApp() { }, [_]) // wait for session to resume - if (!isReady) return null + if (!isReady || !hasCheckedReferrer) return null return ( @@ -96,9 +100,11 @@ function InnerApp() { - - - + + + + + @@ -133,27 +139,29 @@ function App() { * that is set up in the InnerApp component above. */ return ( - - - - + + + + - + + + - - - - + + + + ) } diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 67b89e2627..5cb4f4105f 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -43,6 +43,8 @@ import HashtagScreen from '#/screens/Hashtag' import {ModerationScreen} from '#/screens/Moderation' import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' +import {StarterPackScreen} from '#/screens/StarterPack/StarterPackScreen' +import {Wizard} from '#/screens/StarterPack/Wizard' import {init as initAnalytics} from './lib/analytics/analytics' import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig' @@ -54,8 +56,8 @@ import {useModalControls} from './state/modals' import {useUnreadNotifications} from './state/queries/notifications/unread' import {useSession} from './state/session' import { - setEmailConfirmationRequested, shouldRequestEmailConfirmation, + snoozeEmailConfirmationPrompt, } from './state/shell/reminders' import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings' import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines' @@ -312,7 +314,26 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => MessagesSettingsScreen} options={{title: title(msg`Chat settings`), requireAuth: true}} /> - FeedsScreen} /> + FeedsScreen} + options={{title: title(msg`Feeds`)}} + /> + StarterPackScreen} + options={{title: title(msg`Starter Pack`), requireAuth: true}} + /> + Wizard} + options={{title: title(msg`Create a starter pack`), requireAuth: true}} + /> + Wizard} + options={{title: title(msg`Edit your starter pack`), requireAuth: true}} + /> ) } @@ -367,6 +388,7 @@ function HomeTabNavigator() { contentStyle: pal.view, }}> HomeScreen} /> + HomeScreen} /> {commonScreens(HomeTab)} ) @@ -503,6 +525,11 @@ const FlatNavigator = () => { getComponent={() => MessagesScreen} options={{title: title(msg`Messages`), requireAuth: true}} /> + HomeScreen} + options={{title: title(msg`Home`)}} + /> {commonScreens(Flat as typeof HomeTab, numUnread)} ) @@ -585,7 +612,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) { openModal({name: 'verify-email', showReminder: true}) - setEmailConfirmationRequested() + snoozeEmailConfirmationPrompt() } } diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 1ccb0460c4..1dc2dfa7b0 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -267,6 +267,9 @@ export const atoms = { font_bold: { fontWeight: tokens.fontWeight.bold, }, + font_heavy: { + fontWeight: tokens.fontWeight.heavy, + }, italic: { fontStyle: 'italic', }, diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts index 1bddd95d43..675844e296 100644 --- a/src/alf/tokens.ts +++ b/src/alf/tokens.ts @@ -118,6 +118,7 @@ export const fontWeight = { normal: '400', semibold: '500', bold: '600', + heavy: '700', } as const export const gradients = { diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 982f422134..54d9eaf3b0 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -88,336 +88,355 @@ export function useButtonContext() { return React.useContext(Context) } -export function Button({ - children, - variant, - color, - size, - shape = 'default', - label, - disabled = false, - style, - hoverStyle: hoverStyleProp, - ...rest -}: ButtonProps) { - const t = useTheme() - const [state, setState] = React.useState({ - pressed: false, - hovered: false, - focused: false, - }) - - const onPressIn = React.useCallback(() => { - setState(s => ({ - ...s, - pressed: true, - })) - }, [setState]) - const onPressOut = React.useCallback(() => { - setState(s => ({ - ...s, - pressed: false, - })) - }, [setState]) - const onHoverIn = React.useCallback(() => { - setState(s => ({ - ...s, - hovered: true, - })) - }, [setState]) - const onHoverOut = React.useCallback(() => { - setState(s => ({ - ...s, - hovered: false, - })) - }, [setState]) - const onFocus = React.useCallback(() => { - setState(s => ({ - ...s, - focused: true, - })) - }, [setState]) - const onBlur = React.useCallback(() => { - setState(s => ({ - ...s, - focused: false, - })) - }, [setState]) - - const {baseStyles, hoverStyles} = React.useMemo(() => { - const baseStyles: ViewStyle[] = [] - const hoverStyles: ViewStyle[] = [] - const light = t.name === 'light' - - if (color === 'primary') { - if (variant === 'solid') { - if (!disabled) { - baseStyles.push({ - backgroundColor: t.palette.primary_500, - }) - hoverStyles.push({ - backgroundColor: t.palette.primary_600, - }) - } else { - baseStyles.push({ - backgroundColor: t.palette.primary_700, - }) - } - } else if (variant === 'outline') { - baseStyles.push(a.border, t.atoms.bg, { - borderWidth: 1, - }) - - if (!disabled) { - baseStyles.push(a.border, { - borderColor: t.palette.primary_500, - }) - hoverStyles.push(a.border, { - backgroundColor: light - ? t.palette.primary_50 - : t.palette.primary_950, - }) - } else { - baseStyles.push(a.border, { - borderColor: light ? t.palette.primary_200 : t.palette.primary_900, - }) - } - } else if (variant === 'ghost') { - if (!disabled) { - baseStyles.push(t.atoms.bg) - hoverStyles.push({ - backgroundColor: light - ? t.palette.primary_100 - : t.palette.primary_900, - }) - } - } - } else if (color === 'secondary') { - if (variant === 'solid') { - if (!disabled) { - baseStyles.push({ - backgroundColor: t.palette.contrast_25, - }) - hoverStyles.push({ - backgroundColor: t.palette.contrast_50, - }) - } else { - baseStyles.push({ - backgroundColor: t.palette.contrast_100, - }) - } - } else if (variant === 'outline') { - baseStyles.push(a.border, t.atoms.bg, { - borderWidth: 1, - }) - - if (!disabled) { - baseStyles.push(a.border, { - borderColor: t.palette.contrast_300, - }) - hoverStyles.push(t.atoms.bg_contrast_50) - } else { - baseStyles.push(a.border, { - borderColor: t.palette.contrast_200, - }) - } - } else if (variant === 'ghost') { - if (!disabled) { - baseStyles.push(t.atoms.bg) - hoverStyles.push({ - backgroundColor: t.palette.contrast_100, - }) - } - } - } else if (color === 'negative') { - if (variant === 'solid') { - if (!disabled) { - baseStyles.push({ - backgroundColor: t.palette.negative_500, - }) - hoverStyles.push({ - backgroundColor: t.palette.negative_600, - }) - } else { - baseStyles.push({ - backgroundColor: t.palette.negative_700, - }) - } - } else if (variant === 'outline') { - baseStyles.push(a.border, t.atoms.bg, { - borderWidth: 1, - }) - - if (!disabled) { - baseStyles.push(a.border, { - borderColor: t.palette.negative_500, - }) - hoverStyles.push(a.border, { - backgroundColor: light - ? t.palette.negative_50 - : t.palette.negative_975, - }) - } else { - baseStyles.push(a.border, { - borderColor: light - ? t.palette.negative_200 - : t.palette.negative_900, - }) - } - } else if (variant === 'ghost') { - if (!disabled) { - baseStyles.push(t.atoms.bg) - hoverStyles.push({ - backgroundColor: light - ? t.palette.negative_100 - : t.palette.negative_975, - }) - } - } - } - - if (shape === 'default') { - if (size === 'large') { - baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md) - } else if (size === 'medium') { - baseStyles.push({paddingVertical: 12}, a.px_2xl, a.rounded_sm, a.gap_md) - } else if (size === 'small') { - baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm) - } else if (size === 'xsmall') { - baseStyles.push({paddingVertical: 6}, a.px_sm, a.rounded_sm, a.gap_sm) - } else if (size === 'tiny') { - baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs) - } - } else if (shape === 'round' || shape === 'square') { - if (size === 'large') { - if (shape === 'round') { - baseStyles.push({height: 54, width: 54}) - } else { - baseStyles.push({height: 50, width: 50}) - } - } else if (size === 'small') { - baseStyles.push({height: 34, width: 34}) - } else if (size === 'xsmall') { - baseStyles.push({height: 28, width: 28}) - } else if (size === 'tiny') { - baseStyles.push({height: 20, width: 20}) - } - - if (shape === 'round') { - baseStyles.push(a.rounded_full) - } else if (shape === 'square') { - if (size === 'tiny') { - baseStyles.push(a.rounded_xs) - } else { - baseStyles.push(a.rounded_sm) - } - } - } - - return { - baseStyles, - hoverStyles, - } - }, [t, variant, color, size, shape, disabled]) - - const {gradientColors, gradientHoverColors, gradientLocations} = - React.useMemo(() => { - const colors: string[] = [] - const hoverColors: string[] = [] - const locations: number[] = [] - const gradient = { - primary: tokens.gradients.sky, - secondary: tokens.gradients.sky, - negative: tokens.gradients.sky, - gradient_sky: tokens.gradients.sky, - gradient_midnight: tokens.gradients.midnight, - gradient_sunrise: tokens.gradients.sunrise, - gradient_sunset: tokens.gradients.sunset, - gradient_nordic: tokens.gradients.nordic, - gradient_bonfire: tokens.gradients.bonfire, - }[color || 'primary'] - - if (variant === 'gradient') { - colors.push(...gradient.values.map(([_, color]) => color)) - hoverColors.push(...gradient.values.map(_ => gradient.hover_value)) - locations.push(...gradient.values.map(([location, _]) => location)) - } - - return { - gradientColors: colors, - gradientHoverColors: hoverColors, - gradientLocations: locations, - } - }, [variant, color]) - - const context = React.useMemo( - () => ({ - ...state, +export const Button = React.forwardRef( + ( + { + children, variant, color, size, - disabled: disabled || false, - }), - [state, variant, color, size, disabled], - ) + shape = 'default', + label, + disabled = false, + style, + hoverStyle: hoverStyleProp, + ...rest + }, + ref, + ) => { + const t = useTheme() + const [state, setState] = React.useState({ + pressed: false, + hovered: false, + focused: false, + }) - const flattenedBaseStyles = flatten(baseStyles) + const onPressIn = React.useCallback(() => { + setState(s => ({ + ...s, + pressed: true, + })) + }, [setState]) + const onPressOut = React.useCallback(() => { + setState(s => ({ + ...s, + pressed: false, + })) + }, [setState]) + const onHoverIn = React.useCallback(() => { + setState(s => ({ + ...s, + hovered: true, + })) + }, [setState]) + const onHoverOut = React.useCallback(() => { + setState(s => ({ + ...s, + hovered: false, + })) + }, [setState]) + const onFocus = React.useCallback(() => { + setState(s => ({ + ...s, + focused: true, + })) + }, [setState]) + const onBlur = React.useCallback(() => { + setState(s => ({ + ...s, + focused: false, + })) + }, [setState]) - return ( - { + const baseStyles: ViewStyle[] = [] + const hoverStyles: ViewStyle[] = [] + const light = t.name === 'light' + + if (color === 'primary') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + backgroundColor: t.palette.primary_500, + }) + hoverStyles.push({ + backgroundColor: t.palette.primary_600, + }) + } else { + baseStyles.push({ + backgroundColor: t.palette.primary_700, + }) + } + } else if (variant === 'outline') { + baseStyles.push(a.border, t.atoms.bg, { + borderWidth: 1, + }) + + if (!disabled) { + baseStyles.push(a.border, { + borderColor: t.palette.primary_500, + }) + hoverStyles.push(a.border, { + backgroundColor: light + ? t.palette.primary_50 + : t.palette.primary_950, + }) + } else { + baseStyles.push(a.border, { + borderColor: light + ? t.palette.primary_200 + : t.palette.primary_900, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push(t.atoms.bg) + hoverStyles.push({ + backgroundColor: light + ? t.palette.primary_100 + : t.palette.primary_900, + }) + } + } + } else if (color === 'secondary') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + backgroundColor: t.palette.contrast_25, + }) + hoverStyles.push({ + backgroundColor: t.palette.contrast_50, + }) + } else { + baseStyles.push({ + backgroundColor: t.palette.contrast_100, + }) + } + } else if (variant === 'outline') { + baseStyles.push(a.border, t.atoms.bg, { + borderWidth: 1, + }) + + if (!disabled) { + baseStyles.push(a.border, { + borderColor: t.palette.contrast_300, + }) + hoverStyles.push(t.atoms.bg_contrast_50) + } else { + baseStyles.push(a.border, { + borderColor: t.palette.contrast_200, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push(t.atoms.bg) + hoverStyles.push({ + backgroundColor: t.palette.contrast_25, + }) + } + } + } else if (color === 'negative') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + backgroundColor: t.palette.negative_500, + }) + hoverStyles.push({ + backgroundColor: t.palette.negative_600, + }) + } else { + baseStyles.push({ + backgroundColor: t.palette.negative_700, + }) + } + } else if (variant === 'outline') { + baseStyles.push(a.border, t.atoms.bg, { + borderWidth: 1, + }) + + if (!disabled) { + baseStyles.push(a.border, { + borderColor: t.palette.negative_500, + }) + hoverStyles.push(a.border, { + backgroundColor: light + ? t.palette.negative_50 + : t.palette.negative_975, + }) + } else { + baseStyles.push(a.border, { + borderColor: light + ? t.palette.negative_200 + : t.palette.negative_900, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push(t.atoms.bg) + hoverStyles.push({ + backgroundColor: light + ? t.palette.negative_100 + : t.palette.negative_975, + }) + } + } + } + + if (shape === 'default') { + if (size === 'large') { + baseStyles.push( + {paddingVertical: 15}, + a.px_2xl, + a.rounded_sm, + a.gap_md, + ) + } else if (size === 'medium') { + baseStyles.push( + {paddingVertical: 12}, + a.px_2xl, + a.rounded_sm, + a.gap_md, + ) + } else if (size === 'small') { + baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm) + } else if (size === 'xsmall') { + baseStyles.push({paddingVertical: 6}, a.px_sm, a.rounded_sm, a.gap_sm) + } else if (size === 'tiny') { + baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs) + } + } else if (shape === 'round' || shape === 'square') { + if (size === 'large') { + if (shape === 'round') { + baseStyles.push({height: 54, width: 54}) + } else { + baseStyles.push({height: 50, width: 50}) + } + } else if (size === 'small') { + baseStyles.push({height: 34, width: 34}) + } else if (size === 'xsmall') { + baseStyles.push({height: 28, width: 28}) + } else if (size === 'tiny') { + baseStyles.push({height: 20, width: 20}) + } + + if (shape === 'round') { + baseStyles.push(a.rounded_full) + } else if (shape === 'square') { + if (size === 'tiny') { + baseStyles.push(a.rounded_xs) + } else { + baseStyles.push(a.rounded_sm) + } + } + } + + return { + baseStyles, + hoverStyles, + } + }, [t, variant, color, size, shape, disabled]) + + const {gradientColors, gradientHoverColors, gradientLocations} = + React.useMemo(() => { + const colors: string[] = [] + const hoverColors: string[] = [] + const locations: number[] = [] + const gradient = { + primary: tokens.gradients.sky, + secondary: tokens.gradients.sky, + negative: tokens.gradients.sky, + gradient_sky: tokens.gradients.sky, + gradient_midnight: tokens.gradients.midnight, + gradient_sunrise: tokens.gradients.sunrise, + gradient_sunset: tokens.gradients.sunset, + gradient_nordic: tokens.gradients.nordic, + gradient_bonfire: tokens.gradients.bonfire, + }[color || 'primary'] + + if (variant === 'gradient') { + colors.push(...gradient.values.map(([_, color]) => color)) + hoverColors.push(...gradient.values.map(_ => gradient.hover_value)) + locations.push(...gradient.values.map(([location, _]) => location)) + } + + return { + gradientColors: colors, + gradientHoverColors: hoverColors, + gradientLocations: locations, + } + }, [variant, color]) + + const context = React.useMemo( + () => ({ + ...state, + variant, + color, + size, disabled: disabled || false, - }} - style={[ - a.flex_row, - a.align_center, - a.justify_center, - flattenedBaseStyles, - flatten(style), - ...(state.hovered || state.pressed - ? [hoverStyles, flatten(hoverStyleProp)] - : []), - ]} - onPressIn={onPressIn} - onPressOut={onPressOut} - onHoverIn={onHoverIn} - onHoverOut={onHoverOut} - onFocus={onFocus} - onBlur={onBlur}> - {variant === 'gradient' && ( - - - - )} - - {typeof children === 'function' ? children(context) : children} - - - ) -} + }), + [state, variant, color, size, disabled], + ) + + const flattenedBaseStyles = flatten(baseStyles) + + return ( + + {variant === 'gradient' && ( + + + + )} + + {typeof children === 'function' ? children(context) : children} + + + ) + }, +) +Button.displayName = 'Button' export function useSharedButtonTextStyles() { const t = useTheme() diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx new file mode 100644 index 0000000000..7f3cb88ff3 --- /dev/null +++ b/src/components/FeedCard.tsx @@ -0,0 +1,338 @@ +import React from 'react' +import {GestureResponderEvent, View} from 'react-native' +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 { + useAddSavedFeedsMutation, + usePreferencesQuery, + 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' +import {useTheme} from '#/alf' +import {atoms as a} from '#/alf' +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, 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' + +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 ( + + +
+ + + +
+ + {type === 'feed' && } +
+ + ) +} + +export function Link({ + type, + view, + label, + children, +}: Props & Omit) { + const queryClient = useQueryClient() + + const href = React.useMemo(() => { + return createProfileFeedHref({feed: view}) + }, [view]) + + return ( + { + if (type === 'feed') { + precacheFeedFromGeneratorView(queryClient, view) + } else { + precacheList(queryClient, view) + } + }}> + {children} + + ) +} + +export function Outer({children}: {children: React.ReactNode}) { + return {children} +} + +export function Header({children}: {children: React.ReactNode}) { + return ( + + {children} + + ) +} + +export type AvatarProps = {src: string | undefined; size?: number} + +export function Avatar({src, size = 40}: AvatarProps) { + return +} + +export function AvatarPlaceholder({size = 40}: Omit) { + const t = useTheme() + return ( + + ) +} + +export function TitleAndByline({ + title, + creator, + type, + purpose, +}: { + title: string + creator?: AppBskyActorDefs.ProfileViewBasic + type: 'feed' | 'list' + purpose?: AppBskyGraphDefs.ListView['purpose'] +}) { + const t = useTheme() + + return ( + + + {title} + + {creator && ( + + {type === 'list' && purpose === 'app.bsky.graph.defs#curatelist' ? ( + List by {sanitizeHandle(creator.handle, '@')} + ) : type === 'list' && purpose === 'app.bsky.graph.defs#modlist' ? ( + + Moderation list by {sanitizeHandle(creator.handle, '@')} + + ) : ( + Feed by {sanitizeHandle(creator.handle, '@')} + )} + + )} + + ) +} + +export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) { + const t = useTheme() + + return ( + + + + {creator && ( + + )} + + ) +} + +export function Description({description}: {description?: string}) { + const [rt, isResolving] = useRichText(description || '') + if (!description) return null + return isResolving ? ( + + ) : ( + + ) +} + +export function Likes({count}: {count: number}) { + const t = useTheme() + return ( + + {plural(count || 0, { + one: 'Liked by # user', + other: 'Liked by # users', + })} + + ) +} + +export function Action({ + uri, + pin, + type, + purpose, +}: { + uri: string + pin?: boolean + type: 'feed' | 'list' + purpose?: AppBskyGraphDefs.ListView['purpose'] +}) { + const {hasSession} = useSession() + if (!hasSession || purpose !== 'app.bsky.graph.defs#curatelist') return null + return +} + +function ActionInner({ + uri, + pin, + type, +}: { + uri: string + pin?: boolean + type: 'feed' | 'list' +}) { + const {_} = useLingui() + const {data: preferences} = usePreferencesQuery() + const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} = + useAddSavedFeedsMutation() + const {isPending: isRemovePending, mutateAsync: removeFeed} = + useRemoveFeedMutation() + const savedFeedConfig = React.useMemo(() => { + return preferences?.savedFeeds?.find(feed => feed.value === uri) + }, [preferences?.savedFeeds, uri]) + const removePromptControl = Prompt.usePromptControl() + const isPending = isAddSavedFeedPending || isRemovePending + + const toggleSave = React.useCallback( + async (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + + try { + if (savedFeedConfig) { + await removeFeed(savedFeedConfig) + } else { + await saveFeeds([ + { + type, + value: uri, + pinned: pin || false, + }, + ]) + } + Toast.show(_(msg`Feeds updated!`)) + } catch (e: any) { + logger.error(e, {context: `FeedCard: failed to update feeds`, pin}) + Toast.show(_(msg`Failed to update feeds`)) + } + }, + [_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type], + ) + + const onPrompRemoveFeed = React.useCallback( + async (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + + removePromptControl.open() + }, + [removePromptControl], + ) + + return ( + <> + + + + + ) +} + +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/KnownFollowers.tsx b/src/components/KnownFollowers.tsx index b99fe3398e..7b861dc660 100644 --- a/src/components/KnownFollowers.tsx +++ b/src/components/KnownFollowers.tsx @@ -1,14 +1,14 @@ import React from 'react' import {View} from 'react-native' import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' -import {msg, plural, Trans} from '@lingui/macro' +import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from 'lib/strings/display-names' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' -import {Link} from '#/components/Link' +import {Link, LinkProps} from '#/components/Link' import {Text} from '#/components/Typography' const AVI_SIZE = 30 @@ -29,9 +29,11 @@ export function shouldShowKnownFollowers( export function KnownFollowers({ profile, moderationOpts, + onLinkPress, }: { profile: AppBskyActorDefs.ProfileViewDetailed moderationOpts: ModerationOpts + onLinkPress?: LinkProps['onPress'] }) { const cache = React.useRef>( new Map(), @@ -56,6 +58,7 @@ export function KnownFollowers({ profile={profile} cachedKnownFollowers={cachedKnownFollowers} moderationOpts={moderationOpts} + onLinkPress={onLinkPress} /> ) } @@ -67,10 +70,12 @@ function KnownFollowersInner({ profile, moderationOpts, cachedKnownFollowers, + onLinkPress, }: { profile: AppBskyActorDefs.ProfileViewDetailed moderationOpts: ModerationOpts cachedKnownFollowers: AppBskyActorDefs.KnownFollowers + onLinkPress?: LinkProps['onPress'] }) { const t = useTheme() const {_} = useLingui() @@ -82,15 +87,6 @@ function KnownFollowersInner({ t.atoms.text_contrast_medium, ] - // list of users, minus blocks - const returnedCount = cachedKnownFollowers.followers.length - // db count, includes blocks - const fullCount = cachedKnownFollowers.count - // knownFollowers can return up to 5 users, but will exclude blocks - // therefore, if we have less 5 users, use whichever count is lower - const count = - returnedCount < 5 ? Math.min(fullCount, returnedCount) : fullCount - const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => { const moderation = moderateProfile(f, moderationOpts) return { @@ -105,11 +101,21 @@ function KnownFollowersInner({ } }) + // Does not have blocks applied. Always >= slices.length + const serverCount = cachedKnownFollowers.count + + /* + * We check above too, but here for clarity and a reminder to _check for + * valid indices_ + */ + if (slice.length === 0) return null + return ( - Followed by{' '} - {count > 2 ? ( - <> - {slice.slice(0, 2).map(({profile: prof}, i) => ( - - {prof.displayName} - {i === 0 && ', '} + {slice.length >= 2 ? ( + // 2-n followers, including blocks + serverCount > 2 ? ( + + Followed by{' '} + + {slice[0].profile.displayName} - ))} - {', '} - {plural(count - 2, { - one: 'and # other', - other: 'and # others', - })} - - ) : count === 2 ? ( - slice.map(({profile: prof}, i) => ( - - {prof.displayName} {i === 0 ? _(msg`and`) + ' ' : ''} - - )) + ,{' '} + + {slice[1].profile.displayName} + + , and{' '} + + + ) : ( + // only 2 + + Followed by{' '} + + {slice[0].profile.displayName} + {' '} + and{' '} + + {slice[1].profile.displayName} + + + ) + ) : serverCount > 1 ? ( + // 1-n followers, including blocks + + Followed by{' '} + + {slice[0].profile.displayName} + {' '} + and{' '} + + ) : ( - - {slice[0].profile.displayName} - + // only 1 + + Followed by{' '} + + {slice[0].profile.displayName} + + )} diff --git a/src/components/LinearGradientBackground.tsx b/src/components/LinearGradientBackground.tsx new file mode 100644 index 0000000000..f516b19f5f --- /dev/null +++ b/src/components/LinearGradientBackground.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import {StyleProp, ViewStyle} from 'react-native' +import {LinearGradient} from 'expo-linear-gradient' + +import {gradients} from '#/alf/tokens' + +export function LinearGradientBackground({ + style, + children, +}: { + style: StyleProp + children: React.ReactNode +}) { + const gradient = gradients.sky.values.map(([_, color]) => { + return color + }) + + return ( + + {children} + + ) +} diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx new file mode 100644 index 0000000000..6743a592ba --- /dev/null +++ b/src/components/NewskieDialog.tsx @@ -0,0 +1,135 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyActorDefs, moderateProfile} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {differenceInSeconds} from 'date-fns' + +import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {HITSLOP_10} from 'lib/constants' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {isWeb} from 'platform/detection' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {useDialogControl} from '#/components/Dialog' +import {Newskie} from '#/components/icons/Newskie' +import * as StarterPackCard from '#/components/StarterPack/StarterPackCard' +import {Text} from '#/components/Typography' + +export function NewskieDialog({ + profile, + disabled, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed + disabled?: boolean +}) { + const {_} = useLingui() + const t = useTheme() + const moderationOpts = useModerationOpts() + const control = useDialogControl() + const profileName = React.useMemo(() => { + const name = profile.displayName || profile.handle + if (!moderationOpts) return name + const moderation = moderateProfile(profile, moderationOpts) + return sanitizeDisplayName(name, moderation.ui('displayName')) + }, [moderationOpts, profile]) + const [now] = React.useState(() => Date.now()) + const timeAgo = useGetTimeAgo() + const createdAt = profile.createdAt as string | undefined + const daysOld = React.useMemo(() => { + if (!createdAt) return Infinity + return differenceInSeconds(now, new Date(createdAt)) / 86400 + }, [createdAt, now]) + + if (!createdAt || daysOld > 7) return null + + return ( + + + + + + + + + + + Say hello! + + + + {profile.joinedViaStarterPack ? ( + + {profileName} joined Bluesky using a starter pack{' '} + {timeAgo(createdAt, now, {format: 'long'})} ago + + ) : ( + + {profileName} joined Bluesky{' '} + {timeAgo(createdAt, now, {format: 'long'})} ago + + )} + + {profile.joinedViaStarterPack ? ( + { + control.close() + }}> + + + + + ) : null} + + + + + + ) +} diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx new file mode 100644 index 0000000000..a0d222854b --- /dev/null +++ b/src/components/ProfileCard.tsx @@ -0,0 +1,91 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' + +import {createSanitizedDisplayName} from 'lib/moderation/create-sanitized-display-name' +import {sanitizeHandle} from 'lib/strings/handles' +import {useProfileShadow} from 'state/cache/profile-shadow' +import {useSession} from 'state/session' +import {FollowButton} from 'view/com/profile/FollowButton' +import {ProfileCardPills} from 'view/com/profile/ProfileCard' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {atoms as a, useTheme} from '#/alf' +import {Link} from '#/components/Link' +import {Text} from '#/components/Typography' + +export function Default({ + profile: profileUnshadowed, + moderationOpts, + logContext = 'ProfileCard', +}: { + profile: AppBskyActorDefs.ProfileViewDetailed + moderationOpts: ModerationOpts + logContext?: 'ProfileCard' | 'StarterPackProfilesList' +}) { + const t = useTheme() + const {currentAccount, hasSession} = useSession() + + const profile = useProfileShadow(profileUnshadowed) + const name = createSanitizedDisplayName(profile) + const handle = `@${sanitizeHandle(profile.handle)}` + const moderation = moderateProfile(profile, moderationOpts) + + return ( + + + + + + {name} + + + {handle} + + + {hasSession && profile.did !== currentAccount?.did && ( + + + + )} + + + + + {profile.description && ( + + {profile.description} + + )} + + ) +} + +function Wrapper({did, children}: {did: string; children: React.ReactNode}) { + return ( + + {children} + + ) +} diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index 024867b1a3..4db9c4f8e5 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -5,6 +5,7 @@ import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom' import {msg, plural} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {getModerationCauseKey} from '#/lib/moderation' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' @@ -22,11 +23,16 @@ import {useFollowMethods} from '#/components/hooks/useFollowMethods' import {useRichText} from '#/components/hooks/useRichText' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import { + KnownFollowers, + shouldShowKnownFollowers, +} from '#/components/KnownFollowers' import {InlineLinkText, Link} from '#/components/Link' import {Loader} from '#/components/Loader' import {Portal} from '#/components/Portal' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' +import {ProfileLabel} from '../moderation/ProfileHeaderAlerts' import {ProfileHoverCardProps} from './types' const floatingMiddlewares = [ @@ -45,10 +51,23 @@ const floatingMiddlewares = [ ] export function ProfileHoverCard(props: ProfileHoverCardProps) { + const prefetchProfileQuery = usePrefetchProfileQuery() + const prefetchedProfile = React.useRef(false) + const onPointerMove = () => { + if (!prefetchedProfile.current) { + prefetchedProfile.current = true + prefetchProfileQuery(props.did) + } + } + if (props.disable || isTouchDevice) { return props.children } else { - return + return ( + + + + ) } } @@ -370,7 +389,10 @@ function Inner({ profile: profileShadow, logContext: 'ProfileHoverCard', }) - const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy + const isBlockedUser = + profile.viewer?.blocking || + profile.viewer?.blockedBy || + profile.viewer?.blockingByList const following = formatCount(profile.followsCount || 0) const followers = formatCount(profile.followersCount || 0) const pluralizedFollowers = plural(profile.followersCount || 0, { @@ -401,29 +423,41 @@ function Inner({ /> - {!isMe && ( - - )} + {!isMe && + (isBlockedUser ? ( + + {_(msg`View profile`)} + + ) : ( + + ))} @@ -435,11 +469,23 @@ function Inner({ )} - + - {!blockHide && ( + {isBlockedUser && ( + + {moderation.ui('profileView').alerts.map(cause => ( + + ))} + + )} + + {!isBlockedUser && ( <> ) : undefined} + + {!isMe && + shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( + + + + )} )} diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index d05cab5ab6..315ad0dfda 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -1,10 +1,10 @@ import React from 'react' -import {View} from 'react-native' +import {GestureResponderEvent, View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button, ButtonColor, ButtonText} from '#/components/Button' +import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {Text} from '#/components/Typography' @@ -136,7 +136,7 @@ export function Action({ * Note: The dialog will close automatically when the action is pressed, you * should NOT close the dialog as a side effect of this method. */ - onPress: () => void + onPress: ButtonProps['onPress'] color?: ButtonColor /** * Optional i18n string. If undefined, it will default to "Confirm". @@ -147,9 +147,12 @@ export function Action({ const {_} = useLingui() const {gtMobile} = useBreakpoints() const {close} = Dialog.useDialogContext() - const handleOnPress = React.useCallback(() => { - close(onPress) - }, [close, onPress]) + const handleOnPress = React.useCallback( + (e: GestureResponderEvent) => { + close(() => onPress?.(e)) + }, + [close, onPress], + ) return ( + + ) +} + +function Empty() { + const {_} = useLingui() + const t = useTheme() + const navigation = useNavigation() + const confirmDialogControl = useDialogControl() + const followersDialogControl = useDialogControl() + const errorDialogControl = useDialogControl() + + const [isGenerating, setIsGenerating] = React.useState(false) + + const {mutate: generateStarterPack} = useGenerateStarterPackMutation({ + onSuccess: ({uri}) => { + const parsed = parseStarterPackUri(uri) + if (parsed) { + navigation.push('StarterPack', { + name: parsed.name, + rkey: parsed.rkey, + }) + } + setIsGenerating(false) + }, + onError: e => { + logger.error('Failed to generate starter pack', {safeMessage: e}) + setIsGenerating(false) + if (e.name === 'NOT_ENOUGH_FOLLOWERS') { + followersDialogControl.open() + } else { + errorDialogControl.open() + } + }, + }) + + const generate = () => { + setIsGenerating(true) + generateStarterPack() + } + + return ( + + + + You haven't created a starter pack yet! + + + Starter packs let you easily share your favorite feeds and people with + your friends. + + + + + + + + + + Generate a starter pack + + + + Bluesky will choose a set of recommended accounts from people in + your network. + + + + + { + navigation.navigate('StarterPackWizard') + }} + /> + + + {}} + showCancel={false} + /> + + + ) +} diff --git a/src/components/StarterPack/QrCode.tsx b/src/components/StarterPack/QrCode.tsx new file mode 100644 index 0000000000..08ee03d623 --- /dev/null +++ b/src/components/StarterPack/QrCode.tsx @@ -0,0 +1,119 @@ +import React from 'react' +import {View} from 'react-native' +import QRCode from 'react-native-qrcode-styled' +import ViewShot from 'react-native-view-shot' +import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {isWeb} from 'platform/detection' +import {Logo} from 'view/icons/Logo' +import {Logotype} from 'view/icons/Logotype' +import {useTheme} from '#/alf' +import {atoms as a} from '#/alf' +import {LinearGradientBackground} from '#/components/LinearGradientBackground' +import {Text} from '#/components/Typography' + +interface Props { + starterPack: AppBskyGraphDefs.StarterPackView + link: string +} + +export const QrCode = React.forwardRef(function QrCode( + {starterPack, link}, + ref, +) { + const {record} = starterPack + + if (!AppBskyGraphStarterpack.isRecord(record)) { + return null + } + + return ( + + + + + {record.name} + + + + + Join the conversation + + + + + + + + on + + + + + + + + + + ) +}) + +export function QrCodeInner({link}: {link: string}) { + const t = useTheme() + + return ( + + ) +} diff --git a/src/components/StarterPack/QrCodeDialog.tsx b/src/components/StarterPack/QrCodeDialog.tsx new file mode 100644 index 0000000000..580c6cc7c8 --- /dev/null +++ b/src/components/StarterPack/QrCodeDialog.tsx @@ -0,0 +1,201 @@ +import React from 'react' +import {View} from 'react-native' +import ViewShot from 'react-native-view-shot' +import * as FS from 'expo-file-system' +import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker' +import * as Sharing from 'expo-sharing' +import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {nanoid} from 'nanoid/non-secure' + +import {logger} from '#/logger' +import {saveImageToMediaLibrary} from 'lib/media/manip' +import {logEvent} from 'lib/statsig/statsig' +import {isNative, isWeb} from 'platform/detection' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {DialogControlProps} from '#/components/Dialog' +import {Loader} from '#/components/Loader' +import {QrCode} from '#/components/StarterPack/QrCode' + +export function QrCodeDialog({ + starterPack, + link, + control, +}: { + starterPack: AppBskyGraphDefs.StarterPackView + link?: string + control: DialogControlProps +}) { + const {_} = useLingui() + const [isProcessing, setIsProcessing] = React.useState(false) + + const ref = React.useRef(null) + + const getCanvas = (base64: string): Promise => { + return new Promise(resolve => { + const image = new Image() + image.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = image.width + canvas.height = image.height + + const ctx = canvas.getContext('2d') + ctx?.drawImage(image, 0, 0) + resolve(canvas) + } + image.src = base64 + }) + } + + const onSavePress = async () => { + ref.current?.capture?.().then(async (uri: string) => { + if (isNative) { + const res = await requestMediaLibraryPermissionsAsync() + + if (!res) { + Toast.show( + _( + msg`You must grant access to your photo library to save a QR code`, + ), + ) + return + } + + const filename = `${FS.documentDirectory}/${nanoid(12)}.png` + + // Incase of a FS failure, don't crash the app + try { + await FS.copyAsync({from: uri, to: filename}) + await saveImageToMediaLibrary({uri: filename}) + await FS.deleteAsync(filename) + } catch (e: unknown) { + Toast.show(_(msg`An error occurred while saving the QR code!`)) + logger.error('Failed to save QR code', {error: e}) + return + } + } else { + setIsProcessing(true) + + if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) { + return + } + + const canvas = await getCanvas(uri) + const imgHref = canvas + .toDataURL('image/png') + .replace('image/png', 'image/octet-stream') + + const link = document.createElement('a') + link.setAttribute( + 'download', + `${starterPack.record.name.replaceAll(' ', '_')}_Share_Card.png`, + ) + link.setAttribute('href', imgHref) + link.click() + } + + logEvent('starterPack:share', { + starterPack: starterPack.uri, + shareType: 'qrcode', + qrShareType: 'save', + }) + setIsProcessing(false) + Toast.show( + isWeb + ? _(msg`QR code has been downloaded!`) + : _(msg`QR code saved to your camera roll!`), + ) + control.close() + }) + } + + const onCopyPress = async () => { + setIsProcessing(true) + ref.current?.capture?.().then(async (uri: string) => { + const canvas = await getCanvas(uri) + // @ts-expect-error web only + canvas.toBlob((blob: Blob) => { + const item = new ClipboardItem({'image/png': blob}) + navigator.clipboard.write([item]) + }) + + logEvent('starterPack:share', { + starterPack: starterPack.uri, + shareType: 'qrcode', + qrShareType: 'copy', + }) + Toast.show(_(msg`QR code copied to your clipboard!`)) + setIsProcessing(false) + control.close() + }) + } + + const onSharePress = async () => { + ref.current?.capture?.().then(async (uri: string) => { + control.close(() => { + Sharing.shareAsync(uri, {mimeType: 'image/png', UTI: 'image/png'}).then( + () => { + logEvent('starterPack:share', { + starterPack: starterPack.uri, + shareType: 'qrcode', + qrShareType: 'share', + }) + }, + ) + }) + }) + } + + return ( + + + + + {!link ? ( + + + + ) : ( + <> + + {isProcessing ? ( + + + + ) : ( + + + + + )} + + )} + + + + ) +} diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx new file mode 100644 index 0000000000..23fa10fb39 --- /dev/null +++ b/src/components/StarterPack/ShareDialog.tsx @@ -0,0 +1,180 @@ +import React from 'react' +import {View} from 'react-native' +import * as FS from 'expo-file-system' +import {Image} from 'expo-image' +import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker' +import {AppBskyGraphDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {nanoid} from 'nanoid/non-secure' + +import {logger} from '#/logger' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {saveImageToMediaLibrary} from 'lib/media/manip' +import {shareUrl} from 'lib/sharing' +import {logEvent} from 'lib/statsig/statsig' +import {getStarterPackOgCard} from 'lib/strings/starter-pack' +import {isNative, isWeb} from 'platform/detection' +import * as Toast from 'view/com/util/Toast' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {DialogControlProps} from '#/components/Dialog' +import * as Dialog from '#/components/Dialog' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +interface Props { + starterPack: AppBskyGraphDefs.StarterPackView + link?: string + imageLoaded?: boolean + qrDialogControl: DialogControlProps + control: DialogControlProps +} + +export function ShareDialog(props: Props) { + return ( + + + + ) +} + +function ShareDialogInner({ + starterPack, + link, + imageLoaded, + qrDialogControl, + control, +}: Props) { + const {_} = useLingui() + const t = useTheme() + const {isTabletOrDesktop} = useWebMediaQueries() + + const imageUrl = getStarterPackOgCard(starterPack) + + const onShareLink = async () => { + if (!link) return + shareUrl(link) + logEvent('starterPack:share', { + starterPack: starterPack.uri, + shareType: 'link', + }) + control.close() + } + + const onSave = async () => { + const res = await requestMediaLibraryPermissionsAsync() + + if (!res) { + Toast.show( + _(msg`You must grant access to your photo library to save the image.`), + ) + return + } + + const cachePath = await Image.getCachePathAsync(imageUrl) + const filename = `${FS.documentDirectory}/${nanoid(12)}.png` + + if (!cachePath) { + Toast.show(_(msg`An error occurred while saving the image.`)) + return + } + + try { + await FS.copyAsync({from: cachePath, to: filename}) + await saveImageToMediaLibrary({uri: filename}) + await FS.deleteAsync(filename) + + Toast.show(_(msg`Image saved to your camera roll!`)) + control.close() + } catch (e: unknown) { + Toast.show(_(msg`An error occurred while saving the QR code!`)) + logger.error('Failed to save QR code', {error: e}) + return + } + } + + return ( + <> + + + {!imageLoaded || !link ? ( + + + + ) : ( + + + + Invite people to this starter pack! + + + + Share this starter pack and help people join your community on + Bluesky. + + + + + + + + {isNative && ( + + )} + + + )} + + + ) +} diff --git a/src/components/StarterPack/StarterPackCard.tsx b/src/components/StarterPack/StarterPackCard.tsx new file mode 100644 index 0000000000..ab904d7ffa --- /dev/null +++ b/src/components/StarterPack/StarterPackCard.tsx @@ -0,0 +1,117 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyGraphStarterpack, AtUri} from '@atproto/api' +import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeHandle} from 'lib/strings/handles' +import {useSession} from 'state/session' +import {atoms as a, useTheme} from '#/alf' +import {StarterPack} from '#/components/icons/StarterPack' +import {Link as InternalLink, LinkProps} from '#/components/Link' +import {Text} from '#/components/Typography' + +export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) { + if (!starterPack) return null + return ( + + + + ) +} + +export function Notification({ + starterPack, +}: { + starterPack?: StarterPackViewBasic +}) { + if (!starterPack) return null + return ( + + + + ) +} + +export function Card({ + starterPack, + noIcon, + noDescription, +}: { + starterPack: StarterPackViewBasic + noIcon?: boolean + noDescription?: boolean +}) { + const {record, creator, joinedAllTimeCount} = starterPack + + const {_} = useLingui() + const t = useTheme() + const {currentAccount} = useSession() + + if (!AppBskyGraphStarterpack.isRecord(record)) { + return null + } + + return ( + + + {!noIcon ? : null} + + + {record.name} + + + + Starter pack by{' '} + {creator?.did === currentAccount?.did + ? _(msg`you`) + : `@${sanitizeHandle(creator.handle)}`} + + + + + {!noDescription && record.description ? ( + + {record.description} + + ) : null} + {!!joinedAllTimeCount && joinedAllTimeCount >= 50 && ( + + {joinedAllTimeCount} users have joined! + + )} + + ) +} + +export function Link({ + starterPack, + children, + ...rest +}: { + starterPack: StarterPackViewBasic +} & Omit) { + const {record} = starterPack + const {rkey, handleOrDid} = React.useMemo(() => { + const rkey = new AtUri(starterPack.uri).rkey + const {creator} = starterPack + return {rkey, handleOrDid: creator.handle || creator.did} + }, [starterPack]) + + if (!AppBskyGraphStarterpack.isRecord(record)) { + return null + } + + return ( + + {children} + + ) +} diff --git a/src/components/StarterPack/Wizard/ScreenTransition.tsx b/src/components/StarterPack/Wizard/ScreenTransition.tsx new file mode 100644 index 0000000000..b7cd4e4c1f --- /dev/null +++ b/src/components/StarterPack/Wizard/ScreenTransition.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import {StyleProp, ViewStyle} from 'react-native' +import Animated, { + FadeIn, + FadeOut, + SlideInLeft, + SlideInRight, +} from 'react-native-reanimated' + +import {isWeb} from 'platform/detection' + +export function ScreenTransition({ + direction, + style, + children, +}: { + direction: 'Backward' | 'Forward' + style?: StyleProp + children: React.ReactNode +}) { + const entering = direction === 'Forward' ? SlideInRight : SlideInLeft + + return ( + + {children} + + ) +} diff --git a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx new file mode 100644 index 0000000000..bf250ac354 --- /dev/null +++ b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx @@ -0,0 +1,152 @@ +import React, {useRef} from 'react' +import type {ListRenderItemInfo} from 'react-native' +import {View} from 'react-native' +import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' +import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isWeb} from 'platform/detection' +import {useSession} from 'state/session' +import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State' +import {atoms as a, native, useTheme, web} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import { + WizardFeedCard, + WizardProfileCard, +} from '#/components/StarterPack/Wizard/WizardListCard' +import {Text} from '#/components/Typography' + +function keyExtractor( + item: AppBskyActorDefs.ProfileViewBasic | GeneratorView, + index: number, +) { + return `${item.did}-${index}` +} + +export function WizardEditListDialog({ + control, + state, + dispatch, + moderationOpts, + profile, +}: { + control: Dialog.DialogControlProps + state: WizardState + dispatch: (action: WizardAction) => void + moderationOpts: ModerationOpts + profile: AppBskyActorDefs.ProfileViewBasic +}) { + const {_} = useLingui() + const t = useTheme() + const {currentAccount} = useSession() + + const listRef = useRef(null) + + const getData = () => { + if (state.currentStep === 'Feeds') return state.feeds + + return [ + profile, + ...state.profiles.filter(p => p.did !== currentAccount?.did), + ] + } + + const renderItem = ({item}: ListRenderItemInfo) => + state.currentStep === 'Profiles' ? ( + + ) : ( + + ) + + return ( + + + + + + {state.currentStep === 'Profiles' ? ( + Edit People + ) : ( + Edit Feeds + )} + + + {isWeb && ( + + )} + + + } + stickyHeaderIndices={[0]} + style={[ + web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), + native({ + height: '100%', + paddingHorizontal: 0, + marginTop: 0, + paddingTop: 0, + borderTopLeftRadius: 40, + borderTopRightRadius: 40, + }), + ]} + webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} + keyboardDismissMode="on-drag" + removeClippedSubviews={true} + /> + + ) +} diff --git a/src/components/StarterPack/Wizard/WizardListCard.tsx b/src/components/StarterPack/Wizard/WizardListCard.tsx new file mode 100644 index 0000000000..f1332011d9 --- /dev/null +++ b/src/components/StarterPack/Wizard/WizardListCard.tsx @@ -0,0 +1,182 @@ +import React from 'react' +import {Keyboard, View} from 'react-native' +import { + AppBskyActorDefs, + AppBskyFeedDefs, + moderateFeedGenerator, + moderateProfile, + ModerationOpts, + ModerationUI, +} from '@atproto/api' +import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {DISCOVER_FEED_URI} from 'lib/constants' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {useSession} from 'state/session' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State' +import {atoms as a, useTheme} from '#/alf' +import * as Toggle from '#/components/forms/Toggle' +import {Checkbox} from '#/components/forms/Toggle' +import {Text} from '#/components/Typography' + +function WizardListCard({ + type, + displayName, + subtitle, + onPress, + avatar, + included, + disabled, + moderationUi, +}: { + type: 'user' | 'algo' + profile?: AppBskyActorDefs.ProfileViewBasic + feed?: AppBskyFeedDefs.GeneratorView + displayName: string + subtitle: string + onPress: () => void + avatar?: string + included?: boolean + disabled?: boolean + moderationUi: ModerationUI +}) { + const t = useTheme() + const {_} = useLingui() + + return ( + + + + + {displayName} + + + {subtitle} + + + + + ) +} + +export function WizardProfileCard({ + state, + dispatch, + profile, + moderationOpts, +}: { + state: WizardState + dispatch: (action: WizardAction) => void + profile: AppBskyActorDefs.ProfileViewBasic + moderationOpts: ModerationOpts +}) { + const {currentAccount} = useSession() + + const isMe = profile.did === currentAccount?.did + const included = isMe || state.profiles.some(p => p.did === profile.did) + const disabled = isMe || (!included && state.profiles.length >= 49) + const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar') + const displayName = profile.displayName + ? sanitizeDisplayName(profile.displayName) + : `@${sanitizeHandle(profile.handle)}` + + const onPress = () => { + if (disabled) return + + Keyboard.dismiss() + if (profile.did === currentAccount?.did) return + + if (!included) { + dispatch({type: 'AddProfile', profile}) + } else { + dispatch({type: 'RemoveProfile', profileDid: profile.did}) + } + } + + return ( + + ) +} + +export function WizardFeedCard({ + generator, + state, + dispatch, + moderationOpts, +}: { + generator: GeneratorView + state: WizardState + dispatch: (action: WizardAction) => void + moderationOpts: ModerationOpts +}) { + const isDiscover = generator.uri === DISCOVER_FEED_URI + const included = isDiscover || state.feeds.some(f => f.uri === generator.uri) + const disabled = isDiscover || (!included && state.feeds.length >= 3) + const moderationUi = moderateFeedGenerator(generator, moderationOpts).ui( + 'avatar', + ) + + const onPress = () => { + if (disabled) return + + Keyboard.dismiss() + if (included) { + dispatch({type: 'RemoveFeed', feedUri: generator.uri}) + } else { + dispatch({type: 'AddFeed', feed: generator}) + } + } + + return ( + + ) +} diff --git a/src/components/dms/LeaveConvoPrompt.tsx b/src/components/dms/LeaveConvoPrompt.tsx index 1c42dbca04..7abc76f34c 100644 --- a/src/components/dms/LeaveConvoPrompt.tsx +++ b/src/components/dms/LeaveConvoPrompt.tsx @@ -49,7 +49,7 @@ export function LeaveConvoPrompt({ )} confirmButtonCta={_(msg`Leave`)} confirmButtonColor="negative" - onConfirm={leaveConvo} + onConfirm={() => leaveConvo()} /> ) } diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx index e231ac5baf..c916f4efce 100644 --- a/src/components/forms/DateField/index.tsx +++ b/src/components/forms/DateField/index.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {View} from 'react-native' +import {Keyboard, View} from 'react-native' import DatePicker from 'react-native-date-picker' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -49,7 +49,10 @@ export function DateField({ { + Keyboard.dismiss() + control.open() + }} isInvalid={isInvalid} accessibilityHint={accessibilityHint} /> diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 73a660ea6c..d513a6db99 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -140,6 +140,7 @@ export function createInput(Component: typeof TextInput) { onChangeText, isInvalid, inputRef, + style, ...rest }: InputProps) { const t = useTheme() @@ -196,9 +197,17 @@ export function createInput(Component: typeof TextInput) { textAlignVertical: rest.multiline ? 'top' : undefined, minHeight: rest.multiline ? 80 : undefined, }, + // fix for autofill styles covering border + web({ + paddingTop: 12, + paddingBottom: 12, + marginTop: 2, + marginBottom: 2, + }), android({ paddingBottom: 16, }), + style, ]} /> diff --git a/src/components/hooks/useStarterPackEntry.native.ts b/src/components/hooks/useStarterPackEntry.native.ts new file mode 100644 index 0000000000..b6e4ab05b1 --- /dev/null +++ b/src/components/hooks/useStarterPackEntry.native.ts @@ -0,0 +1,68 @@ +import React from 'react' + +import { + createStarterPackLinkFromAndroidReferrer, + httpStarterPackUriToAtUri, +} from 'lib/strings/starter-pack' +import {isAndroid} from 'platform/detection' +import {useHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' +import {useSetActiveStarterPack} from 'state/shell/starter-pack' +import {DevicePrefs, Referrer} from '../../../modules/expo-bluesky-swiss-army' + +export function useStarterPackEntry() { + const [ready, setReady] = React.useState(false) + const setActiveStarterPack = useSetActiveStarterPack() + const hasCheckedForStarterPack = useHasCheckedForStarterPack() + + React.useEffect(() => { + if (ready) return + + // On Android, we cannot clear the referral link. It gets stored for 90 days and all we can do is query for it. So, + // let's just ensure we never check again after the first time. + if (hasCheckedForStarterPack) { + setReady(true) + return + } + + // Safety for Android. Very unlike this could happen, but just in case. The response should be nearly immediate + const timeout = setTimeout(() => { + setReady(true) + }, 500) + + ;(async () => { + let uri: string | null | undefined + + if (isAndroid) { + const res = await Referrer.getGooglePlayReferrerInfoAsync() + + if (res && res.installReferrer) { + uri = createStarterPackLinkFromAndroidReferrer(res.installReferrer) + } + } else { + const res = await DevicePrefs.getStringValueAsync( + 'starterPackUri', + true, + ) + + if (res) { + uri = httpStarterPackUriToAtUri(res) + DevicePrefs.setStringValueAsync('starterPackUri', null, true) + } + } + + if (uri) { + setActiveStarterPack({ + uri, + }) + } + + setReady(true) + })() + + return () => { + clearTimeout(timeout) + } + }, [ready, setActiveStarterPack, hasCheckedForStarterPack]) + + return ready +} diff --git a/src/components/hooks/useStarterPackEntry.ts b/src/components/hooks/useStarterPackEntry.ts new file mode 100644 index 0000000000..dba801e093 --- /dev/null +++ b/src/components/hooks/useStarterPackEntry.ts @@ -0,0 +1,29 @@ +import React from 'react' + +import {httpStarterPackUriToAtUri} from 'lib/strings/starter-pack' +import {useSetActiveStarterPack} from 'state/shell/starter-pack' + +export function useStarterPackEntry() { + const [ready, setReady] = React.useState(false) + + const setActiveStarterPack = useSetActiveStarterPack() + + React.useEffect(() => { + const href = window.location.href + const atUri = httpStarterPackUriToAtUri(href) + + if (atUri) { + const url = new URL(href) + // Determines if an App Clip is loading this landing page + const isClip = url.searchParams.get('clip') === 'true' + setActiveStarterPack({ + uri: atUri, + isClip, + }) + } + + setReady(true) + }, [setActiveStarterPack]) + + return ready +} diff --git a/src/components/icons/Arrow.tsx b/src/components/icons/Arrow.tsx index eb753e5493..d6fb635e96 100644 --- a/src/components/icons/Arrow.tsx +++ b/src/components/icons/Arrow.tsx @@ -7,3 +7,7 @@ export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z', }) + +export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z', +}) diff --git a/src/components/icons/Newskie.tsx b/src/components/icons/Newskie.tsx new file mode 100644 index 0000000000..ddbb33201e --- /dev/null +++ b/src/components/icons/Newskie.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Newskie = createSinglePathSVG({ + path: 'M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021 2.783 0 5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.872.872 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z', +}) diff --git a/src/components/icons/QrCode.tsx b/src/components/icons/QrCode.tsx new file mode 100644 index 0000000000..e841071f70 --- /dev/null +++ b/src/components/icons/QrCode.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const QrCode_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4ZM13 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5ZM14 13a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z', +}) diff --git a/src/components/icons/StarterPack.tsx b/src/components/icons/StarterPack.tsx new file mode 100644 index 0000000000..8c678bca47 --- /dev/null +++ b/src/components/icons/StarterPack.tsx @@ -0,0 +1,8 @@ +import {createMultiPathSVG} from './TEMPLATE' + +export const StarterPack = createMultiPathSVG({ + paths: [ + 'M11.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672c.734-.197 1.17-.951.973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z', + 'M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z', + ], +}) diff --git a/src/components/icons/TEMPLATE.tsx b/src/components/icons/TEMPLATE.tsx index f49c4280bb..47a5c36b2a 100644 --- a/src/components/icons/TEMPLATE.tsx +++ b/src/components/icons/TEMPLATE.tsx @@ -30,7 +30,7 @@ export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef( export function createSinglePathSVG({path}: {path: string}) { return React.forwardRef(function LogoImpl(props, ref) { - const {fill, size, style, ...rest} = useCommonSVGProps(props) + const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) return ( + {gradient} ) }) } + +export function createMultiPathSVG({paths}: {paths: string[]}) { + return React.forwardRef(function LogoImpl(props, ref) { + const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) + + return ( + + {gradient} + {paths.map((path, i) => ( + + ))} + + ) + }) +} diff --git a/src/components/icons/common.ts b/src/components/icons/common.ts deleted file mode 100644 index 669c157f51..0000000000 --- a/src/components/icons/common.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {StyleSheet, TextProps} from 'react-native' -import type {PathProps, SvgProps} from 'react-native-svg' - -import {tokens} from '#/alf' - -export type Props = { - fill?: PathProps['fill'] - style?: TextProps['style'] - size?: keyof typeof sizes -} & Omit - -export const sizes = { - xs: 12, - sm: 16, - md: 20, - lg: 24, - xl: 28, -} - -export function useCommonSVGProps(props: Props) { - const {fill, size, ...rest} = props - const style = StyleSheet.flatten(rest.style) - const _fill = fill || style?.color || tokens.color.blue_500 - const _size = Number(size ? sizes[size] : rest.width || sizes.md) - - return { - fill: _fill, - size: _size, - style, - ...rest, - } -} diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx new file mode 100644 index 0000000000..662718338c --- /dev/null +++ b/src/components/icons/common.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import {StyleSheet, TextProps} from 'react-native' +import type {PathProps, SvgProps} from 'react-native-svg' +import {Defs, LinearGradient, Stop} from 'react-native-svg' +import {nanoid} from 'nanoid/non-secure' + +import {tokens} from '#/alf' + +export type Props = { + fill?: PathProps['fill'] + style?: TextProps['style'] + size?: keyof typeof sizes + gradient?: keyof typeof tokens.gradients +} & Omit + +export const sizes = { + xs: 12, + sm: 16, + md: 20, + lg: 24, + xl: 28, +} + +export function useCommonSVGProps(props: Props) { + const {fill, size, gradient, ...rest} = props + const style = StyleSheet.flatten(rest.style) + const _size = Number(size ? sizes[size] : rest.width || sizes.md) + let _fill = fill || style?.color || tokens.color.blue_500 + let gradientDef = null + + if (gradient && tokens.gradients[gradient]) { + const id = gradient + '_' + nanoid() + const config = tokens.gradients[gradient] + _fill = `url(#${id})` + gradientDef = ( + + + {config.values.map(([stop, fill]) => ( + + ))} + + + ) + } + + return { + fill: _fill, + size: _size, + style, + gradient: gradientDef, + ...rest, + } +} diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx index 0b48b51d1d..ec7529a4ff 100644 --- a/src/components/moderation/PostAlerts.tsx +++ b/src/components/moderation/PostAlerts.tsx @@ -92,6 +92,8 @@ function PostLabel({ ) : ( diff --git a/src/components/moderation/PostHider.tsx b/src/components/moderation/PostHider.tsx index 8a64742978..b6fb174528 100644 --- a/src/components/moderation/PostHider.tsx +++ b/src/components/moderation/PostHider.tsx @@ -1,6 +1,6 @@ import React, {ComponentProps} from 'react' import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {AppBskyActorDefs, ModerationUI} from '@atproto/api' +import {AppBskyActorDefs, ModerationCause, ModerationUI} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' @@ -45,7 +45,8 @@ export function PostHider({ const [override, setOverride] = React.useState(false) const control = useModerationDetailsDialogControl() const blur = - modui.blurs[0] || (interpretFilterAsBlur ? modui.filters[0] : undefined) + modui.blurs[0] || + (interpretFilterAsBlur ? getBlurrableFilter(modui) : undefined) const desc = useModerationCauseDescription(blur) const onBeforePress = React.useCallback(() => { @@ -134,6 +135,13 @@ export function PostHider({ ) } +function getBlurrableFilter(modui: ModerationUI): ModerationCause | undefined { + // moderation causes get "downgraded" when they originate from embedded content + // a downgraded cause should *only* drive filtering in feeds, so we want to look + // for filters that arent downgraded + return modui.filters.find(filter => !filter.downgraded) +} + const styles = StyleSheet.create({ child: { borderWidth: 0, diff --git a/src/components/moderation/ProfileHeaderAlerts.tsx b/src/components/moderation/ProfileHeaderAlerts.tsx index 287a0bddec..4b48b142d2 100644 --- a/src/components/moderation/ProfileHeaderAlerts.tsx +++ b/src/components/moderation/ProfileHeaderAlerts.tsx @@ -43,7 +43,13 @@ export function ProfileHeaderAlerts({ ) } -function ProfileLabel({cause}: {cause: ModerationCause}) { +export function ProfileLabel({ + cause, + disableDetailsDialog, +}: { + cause: ModerationCause + disableDetailsDialog?: boolean +}) { const t = useTheme() const control = useModerationDetailsDialogControl() const desc = useModerationCauseDescription(cause) @@ -51,6 +57,7 @@ function ProfileLabel({cause}: {cause: ModerationCause}) { return ( <> - + {!disableDetailsDialog && ( + + )} ) } diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts index cdf535dec2..720495ea1d 100644 --- a/src/lib/analytics/types.ts +++ b/src/lib/analytics/types.ts @@ -32,6 +32,8 @@ export type TrackPropertiesMap = { 'Post:ThreadMute': {} // CAN BE SERVER 'Post:ThreadUnmute': {} // CAN BE SERVER 'Post:Reply': {} // CAN BE SERVER + 'Post:EditThreadgateOpened': {} + 'Post:ThreadgateEdited': {} // PROFILE events 'Profile:Follow': { username: string diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index dfaae2e018..5b1c998cb8 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -270,7 +270,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) { return res } -async function createThreadgate( +export async function createThreadgate( agent: BskyAgent, postUri: string, threadgate: ThreadgateSetting[], @@ -296,10 +296,17 @@ async function createThreadgate( } const postUrip = new AtUri(postUri) - await agent.api.app.bsky.feed.threadgate.create( - {repo: agent.session!.did, rkey: postUrip.rkey}, - {post: postUri, createdAt: new Date().toISOString(), allow}, - ) + await agent.api.com.atproto.repo.putRecord({ + repo: agent.session!.did, + collection: 'app.bsky.feed.threadgate', + rkey: postUrip.rkey, + record: { + $type: 'app.bsky.feed.threadgate', + post: postUri, + allow, + createdAt: new Date().toISOString(), + }, + }) } // helpers diff --git a/src/lib/browser.native.ts b/src/lib/browser.native.ts index fb9be56f10..8e045138c8 100644 --- a/src/lib/browser.native.ts +++ b/src/lib/browser.native.ts @@ -1,3 +1,4 @@ export const isSafari = false export const isFirefox = false export const isTouchDevice = true +export const isAndroidWeb = false diff --git a/src/lib/browser.ts b/src/lib/browser.ts index d178a9a64e..08c43fbfdf 100644 --- a/src/lib/browser.ts +++ b/src/lib/browser.ts @@ -5,3 +5,5 @@ export const isSafari = /^((?!chrome|android).)*safari/i.test( export const isFirefox = /firefox|fxios/i.test(navigator.userAgent) export const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 1 +export const isAndroidWeb = + /android/i.test(navigator.userAgent) && isTouchDevice diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 05d1591f56..e0b8998007 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -84,6 +84,7 @@ export const createHitslop = (size: number): Insets => ({ export const HITSLOP_10 = createHitslop(10) export const HITSLOP_20 = createHitslop(20) export const HITSLOP_30 = createHitslop(30) +export const POST_CTRL_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10} export const BACK_HITSLOP = HITSLOP_30 export const MAX_POST_LINES = 25 diff --git a/src/lib/generate-starterpack.ts b/src/lib/generate-starterpack.ts new file mode 100644 index 0000000000..64d30a954f --- /dev/null +++ b/src/lib/generate-starterpack.ts @@ -0,0 +1,164 @@ +import { + AppBskyActorDefs, + AppBskyGraphGetStarterPack, + BskyAgent, + Facet, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation} from '@tanstack/react-query' + +import {until} from 'lib/async/until' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {enforceLen} from 'lib/strings/helpers' +import {useAgent} from 'state/session' + +export const createStarterPackList = async ({ + name, + description, + descriptionFacets, + profiles, + agent, +}: { + name: string + description?: string + descriptionFacets?: Facet[] + profiles: AppBskyActorDefs.ProfileViewBasic[] + agent: BskyAgent +}): Promise<{uri: string; cid: string}> => { + if (profiles.length === 0) throw new Error('No profiles given') + + const list = await agent.app.bsky.graph.list.create( + {repo: agent.session!.did}, + { + name, + description, + descriptionFacets, + avatar: undefined, + createdAt: new Date().toISOString(), + purpose: 'app.bsky.graph.defs#referencelist', + }, + ) + if (!list) throw new Error('List creation failed') + await agent.com.atproto.repo.applyWrites({ + repo: agent.session!.did, + writes: [ + createListItem({did: agent.session!.did, listUri: list.uri}), + ].concat( + profiles + // Ensure we don't have ourselves in this list twice + .filter(p => p.did !== agent.session!.did) + .map(p => createListItem({did: p.did, listUri: list.uri})), + ), + }) + + return list +} + +export function useGenerateStarterPackMutation({ + onSuccess, + onError, +}: { + onSuccess: ({uri, cid}: {uri: string; cid: string}) => void + onError: (e: Error) => void +}) { + const {_} = useLingui() + const agent = useAgent() + const starterPackString = _(msg`Starter Pack`) + + return useMutation<{uri: string; cid: string}, Error, void>({ + mutationFn: async () => { + let profile: AppBskyActorDefs.ProfileViewBasic | undefined + let profiles: AppBskyActorDefs.ProfileViewBasic[] | undefined + + await Promise.all([ + (async () => { + profile = ( + await agent.app.bsky.actor.getProfile({ + actor: agent.session!.did, + }) + ).data + })(), + (async () => { + profiles = ( + await agent.app.bsky.actor.searchActors({ + q: encodeURIComponent('*'), + limit: 49, + }) + ).data.actors.filter(p => p.viewer?.following) + })(), + ]) + + if (!profile || !profiles) { + throw new Error('ERROR_DATA') + } + + // We include ourselves when we make the list + if (profiles.length < 7) { + throw new Error('NOT_ENOUGH_FOLLOWERS') + } + + const displayName = enforceLen( + profile.displayName + ? sanitizeDisplayName(profile.displayName) + : `@${sanitizeHandle(profile.handle)}`, + 25, + true, + ) + const starterPackName = `${displayName}'s ${starterPackString}` + + const list = await createStarterPackList({ + name: starterPackName, + profiles, + agent, + }) + + return await agent.app.bsky.graph.starterpack.create( + { + repo: agent.session!.did, + }, + { + name: starterPackName, + list: list.uri, + createdAt: new Date().toISOString(), + }, + ) + }, + onSuccess: async data => { + await whenAppViewReady(agent, data.uri, v => { + return typeof v?.data.starterPack.uri === 'string' + }) + onSuccess(data) + }, + onError: error => { + onError(error) + }, + }) +} + +function createListItem({did, listUri}: {did: string; listUri: string}) { + return { + $type: 'com.atproto.repo.applyWrites#create', + collection: 'app.bsky.graph.listitem', + value: { + $type: 'app.bsky.graph.listitem', + subject: did, + list: listUri, + createdAt: new Date().toISOString(), + }, + } +} + +async function whenAppViewReady( + agent: BskyAgent, + uri: string, + fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean, +) { + await until( + 5, // 5 tries + 1e3, // 1s delay between tries + fn, + () => agent.app.bsky.graph.getStarterPack({starterPack: uri}), + ) +} diff --git a/src/lib/hooks/__tests__/useTimeAgo.test.ts b/src/lib/hooks/__tests__/useTimeAgo.test.ts new file mode 100644 index 0000000000..e74f9c62db --- /dev/null +++ b/src/lib/hooks/__tests__/useTimeAgo.test.ts @@ -0,0 +1,102 @@ +import {describe, expect, it} from '@jest/globals' +import {MessageDescriptor} from '@lingui/core' +import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns' + +import {dateDiff} from '../useTimeAgo' + +const lingui: any = (obj: MessageDescriptor) => obj.message + +const base = new Date('2024-06-17T00:00:00Z') + +describe('dateDiff', () => { + it(`works with numbers`, () => { + expect(dateDiff(subDays(base, 3), Number(base), {lingui})).toEqual('3d') + }) + it(`works with strings`, () => { + expect(dateDiff(subDays(base, 3), base.toString(), {lingui})).toEqual('3d') + }) + it(`works with dates`, () => { + expect(dateDiff(subDays(base, 3), base, {lingui})).toEqual('3d') + }) + + it(`equal values return now`, () => { + expect(dateDiff(base, base, {lingui})).toEqual('now') + }) + it(`future dates return now`, () => { + expect(dateDiff(addDays(base, 3), base, {lingui})).toEqual('now') + }) + + it(`values < 5 seconds ago return now`, () => { + const then = subSeconds(base, 4) + expect(dateDiff(then, base, {lingui})).toEqual('now') + }) + it(`values >= 5 seconds ago return seconds`, () => { + const then = subSeconds(base, 5) + expect(dateDiff(then, base, {lingui})).toEqual('5s') + }) + + it(`values < 1 min return seconds`, () => { + const then = subSeconds(base, 59) + expect(dateDiff(then, base, {lingui})).toEqual('59s') + }) + it(`values >= 1 min return minutes`, () => { + const then = subSeconds(base, 60) + expect(dateDiff(then, base, {lingui})).toEqual('1m') + }) + it(`minutes round down`, () => { + const then = subSeconds(base, 119) + expect(dateDiff(then, base, {lingui})).toEqual('1m') + }) + + it(`values < 1 hour return minutes`, () => { + const then = subMinutes(base, 59) + expect(dateDiff(then, base, {lingui})).toEqual('59m') + }) + it(`values >= 1 hour return hours`, () => { + const then = subMinutes(base, 60) + expect(dateDiff(then, base, {lingui})).toEqual('1h') + }) + it(`hours round down`, () => { + const then = subMinutes(base, 119) + expect(dateDiff(then, base, {lingui})).toEqual('1h') + }) + + it(`values < 1 day return hours`, () => { + const then = subHours(base, 23) + expect(dateDiff(then, base, {lingui})).toEqual('23h') + }) + it(`values >= 1 day return days`, () => { + const then = subHours(base, 24) + expect(dateDiff(then, base, {lingui})).toEqual('1d') + }) + it(`days round down`, () => { + const then = subHours(base, 47) + expect(dateDiff(then, base, {lingui})).toEqual('1d') + }) + + it(`values < 30 days return days`, () => { + const then = subDays(base, 29) + expect(dateDiff(then, base, {lingui})).toEqual('29d') + }) + it(`values >= 30 days return months`, () => { + const then = subDays(base, 30) + expect(dateDiff(then, base, {lingui})).toEqual('1mo') + }) + it(`months round down`, () => { + const then = subDays(base, 59) + expect(dateDiff(then, base, {lingui})).toEqual('1mo') + }) + it(`values are rounded by increments of 30`, () => { + const then = subDays(base, 61) + expect(dateDiff(then, base, {lingui})).toEqual('2mo') + }) + + it(`values < 360 days return months`, () => { + const then = subDays(base, 359) + expect(dateDiff(then, base, {lingui})).toEqual('11mo') + }) + it(`values >= 360 days return the earlier value`, () => { + const then = subDays(base, 360) + expect(dateDiff(then, base, {lingui})).toEqual(then.toLocaleDateString()) + }) +}) diff --git a/src/lib/hooks/useBottomBarOffset.ts b/src/lib/hooks/useBottomBarOffset.ts new file mode 100644 index 0000000000..945c980620 --- /dev/null +++ b/src/lib/hooks/useBottomBarOffset.ts @@ -0,0 +1,14 @@ +import {useSafeAreaInsets} from 'react-native-safe-area-context' + +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {clamp} from 'lib/numbers' +import {isWeb} from 'platform/detection' + +export function useBottomBarOffset(modifier: number = 0) { + const {isTabletOrDesktop} = useWebMediaQueries() + const {bottom: bottomInset} = useSafeAreaInsets() + return ( + (isWeb && isTabletOrDesktop ? 0 : clamp(60 + bottomInset, 60, 75)) + + modifier + ) +} diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts index 347062bebe..e4e7e14744 100644 --- a/src/lib/hooks/useNotificationHandler.ts +++ b/src/lib/hooks/useNotificationHandler.ts @@ -26,6 +26,7 @@ type NotificationReason = | 'reply' | 'quote' | 'chat-message' + | 'starterpack-joined' type NotificationPayload = | { @@ -142,6 +143,7 @@ export function useNotificationsHandler() { case 'mention': case 'quote': case 'reply': + case 'starterpack-joined': resetToTab('NotificationsTab') break // TODO implement these after we have an idea of how to handle each individual case diff --git a/src/lib/hooks/useTimeAgo.ts b/src/lib/hooks/useTimeAgo.ts new file mode 100644 index 0000000000..efcb4754bb --- /dev/null +++ b/src/lib/hooks/useTimeAgo.ts @@ -0,0 +1,86 @@ +import {useCallback} from 'react' +import {msg, plural} from '@lingui/macro' +import {I18nContext, useLingui} from '@lingui/react' +import {differenceInSeconds} from 'date-fns' + +export type TimeAgoOptions = { + lingui: I18nContext['_'] + format?: 'long' | 'short' +} + +export function useGetTimeAgo() { + const {_} = useLingui() + return useCallback( + ( + earlier: number | string | Date, + later: number | string | Date, + options?: Omit, + ) => { + return dateDiff(earlier, later, {lingui: _, format: options?.format}) + }, + [_], + ) +} + +const NOW = 5 +const MINUTE = 60 +const HOUR = MINUTE * 60 +const DAY = HOUR * 24 +const MONTH_30 = DAY * 30 + +/** + * Returns the difference between `earlier` and `later` dates, formatted as a + * natural language string. + * + * - All month are considered exactly 30 days. + * - Dates assume `earlier` <= `later`, and will otherwise return 'now'. + * - Differences >= 360 days are returned as the "M/D/YYYY" string + * - All values round down + */ +export function dateDiff( + earlier: number | string | Date, + later: number | string | Date, + options: TimeAgoOptions, +): string { + const _ = options.lingui + const format = options?.format || 'short' + const long = format === 'long' + const diffSeconds = differenceInSeconds(new Date(later), new Date(earlier)) + + if (diffSeconds < NOW) { + return _(msg`now`) + } else if (diffSeconds < MINUTE) { + return `${diffSeconds}${ + long ? ` ${plural(diffSeconds, {one: 'second', other: 'seconds'})}` : 's' + }` + } else if (diffSeconds < HOUR) { + const diff = Math.floor(diffSeconds / MINUTE) + return `${diff}${ + long ? ` ${plural(diff, {one: 'minute', other: 'minutes'})}` : 'm' + }` + } else if (diffSeconds < DAY) { + const diff = Math.floor(diffSeconds / HOUR) + return `${diff}${ + long ? ` ${plural(diff, {one: 'hour', other: 'hours'})}` : 'h' + }` + } else if (diffSeconds < MONTH_30) { + const diff = Math.floor(diffSeconds / DAY) + return `${diff}${ + long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd' + }` + } else { + const diff = Math.floor(diffSeconds / MONTH_30) + if (diff < 12) { + return `${diff}${ + long ? ` ${plural(diff, {one: 'month', other: 'months'})}` : 'mo' + }` + } else { + const str = new Date(earlier).toLocaleDateString() + + if (long) { + return _(msg`on ${str}`) + } + return str + } + } +} diff --git a/src/lib/moderation/create-sanitized-display-name.ts b/src/lib/moderation/create-sanitized-display-name.ts new file mode 100644 index 0000000000..16135b2745 --- /dev/null +++ b/src/lib/moderation/create-sanitized-display-name.ts @@ -0,0 +1,21 @@ +import {AppBskyActorDefs} from '@atproto/api' + +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' + +export function createSanitizedDisplayName( + profile: + | AppBskyActorDefs.ProfileViewBasic + | AppBskyActorDefs.ProfileViewDetailed, + noAt = false, +) { + if (profile.displayName != null && profile.displayName !== '') { + return sanitizeDisplayName(profile.displayName) + } else { + let sanitizedHandle = sanitizeHandle(profile.handle) + if (!noAt) { + sanitizedHandle = `@${sanitizedHandle}` + } + return sanitizedHandle + } +} diff --git a/src/lib/moderation/useReportOptions.ts b/src/lib/moderation/useReportOptions.ts index 54b727b76b..91656857e4 100644 --- a/src/lib/moderation/useReportOptions.ts +++ b/src/lib/moderation/useReportOptions.ts @@ -13,6 +13,7 @@ interface ReportOptions { account: ReportOption[] post: ReportOption[] list: ReportOption[] + starterpack: ReportOption[] feedgen: ReportOption[] other: ReportOption[] convoMessage: ReportOption[] @@ -94,6 +95,14 @@ export function useReportOptions(): ReportOptions { }, ...common, ], + starterpack: [ + { + reason: ComAtprotoModerationDefs.REASONVIOLATION, + title: _(msg`Name or Description Violates Community Standards`), + description: _(msg`Terms used violate community standards`), + }, + ...common, + ], feedgen: [ { reason: ComAtprotoModerationDefs.REASONVIOLATION, diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts index 9dfdab909b..56b716677b 100644 --- a/src/lib/routes/links.ts +++ b/src/lib/routes/links.ts @@ -1,3 +1,5 @@ +import {AppBskyGraphDefs, AtUri} from '@atproto/api' + import {isInvalidHandle} from 'lib/strings/handles' export function makeProfileLink( @@ -35,3 +37,18 @@ export function makeSearchLink(props: {query: string; from?: 'me' | string}) { props.query + (props.from ? ` from:${props.from}` : ''), )}` } + +export function makeStarterPackLink( + starterPackOrName: + | AppBskyGraphDefs.StarterPackViewBasic + | AppBskyGraphDefs.StarterPackView + | string, + rkey?: string, +) { + if (typeof starterPackOrName === 'string') { + return `https://bsky.app/start/${starterPackOrName}/${rkey}` + } else { + const uriRkey = new AtUri(starterPackOrName.uri).rkey + return `https://bsky.app/start/${starterPackOrName.creator.handle}/${uriRkey}` + } +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 403c2bb675..8a173b6756 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -42,6 +42,12 @@ export type CommonNavigatorParams = { MessagesConversation: {conversation: string; embed?: string} MessagesSettings: undefined Feeds: undefined + Start: {name: string; rkey: string} + StarterPack: {name: string; rkey: string; new?: boolean} + StarterPackWizard: undefined + StarterPackEdit: { + rkey?: string + } } export type BottomTabNavigatorParams = CommonNavigatorParams & { @@ -93,6 +99,12 @@ export type AllNavigatorParams = CommonNavigatorParams & { Hashtag: {tag: string; author?: string} MessagesTab: undefined Messages: {animation?: 'push' | 'pop'} + Start: {name: string; rkey: string} + StarterPack: {name: string; rkey: string; new?: boolean} + StarterPackWizard: undefined + StarterPackEdit: { + rkey?: string + } } // NOTE diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 9939f60c9c..07ed8c0ca7 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -53,7 +53,14 @@ export type LogEvents = { } 'onboarding:moderation:nextPressed': {} 'onboarding:profile:nextPressed': {} - 'onboarding:finished:nextPressed': {} + 'onboarding:finished:nextPressed': { + usedStarterPack: boolean + starterPackName?: string + starterPackCreator?: string + starterPackUri?: string + profilesFollowed: number + feedsPinned: number + } 'onboarding:finished:avatarResult': { avatarResult: 'default' | 'created' | 'uploaded' } @@ -61,7 +68,12 @@ export type LogEvents = { feedUrl: string feedType: string index: number - reason: 'focus' | 'tabbar-click' | 'pager-swipe' | 'desktop-sidebar-click' + reason: + | 'focus' + | 'tabbar-click' + | 'pager-swipe' + | 'desktop-sidebar-click' + | 'starter-pack-initial-feed' } 'feed:endReached:sampled': { feedUrl: string @@ -73,6 +85,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': {} @@ -103,6 +131,8 @@ export type LogEvents = { 'post:unrepost': { logContext: 'FeedItem' | 'PostThreadItem' | 'Post' } + 'post:mute': {} + 'post:unmute': {} 'profile:follow': { didBecomeMutual: boolean | undefined followeeClout: number | undefined @@ -116,6 +146,7 @@ export type LogEvents = { | 'ProfileMenu' | 'ProfileHoverCard' | 'AvatarButton' + | 'StarterPackProfilesList' } 'profile:unfollow': { logContext: @@ -128,6 +159,7 @@ export type LogEvents = { | 'ProfileHoverCard' | 'Chat' | 'AvatarButton' + | 'StarterPackProfilesList' } 'chat:create': { logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' @@ -139,6 +171,23 @@ export type LogEvents = { | 'ChatsList' | 'SendViaChatDialog' } + 'starterPack:share': { + starterPack: string + shareType: 'link' | 'qrcode' + qrShareType?: 'save' | 'copy' | 'share' + } + 'starterPack:followAll': { + logContext: 'StarterPackProfilesList' | 'Onboarding' + starterPack: string + count: number + } + 'starterPack:delete': {} + 'starterPack:create': { + setName: boolean + setDescription: boolean + profilesCount: number + feedsCount: number + } 'test:all:always': {} 'test:all:sometimes': {} diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 4481935f77..bf2484ccb9 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -1,5 +1,8 @@ export type Gate = // Keep this alphabetic please. + | 'debug_show_feedcontext' + | 'native_pwi_disabled' | 'request_notifications_permission_after_onboarding_v2' | 'show_avi_follow_button' | 'show_follow_back_label_v2' + | 'starter_packs_enabled' diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx index 166d468a1b..94a1e63d0e 100644 --- a/src/lib/statsig/statsig.tsx +++ b/src/lib/statsig/statsig.tsx @@ -14,6 +14,8 @@ import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback' import {LogEvents} from './events' import {Gate} from './gates' +const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV' + type StatsigUser = { userID: string | undefined // TODO: Remove when enough users have custom.platform: @@ -26,6 +28,8 @@ type StatsigUser = { bundleDate: number refSrc: string refUrl: string + referrer: string + referrerHostname: string appLanguage: string contentLanguages: string[] } @@ -33,12 +37,29 @@ type StatsigUser = { let refSrc = '' let refUrl = '' +let referrer = '' +let referrerHostname = '' if (isWeb && typeof window !== 'undefined') { const params = new URLSearchParams(window.location.search) refSrc = params.get('ref_src') ?? '' refUrl = decodeURIComponent(params.get('ref_url') ?? '') } +if ( + isWeb && + typeof document !== 'undefined' && + document != null && + document.referrer +) { + try { + const url = new URL(document.referrer) + if (url.hostname !== 'bsky.app') { + referrer = document.referrer + referrerHostname = url.hostname + } + } catch {} +} + export type {LogEvents} function createStatsigOptions(prefetchUsers: StatsigUser[]) { @@ -94,6 +115,9 @@ const DOWNSAMPLED_EVENTS: Set = 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 @@ -198,6 +222,8 @@ function toStatsigUser(did: string | undefined): StatsigUser { custom: { refSrc, refUrl, + referrer, + referrerHostname, platform: Platform.OS as 'ios' | 'android' | 'web', bundleIdentifier: BUNDLE_IDENTIFIER, bundleDate: BUNDLE_DATE, @@ -230,7 +256,7 @@ AppState.addEventListener('change', (state: AppStateStatus) => { }) export async function tryFetchGates( - did: string, + did: string | undefined, strategy: 'prefer-low-latency' | 'prefer-fresh-gates', ) { try { @@ -254,6 +280,10 @@ export async function tryFetchGates( } } +export function initialize() { + return Statsig.initialize(SDK_KEY, null, createStatsigOptions([])) +} + export function Provider({children}: {children: React.ReactNode}) { const {currentAccount, accounts} = useSession() const did = currentAccount?.did @@ -299,7 +329,7 @@ export function Provider({children}: {children: React.ReactNode}) { = 0.9) { - months = Math.ceil(months) - } else { - months = Math.floor(months) - } - - if (months < 12) { - return `${months}mo` - } else { - return new Date(ts).toLocaleDateString() - } - } -} - export function niceDate(date: number | string | Date) { const d = new Date(date) return `${d.toLocaleDateString('en-us', { @@ -61,3 +19,14 @@ export function getAge(birthDate: Date): number { } return age } + +/** + * Compares two dates by year, month, and day only + */ +export function simpleAreDatesEqual(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ) +} diff --git a/src/locale/i18n.ts b/src/locale/i18n.ts index 9f75f83f3d..332b9309aa 100644 --- a/src/locale/i18n.ts +++ b/src/locale/i18n.ts @@ -1,6 +1,10 @@ -import '@formatjs/intl-locale/polyfill' -import '@formatjs/intl-pluralrules/polyfill' +// Don't remove -force from these because detection is VERY slow on low-end Android. +// https://github.com/formatjs/formatjs/issues/4463#issuecomment-2176070577 +import '@formatjs/intl-locale/polyfill-force' +import '@formatjs/intl-pluralrules/polyfill-force' +import '@formatjs/intl-numberformat/polyfill-force' import '@formatjs/intl-pluralrules/locale-data/en' +import '@formatjs/intl-numberformat/locale-data/en' import {useEffect} from 'react' import {i18n} from '@lingui/core' diff --git a/src/locale/locales/it/messages.po b/src/locale/locales/it/messages.po index e2c2bdb3ac..59660ca2e5 100644 --- a/src/locale/locales/it/messages.po +++ b/src/locale/locales/it/messages.po @@ -165,7 +165,7 @@ msgstr "" #~ msgstr "<0>{following} <1>following" #~ msgid "<0>Choose your<1>Recommended<2>Feeds" -#~ msgstr "<0>Scegli i tuoi<1>feeds<2>consigliati" +#~ msgstr "<0>Scegli i tuoi<1>feed/1><2>consigliati" #~ msgid "<0>Follow some<1>Recommended<2>Users" #~ msgstr "<0>Segui alcuni<1>utenti<2>consigliati" @@ -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 e8dc12ce24..bb41c63317 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}" +msgstr "<0>{0}がフォロー中" + +#: src/components/KnownFollowers.tsx:209 +msgid "Followed by <0>{0} and {1, plural, one {# other} other {# others}}" +msgstr "<0>{0}および{1, plural, other {他#人}}がフォロー中" + +#: src/components/KnownFollowers.tsx:181 +msgid "Followed by <0>{0} and <1>{1}" +msgstr "<0>{0}と<1>{1}がフォロー中" + +#: src/components/KnownFollowers.tsx:168 +msgid "Followed by <0>{0}, <1>{1}, and {2, plural, one {# other} other {# others}}" +msgstr "<0>{0}、<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/>" msgstr "<0><1/>に返信" +#: 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." @@ -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 instead." @@ -5336,6 +5431,10 @@ msgstr "このユーザーはブロックした<0>{0}リストに含まれ msgid "This user is included in the <0>{0} list which you have muted." msgstr "このユーザーはミュートした<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 ea3088188c..5f36a7eb7f 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} 팔로우 중" msgid "<0>Not Applicable. This warning is only available for posts with media attached." msgstr "<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." msgstr "<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}" +msgstr "<0>{0} 님이 팔로우함" + +#: src/components/KnownFollowers.tsx:209 +msgid "Followed by <0>{0} and {1, plural, one {# other} other {# others}}" +msgstr "<0>{0} 님 외 {1, plural, other {#}}명이 팔로우함" + +#: src/components/KnownFollowers.tsx:196 +msgid "Followed by <0>{0} and <1>{1}" +msgstr "<0>{0} 님과 <1>{1} 님이 팔로우함" + +#: src/components/KnownFollowers.tsx:178 +msgid "Followed by <0>{0}, <1>{1}, and {2, plural, one {# other} other {# others}}" +msgstr "<0>{0} 님, <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 -msgid "Replies to this thread are disabled" -msgstr "이 스레드에 대한 답글이 비활성화됩니다." +#: src/view/com/threadgate/WhoCanReply.tsx:66 +msgid "Replies disabled" +msgstr "답글 비활성화됨" -#: src/view/com/composer/Composer.tsx:475 +#: 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 "이 스레드에 대한 답글이 비활성화됨" + +#: 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/>" msgstr "<0><1/> 님에게 보내는 답글" +#: 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/>" msgstr "<0><1/> 님이 재게시함" -#: 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} 리스트에 포함되어 msgid "This user is included in the <0>{0} list which you have muted." msgstr "이 사용자는 내가 뮤트한 <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/routes.ts b/src/routes.ts index de711f5dc2..f241d37a07 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -41,4 +41,8 @@ export const router = new Router({ Messages: '/messages', MessagesSettings: '/messages/settings', MessagesConversation: '/messages/:conversation', + Start: '/start/:name/:rkey', + StarterPackEdit: '/starter-pack/edit/:rkey', + StarterPack: '/starter-pack/:name/:rkey', + StarterPackWizard: '/starter-pack/create', }) diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index dfa10668b6..7cfd38e34f 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -21,6 +21,7 @@ import {logger} from '#/logger' import {useSessionApi} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useRequestNotificationsPermission} from 'lib/notifications/notifications' +import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {FormError} from '#/components/forms/FormError' @@ -69,6 +70,7 @@ export const LoginForm = ({ const {login} = useSessionApi() const requestNotificationsPermission = useRequestNotificationsPermission() const {setShowLoggedOut} = useLoggedOutViewControls() + const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() const onPressSelectService = React.useCallback(() => { Keyboard.dismiss() @@ -116,6 +118,7 @@ export const LoginForm = ({ 'LoginForm', ) setShowLoggedOut(false) + setHasCheckedForStarterPack(true) requestNotificationsPermission('Login') } catch (e: any) { const errMsg = e.toString() diff --git a/src/screens/Login/ScreenTransition.tsx b/src/screens/Login/ScreenTransition.tsx index ab0a223678..6fad266802 100644 --- a/src/screens/Login/ScreenTransition.tsx +++ b/src/screens/Login/ScreenTransition.tsx @@ -1,9 +1,16 @@ import React from 'react' +import {StyleProp, ViewStyle} from 'react-native' import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated' -export function ScreenTransition({children}: {children: React.ReactNode}) { +export function ScreenTransition({ + style, + children, +}: { + style?: StyleProp + children: React.ReactNode +}) { return ( - + {children} ) diff --git a/src/screens/Onboarding/Layout.tsx b/src/screens/Onboarding/Layout.tsx index 75e2f99fcd..02b207303e 100644 --- a/src/screens/Onboarding/Layout.tsx +++ b/src/screens/Onboarding/Layout.tsx @@ -152,7 +152,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) { {children} - + diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index c75dd4fa74..c7a459659f 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -1,11 +1,18 @@ import React from 'react' import {View} from 'react-native' +import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' +import {SavedFeed} from '@atproto/api/dist/client/types/app/bsky/actor/defs' +import {TID} from '@atproto/common-web' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import {useAnalytics} from '#/lib/analytics/analytics' -import {BSKY_APP_ACCOUNT_DID} from '#/lib/constants' +import { + BSKY_APP_ACCOUNT_DID, + DISCOVER_SAVED_FEED, + TIMELINE_SAVED_FEED, +} from '#/lib/constants' import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {preferencesQueryKey} from '#/state/queries/preferences' @@ -14,6 +21,11 @@ import {useAgent} from '#/state/session' import {useOnboardingDispatch} from '#/state/shell' import {uploadBlob} from 'lib/api' import {useRequestNotificationsPermission} from 'lib/notifications/notifications' +import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' +import { + useActiveStarterPack, + useSetActiveStarterPack, +} from 'state/shell/starter-pack' import { DescriptionText, OnboardingControls, @@ -41,17 +53,74 @@ export function StepFinished() { const queryClient = useQueryClient() const agent = useAgent() const requestNotificationsPermission = useRequestNotificationsPermission() + const activeStarterPack = useActiveStarterPack() + const setActiveStarterPack = useSetActiveStarterPack() + const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() const finishOnboarding = React.useCallback(async () => { setSaving(true) - const {interestsStepResults, profileStepResults} = state - const {selectedInterests} = interestsStepResults + let starterPack: AppBskyGraphDefs.StarterPackView | undefined + let listItems: AppBskyGraphDefs.ListItemView[] | undefined + + if (activeStarterPack?.uri) { + try { + const spRes = await agent.app.bsky.graph.getStarterPack({ + starterPack: activeStarterPack.uri, + }) + starterPack = spRes.data.starterPack + + if (starterPack.list) { + const listRes = await agent.app.bsky.graph.getList({ + list: starterPack.list.uri, + limit: 50, + }) + listItems = listRes.data.items + } + } catch (e) { + logger.error('Failed to fetch starter pack', {safeMessage: e}) + // don't tell the user, just get them through onboarding. + } + } + try { + const {interestsStepResults, profileStepResults} = state + const {selectedInterests} = interestsStepResults + await Promise.all([ - bulkWriteFollows(agent, [BSKY_APP_ACCOUNT_DID]), + bulkWriteFollows(agent, [ + BSKY_APP_ACCOUNT_DID, + ...(listItems?.map(i => i.subject.did) ?? []), + ]), (async () => { + // Interests need to get saved first, then we can write the feeds to prefs await agent.setInterestsPref({tags: selectedInterests}) + + // Default feeds that every user should have pinned when landing in the app + const feedsToSave: SavedFeed[] = [ + { + ...DISCOVER_SAVED_FEED, + id: TID.nextStr(), + }, + { + ...TIMELINE_SAVED_FEED, + id: TID.nextStr(), + }, + ] + + // Any starter pack feeds will be pinned _after_ the defaults + if (starterPack && starterPack.feeds?.length) { + feedsToSave.concat( + starterPack.feeds.map(f => ({ + type: 'feed', + value: f.uri, + pinned: true, + id: TID.nextStr(), + })), + ) + } + + await agent.overwriteSavedFeeds(feedsToSave) })(), (async () => { const {imageUri, imageMime} = profileStepResults @@ -63,9 +132,24 @@ export function StepFinished() { if (res.data.blob) { existing.avatar = res.data.blob } + + if (starterPack) { + existing.joinedViaStarterPack = { + uri: starterPack.uri, + cid: starterPack.cid, + } + } + + existing.displayName = '' + // HACKFIX + // creating a bunch of identical profile objects is breaking the relay + // tossing this unspecced field onto it to reduce the size of the problem + // -prf + existing.createdAt = new Date().toISOString() return existing }) } + logEvent('onboarding:finished:avatarResult', { avatarResult: profileStepResults.isCreatedAvatar ? 'created' @@ -96,19 +180,40 @@ export function StepFinished() { }) setSaving(false) + setActiveStarterPack(undefined) + setHasCheckedForStarterPack(true) dispatch({type: 'finish'}) onboardDispatch({type: 'finish'}) track('OnboardingV2:StepFinished:End') track('OnboardingV2:Complete') - logEvent('onboarding:finished:nextPressed', {}) + logEvent('onboarding:finished:nextPressed', { + usedStarterPack: Boolean(starterPack), + starterPackName: AppBskyGraphStarterpack.isRecord(starterPack?.record) + ? starterPack.record.name + : undefined, + starterPackCreator: starterPack?.creator.did, + starterPackUri: starterPack?.uri, + profilesFollowed: listItems?.length ?? 0, + feedsPinned: starterPack?.feeds?.length ?? 0, + }) + if (starterPack && listItems?.length) { + logEvent('starterPack:followAll', { + logContext: 'Onboarding', + starterPack: starterPack.uri, + count: listItems?.length, + }) + } }, [ - state, queryClient, agent, dispatch, onboardDispatch, track, + activeStarterPack, + state, requestNotificationsPermission, + setActiveStarterPack, + setHasCheckedForStarterPack, ]) React.useEffect(() => { diff --git a/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx index 29ba39a0b4..d1d1af6d9f 100644 --- a/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx +++ b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx @@ -5,7 +5,7 @@ import ViewShot from 'react-native-view-shot' import {useAvatar} from '#/screens/Onboarding/StepProfile/index' import {atoms as a} from '#/alf' -const SIZE_MULTIPLIER = 1.5 +const SIZE_MULTIPLIER = 5 export interface PlaceholderCanvasRef { capture: () => Promise diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index 3556bba7a0..5304aa5031 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -181,8 +181,8 @@ export function StepProfile() { image = await openCropper({ mediaType: 'photo', cropperCircleOverlay: true, - height: image.height, - width: image.width, + height: 1000, + width: 1000, path: image.path, }) } diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx index b6d88db712..c63658a44a 100644 --- a/src/screens/Profile/Header/DisplayName.tsx +++ b/src/screens/Profile/Header/DisplayName.tsx @@ -1,10 +1,10 @@ import React from 'react' import {View} from 'react-native' import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' -import {sanitizeHandle} from 'lib/strings/handles' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {Shadow} from '#/state/cache/types' +import {Shadow} from '#/state/cache/types' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx index 9ab24fbbed..268b7350f8 100644 --- a/src/screens/Profile/Header/Handle.tsx +++ b/src/screens/Profile/Header/Handle.tsx @@ -5,19 +5,26 @@ import {Trans} from '@lingui/macro' import {Shadow} from '#/state/cache/types' import {isInvalidHandle} from 'lib/strings/handles' +import {isAndroid} from 'platform/detection' import {atoms as a, useTheme, web} from '#/alf' +import {NewskieDialog} from '#/components/NewskieDialog' import {Text} from '#/components/Typography' export function ProfileHeaderHandle({ profile, + disableTaps, }: { profile: Shadow + disableTaps?: boolean }) { const t = useTheme() const invalidHandle = isInvalidHandle(profile.handle) const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy return ( - + + {profile.viewer?.followedBy && !blockHide ? ( diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx index 64bf71027c..d266decb32 100644 --- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -82,7 +82,7 @@ let ProfileHeaderLabeler = ({ preferences?.moderationPrefs.labelers.find(l => l.did === profile.did) const canSubscribe = isSubscribed || - (preferences ? preferences?.moderationPrefs.labelers.length < 9 : false) + (preferences ? preferences?.moderationPrefs.labelers.length <= 20 : false) const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation() const {mutateAsync: unlikeMod, isPending: isUnlikePending} = useUnlikeMutation() @@ -328,12 +328,12 @@ function CantSubscribePrompt({ Unable to subscribe - We're sorry! You can only subscribe to ten labelers, and you've - reached your limit of ten. + We're sorry! You can only subscribe to twenty labelers, and you've + reached your limit of twenty. - + control.close()} cta={_(msg`OK`)} /> ) diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index f8a87a68e4..4ad84ac633 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -75,6 +75,10 @@ let ProfileHeaderStandard = ({ const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) const unblockPromptControl = Prompt.usePromptControl() const requireAuth = useRequireAuth() + const isBlockedUser = + profile.viewer?.blocking || + profile.viewer?.blockedBy || + profile.viewer?.blockingByList const onPressEditProfile = React.useCallback(() => { track('ProfileHeader:EditProfileButtonClicked') @@ -257,7 +261,7 @@ let ProfileHeaderStandard = ({ - {!isPlaceholderProfile && ( + {!isPlaceholderProfile && !isBlockedUser && ( <> {descriptionRT && !moderation.ui('profileView').blur ? ( @@ -274,6 +278,7 @@ let ProfileHeaderStandard = ({ ) : undefined} {!isMe && + !isBlockedUser && shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( { const pal = usePalette('default') @@ -19,11 +19,11 @@ let ProfileHeaderLoading = (_props: {}): React.ReactNode => { - + - + @@ -58,13 +58,13 @@ const styles = StyleSheet.create({ position: 'absolute', top: 110, left: 10, - width: 84, - height: 84, - borderRadius: 42, + width: 94, + height: 94, + borderRadius: 47, borderWidth: 2, }, content: { - paddingTop: 8, + paddingTop: 12, paddingHorizontal: 14, paddingBottom: 4, }, @@ -73,6 +73,6 @@ const styles = StyleSheet.create({ marginLeft: 'auto', marginBottom: 12, }, - br40: {borderRadius: 40}, + br45: {borderRadius: 45}, br50: {borderRadius: 50}, }) diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx index 2cc1bcab0b..3203d443cc 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -1,6 +1,11 @@ import React from 'react' import {View} from 'react-native' -import {LayoutAnimationConfig} from 'react-native-reanimated' +import Animated, { + FadeIn, + FadeOut, + LayoutAnimationConfig, +} from 'react-native-reanimated' +import {AppBskyGraphStarterpack} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -11,6 +16,8 @@ import {createFullHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {useServiceQuery} from '#/state/queries/service' import {useAgent} from '#/state/session' +import {useStarterPackQuery} from 'state/queries/starter-packs' +import {useActiveStarterPack} from 'state/shell/starter-pack' import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' import { initialState, @@ -26,6 +33,7 @@ import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {AppLanguageDropdown} from '#/components/AppLanguageDropdown' import {Button, ButtonText} from '#/components/Button' import {Divider} from '#/components/Divider' +import {LinearGradientBackground} from '#/components/LinearGradientBackground' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' @@ -38,6 +46,11 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { const {gtMobile} = useBreakpoints() const agent = useAgent() + const activeStarterPack = useActiveStarterPack() + const {data: starterPack} = useStarterPackQuery({ + uri: activeStarterPack?.uri, + }) + const { data: serviceInfo, isFetching, @@ -142,6 +155,31 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { description={_(msg`We're so excited to have you join us!`)} scrollable> + {state.activeStep === SignupStep.INFO && + starterPack && + AppBskyGraphStarterpack.isRecord(starterPack.record) ? ( + + + + {starterPack.record.name} + + + {starterPack.feeds?.length ? ( + + You'll follow the suggested users and feeds once you + finish creating your account! + + ) : ( + + You'll follow the suggested users once you finish creating + your account! + + )} + + + + ) : null} void +}) { + const moderationOpts = useModerationOpts() + const activeStarterPack = useActiveStarterPack() + + const {data: starterPack, isError: isErrorStarterPack} = useStarterPackQuery({ + uri: activeStarterPack?.uri, + }) + + const isValid = + starterPack && + starterPack.list && + AppBskyGraphDefs.validateStarterPackView(starterPack) && + AppBskyGraphStarterpack.validateRecord(starterPack.record) + + React.useEffect(() => { + if (isErrorStarterPack || (starterPack && !isValid)) { + setScreenState(LoggedOutScreenState.S_LoginOrCreateAccount) + } + }, [isErrorStarterPack, setScreenState, isValid, starterPack]) + + if (!starterPack || !isValid || !moderationOpts) { + return + } + + return ( + + ) +} + +function LandingScreenLoaded({ + starterPack, + setScreenState, + // TODO apply this to profile card + + moderationOpts, +}: { + starterPack: AppBskyGraphDefs.StarterPackView + setScreenState: (state: LoggedOutScreenState) => void + moderationOpts: ModerationOpts +}) { + const {record, creator, listItemsSample, feeds, joinedWeekCount} = starterPack + const {_} = useLingui() + const t = useTheme() + const activeStarterPack = useActiveStarterPack() + const setActiveStarterPack = useSetActiveStarterPack() + const {isTabletOrDesktop} = useWebMediaQueries() + const androidDialogControl = useDialogControl() + + const [appClipOverlayVisible, setAppClipOverlayVisible] = + React.useState(false) + + const listItemsCount = starterPack.list?.listItemCount ?? 0 + + const onContinue = () => { + setActiveStarterPack({ + uri: starterPack.uri, + }) + setScreenState(LoggedOutScreenState.S_CreateAccount) + } + + const onJoinPress = () => { + if (activeStarterPack?.isClip) { + setAppClipOverlayVisible(true) + postAppClipMessage({ + action: 'present', + }) + } else if (isAndroidWeb) { + androidDialogControl.open() + } else { + onContinue() + } + } + + const onJoinWithoutPress = () => { + if (activeStarterPack?.isClip) { + setAppClipOverlayVisible(true) + postAppClipMessage({ + action: 'present', + }) + } else { + setActiveStarterPack(undefined) + setScreenState(LoggedOutScreenState.S_CreateAccount) + } + } + + if (!AppBskyGraphStarterpack.isRecord(record)) { + return null + } + + return ( + + + + + + + + {record.name} + + + Starter pack by {`@${creator.handle}`} + + + + {record.description ? ( + + {record.description} + + ) : null} + + + {joinedWeekCount && joinedWeekCount >= 25 ? ( + + + + 123,659 joined this week + + + ) : null} + + + {Boolean(listItemsSample?.length) && ( + + + {listItemsCount <= 8 ? ( + You'll follow these people right away + ) : ( + + You'll follow these people and {listItemsCount - 8} others + + )} + + + {starterPack.listItemsSample?.slice(0, 8).map(item => ( + + + + ))} + + + )} + {feeds?.length ? ( + + + You'll stay updated with these feeds + + + + {feeds?.map(feed => ( + + + + ))} + + + ) : null} + + + + + + + + Download Bluesky + + + + The experience is better in the app. Download Bluesky now and we'll + pick back up where you left off. + + + + { + const rkey = new AtUri(starterPack.uri).rkey + if (!rkey) return + + const googlePlayUri = createStarterPackGooglePlayUri( + creator.handle, + rkey, + ) + if (!googlePlayUri) return + + window.location.href = googlePlayUri + }} + /> + + + + {isWeb && ( + + )} + + ) +} + +function AppClipOverlay({ + visible, + setIsVisible, +}: { + visible: boolean + setIsVisible: (visible: boolean) => void +}) { + if (!visible) return + + return ( + setIsVisible(false)}> + + {/* Webkit needs this to have a zindex of 2? */} + + + Download Bluesky to get started! + + + We'll remember the starter pack you chose and use it when you create + an account in the app. + + + + + ) +} diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx new file mode 100644 index 0000000000..46ce252364 --- /dev/null +++ b/src/screens/StarterPack/StarterPackScreen.tsx @@ -0,0 +1,627 @@ +import React from 'react' +import {View} from 'react-native' +import {Image} from 'expo-image' +import { + AppBskyGraphDefs, + AppBskyGraphGetList, + AppBskyGraphStarterpack, + AtUri, + ModerationOpts, +} from '@atproto/api' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import { + InfiniteData, + UseInfiniteQueryResult, + useQueryClient, +} from '@tanstack/react-query' + +import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs' +import {HITSLOP_20} from 'lib/constants' +import {makeProfileLink, makeStarterPackLink} from 'lib/routes/links' +import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types' +import {logEvent} from 'lib/statsig/statsig' +import {getStarterPackOgCard} from 'lib/strings/starter-pack' +import {isWeb} from 'platform/detection' +import {useModerationOpts} from 'state/preferences/moderation-opts' +import {RQKEY, useListMembersQuery} from 'state/queries/list-members' +import {useResolveDidQuery} from 'state/queries/resolve-uri' +import {useShortenLink} from 'state/queries/shorten-link' +import {useStarterPackQuery} from 'state/queries/starter-packs' +import {useAgent, useSession} from 'state/session' +import * as Toast from '#/view/com/util/Toast' +import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' +import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' +import {CenteredView} from 'view/com/util/Views' +import {bulkWriteFollows} from '#/screens/Onboarding/util' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' +import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {ListMaybePlaceholder} from '#/components/Lists' +import {Loader} from '#/components/Loader' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' +import {FeedsList} from '#/components/StarterPack/Main/FeedsList' +import {ProfilesList} from '#/components/StarterPack/Main/ProfilesList' +import {QrCodeDialog} from '#/components/StarterPack/QrCodeDialog' +import {ShareDialog} from '#/components/StarterPack/ShareDialog' +import {Text} from '#/components/Typography' + +type StarterPackScreeProps = NativeStackScreenProps< + CommonNavigatorParams, + 'StarterPack' +> + +export function StarterPackScreen({route}: StarterPackScreeProps) { + const {_} = useLingui() + const {currentAccount} = useSession() + + const {name, rkey} = route.params + const moderationOpts = useModerationOpts() + const { + data: did, + isLoading: isLoadingDid, + isError: isErrorDid, + } = useResolveDidQuery(name) + const { + data: starterPack, + isLoading: isLoadingStarterPack, + isError: isErrorStarterPack, + } = useStarterPackQuery({did, rkey}) + const listMembersQuery = useListMembersQuery(starterPack?.list?.uri, 50) + + const isValid = + starterPack && + (starterPack.list || starterPack?.creator?.did === currentAccount?.did) && + AppBskyGraphDefs.validateStarterPackView(starterPack) && + AppBskyGraphStarterpack.validateRecord(starterPack.record) + + if (!did || !starterPack || !isValid || !moderationOpts) { + return ( + + ) + } + + if (!starterPack.list && starterPack.creator.did === currentAccount?.did) { + return + } + + return ( + + ) +} + +function StarterPackScreenInner({ + starterPack, + routeParams, + listMembersQuery, + moderationOpts, +}: { + starterPack: AppBskyGraphDefs.StarterPackView + routeParams: StarterPackScreeProps['route']['params'] + listMembersQuery: UseInfiniteQueryResult< + InfiniteData + > + moderationOpts: ModerationOpts +}) { + const tabs = [ + ...(starterPack.list ? ['People'] : []), + ...(starterPack.feeds?.length ? ['Feeds'] : []), + ] + + const qrCodeDialogControl = useDialogControl() + const shareDialogControl = useDialogControl() + + const shortenLink = useShortenLink() + const [link, setLink] = React.useState() + const [imageLoaded, setImageLoaded] = React.useState(false) + + const onOpenShareDialog = React.useCallback(() => { + const rkey = new AtUri(starterPack.uri).rkey + shortenLink(makeStarterPackLink(starterPack.creator.did, rkey)).then( + res => { + setLink(res.url) + }, + ) + Image.prefetch(getStarterPackOgCard(starterPack)) + .then(() => { + setImageLoaded(true) + }) + .catch(() => { + setImageLoaded(true) + }) + shareDialogControl.open() + }, [shareDialogControl, shortenLink, starterPack]) + + React.useEffect(() => { + if (routeParams.new) { + onOpenShareDialog() + } + }, [onOpenShareDialog, routeParams.new, shareDialogControl]) + + return ( + + + ( +
+ )}> + {starterPack.list != null + ? ({headerHeight, scrollElRef}) => ( + + ) + : null} + {starterPack.feeds != null + ? ({headerHeight, scrollElRef}) => ( + + ) + : null} + + + + + + + ) +} + +function Header({ + starterPack, + routeParams, + onOpenShareDialog, +}: { + starterPack: AppBskyGraphDefs.StarterPackView + routeParams: StarterPackScreeProps['route']['params'] + onOpenShareDialog: () => void +}) { + const {_} = useLingui() + const t = useTheme() + const {currentAccount} = useSession() + const agent = useAgent() + const queryClient = useQueryClient() + + const [isProcessing, setIsProcessing] = React.useState(false) + + const {record, creator} = starterPack + const isOwn = creator?.did === currentAccount?.did + const joinedAllTimeCount = starterPack.joinedAllTimeCount ?? 0 + + const onFollowAll = async () => { + if (!starterPack.list) return + + setIsProcessing(true) + + try { + const list = await agent.app.bsky.graph.getList({ + list: starterPack.list.uri, + }) + const dids = list.data.items + .filter(li => !li.subject.viewer?.following) + .map(li => li.subject.did) + + await bulkWriteFollows(agent, dids) + + await queryClient.refetchQueries({ + queryKey: RQKEY(starterPack.list.uri), + }) + + logEvent('starterPack:followAll', { + logContext: 'StarterPackProfilesList', + starterPack: starterPack.uri, + count: dids.length, + }) + Toast.show(_(msg`All accounts have been followed!`)) + } catch (e) { + Toast.show(_(msg`An error occurred while trying to follow all`)) + } finally { + setIsProcessing(false) + } + } + + if (!AppBskyGraphStarterpack.isRecord(record)) { + return null + } + + return ( + <> + + + {isOwn ? ( + + ) : ( + + )} + + + + {record.description || joinedAllTimeCount >= 25 ? ( + + {record.description ? ( + + {record.description} + + ) : null} + {joinedAllTimeCount >= 25 ? ( + + + + + {starterPack.joinedAllTimeCount || 0} people have used this + starter pack! + + + + ) : null} + + ) : null} + + ) +} + +function OverflowMenu({ + starterPack, + routeParams, + onOpenShareDialog, +}: { + starterPack: AppBskyGraphDefs.StarterPackView + routeParams: StarterPackScreeProps['route']['params'] + onOpenShareDialog: () => void +}) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const {currentAccount} = useSession() + const reportDialogControl = useReportDialogControl() + const deleteDialogControl = useDialogControl() + const navigation = useNavigation() + + const { + mutate: deleteStarterPack, + isPending: isDeletePending, + error: deleteError, + } = useDeleteStarterPackMutation({ + onSuccess: () => { + logEvent('starterPack:delete', {}) + deleteDialogControl.close(() => { + if (navigation.canGoBack()) { + navigation.popToTop() + } else { + navigation.navigate('Home') + } + }) + }, + onError: e => { + logger.error('Failed to delete starter pack', {safeMessage: e}) + }, + }) + + const isOwn = starterPack.creator.did === currentAccount?.did + + const onDeleteStarterPack = async () => { + if (!starterPack.list) { + logger.error(`Unable to delete starterpack because list is missing`) + return + } + + deleteStarterPack({ + rkey: routeParams.rkey, + listUri: starterPack.list.uri, + }) + logEvent('starterPack:delete', {}) + } + + return ( + <> + + + {({props}) => ( + + )} + + + {isOwn ? ( + <> + { + navigation.navigate('StarterPackEdit', { + rkey: routeParams.rkey, + }) + }}> + + Edit + + + + { + deleteDialogControl.open() + }}> + + Delete + + + + + ) : ( + <> + + + + Share link + + + + + + + + Report starter pack + + + + + )} + + + + {starterPack.list && ( + + )} + + + + Delete starter pack? + + + Are you sure you want delete this starter pack? + + {deleteError && ( + + + + Unable to delete + + {cleanError(deleteError)} + + + + )} + + + + + + + ) +} + +function InvalidStarterPack({rkey}: {rkey: string}) { + const {_} = useLingui() + const t = useTheme() + const navigation = useNavigation() + const {gtMobile} = useBreakpoints() + const [isProcessing, setIsProcessing] = React.useState(false) + + const goBack = () => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.replace('Home') + } + } + + const {mutate: deleteStarterPack} = useDeleteStarterPackMutation({ + onSuccess: () => { + setIsProcessing(false) + goBack() + }, + onError: e => { + setIsProcessing(false) + logger.error('Failed to delete invalid starter pack', {safeMessage: e}) + Toast.show(_(msg`Failed to delete starter pack`)) + }, + }) + + return ( + + + + Starter pack is invalid + + + + The starter pack that you are trying to view is invalid. You may + delete this starter pack instead. + + + + + + + + + ) +} diff --git a/src/screens/StarterPack/Wizard/State.tsx b/src/screens/StarterPack/Wizard/State.tsx new file mode 100644 index 0000000000..ea9bbf9d33 --- /dev/null +++ b/src/screens/StarterPack/Wizard/State.tsx @@ -0,0 +1,163 @@ +import React from 'react' +import { + AppBskyActorDefs, + AppBskyGraphDefs, + AppBskyGraphStarterpack, +} from '@atproto/api' +import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {msg} from '@lingui/macro' + +import {useSession} from 'state/session' +import * as Toast from '#/view/com/util/Toast' + +const steps = ['Details', 'Profiles', 'Feeds'] as const +type Step = (typeof steps)[number] + +type Action = + | {type: 'Next'} + | {type: 'Back'} + | {type: 'SetCanNext'; canNext: boolean} + | {type: 'SetName'; name: string} + | {type: 'SetDescription'; description: string} + | {type: 'AddProfile'; profile: AppBskyActorDefs.ProfileViewBasic} + | {type: 'RemoveProfile'; profileDid: string} + | {type: 'AddFeed'; feed: GeneratorView} + | {type: 'RemoveFeed'; feedUri: string} + | {type: 'SetProcessing'; processing: boolean} + | {type: 'SetError'; error: string} + +interface State { + canNext: boolean + currentStep: Step + name?: string + description?: string + profiles: AppBskyActorDefs.ProfileViewBasic[] + feeds: GeneratorView[] + processing: boolean + error?: string + transitionDirection: 'Backward' | 'Forward' +} + +type TStateContext = [State, (action: Action) => void] + +const StateContext = React.createContext([ + {} as State, + (_: Action) => {}, +]) +export const useWizardState = () => React.useContext(StateContext) + +function reducer(state: State, action: Action): State { + let updatedState = state + + // -- Navigation + const currentIndex = steps.indexOf(state.currentStep) + if (action.type === 'Next' && state.currentStep !== 'Feeds') { + updatedState = { + ...state, + currentStep: steps[currentIndex + 1], + transitionDirection: 'Forward', + } + } else if (action.type === 'Back' && state.currentStep !== 'Details') { + updatedState = { + ...state, + currentStep: steps[currentIndex - 1], + transitionDirection: 'Backward', + } + } + + switch (action.type) { + case 'SetName': + updatedState = {...state, name: action.name.slice(0, 50)} + break + case 'SetDescription': + updatedState = {...state, description: action.description} + break + case 'AddProfile': + if (state.profiles.length >= 51) { + Toast.show(msg`You may only add up to 50 profiles`.message ?? '') + } else { + updatedState = {...state, profiles: [...state.profiles, action.profile]} + } + break + case 'RemoveProfile': + updatedState = { + ...state, + profiles: state.profiles.filter( + profile => profile.did !== action.profileDid, + ), + } + break + case 'AddFeed': + if (state.feeds.length >= 50) { + Toast.show(msg`You may only add up to 50 feeds`.message ?? '') + } else { + updatedState = {...state, feeds: [...state.feeds, action.feed]} + } + break + case 'RemoveFeed': + updatedState = { + ...state, + feeds: state.feeds.filter(f => f.uri !== action.feedUri), + } + break + case 'SetProcessing': + updatedState = {...state, processing: action.processing} + break + } + + return updatedState +} + +// TODO supply the initial state to this component +export function Provider({ + starterPack, + listItems, + children, +}: { + starterPack?: AppBskyGraphDefs.StarterPackView + listItems?: AppBskyGraphDefs.ListItemView[] + children: React.ReactNode +}) { + const {currentAccount} = useSession() + + const createInitialState = (): State => { + if (starterPack && AppBskyGraphStarterpack.isRecord(starterPack.record)) { + return { + canNext: true, + currentStep: 'Details', + name: starterPack.record.name, + description: starterPack.record.description, + profiles: + listItems + ?.map(i => i.subject) + .filter(p => p.did !== currentAccount?.did) ?? [], + feeds: starterPack.feeds ?? [], + processing: false, + transitionDirection: 'Forward', + } + } + + return { + canNext: true, + currentStep: 'Details', + profiles: [], + feeds: [], + processing: false, + transitionDirection: 'Forward', + } + } + + const [state, dispatch] = React.useReducer(reducer, null, createInitialState) + + return ( + + {children} + + ) +} + +export { + type Action as WizardAction, + type State as WizardState, + type Step as WizardStep, +} diff --git a/src/screens/StarterPack/Wizard/StepDetails.tsx b/src/screens/StarterPack/Wizard/StepDetails.tsx new file mode 100644 index 0000000000..24c992c60b --- /dev/null +++ b/src/screens/StarterPack/Wizard/StepDetails.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useProfileQuery} from 'state/queries/profile' +import {useSession} from 'state/session' +import {useWizardState} from '#/screens/StarterPack/Wizard/State' +import {atoms as a, useTheme} from '#/alf' +import * as TextField from '#/components/forms/TextField' +import {StarterPack} from '#/components/icons/StarterPack' +import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition' +import {Text} from '#/components/Typography' + +export function StepDetails() { + const {_} = useLingui() + const t = useTheme() + const [state, dispatch] = useWizardState() + + const {currentAccount} = useSession() + const {data: currentProfile} = useProfileQuery({ + did: currentAccount?.did, + staleTime: 300, + }) + + return ( + + + + + + Invites, but personal + + + + Invite your friends to follow your favorite feeds and people + + + + + + What do you want to call your starter pack? + + + dispatch({type: 'SetName', name: text})} + /> + + + {state.name?.length ?? 0}/50 + + + + + + + Tell us a little more + + + + dispatch({type: 'SetDescription', description: text}) + } + multiline + style={{minHeight: 150}} + /> + + + + + ) +} diff --git a/src/screens/StarterPack/Wizard/StepFeeds.tsx b/src/screens/StarterPack/Wizard/StepFeeds.tsx new file mode 100644 index 0000000000..6752a95db3 --- /dev/null +++ b/src/screens/StarterPack/Wizard/StepFeeds.tsx @@ -0,0 +1,113 @@ +import React, {useState} from 'react' +import {ListRenderItemInfo, View} from 'react-native' +import {KeyboardAwareScrollView} from 'react-native-keyboard-controller' +import {AppBskyFeedDefs, ModerationOpts} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {useA11y} from '#/state/a11y' +import {DISCOVER_FEED_URI} from 'lib/constants' +import { + useGetPopularFeedsQuery, + useSavedFeeds, + useSearchPopularFeedsQuery, +} from 'state/queries/feed' +import {SearchInput} from 'view/com/util/forms/SearchInput' +import {List} from 'view/com/util/List' +import {useWizardState} from '#/screens/StarterPack/Wizard/State' +import {atoms as a, useTheme} from '#/alf' +import {useThrottledValue} from '#/components/hooks/useThrottledValue' +import {Loader} from '#/components/Loader' +import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition' +import {WizardFeedCard} from '#/components/StarterPack/Wizard/WizardListCard' +import {Text} from '#/components/Typography' + +function keyExtractor(item: AppBskyFeedDefs.GeneratorView) { + return item.uri +} + +export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) { + const t = useTheme() + const [state, dispatch] = useWizardState() + const [query, setQuery] = useState('') + const throttledQuery = useThrottledValue(query, 500) + const {screenReaderEnabled} = useA11y() + + const {data: savedFeedsAndLists} = useSavedFeeds() + const savedFeeds = savedFeedsAndLists?.feeds + .filter(f => f.type === 'feed' && f.view.uri !== DISCOVER_FEED_URI) + .map(f => f.view) as AppBskyFeedDefs.GeneratorView[] + + const {data: popularFeedsPages, fetchNextPage} = useGetPopularFeedsQuery({ + limit: 30, + }) + const popularFeeds = + popularFeedsPages?.pages + .flatMap(page => page.feeds) + .filter(f => !savedFeeds?.some(sf => sf?.uri === f.uri)) ?? [] + + const suggestedFeeds = savedFeeds?.concat(popularFeeds) + + const {data: searchedFeeds, isLoading: isLoadingSearch} = + useSearchPopularFeedsQuery({q: throttledQuery}) + + const renderItem = ({ + item, + }: ListRenderItemInfo) => { + return ( + + ) + } + + return ( + + + + setQuery(t)} + onPressCancelSearch={() => setQuery('')} + onSubmitQuery={() => {}} + /> + + + fetchNextPage() : undefined + } + onEndReachedThreshold={2} + renderScrollComponent={props => } + keyboardShouldPersistTaps="handled" + containWeb={true} + sideBorders={false} + style={{flex: 1}} + ListEmptyComponent={ + + {isLoadingSearch ? ( + + ) : ( + + No feeds found. Try searching for something else. + + )} + + } + /> + + ) +} diff --git a/src/screens/StarterPack/Wizard/StepFinished.tsx b/src/screens/StarterPack/Wizard/StepFinished.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/screens/StarterPack/Wizard/StepProfiles.tsx b/src/screens/StarterPack/Wizard/StepProfiles.tsx new file mode 100644 index 0000000000..8fe7f52fed --- /dev/null +++ b/src/screens/StarterPack/Wizard/StepProfiles.tsx @@ -0,0 +1,101 @@ +import React, {useState} from 'react' +import {ListRenderItemInfo, View} from 'react-native' +import {KeyboardAwareScrollView} from 'react-native-keyboard-controller' +import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {useA11y} from '#/state/a11y' +import {isNative} from 'platform/detection' +import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete' +import {useActorSearchPaginated} from 'state/queries/actor-search' +import {SearchInput} from 'view/com/util/forms/SearchInput' +import {List} from 'view/com/util/List' +import {useWizardState} from '#/screens/StarterPack/Wizard/State' +import {atoms as a, useTheme} from '#/alf' +import {Loader} from '#/components/Loader' +import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition' +import {WizardProfileCard} from '#/components/StarterPack/Wizard/WizardListCard' +import {Text} from '#/components/Typography' + +function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) { + return item?.did ?? '' +} + +export function StepProfiles({ + moderationOpts, +}: { + moderationOpts: ModerationOpts +}) { + const t = useTheme() + const [state, dispatch] = useWizardState() + const [query, setQuery] = useState('') + const {screenReaderEnabled} = useA11y() + + const {data: topPages, fetchNextPage} = useActorSearchPaginated({ + query: encodeURIComponent('*'), + }) + const topFollowers = topPages?.pages.flatMap(p => p.actors) + + const {data: results, isLoading: isLoadingResults} = + useActorAutocompleteQuery(query, true, 12) + + const renderItem = ({ + item, + }: ListRenderItemInfo) => { + return ( + + ) + } + + return ( + + + + setQuery('')} + onSubmitQuery={() => {}} + /> + + + } + keyboardShouldPersistTaps="handled" + containWeb={true} + sideBorders={false} + style={[a.flex_1]} + onEndReached={ + !query && !screenReaderEnabled ? () => fetchNextPage() : undefined + } + onEndReachedThreshold={isNative ? 2 : 0.25} + ListEmptyComponent={ + + {isLoadingResults ? ( + + ) : ( + + Nobody was found. Try searching for someone else. + + )} + + } + /> + + ) +} diff --git a/src/screens/StarterPack/Wizard/index.tsx b/src/screens/StarterPack/Wizard/index.tsx new file mode 100644 index 0000000000..76691dc985 --- /dev/null +++ b/src/screens/StarterPack/Wizard/index.tsx @@ -0,0 +1,575 @@ +import React from 'react' +import {Keyboard, TouchableOpacity, View} from 'react-native' +import { + KeyboardAwareScrollView, + useKeyboardController, +} from 'react-native-keyboard-controller' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {Image} from 'expo-image' +import { + AppBskyActorDefs, + AppBskyGraphDefs, + AtUri, + ModerationOpts, +} from '@atproto/api' +import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg, Plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {logger} from '#/logger' +import {HITSLOP_10} from 'lib/constants' +import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types' +import {logEvent} from 'lib/statsig/statsig' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {enforceLen} from 'lib/strings/helpers' +import { + getStarterPackOgCard, + parseStarterPackUri, +} from 'lib/strings/starter-pack' +import {isAndroid, isNative, isWeb} from 'platform/detection' +import {useModerationOpts} from 'state/preferences/moderation-opts' +import {useListMembersQuery} from 'state/queries/list-members' +import {useProfileQuery} from 'state/queries/profile' +import { + useCreateStarterPackMutation, + useEditStarterPackMutation, + useStarterPackQuery, +} from 'state/queries/starter-packs' +import {useSession} from 'state/session' +import {useSetMinimalShellMode} from 'state/shell' +import * as Toast from '#/view/com/util/Toast' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {CenteredView} from 'view/com/util/Views' +import {useWizardState, WizardStep} from '#/screens/StarterPack/Wizard/State' +import {StepDetails} from '#/screens/StarterPack/Wizard/StepDetails' +import {StepFeeds} from '#/screens/StarterPack/Wizard/StepFeeds' +import {StepProfiles} from '#/screens/StarterPack/Wizard/StepProfiles' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {ListMaybePlaceholder} from '#/components/Lists' +import {Loader} from '#/components/Loader' +import {WizardEditListDialog} from '#/components/StarterPack/Wizard/WizardEditListDialog' +import {Text} from '#/components/Typography' +import {Provider} from './State' + +export function Wizard({ + route, +}: NativeStackScreenProps< + CommonNavigatorParams, + 'StarterPackEdit' | 'StarterPackWizard' +>) { + const {rkey} = route.params ?? {} + const {currentAccount} = useSession() + const moderationOpts = useModerationOpts() + + const {_} = useLingui() + + const { + data: starterPack, + isLoading: isLoadingStarterPack, + isError: isErrorStarterPack, + } = useStarterPackQuery({did: currentAccount!.did, rkey}) + const listUri = starterPack?.list?.uri + + const { + data: profilesData, + isLoading: isLoadingProfiles, + isError: isErrorProfiles, + } = useListMembersQuery(listUri, 50) + const listItems = profilesData?.pages.flatMap(p => p.items) + + const { + data: profile, + isLoading: isLoadingProfile, + isError: isErrorProfile, + } = useProfileQuery({did: currentAccount?.did}) + + const isEdit = Boolean(rkey) + const isReady = + (!isEdit || (isEdit && starterPack && listItems)) && + profile && + moderationOpts + + if (!isReady) { + return ( + + ) + } else if (isEdit && starterPack?.creator.did !== currentAccount?.did) { + return ( + + ) + } + + return ( + + + + ) +} + +function WizardInner({ + currentStarterPack, + currentListItems, + profile, + moderationOpts, +}: { + currentStarterPack?: AppBskyGraphDefs.StarterPackView + currentListItems?: AppBskyGraphDefs.ListItemView[] + profile: AppBskyActorDefs.ProfileViewBasic + moderationOpts: ModerationOpts +}) { + const navigation = useNavigation() + const {_} = useLingui() + const t = useTheme() + const setMinimalShellMode = useSetMinimalShellMode() + const {setEnabled} = useKeyboardController() + const [state, dispatch] = useWizardState() + const {currentAccount} = useSession() + const {data: currentProfile} = useProfileQuery({ + did: currentAccount?.did, + staleTime: 0, + }) + const parsed = parseStarterPackUri(currentStarterPack?.uri) + + React.useEffect(() => { + navigation.setOptions({ + gestureEnabled: false, + }) + }, [navigation]) + + useFocusEffect( + React.useCallback(() => { + setEnabled(true) + setMinimalShellMode(true) + + return () => { + setMinimalShellMode(false) + setEnabled(false) + } + }, [setMinimalShellMode, setEnabled]), + ) + + const getDefaultName = () => { + let displayName + if ( + currentProfile?.displayName != null && + currentProfile?.displayName !== '' + ) { + displayName = sanitizeDisplayName(currentProfile.displayName) + } else { + displayName = sanitizeHandle(currentProfile!.handle) + } + return _(msg`${displayName}'s Starter Pack`).slice(0, 50) + } + + const wizardUiStrings: Record< + WizardStep, + {header: string; nextBtn: string; subtitle?: string} + > = { + Details: { + header: _(msg`Starter Pack`), + nextBtn: _(msg`Next`), + }, + Profiles: { + header: _(msg`People`), + nextBtn: _(msg`Next`), + subtitle: _( + msg`Add people to your starter pack that you think others will enjoy following`, + ), + }, + Feeds: { + header: _(msg`Feeds`), + nextBtn: state.feeds.length === 0 ? _(msg`Skip`) : _(msg`Finish`), + subtitle: _(msg`Some subtitle`), + }, + } + const currUiStrings = wizardUiStrings[state.currentStep] + + const onSuccessCreate = (data: {uri: string; cid: string}) => { + const rkey = new AtUri(data.uri).rkey + logEvent('starterPack:create', { + setName: state.name != null, + setDescription: state.description != null, + profilesCount: state.profiles.length, + feedsCount: state.feeds.length, + }) + Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)]) + dispatch({type: 'SetProcessing', processing: false}) + navigation.replace('StarterPack', { + name: currentAccount!.handle, + rkey, + new: true, + }) + } + + const onSuccessEdit = () => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.replace('StarterPack', { + name: currentAccount!.handle, + rkey: parsed!.rkey, + }) + } + } + + const {mutate: createStarterPack} = useCreateStarterPackMutation({ + onSuccess: onSuccessCreate, + onError: e => { + logger.error('Failed to create starter pack', {safeMessage: e}) + dispatch({type: 'SetProcessing', processing: false}) + Toast.show(_(msg`Failed to create starter pack`)) + }, + }) + const {mutate: editStarterPack} = useEditStarterPackMutation({ + onSuccess: onSuccessEdit, + onError: e => { + logger.error('Failed to edit starter pack', {safeMessage: e}) + dispatch({type: 'SetProcessing', processing: false}) + Toast.show(_(msg`Failed to create starter pack`)) + }, + }) + + const submit = async () => { + dispatch({type: 'SetProcessing', processing: true}) + if (currentStarterPack && currentListItems) { + editStarterPack({ + name: state.name ?? getDefaultName(), + description: state.description, + descriptionFacets: [], + profiles: state.profiles, + feeds: state.feeds, + currentStarterPack: currentStarterPack, + currentListItems: currentListItems, + }) + } else { + createStarterPack({ + name: state.name ?? getDefaultName(), + description: state.description, + descriptionFacets: [], + profiles: state.profiles, + feeds: state.feeds, + }) + } + } + + const onNext = () => { + if (state.currentStep === 'Feeds') { + submit() + return + } + + const keyboardVisible = Keyboard.isVisible() + Keyboard.dismiss() + setTimeout( + () => { + dispatch({type: 'Next'}) + }, + keyboardVisible ? 16 : 0, + ) + } + + return ( + + + + { + if (state.currentStep === 'Details') { + navigation.pop() + } else { + dispatch({type: 'Back'}) + } + }}> + + + + + {currUiStrings.header} + + + + + + {state.currentStep === 'Details' ? ( + + ) : state.currentStep === 'Profiles' ? ( + + ) : state.currentStep === 'Feeds' ? ( + + ) : null} + + + {state.currentStep !== 'Details' && ( +